Giao diện
Commit phân tán
Một việc phải ghi vào nhiều nơi cùng lúc: hai database, hai shard, hay database của hai service. Trang này trả lời câu hỏi làm sao để mọi nơi cùng ghi hoặc cùng không ghi. Nó gồm two-phase commit (2PC), cách MySQL và PostgreSQL cài 2PC, vì sao microservice thường tránh 2PC, và các cách thay thế là saga và outbox. Số liệu trong trang là số đo thật trên MySQL 8.0 và PostgreSQL 17 bằng script trong lab/commit-phan-tan/.
Bài toán: ghi nguyên tử ở nhiều nơi
Trong một database, transaction lo hết: hoặc mọi lệnh ghi cùng có hiệu lực, hoặc không lệnh nào có (Transaction & isolation). Khi dữ liệu nằm ở hai nơi thì không còn transaction chung. Ví dụ chuyển 100.000 đ từ tài khoản ở shard 1 sang tài khoản ở shard 2:
js
await shard1.query('UPDATE accounts SET balance = balance - 100000 WHERE id = 1') // commit ở shard 1
await shard2.query('UPDATE accounts SET balance = balance + 100000 WHERE id = 2') // process chết trước dòng nàyProcess chết giữa hai dòng thì tiền bị trừ ở shard 1 mà không được cộng ở shard 2. Đổi thứ tự thì có lúc tiền được cộng mà không bị trừ. Bài toán atomic commit là: mọi nơi tham gia (participant) phải đi tới cùng một quyết định, hoặc tất cả commit, hoặc tất cả abort, kể cả khi có máy chết giữa chừng.
Two-phase commit
2PC thêm một vai trò mới là coordinator (thường là thư viện trong app hay một transaction manager), và chia commit thành hai pha:
- Pha 1, prepare. Coordinator hỏi từng participant: “commit được không?”. Participant nào trả lời YES thì trước đó phải ghi mọi thay đổi xuống disk, và giữ nguyên lock trên các dòng đã sửa. YES là một lời hứa: từ lúc này, nếu được yêu cầu thì nó phải commit được, kể cả khi vừa crash và khởi động lại.
- Pha 2, commit. Nếu mọi participant trả lời YES, coordinator quyết định commit; chỉ cần một NO (hay không trả lời) là abort. Coordinator ghi quyết định vào log của mình trước, rồi mới gửi cho từng participant, và gửi lại cho tới khi tất cả nhận được.
Có hai điểm không quay lại được. Participant đã vote YES thì không được tự abort nữa. Coordinator đã ghi quyết định thì không được đổi nữa. Chính hai lời hứa này làm mọi bên đi tới cùng một kết quả.
Chạy từng bước các trường hợp, kể cả khi coordinator hay participant chết giữa chừng:
Kịch bản
Bước 1/6
Client bắt đầu transaction toàn cục. A trừ tiền, B cộng tiền; cả hai đang giữ lock trên dòng mình sửa.
Coordinator
Log: trống
Đã gửi: chưa
Participant A
đang làm việcgiữ lock
Vote: chưa
Participant B
đang làm việcgiữ lock
Vote: chưa
Client kháckhông bị chặn
Kịch bản coordinator chết và participant giữ lock trong lúc chờ đã đo thật trên MySQL và PostgreSQL ở bảng bên dưới.
Vì sao 2PC là giao thức blocking
Kịch bản “Coordinator chết trước khi quyết định” cho thấy điểm yếu lớn nhất của 2PC. Participant đã vote YES rồi mà không nhận được quyết định thì nó không biết phải làm gì:
- Nó không được tự abort, vì có thể coordinator đã quyết định commit và đã gửi cho participant khác.
- Nó cũng không được tự commit, vì có thể participant khác đã vote NO.
Vì vậy nó phải giữ nguyên lock và chờ coordinator sống lại. Transaction ở trạng thái này gọi là in doubt. Trong lúc đó mọi transaction khác muốn sửa các dòng bị lock đều phải chờ. Lúc coordinator khởi động lại, log nói lên quyết định: có “commit” thì gửi lại commit, không có quyết định nào thì abort. Cách “không thấy quyết định thì coi như abort” gọi là presumed abort.
Three-phase commit (3PC) được đề xuất để không bị blocking, nhưng nó chỉ đúng khi có giới hạn trên cho độ trễ mạng và thời gian process bị treo, điều mà mạng thật không đảm bảo, nên hầu như không được dùng. Cách thực tế để coordinator không làm treo hệ thống là chạy coordinator trên consensus: quyết định được ghi vào một nhóm node nhân bản, nên một node chết thì node khác đọc được quyết định và gửi tiếp. Spanner làm 2PC theo kiểu này, mỗi participant và coordinator đều là một nhóm Paxos.
2PC trên MySQL và PostgreSQL
Cả hai database đều cho làm participant của 2PC, để một transaction manager bên ngoài điều phối:
sql
-- MySQL: XA transaction
XA START 'tx-1';
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
XA END 'tx-1';
XA PREPARE 'tx-1'; -- pha 1: đã ghi xuống disk, vẫn giữ lock
XA COMMIT 'tx-1'; -- pha 2, có thể gửi từ connection khác
XA RECOVER; -- liệt kê các transaction đang ở trạng thái PREPARED
-- PostgreSQL: phải bật max_prepared_transactions (mặc định 0) rồi restart
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'tx-1';
COMMIT PREPARED 'tx-1'; -- hoặc ROLLBACK PREPARED, từ session bất kỳ
SELECT gid FROM pg_prepared_xacts;Đo thật kịch bản coordinator chết sau khi participant đã prepare, giả lập bằng cách đóng connection mà không gửi COMMIT hay ROLLBACK (hai lượt ngày 2026-10-21, kết quả giống nhau):
| Bước | MySQL 8.0 | PostgreSQL 17 |
|---|---|---|
| Sau khi connection của coordinator đóng | XA RECOVER vẫn liệt kê tx-1 | pg_prepared_xacts vẫn có tx-1 |
| Client khác đọc thường dòng bị sửa | Thấy giá trị cũ (1000) | Thấy giá trị cũ (1000) |
Client khác UPDATE cùng dòng, lock timeout 3 giây | Chờ 3.006–3.010 ms rồi lỗi ER_LOCK_WAIT_TIMEOUT | Chờ 3.003–3.005 ms rồi lỗi 55P03 |
| Restart database | tx-1 vẫn còn | tx-1 vẫn còn |
Gửi COMMIT từ connection mới | Balance thành 900 | Balance thành 900 |
Transaction in doubt sống sót qua cả việc đóng connection lẫn restart database, đúng như lời hứa của YES, và giữ lock cho tới khi có người quyết định. Trong lab, “người quyết định” là lệnh gõ bằng tay. Ngoài đời đó phải là coordinator khởi động lại, hoặc người vận hành.
Chi phí cũng đo được, mỗi transaction một lệnh UPDATE, 300 lần mỗi kiểu trên MySQL (hai lượt):
| Cách commit | p50 |
|---|---|
BEGIN … COMMIT | 0,83–0,87 ms |
XA … XA PREPARE … XA COMMIT | 1,28–1,36 ms |
XA … XA COMMIT … ONE PHASE | 0,99–1,04 ms |
Hai pha tốn thêm khoảng 0,5 ms ngay trên cùng một máy, vì prepare và commit là hai lần ghi log riêng. Qua mạng thật thì thêm ít nhất hai round trip giữa coordinator và mỗi participant, và lock bị giữ suốt khoảng đó.
Tài liệu của hai database đều cảnh báo:
- PostgreSQL nói
PREPARE TRANSACTIONkhông dành cho ứng dụng hay phiên làm việc tay, mà cho transaction manager bên ngoài; không viết transaction manager thì nên đểmax_prepared_transactions = 0. Để transaction prepared lâu thì nó vẫn giữ lock, cảnVACUUMdọn dữ liệu, và trường hợp xấu có thể làm database phải dừng để tránh transaction ID wraparound. - MySQL: XA chỉ dùng được với InnoDB. Mỗi XA transaction ghi vào binlog thành hai phần với hai GTID (lúc prepare và lúc commit), có thể nằm ở hai file binlog khác nhau. XA không được hỗ trợ khi dùng replication filter, và không an toàn với statement-based replication. Trước 8.0.30, server dừng đột ngột đúng lúc
XA PREPAREhayXA COMMITcó thể để server và binlog không khớp nhau. Từ 8.0.29, transaction bị tách khỏi session ngay sauXA PREPARE(xa_detach_on_preparebật mặc định), nên connection khác commit được.
Một coordinator viết bằng Node.js
Lab có một coordinator nhỏ chuyển tiền từ MySQL sang PostgreSQL. Phần lõi:
js
// Rút gọn từ lab/commit-phan-tan/xa.mjs. decisions là log của coordinator;
// ngoài đời phải ghi xuống disk (fsync), tốt nhất là vào một nơi có replication.
async function transfer(xid, amount) {
// Pha 1: prepare ở cả hai participant
await my.query(`XA START '${xid}'`)
await my.query('UPDATE accounts SET balance = balance - ? WHERE id = 2', [amount])
await my.query(`XA END '${xid}'`)
await my.query(`XA PREPARE '${xid}'`)
await pg.query('BEGIN')
await pg.query('UPDATE accounts SET balance = balance + $1 WHERE id = 2', [amount])
await pg.query(`PREPARE TRANSACTION '${xid}'`)
await decisions.save(xid, 'commit') // điểm commit
// Pha 2
await my.query(`XA COMMIT '${xid}'`)
await pg.query(`COMMIT PREPARED '${xid}'`)
}
// Coordinator khởi động lại: có quyết định commit thì commit, không có thì abort
async function recover() {
for (const r of (await my.query('XA RECOVER'))[0])
await my.query(`XA ${(await decisions.get(r.data)) === 'commit' ? 'COMMIT' : 'ROLLBACK'} '${r.data}'`)
for (const r of (await pg.query('SELECT gid FROM pg_prepared_xacts')).rows)
await pg.query(`${(await decisions.get(r.gid)) === 'commit' ? 'COMMIT' : 'ROLLBACK'} PREPARED '${r.gid}'`)
}Đo thật, hai lượt: 20 lần chuyển thành công có p50 4,85–4,89 ms (mỗi lần mở hai connection mới). Cho coordinator crash trước khi ghi quyết định thì lúc phục hồi cả hai bên rollback; crash sau khi ghi quyết định thì cả hai bên commit. Tổng tiền hai bên luôn là 2.000 ở mọi bước.
Vì sao ít dùng 2PC
- Blocking. Coordinator chết là các participant giữ lock cho tới khi nó sống lại, như vừa đo. Một service chậm hay chết kéo theo mọi service khác cùng tham gia.
- Chậm. Thêm ít nhất hai round trip và thêm lần ghi log ở mỗi participant, và lock bị giữ suốt thời gian đó, nên throughput giảm.
- Coordinator là một điểm phải làm cho bền. Log quyết định mất thì không ai biết transaction in doubt phải commit hay abort. Làm coordinator chịu lỗi thì phải chạy nó trên consensus, tức thêm một hệ thống phức tạp nữa.
- Mọi participant phải hỗ trợ prepare. Database quan hệ thì có XA, nhưng API HTTP của service khác, cổng thanh toán, hay email thì không có lệnh “prepare”. Microservices.io ghi thẳng trong phần các ràng buộc của pattern saga: 2PC không phải một lựa chọn.
- Vận hành. Transaction in doubt phải được theo dõi và xử lý, không thì nó giữ lock mãi; PostgreSQL còn bị cản
VACUUM.
2PC bên trong một hệ thống
2PC vẫn rất phổ biến khi tất cả participant nằm trong cùng một hệ thống do một đội viết, vì khi đó mọi bên đều hỗ trợ prepare và coordinator được làm cho bền ngay từ đầu:
- MySQL dùng 2PC nội bộ giữa redo log của InnoDB và binlog: transaction đã prepare trong redo mà chưa có trong binlog thì lúc khôi phục bị rollback, đã có thì commit (MySQL chương 8).
- Spanner và các database phân tán theo kiểu đó dùng 2PC giữa các shard, với coordinator chạy trên Paxos.
- Kafka có transaction để ghi nguyên tử vào nhiều partition, với một transaction coordinator riêng trong cluster.
Không dùng 2PC thì làm gì
1. Tránh phải commit ở nhiều nơi
Cách tốt nhất là thiết kế để dữ liệu cần ghi nguyên tử nằm cùng một chỗ. Ví dụ chọn shard key sao cho tài khoản và các giao dịch của nó cùng shard, hoặc để đơn hàng và các dòng hàng nằm cùng database của Order service (Partitioning, MySQL chương 10). Một transaction cục bộ luôn đơn giản và nhanh hơn mọi cơ chế phân tán.
2. Outbox: ghi database và gửi event cùng một lúc
Bài toán hay gặp nhất trong microservice là ghi vào database của mình và báo cho service khác. Ghi event vào bảng outbox trong cùng transaction với dữ liệu, rồi một tiến trình riêng đọc bảng đó và gửi ra message broker. Kết quả là dữ liệu và event hoặc cùng có, hoặc cùng không. Phía nhận phải idempotent, vì event có thể đến nhiều lần (Hàng đợi bằng database, Xử lý lỗi & idempotency). Outbox không làm hai service commit cùng lúc; nó đảm bảo service thứ hai chắc chắn sẽ nhận được việc, chỉ là muộn hơn một chút.
3. Saga
Saga, theo cách microservices.io mô tả, là một chuỗi transaction cục bộ được điều phối, mỗi transaction chạy trong database của một service. Nếu một bước vi phạm quy tắc nghiệp vụ, saga chạy compensating transaction (compensation) cho các bước đã xong, theo thứ tự ngược lại. Ví dụ đặt hàng:
| Bước | Service | Việc | Compensation |
|---|---|---|---|
| T1 | Order | Tạo đơn ở trạng thái PENDING | Chuyển đơn sang CANCELLED |
| T2 | Kho | Giữ hàng cho đơn | Trả hàng về kho |
| T3 | Thanh toán | Trừ tiền | Hoàn tiền |
| T4 | Order | Chuyển đơn sang CONFIRMED | Không cần |
Có hai cách điều phối:
- Orchestration: một orchestrator (thường là một state machine lưu trong database của Order service) ra lệnh cho từng service và quyết định khi nào compensate. Dễ theo dõi luồng, dễ test.
- Choreography: không có ai điều phối; mỗi service nghe event của service khác rồi tự làm bước của mình. Ít phụ thuộc trung tâm, nhưng luồng nằm rải rác ở nhiều service nên khó theo dõi khi saga dài.
Chọn bước lỗi rồi chạy từng bước:
Bước lỗi
Bước 1/6
Orchestrator yêu cầu service Order: Tạo đơn #42 ở trạng thái PENDING. Transaction cục bộ của Order commit.
Order serviceđơn #42: PENDING
Khocòn bán: 1 · đang giữ cho #42: 0
Thanh toánđã trừ: 0 đ
Orchestration: một orchestrator (thường là một state machine lưu trong database của Order service) ra lệnh cho từng service và quyết định khi nào compensate.
Compensation không phải là rollback. Bước đã commit thì người khác có thể đã thấy và đã dùng kết quả của nó. Một email “đặt hàng thành công” đã gửi thì không thu hồi được, compensation chỉ có thể là gửi thêm email xin lỗi. Vì vậy có ba quy tắc khi thiết kế:
- Compensation phải idempotent và luôn làm được, vì nó có thể chạy nhiều lần và không được phép thất bại vĩnh viễn; lỗi tạm thời thì retry cho tới khi được.
- Có một bước pivot. Bước nào khó hoặc không thể compensate (trừ tiền thật, gửi hàng) thì đặt càng muộn càng tốt. Sau pivot, các bước còn lại chỉ đi tiếp và retry cho tới khi xong, không compensate nữa. Trong ví dụ, T3 là pivot: lỗi ở T4 thì retry T4, không hoàn tiền.
- Đặt bước dễ lỗi lên trước. Kiểm tra tồn kho, giới hạn tín dụng, trạng thái tài khoản trước khi làm những bước tốn kém.
Saga không có isolation
Microservices.io gọi đây là nhược điểm chính của saga: mỗi bước commit xong là thay đổi hiện ra ngay với mọi người, nên các saga chạy đồng thời có thể thấy dữ liệu trung gian của nhau. Trong mô phỏng, khách khác thấy “hết hàng” trong lúc đơn #42 đang giữ sản phẩm, dù sau đó đơn có thể bị huỷ. Các cách giảm hậu quả, microservices.io gọi là countermeasure:
- Semantic lock: đánh dấu dữ liệu đang dở bằng trạng thái (
PENDING, “đang giữ cho đơn #42”). Các thao tác khác thấy trạng thái này thì chờ, từ chối, hoặc hiển thị đúng là đang xử lý. - Cập nhật giao hoán: dùng
stock = stock - 1vàstock = stock + 1thay vì ghi đè giá trị, để compensation không đè lên thay đổi của saga khác. - Đọc lại trước khi ghi: bước sau kiểm tra dữ liệu chưa bị đổi kể từ bước trước, bằng version hay ghi có điều kiện (Chống trùng).
4. Giữ chỗ có hạn (TCC)
Một biến thể hay gặp là Try-Confirm-Cancel: bước “try” chỉ giữ chỗ (giữ hàng, giữ ghế, tạm khoá số tiền) với một thời hạn, “confirm” biến chỗ giữ thành thật, “cancel” hoặc hết hạn thì trả lại. Cách này gần với 2PC hơn saga, vì chưa có gì là thật cho tới khi confirm. Nhưng nó không giữ lock của database, mà giữ chỗ ở mức nghiệp vụ và có hạn. Bài toán Đặt vé không trùng (đang viết) xoay quanh đúng ý giữ chỗ có hạn này.
So sánh
| 2PC | Saga | Outbox | |
|---|---|---|---|
| Kết quả | Atomic: mọi nơi cùng commit hoặc cùng abort | Cuối cùng nhất quán: xong hết, hoặc compensate hết | Event chắc chắn được gửi, phía nhận làm sau |
| Isolation | Có (giữ lock tới khi xong) | Không, dữ liệu trung gian lộ ra | Không áp dụng |
| Khi một bên chết | Các bên khác giữ lock và chờ | Bên khác chạy tiếp, saga retry hoặc compensate sau | Event nằm trong outbox, gửi khi bên kia sống lại |
| Yêu cầu | Mọi participant hỗ trợ prepare, coordinator bền | Mỗi bước có compensation, idempotent | Consumer idempotent |
| Dùng khi | Các participant cùng một hệ thống (database phân tán, nội bộ InnoDB) | Nghiệp vụ nhiều bước qua nhiều service | Ghi database và báo cho nơi khác |
Bài toán: đặt hàng qua ba service
Câu hỏi phỏng vấn hay gặp: Order, Kho và Thanh toán là ba service với ba database; làm sao đặt hàng mà không bị trừ tiền khi hết hàng, hay giữ hàng mãi khi thanh toán lỗi? Câu trả lời tốt thường gồm:
- Nói rõ vì sao không dùng 2PC: cổng thanh toán không hỗ trợ prepare, và một service chậm không được làm treo cả hệ thống.
- Dùng saga có orchestrator trong Order service, lưu trạng thái saga trong database của Order. Mỗi lệnh gửi sang service khác đi qua outbox, mang idempotency key, và mỗi service nhận lệnh đều idempotent (Xử lý lỗi & idempotency).
- Sắp thứ tự: kiểm tra và giữ hàng trước, trừ tiền là pivot, xác nhận đơn sau cùng. Giữ hàng có thời hạn, để orchestrator chết thì hàng cũng tự được trả.
- Đơn ở trạng thái
PENDINGcho tới khi saga xong (semantic lock); giao diện hiện “đang xử lý”, không hiện “thành công”. - Theo dõi các saga kẹt quá lâu và có job xử lý tiếp, giống job đối soát ở trang Xử lý lỗi.
Ghi nhớ
- Atomic commit: mọi participant phải đi tới cùng một quyết định, kể cả khi có máy chết giữa chừng. Commit lần lượt từng nơi thì không đảm bảo được.
- 2PC: pha 1 prepare (vote YES là lời hứa, dữ liệu đã nằm trên disk, lock vẫn giữ), pha 2 coordinator ghi quyết định vào log rồi mới gửi. Đã vote YES thì không được tự quyết.
- 2PC là blocking: coordinator chết thì transaction in doubt giữ lock tới khi nó sống lại. Đo thật trên MySQL và PostgreSQL: transaction prepared sống qua cả restart và chặn mọi lệnh ghi khác trên dòng đó.
- Microservice tránh 2PC vì chậm, blocking, và phần lớn service không có lệnh prepare. 2PC vẫn dùng tốt bên trong một hệ thống, như InnoDB, Spanner, Kafka.
- Thay thế: để dữ liệu cần atomic cùng một chỗ; outbox để ghi database và gửi event; saga với compensation cho nghiệp vụ nhiều bước.
- Saga không có isolation và compensation không phải rollback: cần semantic lock, cập nhật giao hoán, compensation idempotent, và đặt bước khó compensate làm pivot ở cuối.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-21):
| Nguồn | Dùng cho phần |
|---|---|
| PostgreSQL docs, PREPARE TRANSACTION | Trạng thái lưu trên disk, không dành cho ứng dụng, nên để max_prepared_transactions = 0 nếu không có transaction manager, giữ lock, cản VACUUM, transaction ID wraparound, pg_prepared_xacts, COMMIT PREPARED từ session bất kỳ |
| MySQL 8.0 Reference Manual, XA Transaction States | ACTIVE, IDLE, PREPARED; XA RECOVER; XA COMMIT … ONE PHASE; xa_detach_on_prepare từ 8.0.29 |
| MySQL 8.0 Reference Manual, Restrictions on XA Transactions | Chỉ InnoDB, binlog hai phần với hai GTID, prepared tồn tại tới khi có XA COMMIT hay XA ROLLBACK, lỗi trước 8.0.30, không hỗ trợ replication filter, không an toàn với statement-based replication |
| Chris Richardson, Pattern: Saga (microservices.io) | Định nghĩa saga, compensating transaction, choreography và orchestration, thiếu isolation và countermeasure, “2PC is not an option” |
Kết quả tự chạy: max_prepared_transactions của PostgreSQL 17 mặc định là 0 (lab phải bật rồi restart); transaction prepared của MySQL và PostgreSQL còn sau khi đóng connection và sau khi restart; lock bị giữ trong lúc in doubt.
Từ kiến thức chung, chưa đối chiếu lần này:
- Hector Garcia-Molina, Kenneth Salem, Sagas (SIGMOD 1987), bài gốc của saga. Bản PDF là ảnh scan nên không đọc được chữ.
- Martin Kleppmann, Designing Data-Intensive Applications, chương 9: 2PC, coordinator failure, transaction in doubt, 3PC.
- Presumed abort (Mohan, Lindsay, Obermarck, 1986); 3PC (Skeen, 1981) và giả định độ trễ có giới hạn.
- Jim Gray, Leslie Lamport, Consensus on Transaction Commit (2006): chạy commit trên Paxos.
- Spanner (Corbett và cộng sự, 2012): 2PC giữa các nhóm Paxos.
- Transaction của Kafka với transaction coordinator.
- Try-Confirm-Cancel và các quy tắc pivot, đặt bước dễ lỗi lên trước (từ sách Microservices Patterns của Chris Richardson).
Code đã dùng để chạy thử
Chạy bằng npm run lab -- commit-phan-tan (cần npm run lab:up, khoảng 30 giây; script tự bật max_prepared_transactions và restart container MySQL, PostgreSQL). Số trong trang lấy từ hai lượt ngày 2026-10-21 trên MacBook M2, xem Lab.
lab/commit-phan-tan/xa.mjs: chi phí, transaction in doubt, coordinator nhỏ
js
// Trang distributed/commit-phan-tan: two-phase commit bằng XA (MySQL) và PREPARE TRANSACTION (PostgreSQL). Cần lab.
// 1. Chi phí: commit thường so với XA hai pha (PREPARE rồi COMMIT) và XA ONE PHASE.
// 2. Coordinator chết sau khi participant đã PREPARE (giả lập bằng cách đóng connection):
// transaction "in doubt" vẫn còn, vẫn giữ lock (client khác chờ tới lock timeout), vẫn còn sau khi restart database,
// và chỉ kết thúc khi có người gửi COMMIT hoặc ROLLBACK.
// 3. Một coordinator nhỏ viết bằng Node chuyển tiền từ MySQL sang PostgreSQL, ghi quyết định vào log trước khi gửi
// COMMIT; crash trước khi có quyết định thì lúc phục hồi coi như abort (presumed abort).
import mysql from 'mysql2/promise'
import pg from 'pg'
import { execSync } from 'node:child_process'
import { MYSQL, PG, CONTAINERS, sleep } from '../common/config.mjs'
const MY = { ...MYSQL, database: 'cpt' }
const PGC = { ...PG, database: 'cpt' }
const pct = (a, p) => { const s = [...a].sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.ceil(p * s.length) - 1)] }
const ms = (v) => v.toFixed(2).replace('.', ',') + ' ms'
async function waitMysql() {
for (let i = 0; i < 120; i++) { try { const c = await mysql.createConnection(MY); await c.end(); return } catch { await sleep(500) } }
throw new Error('MySQL không lên lại')
}
async function waitPg() {
for (let i = 0; i < 120; i++) { try { const c = new pg.Client(PGC); await c.connect(); await c.end(); return } catch { await sleep(500) } }
throw new Error('PostgreSQL không lên lại')
}
// ---------- chuẩn bị ----------
{
const c = await mysql.createConnection(MYSQL)
await c.query('CREATE DATABASE IF NOT EXISTS cpt')
await c.query('DROP TABLE IF EXISTS cpt.accounts')
await c.query('CREATE TABLE cpt.accounts (id INT PRIMARY KEY, balance INT NOT NULL)')
await c.query('INSERT INTO cpt.accounts VALUES (1, 1000), (2, 1000)')
for (const [x] of (await c.query('XA RECOVER'))[0].map((r) => [r.data])) await c.query(`XA ROLLBACK '${x}'`).catch(() => {})
await c.end()
const a = new pg.Client(PG); await a.connect()
if (!(await a.query("SELECT 1 FROM pg_database WHERE datname = 'cpt'")).rowCount) await a.query('CREATE DATABASE cpt')
const mpt = Number((await a.query('SHOW max_prepared_transactions')).rows[0].max_prepared_transactions)
if (mpt === 0) {
// Mặc định là 0 (tắt). Bật lên cần restart server.
await a.query('ALTER SYSTEM SET max_prepared_transactions = 10')
await a.end()
execSync(`docker restart ${CONTAINERS.pg}`, { stdio: 'ignore' })
await waitPg()
console.log('PostgreSQL: max_prepared_transactions mặc định là 0, đã đặt 10 và restart')
} else await a.end()
const p = new pg.Client(PGC); await p.connect()
for (const r of (await p.query('SELECT gid FROM pg_prepared_xacts')).rows) await p.query(`ROLLBACK PREPARED '${r.gid}'`)
await p.query('DROP TABLE IF EXISTS accounts')
await p.query('CREATE TABLE accounts (id INT PRIMARY KEY, balance INT NOT NULL)')
await p.query('INSERT INTO accounts VALUES (1, 1000), (2, 1000)')
await p.end()
}
// ---------- 1. chi phí ----------
console.log('\n=== 1. Chi phí trên MySQL: 300 transaction mỗi kiểu, mỗi transaction một UPDATE ===')
{
const c = await mysql.createConnection(MY)
const run = async (name, fn) => {
const lat = []
for (let i = 0; i < 300; i++) { const t0 = performance.now(); await fn(i); lat.push(performance.now() - t0) }
console.log(`${name.padEnd(36)} p50 ${ms(pct(lat, 0.5))}, p99 ${ms(pct(lat, 0.99))}`)
}
await run('BEGIN … COMMIT', async () => { await c.query('BEGIN'); await c.query('UPDATE accounts SET balance = balance + 0 WHERE id = 2'); await c.query('COMMIT') })
await run('XA START … XA PREPARE … XA COMMIT', async (i) => {
await c.query(`XA START 'b${i}'`); await c.query('UPDATE accounts SET balance = balance + 0 WHERE id = 2')
await c.query(`XA END 'b${i}'`); await c.query(`XA PREPARE 'b${i}'`); await c.query(`XA COMMIT 'b${i}'`)
})
await run('XA … XA COMMIT ONE PHASE', async (i) => {
await c.query(`XA START 'o${i}'`); await c.query('UPDATE accounts SET balance = balance + 0 WHERE id = 2')
await c.query(`XA END 'o${i}'`); await c.query(`XA COMMIT 'o${i}' ONE PHASE`)
})
await c.end()
}
// ---------- 2a. in doubt trên MySQL ----------
console.log('\n=== 2. Coordinator chết sau khi participant đã PREPARE ===')
{
const a = await mysql.createConnection(MY)
await a.query("XA START 'tx-1'")
await a.query('UPDATE accounts SET balance = balance - 100 WHERE id = 1')
await a.query("XA END 'tx-1'")
await a.query("XA PREPARE 'tx-1'")
a.destroy() // coordinator chết: connection đóng mà không gửi COMMIT hay ROLLBACK
await sleep(300)
const b = await mysql.createConnection(MY)
const [rec] = await b.query('XA RECOVER')
const [[{ balance }]] = await b.query('SELECT balance FROM accounts WHERE id = 1')
console.log(`MySQL: XA RECOVER sau khi connection đóng → ${rec.map((r) => r.data).join(', ')}; đọc thường thấy balance = ${balance} (giá trị cũ)`)
await b.query('SET SESSION innodb_lock_wait_timeout = 3')
const t0 = performance.now()
try { await b.query('UPDATE accounts SET balance = balance + 1 WHERE id = 1') } catch (e) {
console.log(`MySQL: client khác UPDATE cùng dòng → chờ ${Math.round(performance.now() - t0)} ms rồi lỗi ${e.code}`)
}
await b.end()
const r0 = performance.now()
execSync(`docker restart ${CONTAINERS.mysql}`, { stdio: 'ignore' })
await waitMysql()
const c = await mysql.createConnection(MY)
const [rec2] = await c.query('XA RECOVER')
console.log(`MySQL: restart (${Math.round((performance.now() - r0) / 1000)} s) → XA RECOVER vẫn còn: ${rec2.map((r) => r.data).join(', ') || '(không có)'}`)
await c.query("XA COMMIT 'tx-1'")
const [[{ balance: after }]] = await c.query('SELECT balance FROM accounts WHERE id = 1')
console.log(`MySQL: XA COMMIT 'tx-1' từ connection mới → balance = ${after}`)
await c.end()
}
// ---------- 2b. in doubt trên PostgreSQL ----------
{
const a = new pg.Client(PGC); await a.connect()
await a.query('BEGIN')
await a.query('UPDATE accounts SET balance = balance - 100 WHERE id = 1')
await a.query("PREPARE TRANSACTION 'tx-1'")
await a.end()
const b = new pg.Client(PGC); await b.connect()
const rec = (await b.query('SELECT gid FROM pg_prepared_xacts')).rows.map((r) => r.gid)
const bal = (await b.query('SELECT balance FROM accounts WHERE id = 1')).rows[0].balance
console.log(`PostgreSQL: pg_prepared_xacts sau khi connection đóng → ${rec.join(', ')}; đọc thường thấy balance = ${bal} (giá trị cũ)`)
await b.query("SET lock_timeout = '3s'")
const t0 = performance.now()
try { await b.query('UPDATE accounts SET balance = balance + 1 WHERE id = 1') } catch (e) {
console.log(`PostgreSQL: client khác UPDATE cùng dòng → chờ ${Math.round(performance.now() - t0)} ms rồi lỗi ${e.code} (${e.message})`)
}
await b.end()
execSync(`docker restart ${CONTAINERS.pg}`, { stdio: 'ignore' })
await waitPg()
const c = new pg.Client(PGC); await c.connect()
const rec2 = (await c.query('SELECT gid FROM pg_prepared_xacts')).rows.map((r) => r.gid)
console.log(`PostgreSQL: restart → pg_prepared_xacts vẫn còn: ${rec2.join(', ') || '(không có)'}`)
await c.query("COMMIT PREPARED 'tx-1'")
console.log(`PostgreSQL: COMMIT PREPARED 'tx-1' → balance = ${(await c.query('SELECT balance FROM accounts WHERE id = 1')).rows[0].balance}`)
await c.end()
}
// ---------- 3. coordinator nhỏ: chuyển 100 từ tài khoản 2 trên MySQL sang tài khoản 2 trên PostgreSQL ----------
console.log('\n=== 3. Coordinator chuyển tiền giữa MySQL và PostgreSQL ===')
const decisions = new Map() // log của coordinator; ngoài đời phải ghi xuống disk (fsync) trước khi gửi COMMIT
async function transfer(xid, amount, crashAt = null) {
const my = await mysql.createConnection(MY)
const p = new pg.Client(PGC); await p.connect()
const t0 = performance.now()
// Pha 1: prepare ở cả hai participant
await my.query(`XA START '${xid}'`)
await my.query('UPDATE accounts SET balance = balance - ? WHERE id = 2', [amount])
await my.query(`XA END '${xid}'`)
await my.query(`XA PREPARE '${xid}'`)
await p.query('BEGIN')
await p.query('UPDATE accounts SET balance = balance + $1 WHERE id = 2', [amount])
await p.query(`PREPARE TRANSACTION '${xid}'`)
if (crashAt === 'trước khi ghi quyết định') { my.destroy(); await p.end(); return 'crash' }
// Điểm commit: ghi quyết định vào log của coordinator
decisions.set(xid, 'commit')
if (crashAt === 'sau khi ghi quyết định') { my.destroy(); await p.end(); return 'crash' }
// Pha 2: gửi COMMIT cho cả hai
await my.query(`XA COMMIT '${xid}'`)
await p.query(`COMMIT PREPARED '${xid}'`)
const t = performance.now() - t0
my.destroy(); await p.end()
return t
}
async function recover() {
// Coordinator khởi động lại: mọi transaction đang prepared mà log có quyết định commit thì commit, còn lại abort
const my = await mysql.createConnection(MY)
const p = new pg.Client(PGC); await p.connect()
const out = []
for (const r of (await my.query('XA RECOVER'))[0]) {
const d = decisions.get(r.data) === 'commit' ? 'COMMIT' : 'ROLLBACK'
await my.query(`XA ${d} '${r.data}'`); out.push(`MySQL ${r.data}: ${d}`)
}
for (const r of (await p.query('SELECT gid FROM pg_prepared_xacts')).rows) {
const d = decisions.get(r.gid) === 'commit' ? 'COMMIT PREPARED' : 'ROLLBACK PREPARED'
await p.query(`${d} '${r.gid}'`); out.push(`PostgreSQL ${r.gid}: ${d}`)
}
my.destroy(); await p.end()
return out
}
const balances = async () => {
const my = await mysql.createConnection(MY)
const p = new pg.Client(PGC); await p.connect()
const a = (await my.query('SELECT balance FROM accounts WHERE id = 2'))[0][0].balance
const b = (await p.query('SELECT balance FROM accounts WHERE id = 2')).rows[0].balance
my.destroy(); await p.end()
return `MySQL ${a} + PostgreSQL ${b} = ${a + b}`
}
{
const times = []
for (let i = 0; i < 20; i++) times.push(await transfer(`t-ok-${i}`, 10))
console.log(`20 lần chuyển thành công (mỗi lần mở connection mới): p50 ${ms(pct(times, 0.5))}; số dư: ${await balances()}`)
for (const at of ['trước khi ghi quyết định', 'sau khi ghi quyết định']) {
const xid = 't-crash-' + (at.startsWith('trước') ? 'a' : 'b')
await transfer(xid, 100, at)
console.log(`coordinator crash ${at}: số dư lúc đang treo: ${await balances()}`)
console.log(` phục hồi: ${(await recover()).join('; ')} → số dư: ${await balances()}`)
}
}