Một giao dịch trong một database là lời hứa quen thuộc: hoặc tất cả, hoặc không gì. Phần trước vừa chia dữ liệu ra nhiều máy — và lời hứa đó biến mất ngay khi một thao tác chạm hai máy. Trừ tiền ví ở database A, tạo đơn hàng ở database B: nếu B hỏng sau khi A đã commit, tiền mất mà không có đơn. Bài này đo ba cách xử lý trên hai PostgreSQL thật: không làm gì, hai pha commit (2PC) bằng PREPARE TRANSACTION, và saga với bước bù trừ.
Ba cách, ba lời hứa

Hình 1: Hai commit rời, 2PC với hai pha rõ ràng, và saga với bước bù. Khác biệt không nằm ở số dòng mà ở chuyện gì xảy ra khi hỏng giữa chừng.
- Hai commit rời là thứ code "tự nhiên" viết ra: commit A, rồi commit B. Không có lời hứa nào.
- 2PC chia commit làm hai pha. Pha 1, mỗi bên nhận
PREPARE TRANSACTION 'gid': PostgreSQL ghi toàn bộ giao dịch xuống đĩa, giữ nguyên mọi khoá, và hứa rằngCOMMIT PREPARED 'gid'sau này chắc chắn thành công — kể cả sau khi server restart. Pha 2, điều phối viên gửiCOMMIT PREPAREDtới cả hai. Nếu bất kỳ bên nào không hứa được ở pha 1, điều phối viênROLLBACK PREPAREDbên còn lại. - Saga không có giao dịch xuyên máy. Mỗi bước là một giao dịch cục bộ commit ngay; hỏng ở bước sau thì chạy bước bù cho các bước trước — hoàn tiền là một giao dịch mới, không phải rollback.
Đo thật: giá, tính đúng, và cửa sổ lộ

Hình 2: Kết quả thật trên hai PostgreSQL riêng. Mục 2 là lý do cần một trong hai cách; mục 3 và 4 là giá của từng cách.
| hai commit rời | 2PC | saga | |
|---|---|---|---|
| p50 mỗi đơn | 0,30 ms | 0,45 ms | 0,20 ms |
| bước 2 hỏng 100 lần | mất 10.000, không ai biết | đúng | đúng |
| người ngoài thấy lệch (khi thành công) | — | 11,5% | 34,6% |
| điều phối chết giữa chừng | — | khoá giữ vô thời hạn | bước bù có thể không chạy |
Hai commit rời mất tiền im lặng. 100 lần bước tạo đơn hỏng là 100 lần ví bị trừ mà không có đơn: 10.000 biến mất, không lỗi nào ở phía ví, không log nào nối hai sự kiện. Đây là lỗi mà đội hỗ trợ khách hàng phát hiện trước đội kỹ thuật.
2PC đúng, với giá 1,5 lần và một điểm chết mới. 0,45 ms thay vì 0,30 ms: PREPARE là một lượt mạng và một fsync thêm ở mỗi bên (bản ghi prepare phải bền trước khi hứa), rồi COMMIT PREPARED thêm một lượt nữa — bốn round-trip thay vì hai. Trên đĩa ảo fsync rẻ nên chênh lệch chỉ 0,15 ms; trên SSD thật, mỗi fsync là 0,5–2 ms và 2PC đắt hơn hẳn. Khi bước tạo đơn hỏng, điều phối viên ROLLBACK PREPARED bên ví: 100/100 đúng.
Mục 4 là lý do 2PC có tiếng xấu. Điều phối viên chết sau khi cả hai bên đã PREPARE nhưng trước khi gửi COMMIT PREPARED. Hai giao dịch nằm trong pg_prepared_xacts, sống sót qua restart của PostgreSQL, và giữ khoá dòng ví vô thời hạn — giao dịch khác muốn chạm ví treo tới timeout. Hai bên đã hứa nên không bên nào dám tự quyết: commit thì có thể bên kia bị huỷ, huỷ thì có thể bên kia đã commit. Chỉ điều phối viên — hoặc người vận hành đọc log của nó — mới gỡ được. Khi điều phối viên quay lại, đọc log thấy don-999 đã prepare đủ hai bên, COMMIT PREPARED cả hai trong 2,95 ms và mọi thứ nhất quán. Nhưng trong khoảng giữa, hệ thống bị chặn chứ không bị sai: người đọc vẫn thấy trạng thái cũ (ví 1.000.000, 0 đơn).
Saga rẻ nhất và không khoá ai — nhưng lộ. 0,20 ms, hai round-trip, không prepare, không điểm chết chung. Khi hỏng, bước bù trả tiền về: 100/100 đúng về kết quả cuối. Nhưng mục 3 đo điều mà 2PC che được còn saga thì không: trong lúc 300 đơn thành công, một goroutine đọc ví và đơn liên tục thấy 34,6% lần hai bên không khớp — tiền đã trừ, đơn chưa có. Với saga, trạng thái dở là trạng thái đã commit, ai cũng đọc được, báo cáo cuối ngày cũng tính vào.
Và 2PC cũng lộ 11,5%. Đây là điểm ít tài liệu nói: COMMIT PREPARED ở ví và ở đơn hàng là hai lệnh, không đồng thời, nên có một khoảng ngắn ví đã commit mà đơn chưa. 2PC đảm bảo nguyên tử (cả hai cuối cùng đều commit hoặc đều huỷ), không đảm bảo cô lập xuyên hai database — người đọc không bị chặn bởi giao dịch prepared trừ khi đụng đúng dòng bị khoá. Cửa sổ ngắn hơn saga (2,95 ms so với cả một bước nghiệp vụ) nhưng không phải bằng 0.
Đánh đổi cần cân nhắc
2PC khi hai bên đều là database bạn kiểm soát và không ai được thấy trạng thái dở. Chuyển tiền giữa hai shard, ghi đồng thời vào database và hàng đợi hỗ trợ XA. Cái giá bắt buộc đi kèm: điều phối viên phải có log bền của riêng nó (gid nào đã prepare ở đâu) và một quy trình khôi phục; max_prepared_transactions phải bật (mặc định 0, tức PostgreSQL mặc định không cho 2PC); và giám sát pg_prepared_xacts để giao dịch treo không giữ khoá cả đêm.
Saga khi một bên không hỗ trợ prepare hoặc bước kéo dài. Gọi API thanh toán bên thứ ba, gửi email, đặt chỗ ở hệ thống khác — không có PREPARE TRANSACTION cho những thứ đó. Saga là lựa chọn duy nhất, và nó kéo theo hai nghĩa vụ: mọi bước bù phải idempotent và không được hỏng (hoàn tiền hai lần vì retry là lỗi mới), và nghiệp vụ phải chấp nhận trạng thái trung gian là hợp lệ — "đơn đang chờ xác nhận" thay vì "đơn không tồn tại".
Cửa sổ lệch của saga là thứ phải thiết kế, không phải che. 34,6% ở đây là vì hai bước nối tiếp gần như tức thì; thực tế bước hai có thể là một API chậm vài giây, và cửa sổ là vài giây. Cách làm là ghi trạng thái saga (dang_tru_tien, da_tao_don) vào một bảng riêng và để mọi màn hình đọc trạng thái đó — hoặc dùng outbox như sê-ri hàng đợi tin nhắn đã đo.
Có cách thứ tư: đừng tách. Nếu ví và đơn hàng ở cùng một PostgreSQL, một BEGIN ... COMMIT giải quyết tất cả ở 0,15 ms, không cửa sổ, không điều phối. Nhiều hệ thống tách database quá sớm rồi trả giá bằng 2PC hoặc saga cho những thứ lẽ ra là một giao dịch. Chia dữ liệu theo ranh giới mà giao dịch không cần vượt qua là quyết định kiến trúc rẻ nhất trong bài này.
Số đo loopback, đĩa ảo, không có lỗi mạng. 2PC dưới mất gói (phần 1) tệ hơn nhiều: PREPARE thành công nhưng phản hồi không về, điều phối viên không biết bên đó đã hứa hay chưa — và lại rơi vào mục 4. Khoảng trễ giữa hai COMMIT PREPARED qua mạng thật cũng dài hơn 2,95 ms, tức cửa sổ 11,5% nở ra.
Ba ý mang về
- Hai commit rời là mất dữ liệu im lặng: 100 lần bước hai hỏng là 10.000 mất không dấu vết. Chạm hai máy mà không có cơ chế gì là một lỗi đang chờ, không phải tối ưu hoá.
- 2PC mua "cả hai hoặc không gì" bằng 1,5× độ trễ và một điểm chết mới:
PREPARElà fsync và lời hứa; điều phối viên chết giữa hai pha là khoá bị giữ vô thời hạn, sống qua restart, chỉ log của điều phối viên gỡ được. Và nó vẫn lộ 11,5% cho người đọc ngoài — nguyên tử, không cô lập. - Saga rẻ nhất (0,20 ms), không khoá ai, nhưng trạng thái dở là trạng thái đã commit — 34,6% thời gian người ngoài thấy ví và đơn không khớp. Dùng khi một bên không prepare được, thiết kế trạng thái trung gian thành hợp lệ, và bước bù phải idempotent.
Nguồn
- PostgreSQL docs — PREPARE TRANSACTION, COMMIT PREPARED, pg_prepared_xacts: https://www.postgresql.org/docs/16/sql-prepare-transaction.html
- Hector Garcia-Molina, Kenneth Salem — Sagas (SIGMOD 1987): https://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf
- Martin Kleppmann — Designing Data-Intensive Applications, chương 9, mục Distributed Transactions and Consensus (2PC, "in doubt")
- Chris Richardson — Pattern: Saga (microservices.io): https://microservices.io/patterns/data/saga.html
- Jim Gray, Leslie Lamport — Consensus on Transaction Commit (2PC so với Paxos commit): https://www.microsoft.com/en-us/research/publication/consensus-on-transaction-commit/
Phần sau đi về phía đối diện của 2PC: thay vì bắt hai máy đồng ý trước khi ghi, để mỗi máy ghi tự do rồi hợp nhất sau — CRDT, đo thật hai bản sao tách mạng rồi gặp lại không mất cập nhật nào.