Chương 30. Transaction Và Tính Đúng
Ở chương trước, ta nói database quan hệ vẫn là nền tảng vì nó không chỉ lưu dữ liệu, mà còn giúp giữ dữ liệu đúng.
Chương này đi vào một trong những công cụ quan trọng nhất để giữ dữ liệu đúng:
> Transaction.
Nói đơn giản:
> Transaction là một nhóm thao tác dữ liệu hoặc cùng thành công hết, hoặc cùng hủy hết.
Ví dụ:
Tạo order
Tạo order_items
Cập nhật tổng tiền
Ghi event OrderPlaced
Nếu chỉ tạo order mà không tạo order_items, dữ liệu sai.
Nếu tạo order_items mà order không tồn tại, dữ liệu sai.
Transaction giúp gom các thao tác này thành một khối.
Tất cả thành công -> commit
Có lỗi -> rollback
Nhưng transaction không chỉ là kỹ thuật database.
Nó là cách ta trả lời câu hỏi:
> Những điều gì phải đúng cùng nhau ngay lập tức?
---
30.1. Ví dụ quán bánh: ghi đơn phải đủ
Khách đặt 3 bánh:
- Bánh chocolate.
- Bánh dâu.
- Bánh tiramisu.
Hệ thống cần ghi:
Order
OrderItem chocolate
OrderItem dâu
OrderItem tiramisu
Total amount
Nếu chỉ ghi được Order nhưng lỗi khi ghi OrderItem, bếp không biết làm gì.
Nếu ghi item nhưng tổng tiền sai, kế toán sai.
Nếu ghi xong đơn nhưng event không được lưu để báo bếp, đơn có thể bị bỏ quên.
Vì vậy phần tạo đơn nên nằm trong transaction:
begin
create order
create order_items
calculate total
save outbox event
commit
Nếu bước nào lỗi:
rollback
Không có đơn lưng chừng.
---
30.2. Transaction giải quyết vấn đề gì?
Transaction giải quyết vấn đề dữ liệu bị ghi dở.
Không có transaction:
Bước 1 thành công
Bước 2 thành công
Bước 3 lỗi
Hệ thống có thể rơi vào trạng thái nửa đúng nửa sai.
Có transaction:
Bước 1 thành công
Bước 2 thành công
Bước 3 lỗi
-> rollback tất cả
Transaction giúp:
- Không mất một phần dữ liệu.
- Không tạo dữ liệu mồ côi.
- Không ghi trạng thái lưng chừng.
- Giữ invariant quan trọng.
Ví dụ:
- Order phải có item.
- Wallet trừ tiền phải có ledger entry.
- Payment success phải có transaction record.
- GradingJob succeeded phải có result.
---
30.3. ACID hiểu đơn giản
Khi nói transaction, người ta hay nói ACID.
Không cần học quá hàn lâm, chỉ cần hiểu dễ:
Atomicity
Cùng thành công hoặc cùng hủy.
Không có nửa đơn hàng.
Consistency
Sau transaction, dữ liệu vẫn theo luật.
Số lượng không âm.
Order item thuộc order có thật.
Isolation
Các transaction chạy cùng lúc không được nhìn thấy nhau theo cách gây sai.
Hai người cùng mua ghế cuối không được cùng thành công.
Durability
Commit rồi thì dữ liệu phải được lưu bền vững.
Server restart không làm mất giao dịch đã commit.
ACID là cách database giúp hệ thống giữ sự đúng đắn trong lúc nhiều thứ xảy ra cùng lúc.
---
30.4. Khi nào cần transaction?
Cần transaction khi nhiều thay đổi phải đúng cùng nhau ngay.
Ví dụ:
Tạo order + order_items.
Trừ số dư ví + ghi ledger entry.
Giữ ghế + tạo booking.
Cập nhật payment + ghi outbox event.
Chuyển GradingJob sang SUCCEEDED + lưu result.
Nếu một phần thành công, một phần thất bại, hệ thống sai.
Những chỗ này nên có transaction.
Không phải mọi thao tác đều cần transaction phức tạp.
Nhưng phần core nghiệp vụ thì thường cần.
Một câu hỏi tốt:
> Nếu bước này thành công nhưng bước kia thất bại, dữ liệu có sai không?
Nếu có, nghĩ đến transaction.
---
30.5. Khi nào không nên gom vào một transaction?
Không nên gom mọi thứ vào transaction lớn.
Ví dụ xấu:
begin transaction
create order
call payment gateway
send email
call CRM
update analytics
commit
Vấn đề:
- Transaction giữ lâu.
- Lock kéo dài.
- Gọi API ngoài không chắc chắn.
- Email lỗi thì order rollback có hợp lý không?
- CRM chậm làm database lock lâu.
Transaction nên bảo vệ dữ liệu cục bộ cần đúng cùng nhau.
Các việc phụ nên tách bằng outbox/event/queue.
Ví dụ tốt hơn:
begin transaction
create order
create order_items
save OrderPlaced to outbox
commit
worker/consumer:
send email
sync CRM
update analytics
Không phải cứ làm nhiều thứ cùng lúc là transaction tốt.
Transaction càng rộng, rủi ro lock và lỗi càng cao.
---
30.6. Lock là gì?
Lock là cách database ngăn nhiều transaction sửa cùng một dữ liệu theo cách gây xung đột.
Ví dụ:
Ví có 100.000 đồng.
Hai request cùng muốn rút 80.000 đồng.
Nếu không khóa đúng:
Request A đọc balance = 100.000
Request B đọc balance = 100.000
A rút 80.000 -> còn 20.000
B rút 80.000 -> cũng nghĩ còn 20.000
Kết quả sai: user rút 160.000 dù chỉ có 100.000.
Lock giúp một transaction sửa trước, transaction kia phải chờ hoặc thất bại.
Lock không xấu.
Lock là công cụ giữ đúng.
Vấn đề là lock quá lâu hoặc lock quá rộng.
---
30.7. Race condition là gì?
Race condition là lỗi xảy ra khi kết quả phụ thuộc vào thứ tự chạy của các thao tác đồng thời.
Ví dụ:
Chỉ còn 1 ghế A1.
Hai user cùng bấm mua.
Nếu hệ thống xử lý không cẩn thận:
User A thấy ghế còn trống.
User B cũng thấy ghế còn trống.
User A mua thành công.
User B cũng mua thành công.
Bán trùng ghế.
Race condition thường không xuất hiện khi test một mình.
Nó xuất hiện khi nhiều request/job chạy cùng lúc.
Vì vậy rất nguy hiểm.
Concurrency càng cao, race condition càng dễ lộ.
---
30.8. Ví dụ ví tiền: tính đúng quan trọng hơn nhanh
Ví tiền là nơi phải đúng.
Giả sử user có:
balance = 100.000
Họ gửi hai request rút:
80.000
80.000
Hệ thống đúng phải cho tối đa một request thành công.
Cách làm có thể là:
begin transaction
lock wallet row
check balance
create ledger entry
update balance
commit
Hoặc dùng atomic update/constraint tùy thiết kế.
Điểm chính:
> Không được ưu tiên nhanh hơn đúng ở ví tiền.
Nếu phải chờ lock một chút để không mất tiền, đó là chấp nhận được.
---
30.9. Ví dụ đặt vé: không bán trùng ghế
Ghế A1 cho sự kiện X chỉ bán được một lần.
Cách bảo vệ:
- Unique constraint
(event_id, seat_id)cho vé đã bán. - Transaction khi giữ/mua ghế.
- Lock row seat inventory nếu cần.
- State machine cho hold/paid/expired.
Ví dụ:
begin transaction
select seat where event_id=X and seat_id=A1 for update
if status != AVAILABLE:
fail
mark HELD
commit
Hoặc dùng insert với unique constraint:
insert ticket(event_id, seat_id, user_id)
Nếu hai request cùng insert, database chỉ cho một thành công.
Constraint là hàng rào rất mạnh.
---
30.10. Ví dụ AI GradingJob: không chạy hai lần cùng lúc
Một GradingJob không nên bị hai worker cùng chạy nếu chính sách không cho phép.
Nếu queue deliver trùng hoặc worker retry, có thể xảy ra:
Worker A lấy job_123
Worker B cũng lấy job_123
Cả hai cùng gọi Gemini
Cả hai cùng lưu kết quả
Cách bảo vệ thực dụng:
update grading_jobs
set status = 'RUNNING'
where id = 'job_123'
and status in ('PENDING', 'RETRYING')
Nếu update được 1 row:
Worker thắng, được chạy.
Nếu update được 0 row:
Job đã không còn ở trạng thái chạy được.
Worker bỏ qua.
Đây là một dạng atomic state transition.
Nó dùng database để chống race condition.
---
30.11. Atomic update là gì?
Atomic update là update kiểm tra và thay đổi trong một thao tác.
Ví dụ:
update wallets
set balance = balance - 80000
where id = 'wallet_1'
and balance >= 80000
Nếu update thành công 1 row, rút tiền thành công.
Nếu update 0 row, không đủ tiền.
Không cần:
read balance
check in app
update balance
theo kiểu tách rời dễ race.
Atomic update rất hữu ích cho:
- Trừ tồn kho.
- Giữ quota.
- Chuyển trạng thái job.
- Đếm usage.
- Trừ số dư đơn giản.
Nhưng với nghiệp vụ phức tạp, vẫn cần transaction và ledger rõ.
---
30.12. Optimistic locking
Optimistic locking giả định xung đột ít xảy ra.
Ta cho nhiều request đọc cùng lúc.
Khi ghi, kiểm tra version.
Ví dụ:
Order id=123
status=DRAFT
version=5
Request A đọc version 5.
Request B cũng đọc version 5.
A update:
where id=123 and version=5
set status=CONFIRMED, version=6
Thành công.
B update với version 5:
0 row updated
B biết dữ liệu đã bị thay đổi.
Optimistic locking hợp khi:
- Conflict ít.
- Muốn tránh giữ lock lâu.
- Có thể retry hoặc báo conflict.
Ví dụ:
- Cập nhật profile.
- Sửa order draft.
- Chuyển trạng thái job.
---
30.13. Pessimistic locking
Pessimistic locking giả định xung đột có thể xảy ra và ta khóa trước.
Ví dụ:
select * from wallets where id='wallet_1' for update
Transaction khác muốn sửa row đó phải chờ.
Pessimistic locking hợp khi:
- Dữ liệu rất quan trọng.
- Conflict dễ xảy ra.
- Không muốn xử lý retry phức tạp.
- Thao tác ngắn.
Ví dụ:
- Ví tiền.
- Seat inventory.
- Ledger.
- Stock cực nhạy.
Nhưng phải cẩn thận:
- Không giữ lock lâu.
- Không gọi API ngoài khi đang giữ lock.
- Tránh deadlock.
Lock tốt là lock ngắn và đúng chỗ.
---
30.14. Deadlock là gì?
Deadlock xảy ra khi hai transaction chờ nhau mãi.
Ví dụ:
Transaction A lock wallet_1, rồi muốn lock wallet_2.
Transaction B lock wallet_2, rồi muốn lock wallet_1.
A chờ B.
B chờ A.
Database sẽ phát hiện và hủy một transaction.
Cách giảm deadlock:
- Luôn lock theo cùng thứ tự.
- Giữ transaction ngắn.
- Không lock nhiều thứ nếu không cần.
- Retry transaction khi gặp deadlock.
Deadlock không phải tận thế.
Nhưng nếu xảy ra nhiều, thiết kế transaction/lock có vấn đề.
---
30.15. Isolation level là gì?
Isolation level quyết định transaction nhìn thấy dữ liệu của transaction khác như thế nào.
Các mức phổ biến:
- Read committed.
- Repeatable read.
- Serializable.
Không cần học quá sâu ngay.
Chỉ cần hiểu:
> Isolation càng mạnh, càng ít hiện tượng kỳ lạ, nhưng có thể tốn chi phí hơn hoặc dễ conflict hơn.
Trong nhiều hệ thống, default của database là đủ cho nhiều use case.
Nhưng với tiền, booking, inventory nhạy, cần hiểu kỹ hơn.
Nếu gặp lỗi race condition lạ, isolation level và lock là nơi cần xem.
---
30.16. Lost update
Lost update là khi hai transaction cùng đọc giá trị cũ, rồi ghi đè nhau.
Ví dụ:
counter = 10
Request A đọc 10, muốn +1
Request B đọc 10, muốn +1
A ghi 11
B ghi 11
Kết quả đúng phải là 12.
Nhưng bị mất một update.
Cách xử lý:
- Atomic update:
set counter = counter + 1. - Optimistic locking.
- Lock row.
Lost update rất phổ biến nếu code đọc-sửa-ghi không bảo vệ.
---
30.17. Double spending
Double spending là dùng cùng một tài nguyên hai lần.
Ví dụ:
- Rút tiền hai lần từ cùng số dư.
- Dùng coupon một lần nhưng áp dụng hai đơn.
- Bán một ghế cho hai người.
- Dùng một quota nhiều lần.
Double spending thường do race condition.
Cách chống:
- Transaction.
- Lock.
- Unique constraint.
- Atomic update.
- Idempotency key.
- Ledger.
Nghiệp vụ tài chính, booking, quota, inventory đều phải nghĩ đến double spending.
---
30.18. Transaction và external API
Một lỗi rất phổ biến:
begin transaction
update database
call payment provider
call email provider
commit
Không nên.
Vì:
- API ngoài chậm.
- API ngoài timeout.
- Transaction giữ lock lâu.
- Nếu API thành công nhưng transaction rollback thì sao?
- Nếu transaction commit nhưng API response mất thì sao?
Cách tốt hơn:
begin transaction
update local state
save outbox event/job
commit
worker xử lý external API
Hoặc nếu bắt buộc gọi API trong luồng chính, hãy tránh giữ transaction trong lúc gọi.
Gọi mạng trong transaction là mùi thiết kế cần xem lại.
---
30.19. Transaction và outbox
Outbox giúp đảm bảo thay đổi database và message/event đi cùng nhau.
Ví dụ:
begin transaction
update payment = SUCCEEDED
insert outbox event PaymentSucceeded
commit
Nếu commit thành công, event nằm trong outbox.
Dispatcher publish sau.
Nếu transaction rollback, event cũng rollback.
Không có outbox, có thể xảy ra:
Payment update thành công nhưng event publish fail.
Hoặc:
Event publish thành công nhưng payment rollback.
Outbox là bạn rất tốt của transaction trong hệ thống event-driven.
---
30.20. Transaction trong microservices
Trong monolith một database, transaction tương đối dễ.
Trong microservices nhiều database, transaction xuyên service rất khó.
Ví dụ checkout:
Ordering DB
Payment DB
Inventory DB
Delivery DB
Không nên cố có một transaction khổng lồ bao tất cả service nếu không có lý do cực mạnh.
Thường dùng:
- Transaction cục bộ trong từng service.
- Event.
- Saga/workflow.
- Compensation.
- Idempotency.
Ví dụ:
OrderConfirmed
-> PaymentRequested
-> PaymentSucceeded
-> InventoryReserved
Nếu bước sau fail, saga quyết định bù trừ:
Cancel order
Refund payment
Release inventory
Distributed transaction không phải lựa chọn mặc định.
---
30.21. Khi nào chấp nhận eventual consistency?
Không phải mọi dữ liệu phải đúng cùng lúc.
Ví dụ:
Order đã paid.
Analytics chưa cập nhật ngay.
Notification chưa gửi ngay.
Search index chậm vài giây.
Dashboard doanh thu trễ 1 phút.
Thường chấp nhận được.
Nhưng:
Wallet balance sai.
Bán trùng ghế.
Payment ghi hai lần.
GradingJob có hai kết quả cuối.
Không chấp nhận.
Câu hỏi:
> Nếu dữ liệu lệch vài giây/phút, hậu quả là gì?
Nếu hậu quả thấp, eventual consistency có thể ổn.
Nếu hậu quả cao, cần transaction/lock/constraint chặt hơn.
---
30.22. Tính đúng quan trọng hơn tốc độ ở đâu?
Một số nghiệp vụ phải ưu tiên đúng:
- Tiền.
- Ví.
- Ledger.
- Payment.
- Refund.
- Booking ghế/phòng/vé.
- Inventory giới hạn.
- Quyền truy cập bảo mật.
- Điểm thi/chứng chỉ.
- Quota trả phí.
- Kết quả chấm chính thức.
Ở những nơi này, chậm một chút để đúng thường chấp nhận được.
Sai một lần có thể rất tốn để sửa.
Đừng tối ưu tốc độ bằng cách bỏ constraint, bỏ lock, bỏ transaction ở những chỗ này.
---
30.23. Khi nào tốc độ quan trọng hơn tính đúng tuyệt đối?
Một số dữ liệu có thể gần đúng hoặc trễ:
- View count.
- Like count.
- Analytics realtime.
- Recommendation.
- Typing indicator.
- Online presence.
- Search index.
- Cache.
Ví dụ:
Số view video lệch vài lượt trong vài giây thường không sao.
Typing indicator mất một event không sao.
Recommendation cập nhật chậm vài phút không sao.
Những chỗ này có thể dùng async, cache, batch, eventual consistency.
Thiết kế tốt là biết nơi nào cần chặt, nơi nào nên lỏng.
---
30.24. Constraint vẫn cần dù có transaction
Transaction giúp nhóm thao tác đúng cùng nhau.
Constraint giúp chặn dữ liệu sai ở tầng database.
Ví dụ:
unique(event_id, seat_id)
chặn bán trùng ghế.
check(balance >= 0)
có thể giúp chặn số dư âm nếu mô hình cho phép.
unique(provider_event_id)
chống webhook trùng.
Transaction và constraint bổ sung nhau.
Đừng chỉ dùng một cái.
---
30.25. Transaction không thay thế idempotency
Transaction bảo vệ một lần xử lý.
Idempotency bảo vệ khi cùng yêu cầu bị xử lý lại.
Ví dụ:
Worker xử lý refund trong transaction rất đúng.
Nhưng queue deliver cùng job lần nữa.
Nếu không có idempotency key/unique constraint/state check, refund có thể chạy lại.
Vì vậy cần cả:
- Transaction.
- Idempotency.
- Constraint.
- State machine.
Mỗi thứ bảo vệ một loại lỗi khác nhau.
---
30.26. Transaction không thay thế domain logic
Transaction đảm bảo các ghi chú dữ liệu cùng commit/rollback.
Nhưng nó không biết nghiệp vụ của bạn đúng hay sai nếu bạn không diễn đạt.
Ví dụ:
Database có thể commit:
order.status = DELIVERED
order.cancelled_at = now
Nếu schema cho phép, transaction vẫn thành công.
Nhưng nghiệp vụ có thể vô lý:
Đơn đã giao không thể bị cancel theo kiểu đơn mới.
Domain logic phải kiểm tra luật.
Database constraint có thể hỗ trợ một phần.
Transaction không thay thế việc hiểu nghiệp vụ.
---
30.27. Transaction và performance
Transaction có chi phí.
Không phải vì transaction xấu.
Mà vì trong transaction có thể có lock, version, log ghi bền.
Vấn đề thường đến từ:
- Transaction quá dài.
- Lock quá nhiều rows.
- Query trong transaction quá chậm.
- Gọi API ngoài trong transaction.
- User interaction nằm trong transaction.
Nguyên tắc:
- Mở transaction muộn nhất có thể.
- Làm việc cần thiết.
- Commit sớm nhất có thể.
- Không chờ external API trong transaction.
- Index query dùng trong transaction.
Transaction ngắn và đúng chỗ rất mạnh.
Transaction dài và ôm mọi thứ rất nguy hiểm.
---
30.28. Một mẫu transaction tốt
Ví dụ tạo grading result:
begin transaction
load GradingJob for update
if job.status != RUNNING:
rollback/ignore
save GradingResult
mark job = SUCCEEDED
insert outbox event GradingCompleted
commit
Sau commit:
dispatcher publish GradingCompleted
notification/analytics/learning xử lý sau
Điểm tốt:
- Dữ liệu core đúng cùng nhau.
- Event không mất nhờ outbox.
- Không gửi email trong transaction.
- Không gọi AI trong transaction.
- Lock chỉ quanh phần cập nhật trạng thái/kết quả.
Đây là hình dạng thực dụng.
---
30.29. Bảng chọn nhanh
| Tình huống | Cách nghĩ | |---|---| | Nhiều ghi phải đúng cùng nhau | Transaction | | Hai request có thể sửa cùng dữ liệu | Lock/version/atomic update | | Không được tạo trùng | Unique constraint | | Có thể xử lý lại cùng request | Idempotency key | | Cần thông báo sau commit | Outbox | | Nhiều service nhiều DB | Saga/workflow/eventual consistency | | Dữ liệu có thể trễ | Async/event/cache/read model | | Tiền/booking/quota | Ưu tiên tính đúng | | Analytics/view count | Có thể lỏng hơn |
---
30.30. Những lỗi phổ biến
Lỗi 1: Không dùng transaction
Ghi nhiều bảng, lỗi giữa chừng, dữ liệu lưng chừng.
Lỗi 2: Transaction quá rộng
Gọi API ngoài, gửi email, chạy xử lý lâu trong transaction.
Lỗi 3: Không có lock/constraint cho dữ liệu tranh chấp
Bán trùng ghế, rút tiền quá số dư, chạy job hai lần.
Lỗi 4: Check rồi update tách rời
Race condition giữa lúc check và lúc ghi.
Lỗi 5: Không xử lý deadlock
Database hủy transaction nhưng app không retry phù hợp.
Lỗi 6: Tin rằng transaction giải quyết mọi thứ
Không có domain logic, idempotency, constraint, outbox vẫn sai.
Lỗi 7: Tách microservice rồi muốn transaction như một database
Distributed transaction khó hơn rất nhiều.
---
30.31. Checklist thiết kế tính đúng
Khi thiết kế một luồng ghi dữ liệu, hãy hỏi:
- Những dữ liệu nào phải đúng cùng nhau?
- Có cần transaction không?
- Transaction gồm những bước nào?
- Có gọi API ngoài trong transaction không?
- Có request/job nào có thể chạy đồng thời không?
- Có race condition không?
- Cần lock hay optimistic version không?
- Có unique constraint nào cần thêm không?
- Có atomic update nào phù hợp không?
- Nếu transaction deadlock thì retry ra sao?
- Nếu message/event cần phát sau commit thì có outbox không?
- Nếu cùng request bị xử lý lại thì có idempotency không?
- Dữ liệu nào có thể eventual consistency?
- Nơi nào tính đúng quan trọng hơn tốc độ?
Nếu chưa trả lời được, luồng ghi đó có thể đang có bug ẩn.
---
30.32. Tóm tắt bằng AI Judge
Trong AI Judge:
Worker gọi Gemini xong.
Sau đó cần lưu kết quả.
Phần không nên nằm trong transaction:
Gọi Gemini 90 giây.
Phần nên nằm trong transaction:
Lưu GradingResult.
Cập nhật GradingJob = SUCCEEDED.
Ghi outbox event GradingCompleted.
Cần chống race:
Không để hai worker cùng complete một job.
Không để job cancelled vẫn ghi result.
Không để retry ghi kết quả khác sau khi succeeded.
Cách bảo vệ:
- State machine.
- Atomic update.
- Lock nếu cần.
- Unique constraint trên
grading_job_idnếu mỗi job chỉ có một result cuối. - Idempotency khi retry.
Đây là ví dụ tốt cho tư duy:
> Việc lâu chạy ngoài transaction. Việc ghi sự thật cuối cùng chạy trong transaction ngắn và chặt.
---
30.33. Kết luận của chương
Transaction là công cụ giữ tính đúng cho những thay đổi dữ liệu phải đi cùng nhau.
Nó giúp tránh trạng thái lưng chừng:
Một phần ghi thành công, một phần thất bại.
Nhưng transaction không đứng một mình.
Hệ thống đúng cần phối hợp:
- Transaction.
- Constraint.
- Lock.
- Atomic update.
- Optimistic/pessimistic locking.
- Idempotency.
- State machine.
- Outbox.
- Domain logic.
Thông điệp quan trọng nhất:
> Đừng dùng transaction để ôm mọi thứ. Hãy dùng transaction để bảo vệ đúng phần dữ liệu phải đúng cùng nhau, trong thời gian ngắn nhất có thể.
Ở chương tiếp theo, ta sẽ nói về cache: công cụ giúp hệ thống nhanh hơn bằng cách nhớ tạm, nhưng cũng là nơi rất dễ làm dữ liệu trở nên cũ, sai, hoặc khó debug.