Command Palette

Search for a command to run...

[Spring Boot Basics] Transaction trong Spring Boot: @Transactional là gì, đặt ở đâu và khi nào rollback

OrderService.place ở bài 28 có @Transactional, còn bài 26 chỉ dùng annotation này để mở một transaction quanh phần minh họa. Chưa bài nào nói annotation đó mang lại gì, nên đặt ở đâu, và khi nào nó thật sự rollback. Bài này trả lời ba câu hỏi đó bằng cách cố tình làm hỏng use case đặt order: trước hết là không có transaction, sau đó có transaction, rồi lần lượt từng quy tắc exception và từng lỗi khiến @Transactional lặng lẽ không làm gì cả.

Các ví dụ dùng Spring Boot 4.1.1 và Java 21 với PostgreSQL 18 chạy trong Docker, ứng dụng chạy ở port 8130 thay vì 8080 mặc định. Mọi output đều đến từ PostgreSQL, trừ bài test ở cuối phần TransactionTemplate, chạy trên H2. Log dùng --logging.pattern.console=%logger{0}: %msg%n, chỉ in tên ngắn của logger và nội dung message.

@Transactional phía trên ba statement SQL nằm trong cùng một transaction, hoặc cùng commit hoặc cùng rollback

Hai phần đầu cho thấy vấn đề và loại log giúp nhìn thấy transaction; các phần còn lại đi qua từng quy tắc một.

Transaction mang lại gì: một order thất bại giữa chừng

Catalogue, database và cấu hình log

Project là project của bài 28: Order cùng các OrderLine, Product cùng Category, Customer cùng profile, và OrderController phía sau POST /api/customers/{customerId}/orders. Hai mức log giúp nhìn thấy transaction và SQL:

src/main/resources/application.properties
spring.application.name=demo
spring.jpa.open-in-view=false
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG 
src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:55430/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=create
Bash
docker run -d --name sb-a30-pg -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop -p 55430:5432 postgres:18
Bash
./gradlew -q bootJar
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8130 --spring.profiles.active=postgres '--logging.pattern.console=%logger{0}: %msg%n'

ddl-auto=create tạo lại các bảng mỗi lần khởi động, và một runner lúc khởi động nạp lại cùng một bộ dữ liệu: customer 1 là an@example.com, cùng ba product.

idSKUProductStock
1KB-01Mechanical keyboard10
2MS-01Wireless mouse1
3HUB-07USB-C hub5

Trạng thái database được đọc bằng psql bên trong container sau mỗi request:

Bash
docker exec sb-a30-pg psql -U shop -d shop -c 'select id, sku, stock from products order by id' -c 'select (select count(*) from orders) as orders, (select count(*) from order_lines) as order_lines'

Không có @Transactional, không gọi save: tồn kho không bao giờ được ghi

Lần chạy đầu tiên bỏ annotation khỏi place của bài 28 và không đổi gì khác:

src/main/java/com/example/demo/order/OrderService.java
    @Transactional
    public Order place(Long customerId, List<OrderItem> items) {
        Customer customer = customers.findById(customerId)
                .orElseThrow(() -> new CustomerNotFoundException(customerId));
        Order order = new Order(customer);
        for (OrderItem item : items) {
            Product product = productService.reserveStock(item.productId(), item.quantity());
            order.addLine(new OrderLine(product, item.quantity()));
        }
        return orders.save(order);
    }

ProductService.reserveStock vẫn là bản của bài 28: giảm stock trên entity rồi trả entity về. Một order lẽ ra phải thành công, hai bàn phím và một hub:

Bash
curl -i -s -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":2},{"productId":3,"quantity":1}]}' http://localhost:8130/api/customers/1/orders
Text
HTTP/1.1 201
Location: http://localhost:8130/api/orders/1
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 10:34:44 GMT
 
{"id":1,"customerId":1,"lines":[{"productId":1,"quantity":2,"unitPrice":89.90,"lineTotal":179.80},{"productId":3,"quantity":1,"unitPrice":39.00,"lineTotal":39.00}],"total":218.80}
Text
 id |  sku   | stock
----+--------+-------
  1 | KB-01  |    10
  2 | MS-01  |     1
  3 | HUB-07 |     5
(3 rows)
 
 orders | order_lines
--------+-------------
      1 |           2
(1 row)

Status 201, một order có hai line, và không một đơn vị tồn kho nào bị trừ. Log, lược còn các dòng mở và commit transaction cùng SQL, giải thích vì sao:

Text
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
SQL: select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
SQL: select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
SQL: insert into orders (customer_id) values (?)
SQL: insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
SQL: insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
JpaTransactionManager: Initiating transaction commit

Bốn lần gọi repository, bốn transaction, cái nào cũng do chính repository mở rồi commit. Khi findById trả product về, transaction và persistence context của nó đã đóng, nên setStock thay đổi một entity detached mà không ai theo dõi: dirty checking chỉ hoạt động bên trong transaction, như bài 26 đã cho thấy. Order và các line được ghi chỉ vì orders.save persist chúng.

Gọi save cho từng thay đổi: dữ liệu bị ghi dở dang

Code chạy không có transaction phải tự save từng thay đổi, giống reserveStock dùng bộ nhớ ở bài 21:

src/main/java/com/example/demo/product/ProductService.java
    public Product reserveStock(Long id, int quantity) {
        Product product = findById(id);
        if (product.getStock() < quantity) {
            throw new InsufficientStockException(product.getSku(), product.getStock(), quantity);
        }
        product.setStock(product.getStock() - quantity);
        return product; 
        return products.save(product); 
    }

Từ đây bài dùng order này: hai bàn phím và ba con chuột, trong khi kho chỉ còn một con chuột.

Bash
curl -i -s -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":2},{"productId":2,"quantity":3}]}' http://localhost:8130/api/customers/1/orders
Text
HTTP/1.1 409
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 10:35:53 GMT
 
{"detail":"Only 1 of MS-01 in stock, 3 requested","instance":"/api/customers/1/orders","status":409,"title":"Conflict"}
Text
 id |  sku   | stock
----+--------+-------
  1 | KB-01  |     8
  2 | MS-01  |     1
  3 | HUB-07 |     5
(3 rows)
 
 orders | order_lines
--------+-------------
      0 |           0
(1 row)

Client được báo order thất bại, còn hai bàn phím đã bị trừ khỏi kho cho một order không tồn tại. Log, lược theo cùng cách:

Text
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
SQL: select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
SQL: select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit

save merge bàn phím đang detached trong một transaction riêng và commit câu UPDATE trước cả khi con chuột được load. Khi InsufficientStockException thoát ra từ lần gọi reserveStock thứ hai, không còn gì để hoàn tác.

Cùng order đó với @Transactional

src/main/java/com/example/demo/order/OrderService.java
    @Transactional
    public Order place(Long customerId, List<OrderItem> items) {

Request thất bại ấy trả về đúng body 409 như trước, và lần này:

Text
 id |  sku   | stock
----+--------+-------
  1 | KB-01  |    10
  2 | MS-01  |     1
  3 | HUB-07 |     5
(3 rows)
 
 orders | order_lines
--------+-------------
      0 |           0
(1 row)

Không còn gì sót lại. Order hợp lệ của lần chạy đầu, hai bàn phím và một hub, sau đó trả về HTTP/1.1 201 với Location: http://localhost:8130/api/orders/1 cùng body như trước, và ghi đầy đủ:

Text
 id |  sku   | stock
----+--------+-------
  1 | KB-01  |     8
  2 | MS-01  |     1
  3 | HUB-07 |     4
(3 rows)
 
 orders | order_lines
--------+-------------
      1 |           2
(1 row)

Đó là thứ transaction mang lại: các thao tác của một use case cùng có hiệu lực hoặc cùng không. Bên trong transaction, lời gọi save vừa thêm vào reserveStock không gửi SQL nào, vì product vẫn đang managed và dirty checking ghi nó lúc commit, như log ở phần sau cho thấy. Dòng đó được giữ nguyên trong code cho đến hết bài.

Nhìn thấy transaction trong log

Transaction manager mà Spring Boot cấu hình cho JPA

Các phần minh họa không cần HTTP request chạy từ một runner lúc khởi động là TransactionLab, đặt trong package lab và gắn với profile lab. Runner nhận các bean cần kiểm tra qua constructor, và in tồn kho bằng một query JdbcClient nằm ngoài mọi transaction, nên không để lại dòng nào trong log của JpaTransactionManager:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres,lab --spring.main.web-application-type=none '--logging.pattern.console=%logger{0}: %msg%n'
src/main/java/com/example/demo/lab/TransactionLab.java
    @Override
    public void run(ApplicationArguments args) throws Exception {
        context.getBeansOfType(PlatformTransactionManager.class)
                .forEach((name, bean) -> log.info("PlatformTransactionManager bean: {} -> {}", name, bean.getClass().getName()));
        log.info("transactionManager defined in: {}", context.getBeanFactory().getBeanDefinition("transactionManager").getResourceDescription());
        log.info("TransactionTemplate beans: {}", Arrays.toString(context.getBeanNamesForType(TransactionTemplate.class)));
        log.info("OrderService bean class: {}", orderService.getClass().getName());
        log.info("its superclass: {}", orderService.getClass().getSuperclass().getName());
        log.info("AopUtils.isCglibProxy: {}", AopUtils.isCglibProxy(orderService));
        // the demonstrations of the later sections follow
    }
 
    private List<String> stocks() {
        return jdbcClient.sql("select sku, stock from products order by id")
                .query((rs, n) -> rs.getString(1) + "=" + rs.getInt(2))
                .list();
    }
Text
TransactionLab: PlatformTransactionManager bean: transactionManager -> org.springframework.orm.jpa.JpaTransactionManager
TransactionLab: transactionManager defined in: class path resource [org/springframework/boot/hibernate/autoconfigure/HibernateJpaConfiguration.class]
TransactionLab: TransactionTemplate beans: [transactionTemplate]

Có đúng một transaction manager tên transactionManager, kiểu JpaTransactionManager, được định nghĩa bởi HibernateJpaConfiguration trong module spring-boot-hibernate của Boot. Bài 25, khi chỉ có JDBC starter, nhận được JdbcTransactionManager; có JPA starter thì bean đó không được tạo. JpaTransactionManager còn chia sẻ transaction của nó với code JDBC thuần trên cùng DataSource, điều mà phần readOnly xác nhận bằng một query JdbcClient. Boot cũng đăng ký sẵn một TransactionTemplate tên transactionTemplate, được dùng gần cuối bài.

Begin, commit và rollback trong log của JpaTransactionManager

Mọi việc @Transactional làm đều được org.springframework.orm.jpa.JpaTransactionManager log ở mức DEBUG. Toàn bộ log của order thất bại khi place@Transactional:

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.order.OrderService.place]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(1757141441<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@282a596e]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1757141441<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
SQL: select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1757141441<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1757141441<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1757141441<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(1757141441<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction

Phần cuối log của order thành công:

Text
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1945821582<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: insert into orders (customer_id) values (?)
SQL: insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
SQL: insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1945821582<open>)]
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Closing JPA EntityManager after transaction
Dòng logChuyện gì đã xảy ra
Creating new transaction with name [...OrderService.place]: PROPAGATION_REQUIRED,ISOLATION_DEFAULTproxy của OrderService bắt đầu một transaction mang tên method, kèm các thiết lập của nó
Opened new EntityManager [SessionImpl(...)] for JPA transactionmột EntityManager, một persistence context cho cả method
Exposing JPA transaction as JDBC [...]code JDBC dùng được chính connection đó
Found thread-bound EntityManager rồi Participating in existing transactionmột lần gọi repository tham gia transaction đang chạy thay vì mở transaction riêng; mỗi lần gọi một cặp, ở đây là bốn
Initiating transaction commit, Committing JPA transactionmethod trả về; Hibernate flush các UPDATE đang chờ trong lúc commit
Initiating transaction rollback, Rolling back JPA transactionmethod ném exception; không có gì được flush
Closing JPA EntityManager after transactionpersistence context kết thúc cùng transaction

Các INSERT xuất hiện trước commit vì id IDENTITY cần row ngay lúc save; các UPDATE xuất hiện sau Committing, khi dirty checking tìm thấy hai product đã thay đổi. Ở lần chạy thất bại, không có UPDATE nào được gửi.

@Transactional hoạt động thế nào: bean được inject là một proxy

Ba dòng tiếp theo của TransactionLab in ra thứ mà controller thực sự nhận được khi được inject một OrderService:

Text
TransactionLab: OrderService bean class: com.example.demo.order.OrderService$$SpringCGLIB$$0
TransactionLab: its superclass: com.example.demo.order.OrderService
TransactionLab: AopUtils.isCglibProxy: true

Bean không phải OrderService của bạn mà là một subclass sinh ra lúc khởi động, OrderService$$SpringCGLIB$$0, bọc lấy object thật. Mọi lời gọi từ bean khác đều đi qua nó, và với method có @Transactional, nó chạy đúng trình tự mà log đã cho thấy:

  1. OrderController gọi service.place(customerId, items) trên proxy.
  2. Proxy đọc các thuộc tính @Transactional của place và yêu cầu JpaTransactionManager bắt đầu: Creating new transaction.
  3. Proxy gọi place thật. Các lần gọi repository bên trong tìm thấy transaction đang gắn với thread và tham gia vào đó.
  4. Nếu place trả về, proxy commit và trả Order cho controller. Nếu method ném một exception mà quy tắc rollback chọn, proxy rollback rồi ném lại đúng exception đó, và advice biến nó thành 409.

Những gì chạy quanh một lần gọi method @Transactional: caller gọi proxy OrderService$$SpringCGLIB$$0 được inject, JpaTransactionManager log Creating new transaction và Opened new EntityManager, place() chạy với một dòng Participating in existing transaction cho mỗi lần gọi repository, rồi hoặc commit và 201, hoặc rollback, exception bị ném lại và 409, mỗi nhánh kèm các dòng log nguyên văn

Mọi rắc rối thường gặp với @Transactional đều bắt nguồn từ bức hình này: transaction chỉ tồn tại với những lời gọi đi qua proxy, và quyết định commit do proxy đưa ra khi method kết thúc. Spring tạo proxy như thế nào, và CGLIB khác JDK proxy ra sao, thuộc về khóa Advanced.

Vị trí đặt @Transactional

Ở method của service thể hiện use case

Bài 21 đặt quy tắc tồn kho vào OrderService.place vì đặt order là một thao tác nghiệp vụ duy nhất, bất kể ai kích hoạt nó. Transaction thuộc về cùng method đó, vì cùng lý do.

  • Không đặt ở repository. Một method của repository chỉ là một bước của use case. Dữ liệu ghi dở dang ở trên chính là kết quả của việc mỗi lần gọi repository là một transaction.
  • Không đặt ở controller. Order khi đó chỉ nguyên vẹn khi đến qua HTTP. Một scheduled job, một message listener hay một service khác gọi thẳng OrderService.place sẽ chạy nó mà không có transaction, và controller bị trộn chuyện HTTP vào đơn vị công việc.
  • Đặt ở method public của service thực hiện một use case, để mọi caller, dù qua web hay không, đều nhận cùng hành vi tất cả hoặc không gì cả.

Spring Data repository vốn đã làm gì

Ở lần chạy đầu, mỗi lần gọi repository đều có transaction dù project không có annotation nào. Chúng đến từ SimpleJpaRepository, class nằm phía sau mọi JpaRepository từ bài 26. Các annotation của nó, đọc từ jar bằng javap:

Bash
javap -v -cp spring-data-jpa-4.1.1.jar org.springframework.data.jpa.repository.support.SimpleJpaRepository
Vị trí trong SimpleJpaRepository 4.1.1Annotation
class@Transactional(readOnly = true)
save, saveAll, saveAndFlush, saveAllAndFlush@Transactional
delete, deleteById, deleteAll, deleteAllById, deleteAllInBatch, deleteAllByIdInBatch, flush@Transactional
deleteupdate nhận DeleteSpecification hoặc UpdateSpecification@Transactional
findById, findAll, count, existsById và các method đọc kháckhông có annotation riêng, nên readOnly = true ở class được áp dụng

Log khớp với bảng: findById chạy với PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly, còn save với PROPAGATION_REQUIRED,ISOLATION_DEFAULT. PROPAGATION_REQUIRED nghĩa là "tham gia transaction đang chạy, hoặc bắt đầu một transaction". Một class trong profile lab gọi cùng ba lần repository, có và không có annotation riêng:

src/main/java/com/example/demo/lab/RepositoryCalls.java
package com.example.demo.lab;
 
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
 
@Component
@Profile("lab")
public class RepositoryCalls {
 
    private static final Logger log = LoggerFactory.getLogger(RepositoryCalls.class);
 
    private final ProductRepository products;
 
    RepositoryCalls(ProductRepository products) {
        this.products = products;
    }
 
    public void readWithoutTransaction() {
        Product keyboard = products.findById(1L).orElseThrow();
        Product mouse = products.findById(2L).orElseThrow();
        long count = products.count();
        log.info("{}, {}, {} products", keyboard.getSku(), mouse.getSku(), count);
    }
 
    @Transactional
    public void readInOneTransaction() {
        Product keyboard = products.findById(1L).orElseThrow();
        Product mouse = products.findById(2L).orElseThrow();
        long count = products.count();
        log.info("{}, {}, {} products", keyboard.getSku(), mouse.getSku(), count);
    }
}

readWithoutTransaction():

Text
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(38669275<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@1495f70]
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(38669275<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(952876299<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@56a34c0e]
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(952876299<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.count]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(640592192<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@6aa152b7]
SQL: select count(*) from products p1_0
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(640592192<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
RepositoryCalls: KB-01, MS-01, 3 products

readInOneTransaction():

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.RepositoryCalls.readInOneTransaction]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(319123198<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@121ec5c2]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(319123198<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(319123198<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(319123198<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select count(*) from products p1_0
RepositoryCalls: KB-01, MS-01, 3 products
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(319123198<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction

Không có annotation thì ba transaction và ba EntityManager, có annotation thì mỗi thứ một. @Transactional của repository là phương án dự phòng cho những lời gọi nằm ngoài transaction; bên trong transaction của service, nó chỉ tham gia vào.

Đặt ở class hay ở method

SimpleJpaRepository đặt readOnly = true ở class và @Transactional trên các method ghi dữ liệu. Service cũng làm được như vậy:

src/main/java/com/example/demo/order/OrderService.java
@Service
@Transactional(readOnly = true) 
public class OrderService {
 
    // fields and constructor unchanged
 
    @Transactional
    public Order place(Long customerId, List<OrderItem> items) {
        // unchanged
    }
 
    @Transactional(readOnly = true) 
    public Order findById(Long id) {
        return orders.findWithLinesById(id).orElseThrow(() -> new OrderNotFoundException(id));
    }
 
    @Transactional(readOnly = true) 
    public List<Order> findAll() {
        return orders.findAllWithLines();
    }
}

Một GET /api/orders/1 trả về 200, rồi một POST ba con chuột trả về 409, lược còn các dòng bắt đầu và kết thúc transaction:

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.order.OrderService.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Creating new transaction with name [com.example.demo.order.OrderService.place]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Initiating transaction rollback

findById thừa hưởng readOnly từ class. place thì không: annotation trên method thay thế hoàn toàn annotation ở class, các thuộc tính không được gộp với nhau. Annotation ở class cũng áp dụng cho những method public thêm vào sau này, và đó là lý do nên chọn cách này cho service chủ yếu đọc dữ liệu.

Khi nào @Transactional rollback?

Proxy quyết định đúng lúc method kết thúc, dựa trên thứ thoát ra khỏi method. Mỗi nhánh trong hình là một lần chạy bên dưới.

Cây quyết định: nếu không có exception nào thoát khỏi method, kể cả exception đã catch bên trong, transaction commit; nếu có, rule rollbackFor khớp thì rollback còn rule noRollbackFor khớp thì commit; không có rule nào khớp thì RuntimeException hoặc Error gây rollback, checked exception thì commit; mỗi kết quả kèm dòng log quan sát được

Exception unchecked thì rollback

InsufficientStockException kế thừa RuntimeException từ bài 21, và log của order thất bại đã cho thấy kết quả: Initiating transaction rollback, không có UPDATE, và tồn kho trong psql không đổi. Với mọi Error cũng vậy.

Exception checked thì commit

Biến exception thành checked đòi hỏi mệnh đề throws ở mọi nơi nó đi qua:

src/main/java/com/example/demo/product/InsufficientStockException.java
public class InsufficientStockException extends RuntimeException { 
public class InsufficientStockException extends Exception { 
src/main/java/com/example/demo/product/ProductService.java
    public Product reserveStock(Long id, int quantity) { 
    public Product reserveStock(Long id, int quantity) throws InsufficientStockException { 
src/main/java/com/example/demo/order/OrderService.java
    @Transactional
    public Order place(Long customerId, List<OrderItem> items) { 
    public Order place(Long customerId, List<OrderItem> items) throws InsufficientStockException { 
src/main/java/com/example/demo/order/OrderController.java
    @PostMapping("/api/customers/{customerId}/orders")
    public ResponseEntity<OrderResponse> place(@PathVariable Long customerId,
                                               @Valid @RequestBody PlaceOrderRequest request) { 
                                               @Valid @RequestBody PlaceOrderRequest request) throws InsufficientStockException { 
src/main/java/com/example/demo/common/GlobalExceptionHandler.java
    @ExceptionHandler(InsufficientStockException.class)
    public ProblemDetail conflict(RuntimeException e) { 
    public ProblemDetail conflict(Exception e) { 

OrderController cũng import thêm com.example.demo.product.InsufficientStockException. Order thất bại vẫn trả về đúng body 409 như trước. Phần cuối log:

Text
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(43570430<open>)]
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Closing JPA EntityManager after transaction
Text
 id |  sku   | stock
----+--------+-------
  1 | KB-01  |     8
  2 | MS-01  |     1
  3 | HUB-07 |     5
(3 rows)
 
 orders | order_lines
--------+-------------
      0 |           0
(1 row)

Dữ liệu ghi dở dang quay lại, lần này ngay bên trong một transaction. Exception vẫn thoát khỏi place và vẫn thành 409, nhưng mặc định Spring chỉ rollback với RuntimeExceptionError; checked exception được xem là một kết quả mà method đã khai báo, nên proxy commit số tồn kho mới của bàn phím.

rollbackFor và noRollbackFor

Khi một checked exception phải gây rollback, hãy nêu tên nó:

src/main/java/com/example/demo/order/OrderService.java
    @Transactional
    @Transactional(rollbackFor = InsufficientStockException.class) 
    public Order place(Long customerId, List<OrderItem> items) throws InsufficientStockException {

Cùng request đó, lược còn các dòng bắt đầu và kết thúc transaction:

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.order.OrderService.place]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,-com.example.demo.product.InsufficientStockException
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(750252235<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction

psql lại cho thấy 10, 1 và 5, không có order nào. Rule là một phần trong định nghĩa của transaction: dấu - trước tên class exception nghĩa là "rollback với exception này". noRollbackFor là rule ngược lại và được log với dấu +. Nó hợp với method phải giữ lại phần việc đã làm dù vẫn báo lỗi, chẳng hạn giữ chỗ phần tồn kho còn lại rồi báo phần thiếu:

src/main/java/com/example/demo/lab/RollbackRulesLab.java
    @Transactional(noRollbackFor = InsufficientStockException.class)
    public void reserveUpTo(Long productId, int quantity) {
        Product product = products.findById(productId).orElseThrow();
        int reserved = Math.min(product.getStock(), quantity);
        product.setStock(product.getStock() - reserved);
        if (reserved < quantity) {
            throw new InsufficientStockException(product.getSku(), reserved, quantity);
        }
    }

TransactionLab gọi rollbackRulesLab.reserveUpTo(2L, 3), trong lần chạy mà InsufficientStockException vẫn còn là unchecked:

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.RollbackRulesLab.reserveUpTo]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,+com.example.demo.product.InsufficientStockException
JpaTransactionManager: Opened new EntityManager [SessionImpl(1244881865<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@63cf6497]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1244881865<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1244881865<open>)]
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: threw com.example.demo.product.InsufficientStockException: Only 1 of MS-01 in stock, 3 requested
TransactionLab: stocks after: [KB-01=12, MS-01=0, HUB-07=9]

Một RuntimeException thoát khỏi method mà transaction vẫn commit: MS-01 từ 1 về 0. KB-01 và HUB-07 đã bị các phần minh họa trước đó trong cùng lần chạy thay đổi. Phần còn lại của bài quay về InsufficientStockException unchecked, bỏ các mệnh đề throws và trả lại tham số của handler:

src/main/java/com/example/demo/product/InsufficientStockException.java
public class InsufficientStockException extends Exception { 
public class InsufficientStockException extends RuntimeException { 

place quay lại dùng @Transactional không có thuộc tính.

Exception đã catch bên trong method thì commit

Có những order nên được đặt với những gì còn hàng. Một use case thứ hai catch exception theo từng line và bỏ qua line đó:

src/main/java/com/example/demo/order/OrderService.java
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);
 
    // ...
 
    @Transactional
    public Order placeAvailable(Long customerId, List<OrderItem> items) {
        Customer customer = customers.findById(customerId)
                .orElseThrow(() -> new CustomerNotFoundException(customerId));
        Order order = new Order(customer);
        for (OrderItem item : items) {
            try {
                Product product = productService.reserveStock(item.productId(), item.quantity());
                order.addLine(new OrderLine(product, item.quantity()));
            } catch (InsufficientStockException e) {
                log.info("Skipping a line: {}", e.getMessage());
            }
        }
        return orders.save(order);
    }
src/main/java/com/example/demo/order/OrderController.java
    @PostMapping("/api/customers/{customerId}/orders")
    public ResponseEntity<OrderResponse> place(@PathVariable Long customerId,
                                               @RequestParam(defaultValue = "false") boolean skipUnavailable, 
                                               @Valid @RequestBody PlaceOrderRequest request) {
        List<OrderItem> items = request.lines().stream()
                .map(line -> new OrderItem(line.productId(), line.quantity()))
                .toList();
        Order order = service.place(customerId, items); 
        Order order = skipUnavailable 
                ? service.placeAvailable(customerId, items) 
                : service.place(customerId, items); 
Bash
curl -i -s -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":2},{"productId":2,"quantity":3}]}' 'http://localhost:8130/api/customers/1/orders?skipUnavailable=true'
Text
HTTP/1.1 201
Location: http://localhost:8130/api/orders/1
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 10:45:42 GMT
 
{"id":1,"customerId":1,"lines":[{"productId":1,"quantity":2,"unitPrice":89.90,"lineTotal":179.80}],"total":179.80}
Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.order.OrderService.placeAvailable]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(306657394<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@101954ff]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(306657394<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
SQL: select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(306657394<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(306657394<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(306657394<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
OrderService: Skipping a line: Only 1 of MS-01 in stock, 3 requested
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(306657394<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: insert into orders (customer_id) values (?)
SQL: insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(306657394<open>)]
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Closing JPA EntityManager after transaction
Text
 id |  sku   | stock
----+--------+-------
  1 | KB-01  |     8
  2 | MS-01  |     1
  3 | HUB-07 |     5
(3 rows)
 
 orders | order_lines
--------+-------------
      1 |           1
(1 row)

Exception không bao giờ thoát khỏi placeAvailable, nên proxy thấy method trả về bình thường và commit order cùng một line. Hãy để ý reserveStock lúc này: nó không có @Transactional riêng, nên exception của nó đến được khối catch mà không đi qua proxy transaction nào trên đường đi. Phần bẫy rollback-only thay đổi đúng điểm đó.

readOnly = true thay đổi những gì

Entity bị thay đổi không được ghi xuống

src/main/java/com/example/demo/lab/ReadOnlyLab.java
    @Transactional(readOnly = true)
    public void changeStock(Long productId) {
        Product product = products.findById(productId).orElseThrow();
        product.setStock(product.getStock() + 100);
        log.info("stock of {} set to {} on a managed entity", product.getSku(), product.getStock());
    }

TransactionLab gọi readOnlyLab.changeStock(1L):

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.ReadOnlyLab.changeStock]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(291728707<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@753b6846]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(291728707<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
ReadOnlyLab: stock of KB-01 set to 110 on a managed entity
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(291728707<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: stocks after: [KB-01=10, MS-01=1, HUB-07=5]

Entity đang managed và đã bị thay đổi, transaction commit, nhưng không có UPDATE nào được gửi: trong transaction read-only, Hibernate 7.4 không flush những thay đổi mà dirty checking lẽ ra đã tìm thấy. Không exception, không cảnh báo; thay đổi đơn giản là mất. Điều đó vô hại trong method query, nhưng là bug âm thầm trong method có nhiệm vụ ghi dữ liệu, và đó cũng là cách readOnly = true ở class gây hại khi một method ghi quên @Transactional riêng của nó.

PostgreSQL từ chối thao tác ghi trong transaction read-only

Một thao tác ghi đi tới database thì khác. Với id IDENTITY, save gửi INSERT ngay lập tức, có flush hay không:

src/main/java/com/example/demo/lab/ReadOnlyLab.java
    @Transactional(readOnly = true)
    public void saveNewProduct() {
        String readOnly = jdbcClient.sql("select current_setting('transaction_read_only')").query(String.class).single();
        log.info("transaction_read_only = {}", readOnly);
        Category keyboards = categories.findById(1L).orElseThrow();
        products.save(new Product("Compact keyboard", "KB-02", new BigDecimal("59.00"), 5, keyboards));
    }
Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.ReadOnlyLab.saveNewProduct]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(107240479<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@237e4ed9]
ReadOnlyLab: transaction_read_only = on
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(107240479<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(107240479<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: insert into products (category_id,name,price,sku,stock) values (?,?,?,?,?)
error: HHH000247: ErrorCode: 0, SQLState: 25006
error: ERROR: cannot execute INSERT in a read-only transaction
JpaTransactionManager: Participating transaction failed - marking existing transaction as rollback-only
JpaTransactionManager: Setting JPA transaction on EntityManager [SessionImpl(107240479<open>)] rollback-only
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(107240479<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction

Chuỗi exception mà TransactionLab in ra:

Text
TransactionLab:   org.springframework.orm.jpa.JpaSystemException: could not execute statement [ERROR: cannot execute INSERT in a read-only transaction] [insert into products (category_id,name,price,sku,stock) values (?,?,?,?,?)]
TransactionLab:   org.hibernate.exception.GenericJDBCException: could not execute statement [ERROR: cannot execute INSERT in a read-only transaction] [insert into products (category_id,name,price,sku,stock) values (?,?,?,?,?)]
TransactionLab:   org.postgresql.util.PSQLException: ERROR: cannot execute INSERT in a read-only transaction
  • Bản thân connection là read-only. current_setting('transaction_read_only') trả về on, đọc bằng JdbcClient bên trong transaction JPA, nghĩa là PostgreSQL biết cờ này, không chỉ Hibernate.
  • save không có transaction read-write riêng. @Transactional không có readOnly của nó tham gia vào transaction read-only đang chạy, và INSERT thất bại với SQLState 25006.
  • Exception rất chung chung. Nó đến dưới dạng JpaSystemException chứ không phải một kiểu DataAccessException cụ thể, nên handler viết cho DataIntegrityViolationException không bắt được.

readOnly = true nên đặt trên method query, hoặc ở class của service chủ yếu đọc dữ liệu, kèm @Transactional trên từng method ghi. Nó khiến dirty checking không flush, và trên PostgreSQL, nó khiến database từ chối thao tác ghi.

Self-invocation: gọi method @Transactional từ chính bean đó

Một job nhập hàng cộng tồn kho cho nhiều product. restock có transaction; restockAll gọi nó trong vòng lặp:

src/main/java/com/example/demo/lab/Restocker.java
package com.example.demo.lab;
 
import java.util.List;
 
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
 
@Component
@Profile("lab")
public class Restocker {
 
    private static final Logger log = LoggerFactory.getLogger(Restocker.class);
 
    private final ProductRepository products;
 
    Restocker(ProductRepository products) {
        this.products = products;
    }
 
    public void restockAll(List<Long> productIds, int quantity) {
        for (Long productId : productIds) {
            restock(productId, quantity);
        }
    }
 
    @Transactional
    public void restockAllInOneTransaction(List<Long> productIds, int quantity) {
        for (Long productId : productIds) {
            restock(productId, quantity);
        }
    }
 
    @Transactional
    public void restock(Long productId, int quantity) {
        log.info("restock({}, {}): transaction active = {}", productId, quantity,
                TransactionSynchronizationManager.isActualTransactionActive());
        Product product = products.findById(productId).orElseThrow();
        product.setStock(product.getStock() + quantity);
    }
}

TransactionSynchronizationManager.isActualTransactionActive() cho biết có transaction nào đang chạy trên thread hiện tại hay không. restock dựa vào dirty checking, nên không có transaction thì thay đổi của nó không được ghi. Trước tiên, TransactionLab gọi thẳng restocker.restock(3L, 1):

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.Restocker.restock]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(476119323<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@75af5f2a]
Restocker: restock(3, 1): transaction active = true
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(476119323<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(476119323<open>)]
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: stocks after: [KB-01=10, MS-01=1, HUB-07=6]

Sau đó là restocker.restockAll(List.of(1L, 3L), 1):

Text
Restocker: restock(1, 1): transaction active = false
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(1007392323<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@746f1505]
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1007392323<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
Restocker: restock(3, 1): transaction active = false
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(1089993541<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@4c3c1963]
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1089993541<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: stocks after: [KB-01=10, MS-01=1, HUB-07=6]

Cùng method @Transactional ấy chạy hai lần với transaction active = false. Không có dòng Creating new transaction with name [...Restocker.restock] nào, chỉ có transaction read-only riêng của repository cho findById, không UPDATE nào được gửi, và không product nào được cộng thêm tồn kho. restockAll không có annotation, nên proxy chuyển thẳng lời gọi cho object thật, và bên trong object thật, restock(productId, quantity) nghĩa là this.restock(...): một lời gọi Java bình thường trên target, không bao giờ vòng ra ngoài qua proxy.

Hai panel. Bên trái: TransactionLab gọi restocker.restock(3L, 1), lời gọi đi qua biên của proxy nơi transaction bắt đầu, log có Creating new transaction with name [com.example.demo.lab.Restocker.restock], transaction active = true và một câu update, HUB-07 từ 5 lên 6. Bên phải: TransactionLab gọi restocker.restockAll(List.of(1L, 3L), 1), lời gọi đi thẳng qua proxy mà không có transaction, rồi this.restock() nằm gọn trong target object, log có transaction active = false và chỉ các transaction SimpleJpaRepository.findById, tồn kho không đổi

Hai cách sửa self-invocation

Đặt annotation lên method được gọi từ bên ngoài. restockAllInOneTransaction chính là restockAll có thêm @Transactional, và các lời gọi this.restock bên trong khi đó chạy trong transaction của nó. Lược còn các dòng transaction, các cờ và các UPDATE:

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.Restocker.restockAllInOneTransaction]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Restocker: restock(1, 1): transaction active = true
Restocker: restock(3, 1): transaction active = true
JpaTransactionManager: Initiating transaction commit
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
TransactionLab: stocks after: [KB-01=12, MS-01=1, HUB-07=8]

Chuyển vòng lặp sang bean khác, để lần gọi restock nào cũng đi qua proxy:

src/main/java/com/example/demo/lab/RestockBatch.java
package com.example.demo.lab;
 
import java.util.List;
 
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
 
@Component
@Profile("lab")
public class RestockBatch {
 
    private final Restocker restocker;
 
    RestockBatch(Restocker restocker) {
        this.restocker = restocker;
    }
 
    public void restockAll(List<Long> productIds, int quantity) {
        for (Long productId : productIds) {
            restocker.restock(productId, quantity);
        }
    }
}
Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.Restocker.restock]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Restocker: restock(1, 1): transaction active = true
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.Restocker.restock]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Restocker: restock(3, 1): transaction active = true
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
TransactionLab: stocks after: [KB-01=11, MS-01=1, HUB-07=7]

Lần chạy này diễn ra trước restockAllInOneTransaction, nên các con số thấp hơn một đơn vị. Hai cách sửa khác nhau về phạm vi. Cách thứ nhất cho cả batch một transaction, nên một lỗi sẽ rollback mọi product; cách thứ hai cho mỗi product một transaction, nên các product trước vẫn được commit khi một product sau thất bại. Hãy chọn theo điều mà batch phải đảm bảo. Các cách khác, như inject bean vào chính nó, thuộc về khóa Advanced.

@Transactional trên method private, package-private và protected

src/main/java/com/example/demo/lab/VisibilityLab.java
package com.example.demo.lab;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
 
@Component
@Profile("lab")
public class VisibilityLab {
 
    private static final Logger log = LoggerFactory.getLogger(VisibilityLab.class);
 
    @Transactional
    public void publicMethod() {
        report("public");
    }
 
    @Transactional
    protected void protectedMethod() {
        report("protected");
    }
 
    @Transactional
    void packagePrivateMethod() {
        report("package-private");
    }
 
    @Transactional
    private void privateMethod() {
        report("private");
    }
 
    private static void report(String visibility) {
        log.info("{} method: transaction active = {}", visibility,
                TransactionSynchronizationManager.isActualTransactionActive());
    }
}

TransactionLab, nằm cùng package, liệt kê các method mà class proxy khai báo, rồi gọi từng method. Method private không gọi được từ class khác, nên nó được gọi bằng reflection trên chính object proxy:

src/main/java/com/example/demo/lab/TransactionLab.java
        log.info("bean class: {}", visibilityLab.getClass().getName());
        for (Method m : VisibilityLab.class.getDeclaredMethods()) {
            if (!m.getName().endsWith("Method")) continue;
            boolean overridden = Arrays.stream(visibilityLab.getClass().getDeclaredMethods())
                    .anyMatch(p -> p.getName().equals(m.getName()));
            log.info("{} {}: overridden by the proxy class = {}", Modifier.toString(m.getModifiers()), m.getName(), overridden);
        }
        visibilityLab.publicMethod();
        visibilityLab.protectedMethod();
        visibilityLab.packagePrivateMethod();
        Method privateMethod = VisibilityLab.class.getDeclaredMethod("privateMethod");
        privateMethod.setAccessible(true);
        try {
            privateMethod.invoke(visibilityLab);
        } catch (InvocationTargetException e) {
            chain(e.getCause());
        }

Lược còn danh sách method, các dòng bắt đầu transaction và các cờ:

Text
TransactionLab: bean class: com.example.demo.lab.VisibilityLab$$SpringCGLIB$$0
TransactionLab: protected protectedMethod: overridden by the proxy class = true
TransactionLab:  packagePrivateMethod: overridden by the proxy class = true
TransactionLab: public publicMethod: overridden by the proxy class = true
TransactionLab: private privateMethod: overridden by the proxy class = false
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.VisibilityLab.publicMethod]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
VisibilityLab: public method: transaction active = true
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.VisibilityLab.protectedMethod]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
VisibilityLab: protected method: transaction active = true
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.VisibilityLab.packagePrivateMethod]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
VisibilityLab: package-private method: transaction active = true
VisibilityLab: private method: transaction active = false

Trên Spring Framework 7.0.9, quy tắc "chỉ method public mới có transaction" không còn đúng với proxy dựa trên class. Subclass được sinh ra đã override method protected và method package-private, và cả hai lời gọi đều bắt đầu transaction. Công tắc đó vẫn còn trong spring-tx: AbstractFallbackTransactionAttributeSource không trả về thuộc tính transaction nào cho method không public khi allowPublicMethodsOnly() là true.

Bash
javap -c -p -cp spring-tx-7.0.9.jar org.springframework.transaction.interceptor.AbstractFallbackTransactionAttributeSource
Text
  protected org.springframework.transaction.interceptor.TransactionAttribute computeTransactionAttribute(java.lang.reflect.Method, java.lang.Class<?>);
    Code:
       0: aload_0
       1: invokevirtual #111                // Method allowPublicMethodsOnly:()Z
       4: ifeq          19
       7: aload_1
       8: invokevirtual #114                // Method java/lang/reflect/Method.getModifiers:()I
      11: invokestatic  #120                // Method java/lang/reflect/Modifier.isPublic:(I)Z
      14: ifne          19
      17: aconst_null
      18: areturn

Trong ứng dụng Boot 4.1.1 này, phép kiểm tra cho method protected và package-private đi qua. Method private thì khác: subclass không override được nó, nên proxy không có gì để chặn, và kể cả khi được gọi trên object proxy, nó vẫn chạy không có transaction. Vì method private chỉ gọi được từ bên trong class của nó, đằng nào đó cũng là self-invocation. Hãy giữ @Transactional trên các method public tạo nên API của service; các mức truy cập khác chạy được ở đây, nhưng hiếm khi được bean khác gọi tới.

Bẫy rollback-only và UnexpectedRollbackException

ProductService.reserveStock là một use case độc lập, nên theo lời khuyên ở trên, nó cũng có @Transactional:

src/main/java/com/example/demo/product/ProductService.java
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 
 
@Service
public class ProductService {
 
    // ...
 
    @Transactional
    public Product reserveStock(Long id, int quantity) {

Không đổi gì khác. Request đã trả về 201 với một line ở phần trước:

Bash
curl -i -s -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":2},{"productId":2,"quantity":3}]}' 'http://localhost:8130/api/customers/1/orders?skipUnavailable=true'
Text
HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 02:45:35 GMT
Connection: close
 
{"timestamp":"2026-09-14T02:45:35.323Z","status":500,"error":"Internal Server Error","path":"/api/customers/1/orders"}

Log, đã bỏ các stack frame:

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.order.OrderService.placeAvailable]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@5b68556]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
SQL: select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Participating transaction failed - marking existing transaction as rollback-only
JpaTransactionManager: Setting JPA transaction on EntityManager [SessionImpl(1114214325<open>)] rollback-only
OrderService: Skipping a line: Only 1 of MS-01 in stock, 3 requested
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: insert into orders (customer_id) values (?)
SQL: insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1114214325<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
[dispatcherServlet]: Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only] with root cause
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
Text
 id |  sku   | stock
----+--------+-------
  1 | KB-01  |    10
  2 | MS-01  |     1
  3 | HUB-07 |     5
(3 rows)
 
 orders | order_lines
--------+-------------
      0 |           0
(1 row)

Hai câu INSERT đã được gửi rồi bị rollback, tồn kho của bàn phím không bao giờ được flush, và client nhận 500 từ cơ chế xử lý lỗi mặc định của Spring Boot, vì không có @ExceptionHandler nào dành cho UnexpectedRollbackException.

Năm bước theo thời gian: placeAvailable() bắt đầu transaction; reserveStock(2, 3) tham gia transaction đó và ném InsufficientStockException; JpaTransactionManager log Participating transaction failed và đánh dấu transaction chung là rollback-only; placeAvailable() catch exception, lưu order rồi trả về; proxy bên ngoài commit, transaction bị rollback và UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only đến client dưới dạng HTTP/1.1 500

Khác biệt so với phần trước chỉ là một annotation. Giờ reserveStock có proxy riêng, và proxy đó cũng nằm giữa exception và khối catch. Khi InsufficientStockException thoát khỏi reserveStock, proxy của nó áp dụng quy tắc rollback: một RuntimeException, vậy thì rollback. Nó không thể rollback một transaction mà nó chỉ tham gia, nên nó đánh dấu transaction chung là rollback-only (Participating transaction failed - marking existing transaction as rollback-only). placeAvailable catch exception và trả về bình thường, proxy của nó yêu cầu commit, còn transaction manager thì rollback và ném UnexpectedRollbackException, để caller không tưởng rằng order đã được lưu.

Catch exception từ save()

Method của repository cũng là một proxy transaction. Catch lỗi vi phạm constraint từ save bên trong transaction của service:

src/main/java/com/example/demo/lab/RollbackRulesLab.java
    @Transactional
    public void saveDuplicateSkuAndCatch() {
        Category keyboards = categories.findById(1L).orElseThrow();
        try {
            products.save(new Product("Keyboard copy", "KB-01", new BigDecimal("79.00"), 1, keyboards));
        } catch (DataIntegrityViolationException e) {
            log.info("caught {}", e.getClass().getName());
        }
    }

Log tính từ câu INSERT:

Text
SQL: insert into products (category_id,name,price,sku,stock) values (?,?,?,?,?)
error: HHH000247: ErrorCode: 0, SQLState: 23505
error: ERROR: duplicate key value violates unique constraint "products_sku_key"
  Detail: Key (sku)=(KB-01) already exists.
JpaTransactionManager: Participating transaction failed - marking existing transaction as rollback-only
JpaTransactionManager: Setting JPA transaction on EntityManager [SessionImpl(483248328<open>)] rollback-only
RollbackRulesLab: caught org.springframework.dao.DataIntegrityViolationException
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(483248328<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: threw org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only

Cùng một bẫy, cùng một thông báo. SimpleJpaRepository.save@Transactional, nên proxy của nó đã đánh dấu transaction trước khi khối catch kịp chạy.

Cách tránh bẫy

  • Đừng coi exception từ một method có transaction khác là kết quả bình thường. Ở phần trước, không có proxy nào nằm giữa exception của reserveStock và khối catch, và order được commit với một line. Khi method đó buộc phải giữ @Transactional, hãy kiểm tra trước (còn đủ hàng không, SKU đã tồn tại chưa, đúng câu hỏi mà existsBySku ở bài 27 trả lời) và chỉ gọi khi chắc chắn thành công.
  • Hoặc để exception lan ra ngoài. place làm như vậy: cả order rollback và client nhận một 409 dễ hiểu.
  • Khi một phần việc phải commit hoặc rollback độc lập, phần đó cần transaction riêng, và đó là việc của @Transactional(propagation = Propagation.REQUIRES_NEW); propagation được trình bày trong khóa Advanced.

jakarta.transaction.Transactional và @Transactional của Spring

Có hai annotation cùng tên @Transactional, và IDE gợi ý cả hai. Annotation của Jakarta đã có sẵn trên classpath vì Hibernate phụ thuộc vào nó:

Bash
./gradlew dependencies --configuration compileClasspath
Text
|    |    |    +--- org.hibernate.orm:hibernate-core:7.4.5.Final
|    |    |    |    +--- org.hibernate.orm:hibernate-platform:7.4.5.Final
|    |    |    |    |    +--- org.hibernate.orm:hibernate-core:7.4.5.Final (c)
|    |    |    |    |    +--- jakarta.persistence:jakarta.persistence-api:3.2.0 (c)
|    |    |    |    |    \--- jakarta.transaction:jakarta.transaction-api:2.0.1 (c)
|    |    |    |    +--- jakarta.persistence:jakarta.persistence-api:3.2.0
|    |    |    |    \--- jakarta.transaction:jakarta.transaction-api:2.0.1

Thuộc tính của chúng, đọc bằng javap:

Bash
javap -cp spring-tx-7.0.9.jar org.springframework.transaction.annotation.Transactional
Text
Compiled from "Transactional.java"
public interface org.springframework.transaction.annotation.Transactional extends java.lang.annotation.Annotation {
  public abstract java.lang.String value();
  public abstract java.lang.String transactionManager();
  public abstract java.lang.String[] label();
  public abstract org.springframework.transaction.annotation.Propagation propagation();
  public abstract org.springframework.transaction.annotation.Isolation isolation();
  public abstract int timeout();
  public abstract java.lang.String timeoutString();
  public abstract boolean readOnly();
  public abstract java.lang.Class<? extends java.lang.Throwable>[] rollbackFor();
  public abstract java.lang.String[] rollbackForClassName();
  public abstract java.lang.Class<? extends java.lang.Throwable>[] noRollbackFor();
  public abstract java.lang.String[] noRollbackForClassName();
}
Bash
javap -cp jakarta.transaction-api-2.0.1.jar jakarta.transaction.Transactional
Text
Compiled from "Transactional.java"
public interface jakarta.transaction.Transactional extends java.lang.annotation.Annotation {
  public abstract jakarta.transaction.Transactional$TxType value();
  public abstract java.lang.Class[] rollbackOn();
  public abstract java.lang.Class[] dontRollbackOn();
}

Spring tôn trọng cả hai. Một bean dùng annotation của Jakarta, với checked exception:

src/main/java/com/example/demo/lab/JakartaLab.java
package com.example.demo.lab;
 
import java.io.IOException;
 
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
 
import jakarta.transaction.Transactional;
 
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
 
@Component
@Profile("lab")
public class JakartaLab {
 
    private final ProductRepository products;
 
    JakartaLab(ProductRepository products) {
        this.products = products;
    }
 
    @Transactional
    public void importStock(Long productId) throws IOException {
        Product product = products.findById(productId).orElseThrow();
        product.setStock(product.getStock() + 1);
        throw new IOException("stock file truncated");
    }
 
    @Transactional(rollbackOn = IOException.class)
    public void importStockRollbackOn(Long productId) throws IOException {
        Product product = products.findById(productId).orElseThrow();
        product.setStock(product.getStock() + 1);
        throw new IOException("stock file truncated");
    }
}

Hai lời gọi từ TransactionLab, lược còn các dòng transaction, câu SQL ghi dữ liệu và kết quả:

Text
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.JakartaLab.importStock]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Initiating transaction commit
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
TransactionLab: threw java.io.IOException: stock file truncated
TransactionLab: stocks after: [KB-01=12, MS-01=1, HUB-07=9]
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.JakartaLab.importStockRollbackOn]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,-java.io.IOException
JpaTransactionManager: Initiating transaction rollback
TransactionLab: threw java.io.IOException: stock file truncated
TransactionLab: stocks after: [KB-01=12, MS-01=1, HUB-07=9]

Annotation của Jakarta bắt đầu một transaction JpaTransactionManager giống của Spring, commit khi gặp checked exception giống của Spring, và rollbackOn của nó trở thành đúng rule -java.io.IOExceptionrollbackFor tạo ra. HUB-07 chỉ tăng từ 8 lên 9 một lần, do method không có rule.

@Transactional của Springjakarta.transaction.Transactional
Rollback với các exception nàyrollbackFor, rollbackForClassNamerollbackOn
Không rollback với các exception nàynoRollbackFor, noRollbackForClassNamedontRollbackOn
Propagationpropagationvalue, kiểu Transactional.TxType
readOnly, timeout, isolation, transactionManager, labelkhông có
Checked exception không có rule, trong các lần chạy nàycommitcommit

Hãy dùng annotation của Spring. Annotation của Jakarta không có readOnly, nên không thể biểu diễn một service mặc định read-only ở class, và trộn hai annotation trong cùng code base buộc người đọc phải xem import mới biết thuộc tính nào áp dụng.

TransactionTemplate cho trường hợp hiếm cần viết transaction bằng code

Bean transactionTemplate của Boot chạy một lambda trong transaction mà không cần proxy, nên nó cũng không bị ảnh hưởng bởi self-invocation. Nó hợp với code không phải method của service, như một runner lúc khởi động; seeder ở bài 28 đã dùng nó. Trong TransactionLab:

src/main/java/com/example/demo/lab/TransactionLab.java
        Integer stock = transactionTemplate.execute(status -> {
            Product hub = products.findById(3L).orElseThrow();
            hub.setStock(hub.getStock() + 10);
            return hub.getStock();
        });
        log.info("execute returned {}, stocks after: {}", stock, stocks());
 
        transactionTemplate.executeWithoutResult(status -> {
            Product hub = products.findById(3L).orElseThrow();
            hub.setStock(0);
            status.setRollbackOnly();
        });
        log.info("stocks after: {}", stocks());
Text
JpaTransactionManager: Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(897736003<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@5f84e764]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(897736003<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(897736003<open>)]
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: execute returned 19, stocks after: [KB-01=12, MS-01=0, HUB-07=19]
JpaTransactionManager: Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(1244172753<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@7b6d88d1]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1244172753<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Transactional code has requested rollback
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(1244172753<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: stocks after: [KB-01=12, MS-01=0, HUB-07=19]

Transaction không gắn với method nào để lấy tên, nên log ghi name [null]. Giá trị trả về của lambda được execute trả lại; một RuntimeException bị ném ra sẽ gây rollback, còn status.setRollbackOnly() rollback mà không cần exception. Với code của service, annotation vẫn là cách làm chuẩn.

Transaction cũng xuất hiện trong test: một test method có @Transactional sẽ bị rollback khi kết thúc. Trong một class hai test chạy trên H2, product được lưu trong test có transaction đã biến mất ở test tiếp theo, test đó đếm được 3 product chứ không phải 4; Chương 6 sẽ nói về test.

Tổng hợp hành vi của @Transactional

Mỗi dòng là một lần chạy ở trên, trên PostgreSQL 18.6 trừ khi ghi H2.

Tình huốngCó transaction không?Kết quả
place() không có @Transactional, reserveStock không gọi save, order hợp lệkhông, mỗi lần gọi repository một transaction201, order và line được insert, tồn kho không đổi
place() không có @Transactional, có save trong reserveStock, product thứ hai thiếu hàngkhông, mỗi lần gọi repository một transaction409, KB-01 bị commit còn 8, không có order: dữ liệu ghi dở dang
@Transactional place(), InsufficientStockException extends RuntimeExceptionrollback, 409, không ghi gì
@Transactional place(), order hợp lệcommit, 201, order, line và cả hai thay đổi tồn kho
@Transactional place(), InsufficientStockException extends Exceptioncommit, 409, KB-01 còn 8, không có order
rollbackFor = InsufficientStockException.class, checked exceptionrollback, 409
noRollbackFor = InsufficientStockException.class, unchecked exceptioncommit, MS-01 từ 1 về 0
exception được catch trong placeAvailable(), ném từ method không có @Transactionalcommit, 201 với một line
exception được catch trong placeAvailable(), ném từ method @Transactional của bean kháccó, dùng chungrollback, UnexpectedRollbackException, 500
DataIntegrityViolationException từ save() được catch bên trong @Transactionalcó, dùng chungrollback, UnexpectedRollbackException
ba lần gọi repository trong method không có @Transactionalba transaction riêngba lần commit
cùng các lần gọi đó trong method @Transactionalmộtmột lần commit
readOnly = true, entity managed bị thay đổicó, read-onlycommit không có UPDATE, mất thay đổi
readOnly = true, save() một entity mớicó, transaction_read_only = onERROR: cannot execute INSERT in a read-only transaction, rollback
restock() được gọi từ bean kháccommit
restock() được gọi qua this.restock() từ restockAll()khôngkhông có UPDATE, mất thay đổi
@Transactional restockAllInOneTransaction() gọi this.restock()có, một cho cả haicommit
@Transactional trên method protected hoặc package-privatetransaction được tạo
@Transactional trên method privatekhôngkhông bị chặn
jakarta.transaction.Transactional, checked exceptioncommit
jakarta.transaction.Transactional(rollbackOn = IOException.class)rollback
TransactionTemplate.executecommit
status.setRollbackOnly() trong TransactionTemplaterollback
test method có @Transactional (H2)rollback sau khi test kết thúc

FAQ

@Transactional làm gì trong Spring Boot?

Nó khiến proxy của bean bắt đầu transaction trước khi method chạy, rồi commit hoặc rollback khi method kết thúc, để mọi thao tác với database bên trong method cùng có hiệu lực hoặc cùng không. Với JPA starter, Spring Boot 4.1.1 cấu hình một JpaTransactionManager tên transactionManager, và log DEBUG của nó cho thấy từng Creating new transaction, Participating in existing transaction, commit và rollback.

@Transactional có rollback với checked exception không?

Mặc định là không. Với InsufficientStockException extends Exception, order thất bại log Initiating transaction commit và commit thay đổi tồn kho của product đầu tiên xuống PostgreSQL. Chỉ RuntimeExceptionError gây rollback, trừ khi bạn thêm rollbackFor; rule đó hiện trong log là -com.example.demo.product.InsufficientStockException và biến chính lần chạy ấy thành rollback.

Vì sao @Transactional không có tác dụng khi method được gọi từ cùng class?

Vì lời gọi không đi qua proxy. restockAll() gọi restock() trên this chạy với TransactionSynchronizationManager.isActualTransactionActive() trả về false, không có dòng Creating new transaction nào cho restock, và không có UPDATE. Hãy đặt @Transactional lên method được gọi từ bên ngoài, hoặc chuyển method được gọi sang bean khác.

Nên đặt @Transactional ở service hay ở repository?

Ở method của service thực hiện một use case. Spring Data repository vốn đã có transaction cho từng lần gọi, @Transactional(readOnly = true) trên SimpleJpaRepository cùng @Transactional trên các method ghi, và việc mỗi lần gọi repository là một transaction chính là thứ đã để lại hai bàn phím bị giữ chỗ cho một order không bao giờ được lưu.

readOnly = true làm gì với Hibernate và PostgreSQL?

Với Hibernate 7.4.5, thay đổi trên entity managed trong transaction read-only không được flush: transaction commit mà không có UPDATE. Trên PostgreSQL 18.6, connection cũng read-only, transaction_read_only = on, và một câu INSERT thất bại với ERROR: cannot execute INSERT in a read-only transaction, SQLState 25006, đến phía Java dưới dạng JpaSystemException.

Điều gì gây ra lỗi "Transaction silently rolled back because it has been marked as rollback-only"?

Một method @Transactional của bean khác, kể cả method của repository, đã ném RuntimeException bên trong transaction của bạn, và code của bạn catch nó rồi trả về bình thường. Proxy bên trong log Participating transaction failed - marking existing transaction as rollback-only, lần commit bên ngoài trở thành rollback, và UnexpectedRollbackException đến client dưới dạng HTTP 500. Hãy kiểm tra trước khi gọi thay vì catch, hoặc để exception lan ra ngoài.

Nên dùng jakarta.transaction.Transactional hay @Transactional của Spring?

Của Spring. Spring chạy được cả hai, và cả hai đều commit khi gặp checked exception, nhưng annotation của Jakarta chỉ có value, rollbackOndontRollbackOn: không có readOnly, timeout hay isolation, và rule rollback mang tên khác với rollbackFornoRollbackFor.

Kết luận

Transaction là thứ khiến một use case trở thành tất cả hoặc không gì cả. Không có nó, mỗi lần gọi repository tự commit riêng: một order hoặc mất thay đổi tồn kho, hoặc để lại hàng bị giữ chỗ cho một order không tồn tại. Có @Transactional trên OrderService.place, proxy OrderService$$SpringCGLIB$$0 được inject bắt đầu một transaction của JpaTransactionManager, mọi lần gọi repository tham gia vào đó, và kết quả của method quyết định giữa commit và rollback, tất cả đều nhìn thấy được trong log DEBUG.

Các quy tắc quyết định không nhiều nhưng dễ hiểu sai: exception unchecked thì rollback còn checked thì commit, trừ khi rollbackFor nói khác; exception đã catch bên trong method thì commit, trừ khi một proxy transaction khác đã đánh dấu transaction là rollback-only; readOnly = true bỏ qua thay đổi trên entity và khiến PostgreSQL từ chối thao tác ghi; còn annotation chỉ có tác dụng với lời gọi đi qua proxy, nên lời gọi qua this và method private đều bị loại. Chỗ của nó là method public của service thể hiện use case, cùng readOnly = true ở class cho những service chủ yếu đọc dữ liệu.

Schema mà tất cả những thứ này chạy trên đó vẫn do ddl-auto=create tạo ra. Bài tiếp theo thay nó bằng database migration: Flyway (hoặc Liquibase), và vì sao không nên để ddl-auto quản lý schema ở production.

Bài viết liên quan

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

[Spring Boot Basics] Logging trong Spring Boot: SLF4J, Logback, log level và ghi log ra file

Logging trong Spring Boot 4.1.1: SLF4J là facade và Logback là implementation, hai bridge jul-to-slf4j và log4j-to-slf4j, parameterised và fluent logging, ghi log exception, log level, cây logger và log group, --debug so với --trace, pattern dòng log mặc định, logging.file.name kèm rotation, logback-spring.xml với springProfile, MDC và chuyển sang Log4j2.

[Spring Boot Basics] Auto-configuration trong Spring Boot hoạt động ra sao: conditional, back-off và báo cáo --debug

Mổ xẻ và đo đạc cơ chế auto-configuration của Spring Boot 4.1.1: @EnableAutoConfiguration và AutoConfigurationImportSelector, các file META-INF/spring/…AutoConfiguration.imports mà Boot 4 tách ra nhiều module nhỏ, họ annotation @ConditionalOnClass / @ConditionalOnMissingBean cùng một Condition tự viết, màn demo back-off có số liệu trước và sau, và cách đọc báo cáo CONDITIONS EVALUATION REPORT từ --debug.

[Spring Boot Basics] Unit test trong Spring Boot: JUnit 6, AssertJ và Mockito cho tầng Service

Unit test cho tầng service của ứng dụng Spring Boot 4.1.1 với JUnit, AssertJ và Mockito: unit test thay thế những gì, test task của Gradle và report, mỗi test method một instance mới được chứng minh bằng identity, @Nested và tên hiển thị của parameterized test trong JUnit 6, bẫy isEqualTo với BigDecimal và soft assertion cùng thông báo lỗi, @Mock với constructor injection so với @InjectMocks truyền null, stub, verify và ArgumentCaptor, UnnecessaryStubbingException và PotentialStubbingProblem dưới strict stubs, một Clock cố định, và nạp Mockito dưới dạng -javaagent để bỏ cảnh báo self-attaching.