@Transactional là chú thích được dùng nhiều nhất và bị hiểu sai nhiều nhất trong Spring. Bài này đo bốn hành vi của nó.
Một: tự gọi thì không có giao dịch
public void ngoaiKhongCoTx() {
in("trước khi tự gọi");
trongCoTx(); // gọi thẳng
}
@Transactional public void trongCoTx() { in("bên trong @Transactional"); }
trước khi tự gọi giaoDich=false
bên trong @Transactional giaoDich=false <- vẫn false
Phương thức có @Transactional, nhưng không có giao dịch nào.
Lý do nằm ở cách Spring cài đặt: nó bọc bean của bạn trong một proxy, và proxy mới là thứ mở giao dịch. Khi bạn gọi trongCoTx() từ bên trong cùng lớp, lời gọi đó là this.trongCoTx() — đi thẳng tới đối tượng thật, không qua proxy.
Đây không phải lỗi mà là hệ quả tất yếu của proxy. Nhưng nó im lặng: không cảnh báo, không lỗi, chỉ là dữ liệu không được bảo vệ.
Cùng lý do đó, @Transactional không có tác dụng trên phương thức private, final, hay static. Với proxy dựa trên CGLIB, final khiến không ghi đè được; Spring 6 có ghi cảnh báo cho trường hợp này, nhưng đừng trông vào đó.
Ba cách chữa:
// 1. Tách sang bean khác <- tôi khuyên cách này
@Service class DichVuTrong { @Transactional public void lam() { } }
// 2. Tự tiêm chính mình
@Autowired @Lazy private DichVu chinhMinh;
public void ngoai() { chinhMinh.trong(); }
// 3. Dùng TransactionTemplate
txTemplate.execute(st -> { ...; return null; });
Cách một thường lộ ra rằng lớp đang làm hai việc, và tách ra là cải thiện thiết kế chứ không chỉ là mẹo.
Cách ba đáng nhớ hơn nhiều người nghĩ: nó làm ranh giới giao dịch hiện rõ trong mã thay vì ẩn sau một chú thích.
Hai: checked exception không rollback
ném RuntimeException -> số bản ghi +0 (đã rollback)
ném Exception (checked) -> số bản ghi +1 (ĐÃ COMMIT)
checked + rollbackFor -> số bản ghi +0
Dòng giữa là hành vi làm nhiều người mất dữ liệu.
Mặc định của Spring: rollback với RuntimeException và Error, commit với checked exception.
Quy ước này kế thừa từ EJB, dựa trên ý tưởng "checked exception là tình huống nghiệp vụ dự kiến, nên phần đã làm vẫn hợp lệ". Có thể tranh luận, nhưng thực tế gần như không ai muốn hành vi đó.
@Transactional(rollbackFor = Exception.class)
Với dự án dùng checked exception, tôi đặt cấu hình này ở mọi chỗ. Cách gọn hơn là tự viết một chú thích:
@Target(METHOD) @Retention(RUNTIME)
@Transactional(rollbackFor = Exception.class)
public @interface GiaoDich {}
Chiều ngược lại cũng có: noRollbackFor để không rollback với một ngoại lệ runtime cụ thể — hữu ích khi bạn ném ngoại lệ để báo tình huống nghiệp vụ mà vẫn muốn giữ dữ liệu đã ghi.
Ba: readOnly âm thầm bỏ qua thay đổi
@Transactional(readOnly = true)
public void chiDoc() {
var m = repo.findAll().get(0);
m.ten = "ĐÃ SỬA readOnly"; // sửa entity đang được quản lý
}
trước = Ky thuat sau = Ky thuat
Thay đổi không được lưu, và không có ngoại lệ nào.
Hibernate đặt FlushMode.MANUAL khi giao dịch là chỉ đọc, nên dirty checking không đẩy UPDATE nào ra. Với ai chưa biết, đây là một giờ gỡ lỗi: mã trông đúng, log không có gì, dữ liệu không đổi.
Đổi lại, readOnly = true đáng dùng cho mọi phương thức chỉ đọc:
- Hibernate bỏ qua dirty checking → nhanh hơn và tốn ít bộ nhớ hơn trên tập dữ liệu lớn
- Driver báo cho CSDL biết → PostgreSQL và MySQL định tuyến được sang bản sao đọc
- Nó là tài liệu: đọc chữ ký phương thức là biết nó không ghi
Bốn: nó phải chạy trên một bean của Spring
new DichVu().lam(); // không có giao dịch, dù có @Transactional
Chỉ bean do container quản lý mới được bọc proxy. Đây là lý do new một service trong test rồi thấy giao dịch không hoạt động.
Đặt @Transactional ở đâu
Ở tầng service, không ở repository, không ở controller.
Repository đã có giao dịch riêng cho từng phương thức (Spring Data bọc sẵn), nhưng giao dịch một phương thức repository hầu như không bao giờ là đơn vị nghiệp vụ đúng: trừ tiền tài khoản A và cộng vào tài khoản B phải cùng một giao dịch.
Controller thì quá ngoài — nó sẽ giữ kết nối trong lúc serialize JSON, đúng vấn đề OSIV ở bài 25.
Đặt trên lớp rồi ghi đè cho từng phương thức là mẫu gọn:
@Service
@Transactional(readOnly = true) // mặc định cho cả lớp
class DichVuDon {
public Don lay(String id) { } // kế thừa readOnly
@Transactional // ghi đè: cho phép ghi
public Don tao(DonMoi d) { }
}
Mặc định an toàn, và mỗi phương thức ghi phải khai tường minh.
Giữ giao dịch ngắn
Giao dịch giữ một kết nối từ pool. Bài 34 sẽ đo, nhưng nguyên tắc thì rõ ngay: đừng gọi HTTP ra ngoài bên trong giao dịch.
@Transactional
public void xuLy(Don d) {
repo.save(d);
thanhToan.goi(d); // SAI: giữ kết nối CSDL suốt lời gọi mạng
}
Bài 20 đo được RestClient mặc định không có phép chờ. Ghép hai điều đó: một dịch vụ treo giữ luôn kết nối CSDL của bạn, và pool cạn sau vài chục request.
Cách đúng: ghi CSDL trong giao dịch, gọi ra ngoài sau khi commit:
@TransactionalEventListener(phase = AFTER_COMMIT)
public void sauKhiLuu(SuKienDonMoi e) { thanhToan.goi(e.maDon()); }
Blog này dùng đúng mẫu đó để gửi thư báo bài mới — thư chỉ gửi khi bài đã thật sự được lưu.
Thử ba mươi giây
grep -rn "@Transactional" --include='*.java' src/main | grep -iE "private|final|Controller"
Mỗi kết quả là một chú thích không có tác dụng gì. Và tìm thêm chỗ tự gọi:
grep -rn "this\.\|^\s\+[a-z][a-zA-Z]*(" --include='*.java' src/main/java/**/service/
Ngày mai: mức lan truyền — và UnexpectedRollbackException, ngoại lệ khó hiểu nhất của Spring.