Về bản web

caltalys.dev

Hai method, hai @Transactional - mà chỉ một transaction để chết chung

@Transactional không nói mở transaction, nó nói method này cần chạy trong một transaction. Quyền quyết định nằm ở cái vỏ, và cái vỏ thò tay vào túi của thread.

Caltalys · 08/09/2026 · 9 phút đọc

Bài trước kết thúc bằng một câu hỏi treo, và thằng em nhắn lại sau đúng một ngày:

thang em Em nghĩ cả tối. Lá cờ rollback-only của vỏ notifier cắm được lên transaction của em, thì chỉ có một cách giải thích: hai cái vỏ đang dùng chung một transaction. Nhưng như thế thì vô lý mà anh. Em viết @Transactional hai chỗ. Em tưởng em có hai transaction.

Nó tưởng thế. Mình từng tưởng thế. Đa số người viết Spring nhiều năm vẫn đang tưởng thế. Và đây là hiểu lầm đắt nhất của cả mùa này:

@Transactional không có nghĩa là “mở một transaction”. Nó có nghĩa là “method này cần chạy trong một transaction”.

Hai câu đó khác nhau một trời. “Mở” là mệnh lệnh. “Cần” là lời khai - và lời khai thì để cho cái vỏ quyết. Quyết thế nào? Ở bài trước mình tả việc của TransactionInterceptor là “mở (hoặc mượn) một transaction” và hứa sẽ giải thích chữ mượn. Đây: trước khi mở gì, cái vỏ thò tay vào túi của thread hiện tại và hỏi một câu - “trong túi đã có transaction nào đang chạy chưa?”

Chưa có - nó mở cái mới, nhét vào túi. Có rồi - và đây là mặc định, cái mặc định tên là REQUIRED - nó không mở gì cả. Nó dùng luôn cái đang có. Nhập hội. Đi chung.

Giờ tua lại vụ án bài trước bằng con mắt mới. Controller gọi placeOrder. Vỏ của OrderService sờ túi: trống - mở transaction T1, nhét vào túi. Bên trong, placeOrder gọi notifier.notifyWarehouse. Vỏ của notifier sờ túi: có T1 rồi - thôi khỏi mở, đi chung T1. Rồi notifyWarehouse nổ RuntimeException. Vỏ của notifier thấy, tra bảng luật: phải rollback. Nhưng rollback thế nào đây? T1 không phải của nó. Nó chỉ là khách đi chung - khách thì không được đốt xe. Thứ duy nhất nó làm được là cắm lá cờ rollback-only lên T1 rồi ném exception đi tiếp: “tôi không có quyền hủy, nhưng tôi tuyên bố chuyến này phải hủy.”

Đó là câu trả lời cho bài trước, trọn vẹn: lá cờ cắm lên được transaction “của bạn” vì transaction đó chưa bao giờ là của riêng ai. Hai chữ @Transactional, hai cái vỏ, một chuyến xe.

Và từ đó suy ra một hệ quả mà thằng em va phải ngay khi định “fix”: inner rollback thì outer chết theo, kể cả khi outer đã catch cẩn thận. Không phải bug. Là số học của việc đi chung: một chuyến xe không thể vừa tới nơi vừa quay đầu.

“Vậy em cho nó đi xe riêng,” nó nhắn. “Em tra docs rồi: REQUIRES_NEW.”

@Service
public class NotificationService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void notifyWarehouse(Order order) { ... }
}

Đúng. REQUIRES_NEW nghĩa là: kệ trong túi có gì, tôi đi xe riêng. Vỏ của notifier giờ treo T1 lại - nguyên trạng, chưa commit chưa rollback, để đó - mở T2 mới toanh, chạy xong commit hoặc rollback T2 độc lập, rồi mới nhấc T1 dậy chạy tiếp. T2 chết không kéo T1 theo. Thằng em test: notify lỗi, đơn vẫn ghi, log gọn gàng. Nó deploy, và mình không nghe tin gì trong ba tuần.

Tuần thứ tư, nó nhắn lúc mười giờ đêm, kiểu tin nhắn không có lời chào:

thang em - 22:00 Anh. Production treo. Không phải chậm - treo cứng. Mọi request đứng im rồi chết cùng lúc sau ba mươi giây với Connection is not available, request timed out. CPU thấp, DB idle, không một exception nghiệp vụ nào. Rồi tự nhiên… hết treo. Xong lát sau treo tiếp. Em không hiểu app em đang đợi cái gì.

Nó đang đợi chính nó.

Để thấy điều đó, phải thêm một nhân vật mà nãy giờ mình giấu mặt, dù nó đứng sau mọi chuyện: connection pool. Transaction không phải khái niệm trên trời - mỗi transaction đang mở là một connection thật đang bị cầm, mượn từ một cái pool có đếm đầu, mặc định của HikariCP là mười. Mười cái. Hết mười thì ai tới sau xếp hàng đứng chờ, và sau ba mươi giây chờ thì nhận đúng cái timeout thằng em thấy.

Giờ soi lại REQUIRES_NEW bằng con mắt của cái pool. “Treo T1 lại” nghe rất nhẹ nhàng - nhưng T1 bị treo không trả connection. Nó giữ nguyên connection thứ nhất, ngồi đợi. Và “mở T2” nghĩa là ra pool mượn connection thứ hai. Một request, một khoảnh khắc, hai connection.

Bây giờ cho mười request đăng ký cùng lúc - một đợt flash sale, một cú retry storm, không cần nhiều. Mười request cùng vào placeOrder, mỗi thằng mượn một connection cho T1. Pool: 10/10, sạch bách. Rồi cả mười cùng chạy tới notifyWarehouse, cả mười cùng chìa tay ra pool: “cho mượn cái thứ hai.”

Pool trống. Mười thằng đứng chờ. Mỗi thằng đang cầm một connection trong tay - đúng cái thứ mà thằng đứng cạnh đang chờ - và không thằng nào nhả, vì T1 của nó chỉ được trả sau khi T2 xong, mà T2 thì cần cái connection không bao giờ tới. Mười kẻ chết khát, tay ai cũng cầm một chai nước của người bên cạnh.

Ba mươi giây sau, timeout đồng loạt - vì thế mà “chết cùng lúc”. Các request treo nhả connection ra, pool thở được, app “tự nhiên hết treo” - cho tới đợt mười request kế tiếp. Đúng từng triệu chứng thằng em tả, kể cả cái nhịp treo-rồi-tỉnh làm nó hoang mang nhất.

Không có bug ở đâu cả. REQUIRED làm đúng việc: đi chung. REQUIRES_NEW làm đúng việc: xe riêng. Pool làm đúng việc: hết thì xếp hàng. Chỉ là không ai nói cho thằng em nghe cái giá ghi ở góc hợp đồng: xe riêng nghĩa là hai suất đỗ, và bãi đỗ thì hữu hạn.

Toàn bộ propagation - cái danh sách bảy giá trị trông rất dọa người trong docs - thật ra chỉ là bảy câu trả lời khác nhau cho đúng một câu hỏi: “trong túi có transaction rồi thì mượn hay xin mới, và ai trả lúc nào?” REQUIRED: có thì đi chung, chưa có thì mở - một connection. REQUIRES_NEW: luôn xe riêng - có thể hai connection chồng nhau. SUPPORTS, NOT_SUPPORTED, MANDATORY, NEVER - bạn tự đọc được hết bằng cùng một câu hỏi đó, không cần học thuộc. NESTED thì phải tách riêng, vì nó đúng là ngoại lệ: nó không xin xe riêng, nó cắm một cái mốc trên chính chuyến xe đang đi - savepoint - nên nó chẳng ăn thêm suất nào của pool cả. Nói cho đúng phạm vi: trên stack JDBC, với REQUIRED và REQUIRES_NEW - hai giá trị chiếm gần hết code bạn sẽ viết - propagation không phải cấu hình transaction, nó là chính sách mượn connection. Đó là mental model để bạn tự suy ra hành vi, không phải định nghĩa trong spec: mang nó sang JTA, hay sang một method chẳng chạm database, thì phải dựng lại từ đầu.

Fix cho thằng em, theo thứ tự mình khuyên: một - hỏi lại đề bài: notify có cần transaction không, hay NOT_SUPPORTED là đủ - nó treo transaction đang có lại, nên method không còn giữ một suất theo suốt chiều dài transaction nữa; còn nếu bên trong nó vẫn chạm database thì tất nhiên vẫn phải mượn connection cho câu lệnh đó. Mượn theo câu lệnh, không giữ theo transaction: đó mới là điều NOT_SUPPORTED hứa - hoặc đẩy hẳn sang sau-commit bằng event. Hai - nếu REQUIRES_NEW là thật sự cần, thì định cỡ pool với ý thức rằng mỗi request lồng sẽ ăn hai suất, và giữ đoạn xe-riêng ngắn nhất có thể. Ba - cách rẻ nhất mà ít ai làm: đo. Bật metric của Hikari (hikaricp.connections.pending) và bạn sẽ nhìn thấy hàng người xếp trước khi họ chết khát.

Muốn tự thấy tận mắt mà không cần flash sale: hạ pool xuống còn hai - spring.datasource.hikari.maximum-pool-size=2 - rồi bắn ba request song song vào endpoint có REQUIRES_NEW lồng bên trong. Ba mươi giây im lặng, rồi cả ba cùng nổ. Một lần nhìn cảnh đó bằng mắt đáng giá hơn mọi sơ đồ propagation bạn từng học thuộc.

Còn nguyên lý, thứ sống lâu hơn Hikari: mọi “ngữ cảnh” mà framework trao cho bạn - transaction, session, lock - đều được thế chấp bằng một tài nguyên hữu hạn ở tầng dưới. Ngữ cảnh lồng nhau thì tài nguyên chồng nhau, và hệ thống lồng-tài-nguyên-hữu-hạn nào cũng có đúng một kịch bản chết: mọi người cùng giữ suất thứ nhất và cùng chờ suất thứ hai. Thread pool lồng thread pool, lock lồng lock, connection lồng connection - cùng một cái chết, khác mỗi tên tài nguyên.

Thằng em, sau khi hạ pool xuống hai và tự nhìn ba request của nó chết chùm, nhắn:

thang em Giờ em mới hiểu @Transactional khai cái gì. Nó không khai ‘tôi mở transaction’. Nó khai ‘tôi định mượn xe kiểu này’ - còn xe lấy đâu ra, và bãi còn chỗ không, là chuyện của cái vỏ với cái pool, sau lưng em.

Đúng rồi đó. Transaction không sống trong code em viết - nó sống ở cái vỏ, và ăn ở cái pool. 😃

Tài nguyên đi kèm

  • ZIP Source demo - tập 2.2

    JDK 17. mvn spring-boot:run → Episode22Runner in khối [EP 2.2]: pool 2 connection, hai thread vào outer REQUIRED rồi cùng xin thêm một suất cho inner REQUIRES_NEW.

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

https://caltalys.dev/spring-propagation-shared-transaction

Caltalys · 08/09/2026 · Spring · Transaction · Proxy