Two-phase commit (2PC) — và vì sao nó blocking, đắt
2PC để atomic commit xuyên nhiều node: coordinator + prepare/commit hai pha. Vì sao coordinator chết giữa chừng làm participant treo (blocking), và giá phải trả.
TL;DR: Two-phase commit (2PC) là nghi thức để nhiều node cùng commit hoặc cùng abort một giao dịch — giải bài toán atomicity xuyên node (bài 01). Một coordinator hỏi mọi participant qua hai pha: prepare (participant ghi bền, hứa yes/no) rồi commit/abort (chỉ commit nếu mọi participant đều yes). Điểm mấu chốt: sau khi nói "yes", participant mất quyền tự quyết — phải chờ coordinator, giữ khoá. Nếu coordinator chết sau pha 1, participant kẹt "đã hứa, chưa biết kết quả" — treo (blocking), giữ khoá tới khi coordinator sống lại. Cộng với chi phí nhiều round-trip + giữ khoá lâu, đó là lý do 2PC đắt và mong manh.
Bài 01 đặt bài toán "làm sao nhiều node cùng quyết commit". 2PC là câu trả lời kinh điển. Hiểu nó — và đặc biệt là nó hỏng ở đâu — là chìa khoá để hiểu vì sao bài sau khuyên tránh distributed transaction.
1. Analogy — chủ hôn hỏi "hai bên đồng ý chứ?"
Trong lễ cưới, chủ hôn (coordinator) hỏi lần lượt: "Cô dâu đồng ý chứ?" — "Vâng." "Chú rể đồng ý chứ?" — "Vâng." Chỉ sau khi cả hai nói "vâng", chủ hôn mới tuyên bố "hai người đã thành vợ chồng" (commit). Nếu một người nói "không", lễ dừng (abort). Đây đúng là hai pha: hỏi ý (prepare) rồi tuyên bố (commit).
Nhưng tưởng tượng: cả hai đã nói "vâng", và đúng lúc đó chủ hôn ngất xỉu trước khi kịp tuyên bố. Cô dâu chú rể đứng đó — đã hứa, không rút lời được, nhưng cũng chưa được tuyên bố là vợ chồng. Họ kẹt, không biết nên chờ hay giải tán, cho tới khi chủ hôn tỉnh lại. Đó chính là blocking của 2PC: người đã hứa mất quyền tự quyết, và cái chết của coordinator làm họ treo.
| Lễ cưới | 2PC |
|---|---|
| Chủ hôn | Coordinator |
| Cô dâu, chú rể | Participant |
| "Đồng ý chứ?" | Pha 1: prepare |
| "Tuyên bố vợ chồng" | Pha 2: commit |
| Một người nói "không" → dừng | Một "no" → abort |
| Đã "vâng" thì không rút lời | Đã "yes" thì mất quyền tự quyết |
| Chủ hôn ngất sau khi cả hai "vâng" | Coordinator chết sau pha 1 → treo |
2PC = hỏi hết rồi mới chốt. Sức mạnh: all-or-nothing. Tử huyệt: ai đã hứa thì kẹt nếu người chốt (coordinator) chết — treo giữ khoá.
2. Cơ chế — hai pha, từng bước
flowchart TB C["Coordinator"] -->|"1. prepare?"| P1["Participant 1"] C -->|"1. prepare?"| P2["Participant 2"] P1 -->|"ghi ben + yes"| C P2 -->|"ghi ben + yes"| C C -->|"2. commit (vi ALL yes)"| P1 C -->|"2. commit"| P2
Đọc kỹ ba bước dưới đây rồi đoán: khoảnh khắc chính xác nào khiến giao dịch chắc chắn sẽ commit — dù bất kỳ node nào (kể cả coordinator) crash ngay sau đó? Là lúc participant nói "yes", hay một thời điểm khác?
- Pha 1 — prepare. Coordinator gửi "prepare" tới mọi participant. Mỗi participant: thực hiện mọi thay đổi và ghi bền (fsync) chúng vào log cục bộ ở trạng thái "prepared", rồi trả lời yes (nghĩa: "tôi chắc chắn commit được, kể cả nếu tôi crash rồi sống lại") hoặc no (không làm được → phải abort).
- Điểm không quay đầu. Sau khi coordinator nhận đủ câu trả lời, nó ghi bền quyết định (commit nếu tất cả yes, abort nếu có no). Đây là khoảnh khắc quyết định — một khi coordinator đã ghi "commit", giao dịch sẽ commit dù có crash.
- Pha 2 — commit/abort. Coordinator gửi quyết định tới mọi participant; participant thực thi (chuyển "prepared" thành "committed" hoặc rollback) và báo lại. Xong.
Đáp án câu đoán nằm ở bước 2: giao dịch trở nên chắc chắn sẽ commit đúng khoảnh khắc coordinator ghi bền quyết định "commit" — không phải lúc participant nói "yes". Trước dấu đó, nếu coordinator crash thì khi tỉnh dậy nó vẫn có thể abort. Sau dấu đó, dù coordinator crash, lúc sống lại nó đọc log thấy "commit" và gửi lại commit cho mọi participant — giao dịch hoàn tất, không đường lùi. Participant nói "yes" chỉ là lời hứa sẽ theo được kết quả, chưa phải quyết định; quyết định là của riêng coordinator, chốt bằng đúng một lần ghi bền. Chính vì thế participant sau khi "yes" phải chờ — nó không nắm quyết định (mục sau đào sâu điều này).
Điểm cốt lõi phân biệt 2PC với "gọi hai update": lời hứa ở pha 1. Khi participant nói "yes", nó đã ghi bền và cam kết — nó không được tự abort nữa, phải chờ coordinator. Chính lời hứa này cho all-or-nothing, nhưng cũng chính nó tạo ra blocking.
3. Vì sao coordinator chết làm participant treo?
Một participant đã nói "yes" ở pha 1 (đã ghi bền, giữ khoá). Rồi coordinator chết trước khi gửi quyết định pha 2. Bạn đoán: vì sao participant này không thể tự suy luận ra được nên commit hay abort, dù nó biết rõ phần việc của chính mình đã xong? Cụ thể, nó đang thiếu thông tin gì?
Sau khi nói "yes", participant ở trạng thái in-doubt (nghi ngờ): nó đã hứa nên không được tự abort (biết đâu các participant khác cũng đã yes và coordinator đã quyết commit — tự abort sẽ phá all-or-nothing); nhưng nó cũng không được tự commit (biết đâu một participant khác đã no và quyết định là abort). Nó chỉ có thể chờ coordinator.
Trong lúc chờ, nó phải giữ khoá trên dữ liệu của giao dịch (để bảo toàn khả năng commit/abort). Nên nếu coordinator chết và không hồi phục nhanh, participant treo vô hạn, giữ khoá — chặn mọi giao dịch khác đụng dữ liệu đó. Một coordinator chết có thể làm đơ một phần hệ thống. Đây là lý do 2PC gọi là blocking protocol, và là điểm yếu mà các giao thức đồng thuận (Module 6) cố khắc phục.
Ở Java concurrency, bạn thấy một luồng giữ khoá (synchronized) chặn luồng khác đụng cùng dữ liệu, và thứ tự happens-before đảm bảo ai thấy gì. 2PC là cùng ý ở tầng phân tán: participant "giữ khoá" chờ coordinator, và commit chỉ có hiệu lực sau khi coordinator quyết — nhưng khác biệt chí mạng là ở đây "luồng giữ khoá" (coordinator) có thể chết, để lại khoá treo mà không ai gỡ.
4. Vì sao 2PC đắt (kể cả khi không ai chết)?
Ngay cả đường "hạnh phúc" (không lỗi), 2PC vẫn tốn:
- Nhiều round-trip: ít nhất hai vòng gửi-nhận (prepare + commit) tới mọi participant → độ trễ commit tăng theo participant chậm nhất (nhớ tail latency).
- fsync ở mỗi node: mỗi participant fsync ít nhất hai lần — ghi bền trạng thái "prepared" ở pha 1, rồi ghi bền "committed" ở pha 2 — cộng thêm coordinator fsync quyết định của nó → nhiều lần ghi đĩa đồng bộ trên toàn giao dịch.
- Giữ khoá lâu: khoá được giữ suốt cả hai pha (từ prepare tới khi nhận commit), dài hơn hẳn giao dịch một node → giảm đồng thời, tăng tranh chấp.
Cộng lại: 2PC làm mỗi giao dịch xuyên node chậm hơn nhiều lần một giao dịch nội bộ, và nhân lên khi nhiều participant. Đây là lý do — cùng với blocking — mà 2PC bị coi là giải pháp cuối, không phải mặc định.
5. Pitfall — dùng 2PC như cách atomic mặc định cho mọi thao tác xuyên service
Sai lầm: mỗi khi một thao tác đụng nhiều service/DB, quấn 2PC (distributed transaction) quanh nó cho "chắc chắn atomic".
❌ Moi thao tac xuyen service -> 2PC
-> moi giao dich cham (nhieu round-trip + fsync + giu khoa lau)
-> coordinator chet -> nhieu participant treo giu khoa -> do mot phan he
-> he scale kem, mong manh
✅ Hoi: thao tac nay co THAT SU can atomic xuyen node khong?
-> phan lon: dung saga (buoc bu tru) + idempotency -> re hon nhieu (bai 04)
Hệ quả: hệ thống chậm, mong manh (một coordinator chết kéo theo treo dây chuyền), và khó mở rộng — đúng những thứ ta muốn tránh khi phân tán.
Hướng đúng: coi 2PC là công cụ đắt, dùng khi thật cần atomic tuyệt đối xuyên node và không có cách bù trừ. Với phần lớn quy trình nghiệp vụ (đặt hàng, chuyển tiền qua nhiều dịch vụ), saga + idempotency (bài 04) cho kết quả đủ đúng mà không gánh blocking/chi phí của 2PC. Mặc định nên là "tránh 2PC", chỉ dùng khi phân tích cho thấy buộc phải.
6. 📚 Deep Dive
- Designing Data-Intensive Applications (Kleppmann) — Chương 9, mục "Atomic Commit and Two-Phase Commit" — cơ chế 2PC, trạng thái in-doubt, vì sao coordinator là single point làm 2PC blocking, và XA transactions thực tế.
- Phân biệt 2PC (atomic commit — Module này) với 2PL (two-phase locking — isolation, bài 02): hai "two-phase" khác nhau hoàn toàn, chỉ trùng tên.
- Three-phase commit (3PC) từng được đề xuất để bớt blocking nhưng giả định mạng đồng bộ (không thực tế); lời giải thật cho blocking là consensus (Raft/Paxos, Module 6).
Ghi chú: DDIA nhấn: 2PC đảm bảo atomicity nhưng đánh đổi availability (blocking khi coordinator chết). Consensus (Module 6) cho atomic commit không blocking bằng cách bỏ single coordinator.
7. Liên hệ các bài khác
- Bài 01 — Ôn ACID: 2PC là câu trả lời cho "làm sao nhiều node cùng commit" mà bài 01 đặt ra.
- Bài 02 — Anomaly & serializability: 2PC (atomic commit) khác 2PL (isolation) — hai "two-phase" dễ nhầm.
- Bài 04 — Tránh distributed txn: vì 2PC đắt + blocking, bài sau chỉ cách tránh bằng saga/idempotency.
- Module 6 — Consensus: consensus khắc phục blocking của 2PC bằng cách bỏ single coordinator — atomic commit không treo khi một node chết.
- Module 5 — Mạng không tin cậy: blocking của 2PC bắt nguồn từ "không phân biệt coordinator chết với chậm".
8. Tóm tắt
- 2PC: coordinator + participant; pha 1 prepare (participant ghi bền + hứa yes/no), pha 2 commit/abort (chỉ commit nếu tất cả yes). Đạt all-or-nothing xuyên node.
- Sau khi nói "yes", participant vào trạng thái in-doubt: mất quyền tự quyết, giữ khoá, chỉ được chờ coordinator.
- Coordinator chết sau pha 1 → participant treo (blocking), giữ khoá vô hạn → đơ một phần hệ. Đây là điểm yếu chí mạng.
- Đắt kể cả không lỗi: nhiều round-trip + fsync mỗi node + giữ khoá lâu → chậm, giảm đồng thời.
- 2PC (atomic commit) ≠ 2PL (locking cho isolation) — trùng tên, khác việc.
- Anti-pattern: rải 2PC cho mọi thao tác xuyên service → chậm + mong manh. Mặc định nên tránh, chỉ dùng khi buộc phải.
9. Tự kiểm tra
Q1Mô tả hai pha của 2PC. Điều gì participant làm ở pha 1 khiến nó khác với 'chỉ gọi update rồi commit'?▸
Pha 1 (prepare): coordinator hỏi mọi participant "commit được không"; mỗi participant thực hiện thay đổi, ghi bền (fsync) chúng ở trạng thái "prepared", rồi trả yes (hứa chắc chắn commit được) hoặc no. Pha 2 (commit/abort): nếu tất cả yes, coordinator gửi commit; nếu có no, gửi abort; participant thực thi.
Khác biệt cốt lõi ở pha 1: participant ghi bền và hứa — sau khi nói "yes" nó không được tự abort, phải commit được kể cả nếu crash rồi sống lại. Chính lời hứa ràng buộc này tạo ra all-or-nothing thật (mọi node cùng có khả năng commit trước khi bất kỳ node nào commit), thứ mà "gọi update rồi commit" độc lập không có.
Q2Trạng thái 'in-doubt' của participant là gì? Vì sao nó không được tự commit cũng không được tự abort khi coordinator im lặng?▸
In-doubt: participant đã nói "yes" (đã ghi bền, giữ khoá) nhưng chưa nhận quyết định pha 2 từ coordinator.
Không được tự commit vì biết đâu một participant khác đã trả "no" → quyết định thật là abort → tự commit sẽ phá all-or-nothing. Không được tự abort vì biết đâu mọi participant đã yes và coordinator đã quyết commit → tự abort cũng phá all-or-nothing. Nó không có đủ thông tin để biết kết quả toàn cục, nên lựa chọn an toàn duy nhất là chờ coordinator — giữ khoá trong lúc chờ.
Q3Vì sao coordinator chết sau pha 1 có thể làm 'đơ một phần hệ thống', không chỉ một giao dịch?▸
Vì participant in-doubt phải giữ khoá trên dữ liệu của giao dịch (để bảo toàn khả năng commit/abort) trong khi chờ. Nếu coordinator chết và không hồi phục nhanh, participant treo vô hạn, khoá không được nhả.
Mọi giao dịch khác muốn đụng dữ liệu đang bị khoá đó cũng phải chờ → chúng cũng đơ. Một coordinator chết vì thế lan ra chặn nhiều giao dịch trên nhiều participant — "đơ một phần hệ" chứ không chỉ giao dịch gốc. Đây là bản chất blocking của 2PC, và là lý do người ta muốn atomic commit không phụ thuộc một coordinator đơn (→ consensus, Module 6).
Q4Kể cả khi không node nào chết, vì sao 2PC vẫn đắt hơn nhiều một giao dịch trên một node?▸
Ba chi phí cộng lại: (1) nhiều round-trip — ít nhất hai vòng (prepare + commit) tới mọi participant, nên độ trễ commit bị kéo bởi participant chậm nhất (tail latency). (2) fsync ở mỗi node — mỗi participant fsync ít nhất hai lần (ghi bền "prepared" ở pha 1, rồi "committed" ở pha 2), cộng coordinator fsync quyết định của nó → nhiều lần ghi đĩa đồng bộ. (3) giữ khoá lâu — khoá được giữ suốt cả hai pha, dài hơn hẳn giao dịch nội bộ → giảm đồng thời, tăng tranh chấp.
Nhân các chi phí này với số participant, mỗi giao dịch xuyên node chậm hơn nhiều lần giao dịch một node — nên 2PC là giải pháp cuối, không phải mặc định.
Q5Vì sao nói '2PC và 2PL là hai two-phase khác nhau'? Mỗi cái giải quyết đảm bảo nào của transaction?▸
2PC (two-phase commit) giải quyết atomicity xuyên node: hai pha ở đây là prepare và commit — cơ chế để nhiều node cùng commit/abort một giao dịch.
2PL (two-phase locking) giải quyết isolation (bài 02): hai pha ở đây là giữ khoá tăng dần (growing phase) rồi nhả khoá (shrinking phase) — cơ chế đạt serializable bằng khoá.
Chúng chỉ trùng chữ "two-phase" nhưng khác hoàn toàn: một về commit xuyên node, một về khoá cho đồng thời. Nhầm lẫn hai cái là lỗi phổ biến; nhớ: 2PCommit = atomicity, 2PLock = isolation.
Bài tiếp theo: Khi nào tránh distributed transaction — saga & idempotency
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên