Tuần đó team bật thêm một máy app thứ hai trên staging — trong vận hành người ta hay gọi mỗi máy đang chạy app là một pod hoặc instance. Mục tiêu đơn giản: test “gần production” hơn, vì production thật luôn chạy nhiều máy song song để chịu tải.
Cậu junior phụ trách API nhận bút toán từ kênh đối tác. Mỗi request mang một mã tham chiếu refNo. Nghiệp vụ cứng: cùng một mã chỉ được ghi một lần — trùng là sai sổ. Code cậu ấy viết trông rất “đúng sách”:
@Transactional
public void ingest(LedgerRequest req) {
// Buoc 1: hoi DB da co ma nay chua?
if (repo.existsByRefNo(req.refNo())) {
throw new DuplicateRefException(req.refNo());
}
// Buoc 2: chua co thi ghi moi
repo.save(toEntity(req));
}
Logic nghe hợp lý: hỏi trước, ghi sau. Test trên một máy app — vài chục request tay — không trùng. Rồi QC bật công cụ bắn tải nhẹ, cố tình gửi cùng một refNo vào hệ thống đang có hai máy. Lần thì bảng mọc hai dòng cùng mã. Lần thì một request 200, request kia văng lỗi “vi phạm unique” dù code đã exists trước. Cậu ấy mang laptop sang bàn tôi, gạch dưới hai dòng log SQL gần như cùng một giây, từ hai host khác nhau:
“Em đã check rồi mà anh. Sao vẫn lọt?”
Câu hỏi hay. Và câu trả lời không phải “thiếu @Transactional”. Annotation đó đang có. Cái hỏng nằm ở chỗ tinh hơn: hai bước “hỏi rồi ghi” không phải một quyết định không thể xen kẽ — và chạy nhiều máy chỉ làm chỗ hở đó dễ đụng hơn, không phải tự sinh ra chỗ hở.
Tiết lộ. Đoạn exists rồi save là kiểu hỏi rồi mới làm (check-then-act). Giữa hai câu SQL, máy app khác vẫn kịp chèn cùng refNo. Khoá trong một process Java (synchronized, biến static…) không chặn được máy khác. Với bài toán tạo mới, cấm trùng mã, chốt thật phải do database giữ: cột UNIQUE (hoặc ghi kiểu “có rồi thì bỏ qua/cập nhật” — UPSERT), rồi app xử lý khi DB từ chối. Còn khi cộng/trừ số dư trên dòng đã có, bẫy hay gặp là app đọc số dư ra Java, tính, rồi ghi đè — hai request chen nhau sẽ mất một lần trừ. Với tiền, ưu tiên một câu SQL cộng/trừ atomic (hoặc khoá hàng rồi mới tính); @Version hợp hơn với form ít đụng. Bài này kể từ check trùng, rồi sang cộng trừ số dư và bản đồ chọn cơ chế.
Cảnh 1: hai máy cùng “nhìn thấy chưa có”
Giả sử mã TX-9921 chưa có trong bảng. Hai máy app nhận request gần như cùng lúc:
May A: "TX-9921 da co chua?" --> chua (SELECT)
May B: "TX-9921 da co chua?" --> chua (SELECT van thay the gioi cu)
May A: ghi moi TX-9921 --> OK
May B: ghi moi TX-9921 --> OK neu bang khong cam trung
--> hoac DB ban loi neu da co cot UNIQUE
Đây không phải ma thuật. Đây là cửa sổ thời gian giữa hai câu lệnh. Hình dung hai người cùng nhìn vào một quyển sổ đăng ký phòng họp trống, cả hai đều thấy “chưa ai đặt”, rồi cả hai cùng ghi tên mình — trừ khi quyển sổ có một người gác (database) từ chối tên trùng.
@Transactional giúp một request của một máy gói các thao tác của chính nó (mở giao dịch, commit hoặc rollback nếu lỗi). Nó không xếp hàng toàn bộ request từ mọi máy thành một hàng đơn. Nói cách khác: transaction bảo vệ câu chuyện của một request, không phải mọi request trên mọi máy cùng lúc.
Chạy một máy app thường “may” hơn vì ít khi hai request chạm đúng cửa sổ đó. Bật hai máy + tải nhẹ là đủ để cửa sổ hiện ra. Chi tiết anomaly kiểu này (lost update, race khi insert) được mổ kỹ trong bài Isolation levels & anomalies và UPSERT.
sequenceDiagram
participant A as May app A
participant B as May app B
participant DB as Database
A->>DB: Hoi: da co refNo?
B->>DB: Hoi: da co refNo?
Note over A,B: Ca hai nhan "chua co"
A->>DB: Ghi moi
B->>DB: Ghi moi
Note over DB: Hai dong trung — hoac DB ban loi UNIQUESửa cảnh 1: để database làm “người gác cổng”
Với bài toán “mã này chưa từng tồn tại thì tạo; đã có thì từ chối”, đừng hy vọng @Version cứu — vì chưa có dòng thì chưa có số version để so. Cũng đừng hy vọng synchronized trên service: mỗi máy app là một process riêng, khoá local không xuyên máy.
Cách 1 — Cột UNIQUE + bắt lỗi khi trùng
Bạn khai với database: cột ref_no không được trùng. Ai cố ghi trùng sẽ bị DB từ chối. App chỉ việc thử ghi và dịch lỗi đó thành thông báo nghiệp vụ rõ ràng.
ALTER TABLE ledger_entry
ADD CONSTRAINT uq_ledger_ref_no UNIQUE (ref_no);
@Transactional
public void ingest(LedgerRequest req) {
try {
// De DB phan: trung thi nem loi toan ven du lieu
repo.saveAndFlush(toEntity(req));
} catch (DataIntegrityViolationException ex) {
throw new DuplicateRefException(req.refNo());
}
}
Spring gom lỗi “vi phạm ràng buộc DB” thành DataIntegrityViolationException — cùng họ dịch exception mà stereotype @Repository bật lên. App không tự làm trọng tài bằng SELECT nữa; database là trọng tài cuối.
Cách 2 — UPSERT khi nghiệp vụ là “gửi lại cùng mã thì bỏ qua / cập nhật”
Đôi khi đối tác gửi lại cùng refNo (retry mạng). Lúc đó bạn không muốn hai dòng, cũng không muốn 500 ầm ĩ — bạn muốn idempotent: lần hai không tạo thêm. Pattern UPSERT / ON CONFLICT hợp hơn chuỗi if exists.
Tóm lại cảnh 1: check trùng bằng SELECT trên app là lời hứa mềm; UNIQUE (hoặc UPSERT) là lời hứa cứng.
Cảnh 2: cùng một gốc — nhưng là cộng trừ số dư, không phải “ghi đè form”
Vài ngày sau, QC gặp chuyện khác. Không phải check trùng refNo nữa, mà là hai giao dịch chạm cùng một tài khoản.
Trong hệ tài chính, ta hiếm khi làm kiểu “mở form thấy số dư 50 triệu, gõ 40 triệu, bấm Lưu” như sửa hồ sơ. Nghiệp vụ thật gần như luôn là delta: trừ 600.000, cộng lãi 12.500, hold 1 triệu — tức cộng/trừ lên số đang có, không thay cả số dư bằng một giá trị tuyệt đối do UI gõ. Vậy bug hay gặp không phải “hai người gõ hai con số khác nhau”, mà là dev viết cộng trừ trong Java theo ba bước:
@Transactional
public void debit(long accountId, long amountVnd) {
Account acc = repo.findById(accountId).orElseThrow();
if (acc.getBalanceVnd() < amountVnd) {
throw new InsufficientFundsException();
}
// Doc ra → tru trong bo nho app → ghi de ca dong
acc.setBalanceVnd(acc.getBalanceVnd() - amountVnd);
repo.save(acc);
}
Nghe đúng: đủ tiền thì trừ. Trên một máy, test tay từng giao dịch — xanh. UAT bắn hai request trừ cùng lúc (hai kênh, hai pod, hoặc retry + request mới) trên tài khoản còn 1.000.000:
So du ban dau: 1_000_000
Request A: doc 1_000_000, tru 600_000, tinh ra 400_000, chuan bi ghi
Request B: doc 1_000_000, tru 600_000, tinh ra 400_000, chuan bi ghi
A ghi: balance = 400_000
B ghi: balance = 400_000 <-- de mat lan tru cua A
Ket qua: so du 400_000, nhung da "tru" tong 1_200_000
(hai lan 600k) — thieu 200_000 so voi so sach
Cả hai request HTTP 200, audit có hai bút toán trừ, sổ cái lệch. Đó vẫn là lost update (mất một bản cập nhật): hai bên cùng đọc một snapshot, cùng tính, bản ghi sau đè bản trước. Lesson isolation dùng đúng kiểu ví dụ trừ tiền này; Propagation & isolation gợi ý khoá khi cần.
Cùng gốc với cảnh 1: đọc → quyết định → ghi mà khoảng giữa bị xen. Khác chỗ: dòng đã có, và nghiệp vụ là cộng/trừ, nên cách vá đúng nghề thường không phải “form + @Version trước” — mà là để phép cộng/trừ xảy ra trên database.
Cách đúng cho số dư: một câu SQL atomic (ưu tiên)
“Atomic” ở đây nghĩa gọn: một câu lệnh vừa đọc điều kiện vừa ghi kết quả, không chừa cửa sổ app:
UPDATE account
SET balance_vnd = balance_vnd - 600000
WHERE id = ?
AND balance_vnd >= 600000;
-- rows updated = 1 --> tru thanh cong
-- rows updated = 0 --> khong du tien (hoac id sai)
@Modifying
@Query("""
update Account a
set a.balanceVnd = a.balanceVnd - :amount
where a.id = :id and a.balanceVnd >= :amount
""")
int debitIfEnough(long id, long amount);
Hai request trừ 600k trên 1 triệu: một cái 1 row, một cái 0 row — không còn hai lần trừ “ảo” còn 400k. Đây là hướng production hay chọn cho counter/số dư: đơn giản, ít deadlock, multi-instance tự an toàn vì DB làm trọng tài.
Khi logic phức tạp hơn cộng/trừ một cột: khoá hàng (FOR UPDATE)
Đôi khi không gói gọn trong một UPDATE: phải đọc số dư, kiểm vài rule (hạn mức ngày, trạng thái phong toả), ghi bút toán con, rồi mới trừ. Lúc đó khoá bi quan — đọc kèm giữ hàng — hợp hơn:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Account a where a.id = :id")
Optional<Account> findByIdForUpdate(long id);
@Transactional
public void debitWithRules(long accountId, long amountVnd) {
Account acc = repo.findByIdForUpdate(accountId).orElseThrow();
// ... nhieu buoc quyet dinh trong Java, hang dang bi giu ...
acc.setBalanceVnd(acc.getBalanceVnd() - amountVnd);
// bút toán chi tiết, audit...
}
SELECT … FOR UPDATE: request khác chạm cùng tài khoản chờ (hoặc timeout). Chi tiết: locks & deadlock.
Cái bẫy: giữ khoá rồi gọi HTTP ngoài / sleep → hàng bị giữ lâu, hệ thống nghẽn. Giữ transaction ngắn. Và: FOR UPDATE không thay UNIQUE ở cảnh 1 (tạo mới trùng mã).
Còn @Version (khoá lạc quan) đứng chỗ nào?
Lạc quan: “ít khi đụng; cứ làm, lúc ghi mới biết có ai chen.” JPA @Version gắn cột đếm; mỗi lần UPDATE entity kèm WHERE version = ? — 0 row thì ném OptimisticLockException (Spring hay bọc ObjectOptimisticLockingFailureException).
@Entity
public class Account {
@Id
private Long id;
private long balanceVnd;
@Version
private long version;
}
@Version có bắt được lost update nếu vẫn find → setBalance → save. Nhưng với số dư cạ liên tục, conflict sẽ nổ dày → retry liên tục, UX/API xấu. Với counter/số dư, SQL atomic hoặc FOR UPDATE thường đúng nghề hơn.
@Version hợp khi: ít người sửa cùng lúc (hồ sơ khách, cấu hình sản phẩm, metadata), chấp nhận 409 và tải lại — không phải đường chính của “trừ tiền realtime”.
Bản đồ mang về — “đang giải bài gì?”
| Bạn đang làm gì? | Hay thấy gì khi hỏng? | Nên dùng | Đừng lấy làm tường chính |
|---|---|---|---|
Tạo mới, cấm trùng mã (refNo…) | Hai máy exists → hai dòng / lỗi unique “lạ” | UNIQUE + bắt lỗi, hoặc UPSERT | exists app; @Version (chưa có dòng) |
| Cộng/trừ một cột số dư/counter | Hai giao dịch trừ, sổ lệch (mất một lần trừ) | UPDATE … SET x = x ± ? atomic (+ điều kiện WHERE) | find → set → save trong Java |
| Cộng/trừ + nhiều bước rule trên cùng tài khoản | Logic không gói một câu SQL; cần lần lượt | SELECT FOR UPDATE rồi mới tính/ghi | Optimistic retry dày trên số dư nóng |
| Sửa entity ít đụng (hồ sơ, config) | Hai form đè nhau, cả hai “thành công” | @Version + 409 | Coi @Version là đủ cho mọi bài toán tiền |
@Transactional vẫn cần để một request commit/rollback trọn (xem proxy và blog self-invocation). Nhưng có transaction không xếp hàng hai pod. Cũng không mặc định SERIALIZABLE “cho chắc” thay cho UNIQUE / SQL atomic / FOR UPDATE đúng chỗ.
// SAI (canh 1) — check trung o app
if (!repo.existsByRefNo(ref)) {
repo.save(entity);
}
// DUNG (canh 1) — UNIQUE + bat loi
try {
repo.saveAndFlush(entity);
} catch (DataIntegrityViolationException e) {
throw new DuplicateRefException(ref);
}
// SAI (canh 2) — cong tru so du trong Java roi save de
Account a = repo.findById(id).orElseThrow();
a.setBalanceVnd(a.getBalanceVnd() - amount);
repo.save(a);
// DUNG (canh 2, uu tien) — mot cau UPDATE atomic
int n = repo.debitIfEnough(id, amount); // rows==0 → không đủ tiền
// DUNG (canh 2, nhieu buoc) — khoa hang roi moi tinh
Account a = repo.findByIdForUpdate(id).orElseThrow();
// rules...
a.setBalanceVnd(a.getBalanceVnd() - amount);
Hai dòng log SQL cùng một giây trên hai host hôm đó không phải “staging xui”. Chúng là ảnh chụp cửa sổ hỏi rồi mới làm. Cậu junior gỡ exists khỏi vai trò tường chính, thêm UNIQUE (ref_no); luồng trừ tiền đổi từ “đọc ra Java rồi ghi đè” sang UPDATE atomic (và chỗ rule dài thì FOR UPDATE). Bắn lại cùng refNo vào hai máy — một ghi được, một conflict rõ. Bắn hai lệnh trừ trên cùng số dư — một thành công, một “không đủ tiền”, sổ khớp. Production nhiều máy không đòi ngay khoá phân tán thần thánh: để database tham gia phán — UNIQUE khi tạo mới, cộng/trừ atomic hoặc khoá hàng khi đụng số dư.
Muốn đào lost update và isolation: Isolation levels & anomalies trong khoá SQL Database, và Propagation & isolation trong khoá Spring REST API & Data JPA. Ingest idempotent: ON CONFLICT. Khoá hàng Postgres: locks & deadlock.
Bài viết này đáng chia sẻ?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Sẵn sàng học sâu hơn?
Biến những gì vừa đọc thành kỹ năng thật với khoá học của OLHub, hoặc mang câu hỏi của bạn ra thảo luận cùng cộng đồng.
Đọc tiếp
Bài viết liên quan
N+1 query: vì sao một lần xuất sao kê bắn ra 5.001 câu SQL?
Xuất sao kê treo chục giây vì Hibernate bắn 5.001 câu SQL thay vì 1 — còn màn danh sách phân trang thì che giấu bug. Cơ chế N+1 query trong JPA và 4 cách fix.
@Component, @Service, @Repository khác gì, thay được nhau không?
@Service, @Repository, @Controller đều là @Component bên dưới — nhưng chỉ @Repository dịch exception, nên đổi nhầm annotation có thể lặng lẽ làm hỏng app.
Vì sao @Transactional không rollback dù đã đặt đúng chỗ?
@Transactional nằm trên method mà dữ liệu vẫn lưu một nửa? Vì nó là AOP proxy: self-invocation bỏ qua proxy, còn checked exception mặc định không rollback.
Vì sao không lưu tiền bằng double — và dùng BigDecimal sao cho đúng?
Báo cáo tổng lãi lệch 9 đồng dù từng chi nhánh khớp: cộng 10 triệu dòng bằng double, sai số mỗi phép cộng gom thành tiền thật. Cơ chế IEEE 754 và BigDecimal.