Về bản web

caltalys.dev

getClass() của tôi in ra `$$SpringCGLIB$$`, tôi có làm hỏng gì không?

Vật được trao không phải vật bạn tưởng: vì sao Spring Boot đúc proxy bằng CGLIB kể cả khi đã có interface, và vì sao method final làm @Transactional bốc hơi.

Caltalys · 21/08/2026 · 15 phút đọc

Thằng em lại nhắn. Lần này nó không hỏi, nó thú tội:

@Service
public class UserService implements UserFinder {
    @Transactional
    public User findById(Long id) {

        return repo.findById(id).orElseThrow();
    }
}

@RestController
public class UserController {
    private final UserFinder userService;   // Spring đưa cho tôi cái này

    @GetMapping("/users/{id}")
    public User get(@PathVariable Long id) {
        log.info("tôi đang cầm cái gì: {}", userService.getClass().getName());
        // in ra: com.acme.UserService$$SpringCGLIB$$0
        return userService.findById(id);
    }
}
thang em Anh ơi, em log ra để debug thì thấy cái này. UserService$$SpringCGLIB$$0. Em không viết class nào tên vậy. Em có build hỏng gì không? Có phải máy em bị gì không?

Máy nó không sao. Cái tên đó là thật, và nó là thứ tử tế nhất Spring từng làm cho nó - chỉ là chưa ai nói cho nó biết.

Nhưng trước khi giải thích cái tên, mình phải đưa nó về một chỗ nó đã đứng rồi mà không biết.

Nhớ bài về refresh() không? Mình đã kể: mỗi bean đi qua bảy chặng. Chặng (1) constructor, chặng (2) inject field, chặng (4) @PostConstruct… và chặng (7), chặng cuối cùng, Spring duyệt cái List<BeanPostProcessor> lần cuối và hỏi: “mày có muốn bọc bean này lại không?”

Lúc đó có một thằng giơ tay: AutoProxyCreator. Mình đã dặn bạn nhớ cái tên đó.

Hôm nay là ngày nó ra tay.

Chặng (7) là trạm cuối của dây chuyền lắp ráp. Bean của bạn đi hết bảy chặng, được đúc xong, đủ đầy, sẵn sàng. Và ở đúng cái cửa ra, AutoProxyCreator chặn lại, cầm bean của bạn, bọc nó vào một lớp vỏ, rồi đưa lớp vỏ cho người tiếp theo.

Mùa trước bạn học được rằng có một dây chuyền. Câu hỏi tự nhiên tiếp theo - câu mà thằng em vừa vô tình hỏi bằng một dòng log - là: cái mà trạm cuối trao ra tay tôi, có còn là bean tôi viết không?

Không.

Trước hết, thống nhất ba từ, vì chỗ này ai cũng lẫn. Target là object thật - cái UserService bạn viết, có logic của bạn. Proxy là lớp vỏ Spring sinh ra lúc runtime, bọc quanh target. Advice là việc mà cái vỏ làm thêm: mở transaction, ghi log, đọc cache.

Nói gọn: target là diễn viên, proxy là người đóng thế, advice là cú đánh mà người đóng thế nhận thay. Nhìn từ ngoài giống hệt diễn viên. Nhận mọi cú đánh thay. Đánh xong mới đẩy diễn viên thật vào đọc thoại. Đoàn phim không cần biết ai đang đứng đó - đó là toàn bộ ý tưởng.

Và bây giờ, câu hỏi thật sự của bài: người đóng thế đó được đúc bằng cách nào?

Java cho Spring đúng hai lựa chọn, và chúng khác nhau về bản chất.

Cách một - JDK dynamic proxy. Java có sẵn java.lang.reflect.Proxy. Bạn đưa nó một danh sách interface, nó sinh ra một class mới implement đúng những interface đó, và mọi lời gọi đều bị chặn về một chỗ. Cái vỏ này mặc đồng phục - nó là “một UserFinder”, giống target ở chỗ cùng đeo biển. Nhưng nó không phải họ hàng gì với UserService. Tên nó trông như jdk.proxy2.$Proxy123.

Cách hai - CGLIB. Không cần interface. Nó sinh ra một subclass của chính UserService, override từng method, và trong mỗi method override nó chèn advice rồi gọi super. Cái vỏ này là con ruột của target. Tên nó - chính là cái làm thằng em hoảng - trông như UserService$$SpringCGLIB$$0.

Nhớ kỹ chỗ này, nó là chìa khóa của cả bài:

JDK proxy đeo cùng biển; CGLIB proxy là con ruột.

Một cái giống bạn vì cùng interface. Một cái giống bạn vì kế thừa bạn.

Cái tên $$SpringCGLIB$$0 chỉ là Spring đặt tên cho đứa con đó. $$ là quy ước “class này sinh lúc runtime, đừng tìm nó trong source”. Con số cuối là số thứ tự. Không có gì hỏng cả.

Một chuyện phải nói trước khi bạn đi so với máy mình: cái tên này đổi theo version. Spring Framework 5 (Boot 2.x) sinh ra UserService$$EnhancerBySpringCGLIB$$1a2b3c4d. Spring Framework 6 (Boot 3.x) - bản mà cả bài này chạy trên đó - rút gọn thành UserService$$SpringCGLIB$$0, và Spring Framework 7 (Boot 4.x) giữ nguyên quy ước đó. Cùng một cơ chế, khác cái nhãn. Nếu bạn đang đọc một bài blog viết năm 2019 và thấy tên khác, không phải bạn sai. Chỉ là cái nhãn đã đổi. Cơ chế thì không.

Bây giờ quay lại câu hỏi thật: tại sao thằng em nhận CGLIB?

Đây là chỗ hay nhất bài, và là chỗ mọi người đọc docs xong vẫn ngơ ngác.

UserService của thằng em có implement interface - UserFinder, rành rành ở dòng đầu. Mở tài liệu tham chiếu của Spring Framework ra đọc, nó nói rõ: nếu target implement ít nhất một interface, dùng JDK dynamic proxy; không có interface nào thì mới CGLIB.

Theo docs, thằng em phải nhận $Proxy123. Nó nhận $$SpringCGLIB$$0.

Docs sai? Không. Docs đó là docs của Spring Framework. Còn thằng em đang chạy Spring Boot.

Từ Spring Boot 2.0, Boot đặt spring.aop.proxy-target-class mặc định thành true - nghĩa là luôn CGLIB, kể cả khi có interface. Trước 2.0 thì không: không cấu hình gì thì theo luật của Framework, JDK-first. Boot 2.0 lật ngược mặc định đó, và từ đó tới nay mọi app Boot đều mặc định class-based proxy. Tài liệu Spring Framework hiện tại thậm chí phải ghi thêm một câu thừa nhận: core framework thiên về interface-based proxy, nhưng Spring Boot có thể bật class-based proxy mặc định tùy cấu hình.

Nên câu trả lời cho thằng em: không phải mày làm gì sai. Là mày đọc docs của tầng dưới, còn mày đang sống ở tầng trên. Boot là một lớp opinionated đặt trên Framework, và một trong những “opinion” của nó là: thà CGLIB còn hơn để dev ăn lỗi vì quên interface.

Vì sao Boot chọn vậy? Vì luật JDK-first có một cái bẫy chết người: bạn có interface, Spring đúc JDK proxy, và cái proxy đó chỉ có những method nằm trên interface. Method public nào bạn viết thêm ở class mà quên khai lên interface - proxy không có. Autowire bằng class cụ thể - nổ. Boot quyết định thà mọi người đều là con ruột, còn hơn nửa đội mặc đồng phục thiếu cúc.

Vậy muốn quay về JDK proxy thì sao? Tắt được. Nhưng chỉ đúng một cách:

spring.aop.proxy-target-class: false   # ăn - về luật JDK-first

Còn cách này, cách mà trực giác ai cũng chọn:

@EnableAspectJAutoProxy(proxyTargetClass = false)       // KHÔNG ăn trên Boot
@EnableTransactionManagement(proxyTargetClass = false)  // cũng không

Bạn viết rành rành false, và Spring vẫn đúc CGLIB. Không lỗi, không warning.

Lý do: proxy type là một công tắc toàn context, không phải per-annotation. AopAutoConfiguration của Boot đã bật CGLIB cho cả context; annotation của bạn nói gì cũng không hạ nó xuống được. Spring team biết chuyện này - có hẳn một issue về nó (spring-boot#12194) - và đóng với trạng thái declined: đây không phải bug, là hệ quả kiến trúc. Kết luận chính thức của họ: không có cách nào override mặc định đó bằng annotation của Framework, phải dùng property.

Tệ hơn nữa, và đây là chi tiết mình thích nhất: khi bạn dính lỗi proxy trong tình huống này, thông báo lỗi của Spring khuyên bạn set proxyTargetClass=false - đúng cái bạn đã set. Chính lead của Spring Data cũng phàn nàn y hệt trong issue đó: không một dòng nào cho bạn biết có một mặc định của Boot đang đè lên. Bạn ngồi nhìn code mình viết false, nhìn log bảo hãy set false, và không hiểu vũ trụ đang trêu mình chỗ nào.

Quen không? Lại là một tầng trung gian - lần này không phải giữa bạn và object, mà giữa cấu hình bạn viết và cấu hình thật sự có hiệu lực.

Nhưng khoan. Nếu proxy là subclass thì điều gì xảy ra với một method không cho kế thừa?

Bạn nên tự hỏi câu này, và mình đoán bạn đang định hỏi. Subclass thì phải override method mới chèn được advice vào. Vậy nếu một method không override được thì sao?

Đúng rồi đó. Đó là triệu chứng thứ hai của hôm nay, và nó không hề trông giống cái đầu tiên.

@Service
public class ReportService {                // không implement interface nào
    private final ReportRepository repo;    // gán trong constructor

    @Transactional
    public final void generate() {          // final
        repo.save(...);
    }
}

@Transactional ở đó. Khởi động êm ru: không exception, không một warning nào ở mức log mặc định. Rồi gọi generate() một phát, và bạn ăn NullPointerException ngay dòng repo.save - trên một field bạn khai final, gán trong constructor, thứ đáng lẽ không bao giờ null nổi.

Cơ chế thì bạn vừa học xong: CGLIB đúc proxy bằng cách sinh subclass và override từng method để chèn advice. final nghĩa là không cho override. Trình biên dịch của Java nói không. Nên method generate() trong proxy… chính là method gốc, kế thừa nguyên xi từ cha, không có advice nào bọc quanh.

Và đây là nửa mà ai cũng bỏ sót: một method không được override thì cũng không được chuyển sang target. Nó chạy tại chỗ, trên chính cái vỏ. Mà cái vỏ thì không có repo - nó chưa từng chạy constructor của bạn, chưa từng được inject cái gì. (Vì sao một đứa con ruột lại rỗng ruột như vậy - để dành bài sau, nó đáng cả một bài.)

Spring biết trước tai nạn này và có nói - ở mức DEBUG, tức là chỗ không ai bật: “Final method […] cannot get proxied via CGLIB: Calls to this method will NOT be routed to the target instance and might lead to NPEs against uninitialized fields in the proxy instance.” Một câu mô tả chính xác thứ vừa xảy ra với bạn, nằm trong dòng log bạn chưa từng đọc.

Và chữ might ở đó mới là chỗ đáng sợ. NullPointerException là phiên bản may mắn: nó ồn ào, nó chỉ đúng dòng, bạn sửa trong mười phút. Đổi một chi tiết nhỏ thôi - generate() không chạm field nào, hoặc nó được gọi từ bên trong chính ReportService - thì chẳng có NPE nào cả. Code chạy trơn tru, kết quả đúng, transaction bốc hơi không một dấu vết. Đó mới là phiên bản im lặng, và nó là bài sau.

Hai triệu chứng. Một cái là cái tên lạ trong dòng log, một cái là một method final cư xử như thể nó thuộc về một object khác. Trông chẳng liên quan gì tới nhau. Nhưng cùng một nguyên nhân, nằm ở đúng một chỗ: proxy là một subclass sinh lúc runtime. Cái tên lạ là chữ ký của subclass đó. final là thứ chặn subclass đó làm việc. Cùng một cơ chế, nhìn từ hai phía.

Nói thẳng: proxy là một cú tráo hàng sau lưng bạn. Spring lấy object bạn viết, giấu nó đi, đưa bạn một đứa trông giống hệt. Đổi lại, bạn được viết @Transactional một dòng thay vì mười lăm dòng try-catch-commit-rollback. Đó là món hời - nhưng nó có điều kiện: advice chỉ tồn tại ở lớp vỏ.

Nên mọi thứ ngăn advice chạy đều làm nó bốc hơi. Ba ca hay gặp nhất - và đừng gộp chúng lại, vì chúng chặn ở ba tầng khác nhau, đây là chỗ gần như ai cũng nhớ sai:

  • final class - CGLIB không sinh nổi subclass, nên không có vỏ nào cả. Đây là ca duy nhất trong ba ca nổ thành tiếng: context không khởi động được, Spring ném AopConfigException kèm đúng câu “Common causes of this problem include using a final class”.
  • final method - vỏ vẫn dựng được, bean vẫn là proxy đàng hoàng. Chỉ riêng method đó không override được, nên nó vừa không có advice vừa không được chuyển sang target - đúng cái NPE bạn vừa gặp.
  • private method - vỏ vẫn dựng được, và vấn đề còn không nằm ở chuyện được phép hay không: private không được kế thừa, subclass không nhìn thấy nó. Method đó chưa từng là một điểm có thể chặn.

Ba cái tên, ba tầng chặn: không dựng được vỏ / dựng được vỏ nhưng method không override được / dựng được vỏ nhưng method không nằm trong tầm mắt của vỏ. Chúng không phải ba bug. Chúng là ba biến thể của một câu duy nhất.

Và đây là câu đắt nhất hôm nay, câu mà cả mùa này xoay quanh: advice chỉ chạy khi một lời gọi vừa có thể bị chặn, vừa thật sự đi vào qua cái vỏ. Ba trường hợp trên đều hỏng ở vế đầu - hoặc không có vỏ, hoặc có vỏ mà method không phải chỗ chặn được. Còn nếu vế đầu thỏa hết - vỏ dựng ngon, method public, advice nằm sẵn đó chờ - mà lời gọi vẫn không đi qua nó thì sao?

Cứ giữ câu hỏi đó. Nó là toàn bộ bài sau.

Nếu bạn muốn tự kiểm chứng toàn bộ bài này, chỉ cần ba mươi giây. Đừng tin mình. Log thử:

log.info("{}", AopUtils.isAopProxy(bean));        // có phải proxy không
log.info("{}", AopUtils.isCglibProxy(bean));      // con ruột?
log.info("{}", AopUtils.isJdkDynamicProxy(bean)); // hay đồng phục?
log.info("{}", AopUtils.getTargetClass(bean));    // ai đang trốn trong vỏ

Phân vai cho rõ, vì hai thứ này trả lời hai câu khác nhau. getClass() cho bạn biết có một cái vỏ - nó là biên lai, và nó đủ để mở màn câu chuyện hôm nay. Nhưng nó không cho bạn biết ai đang ở trong vỏ. Một bean có thể bị bọc nhiều lớp - transaction bọc ngoài cache bọc ngoài async, mỗi annotation một lớp - và lúc đó getClass() chỉ khai ra lớp ngoài cùng. Muốn biết target thật, chỉ AopUtils.getTargetClass() trả lời đúng. Nhìn vỏ thì dùng getClass(); hỏi ruột thì dùng AopUtils.

Rồi thêm spring.aop.proxy-target-class=false vào application.yml, chạy lại, và xem cái tên đổi từ $$SpringCGLIB$$0 sang $Proxy123. Ba mươi giây đó dạy bạn nhiều hơn ba bài blog - kể cả bài này.

Còn nguyên lý, thứ sẽ sống lâu hơn mọi cái nhãn $$: muốn thêm hành vi vào một object mà không sửa code của nó, bạn phải chặn ở đường vào. Chặn được thì phải có một kẻ đứng giữa. Có kẻ đứng giữa thì thứ bạn cầm trên tay không còn là thứ bạn tưởng. Spring gọi nó là proxy; Hibernate cũng làm y hệt với lazy entity - và bạn sẽ gặp lại nó ở mùa sau; JavaScript gọi là Proxy; Python gọi là decorator. Cái tên đổi, cái nhãn đổi, cơ chế thì không.

Thằng em đọc xong, im một lúc, rồi nhắn:

thang em À. Vậy $$SpringCGLIB$$ không phải lỗi - nó là biên lai. Nó chứng minh Spring đã tráo hàng, và tráo thành công.

Đúng rồi đó. Cái vỏ đó là bạn của em. Chỉ cần em nhớ nó ở đó. 😃

Tài nguyên đi kèm

  • ZIP Source demo - tập 1.1

    JDK 17. mvn spring-boot:run → Episode11Runner in khối [EP 1.1]: caller đang cầm class có $SpringCGLIB$, còn method final của ReportService thì chạy trót lọt không ai chặn.

    https://caltalys.dev/downloads/book1/1.1-spring-deep-dive-demo.zip

https://caltalys.dev/spring-proxy-cglib

Caltalys · 21/08/2026 · Spring · Proxy · AOP