Transaction trong MongoDB — atomic 1 document, multi-document, snapshot isolation và những anomaly vẫn lọt
50 người giành 10 chỗ cuối của một khoá học, mỗi người chạy transaction "đếm học viên rồi insert": 20/20 vòng vượt sức chứa, cao nhất 70/30 — write skew, thứ snapshot
isolation không chặn (PostgreSQL REPEATABLE READ cũng lọt, SERIALIZABLE chặn). Một lệnh update có điều kiện: đúng 30, nhanh hơn ~10 lần so với vá bằng transaction. 20 client mua cùng một sản phẩm trong transaction: callback
chạy ×4,2–4,3 lần, 59–72 đơn/s (không transaction: 288–381), email trong callback thành 714 email cho 161 đơn. Bẫy nổ trễ nhất: laravel-mongodb 5.11 transaction() trả về thành công
cho transaction server đã huỷ (failover lúc commit, hoặc quá 60 s) — không lỗi, không dữ liệu. Oplog, write concern, retryable writes: bài 7; MVCC, lỗi 388/251:
bài 6; xuyên shard: bài 9; outbox qua change stream: bài 10.
Thuật ngữ dùng trong bài
multi-document transaction (nhiều lệnh, nhiều document, commit hoặc huỷ cùng nhau) · session (phiên logic phía server, idlsid, mỗi transaction mộttxnNumber) · snapshot isolation (mọi lệnh đọc trong transaction thấy dữ liệu tại cùng một thời điểm) · write skew (2 transaction cùng đọc một điều kiện, mỗi bên ghi một document khác, gộp lại phá điều kiện) · lost update (2 bên đọc-sửa-ghi cùng giá trị, bên sau đè mất bên trước) · WriteConflict (lỗi 112: document đã bị transaction khác sửa) · TransientTransactionError (nhãn lỗi: chạy lại cả transaction là an toàn) · UnknownTransactionCommitResult (nhãn: không biết commit đã thành chưa, chỉ được gửi lại commit) · withTransaction (hàm của driver tự chạy lại theo nhãn) · causal consistency (đọc sau thấy ghi trước của chính mình, kể cả ở secondary) · outbox (ghi "việc cần gửi" vào DB trong cùng transaction, gửi sau) · materialize conflict (cố ý ghi chung một document để 2 transaction đụng nhau).
Kiểm chứng: Context7 (/mongodb/mongo,/websites/mongodb_manual,/mongodb/laravel-mongodb); source v9.0: repl README (mục Transactions), storage README, README sessions and transactions; manual Production Considerations; release notes 9.0, 8.0; source driver (WithTransaction.php,ManagesTransactions.php,sessions.js); Jepsen. Output:labs/mongodb/out/08-*.txt, môi trường ở Phụ lục. Máy dùng chung: số ms và throughput chỉ để so tỉ lệ.
Trả lời 30 giây
MongoDB có hai tầng atomic. Một lệnh ghi trên một document là một storage transaction của WiredTiger: document, mọi index và entry oplog cùng thành hoặc cùng không; hai lệnh đụng cùng document thì server tự retry — nên$inc,findOneAndUpdate, update có điều kiện đủ cho phần lớn bài toán, và rẻ nhất. Multi-document transaction (4.0 replica set, 4.2 sharded) giữ storage transaction đó mở qua nhiều lệnh của một session: mọi lệnh đọc trên một snapshot, ghi còn riêng tư, commit thành một entryapplyOps→ all-or-nothing. Isolation là snapshot isolation: chặn dirty read, non-repeatable read, lost update trên cùng document, nhưng để lọt write skew. Xung đột theo kiểu first-writer-wins: người ghi sau nhận WriteConflict ngay, cả transaction bị huỷ, nhãnTransientTransactionError→ driver chạy lại cả callback, nên callback phải idempotent và không có side effect. Write thường đụng document mà transaction đang giữ thì chờ. Giới hạn: 60 s, chờ khoá 5 ms, cache, 10 000 transaction mở. Quy tắc: thiết kế để invariant nằm trong 1 document; transaction cho phần còn lại — ngắn, ít document.
1. Bài toán gốc và bối cảnh ra đời
Đến 3.x, MongoDB chỉ hứa atomic trên một document; lời khuyên là nhúng (embed) dữ liệu luôn đổi cùng nhau. Phải đổi nhiều document (chuyển tiền, trừ kho + tạo đơn) thì app tự làm
"two-phase commit" bằng tay: trạng thái pending → applied → done cộng job dọn khi app chết giữa chừng — manual 3.x có hẳn tutorial. Nặng, dễ sai, người đọc vẫn thấy nửa chừng.
Thứ cản đường là storage engine: MMAPv1 khoá cả collection/database và không có MVCC. WiredTiger (mặc định từ 3.2) mang tới khoá mức document, MVCC và snapshot; MongoDB thêm timestamp
vào WiredTiger để gắn mỗi phiên bản với một thời điểm trong oplog (bài 6). 3.6 (2017) thêm các viên gạch còn lại: logical session (lsid), cluster time
(đồng hồ logic lai — hybrid logical clock), retryable writes, causal consistency (Tyulenev et al., SIGMOD 2019). Rồi
4.0 (2018): transaction trên replica set; 4.2 (2019): transaction xuyên shard bằng two-phase commit, bỏ giới hạn tổng 16 MB (nhiều entry oplog); 4.4:
tạo collection/index trong transaction; 5.0: mặc định w:"majority"; 8.0.30 và 9.0: giới hạn số transaction mở (maxConcurrentMultiDocumentTransactions,
SERVER-108215). FCV phải ≥ 4.0 (replica set) / 4.2 (sharded) mới dùng được.
Tối ưu cho: giữ một invariant qua vài document, transaction ngắn (ms), ít tranh chấp; đọc nhất quán qua nhiều collection. Không tối ưu cho: invariant cần serializable (write skew), document nóng (huỷ rồi chạy lại hàng loạt), batch lớn hoặc chạy lâu (60 s, cache), và kiểu "bọc mọi request trong transaction" quen tay từ SQL. Manual nói thẳng: transaction "should not be a replacement for effective schema design".
2. Mô hình tư duy cốt lõi
- Document là đơn vị atomic — rẻ, và server tự retry. Một lệnh ghi = một storage transaction. Hai lệnh đụng cùng document thì WiredTiger báo WriteConflict và server chạy lại
lệnh sau (client không thấy gì). Vì vậy
$inckhông bao giờ mất. - Transaction = kéo dài storage transaction đó qua nhiều lệnh của một session. Snapshot chọn một lần, ghi gom lại, commit thành một entry oplog. Các lệnh trước đã chạy trên snapshot cũ, nên server không thể tự chạy lại một lệnh bị WriteConflict — nó huỷ cả transaction và đẩy việc chạy lại cho client.
Mọi hành vi khác suy ra từ hai ý này:
- Đọc không khoá (MVCC): reader và transaction không chặn nhau — lab:
findtrả bản cũ trong 2–22 ms khi transaction đang giữ document. - Ghi trong transaction không chờ: đụng nhau là lỗi ngay → chạy lại ở client → callback phải idempotent, side effect chạy lại theo.
- Snapshot isolation chứ không serializable: hai transaction ghi hai document khác nhau thì không có gì để đụng → write skew.
- Ghi ngoài transaction vẫn là lệnh 1 document, server retry tới khi được → nó chờ transaction đang giữ document (dài bao nhiêu chờ bấy nhiêu).
- Trạng thái transaction nằm trên session: lệnh quên truyền session chạy ngoài transaction.
sequenceDiagram participant A as App (driver) participant P as Primary participant S as Secondary A->>P: insert + lsid, txnNumber 7, startTransaction, autocommit false Note over P: mở storage transaction, chọn snapshot,
giữ khoá IX global + RSTL tới hết transaction P-->>A: ok (ghi còn riêng tư, tài nguyên cất vào session) A->>P: update (cùng lsid, txnNumber 7) Note over P: đọc trên snapshot cũ,
đụng bản người khác vừa ghi thì WriteConflict A->>P: commitTransaction (writeConcern majority) Note over P: gom thành 1 entry applyOps vào oplog,
commit storage transaction P->>S: entry applyOps S-->>P: đã ghi tới optime đó P-->>A: ok khi đa số đã có
3. Internals và quyết định thiết kế
3.1 Một document: phần lớn bài toán không cần transaction
Lab 08-atomic: 50 process PHP (pcntl_fork, mỗi process một connection, cùng vạch xuất phát) gọi query builder của laravel-mongodb vào một document, 200 lần mỗi process.
A increment() 50 process × 200: n = 10000 / 10000 · mất 0 (0.0%) · 9.1 s
B first() rồi update(n+1) 50 process × 200: n = 1208 / 10000 · mất 8792 (87.9%) · 17.6 s
C optimistic theo version 50 process × 200: n = 10000 / 10000 · mất 0 (0.0%) · đọc lại vì hụt version 33016 lần · 40.5 s
D1 đọc-kiểm-trừ 50 người giành 30 chỗ: giữ được 45 chỗ · seats còn -15
D2 1 lệnh có điều kiện seats > 0 50 người giành 30 chỗ: giữ được 30 chỗ · seats còn 0
increment() gửi $inc: đọc và ghi trong một storage transaction, đụng nhau thì server chạy lại → đúng tuyệt đối. B mất 88 % vì giữa first() và update()
người khác đã ghi. Optimistic concurrency (C, optimistic locking) đúng, nhưng 50 người ghi một document thì mỗi lần thành công tốn ~3,3 lần đọc lại, chậm
4,5× — hợp tranh chấp thấp (2 admin sửa một sản phẩm), không hợp bộ đếm. D2 là compare-and-set: điều kiện trong filter, thay đổi trong modifier, cùng một lệnh.
// giữ 1 chỗ: điều kiện + thay đổi trong CÙNG một lệnh → atomic, không cần transaction
$ok = DB::connection('mongodb')->table('courses')
->where('_id', $courseId)->where('seats', '>', 0)->decrement('seats'); // 1 = giữ được, 0 = hết chỗ
// optimistic: chỉ khớp nếu version chưa đổi; trả 0 → đọc lại rồi thử lại
$n = DB::connection('mongodb')->table('products')->where('_id', $id)->where('version', $v)
->update(['price' => $new, 'version' => $v + 1]);
Bẫy: (1) Eloquent find() rồi save() với giá trị tính ở PHP là cùng kiểu đọc-rồi-ghi như B. (2) Cần giá trị sau khi đổi (số ghế, số thứ tự đơn) thì dùng
findOneAndUpdate với returnDocument: 'after', đừng update rồi đọc lại. (3) Mẫu này chỉ giữ được invariant nằm trong một document — invariant chạm 2 document trở lên
cần thiết kế lại hoặc transaction.
3.2 Vòng đời một transaction: session, txnNumber, một entry oplog
Theo README repl: mọi lệnh trong transaction mang lsid + txnNumber + autocommit: false, lệnh đầu thêm startTransaction: true. Trạng thái nằm trong
TransactionParticipant gắn vào session; mỗi lúc chỉ một lệnh được "check out" session. Lệnh đầu lấy khoá IX global (kèm RSTL), giữ tới hết transaction — vì thế
stepdown phải huỷ mọi transaction chưa prepare — và mở một storage transaction. Giữa hai lệnh, khoá và storage transaction được cất vào session. Commit: gom các thao tác thành entry applyOps, commit storage transaction, cập nhật config.transactions. Abort: bỏ storage transaction, không ghi oplog.
-- 08-basics B: chuyển 30 từ A sang B + tạo 1 đơn
entry oplog mới trước commit: 0, sau commit: 1
op:c ns:admin.$cmd txnNumber:3 lsid:có prevOpTime.ts:{"$timestamp":"0"} · applyOps 3 lệnh: u lab.acc {"_id":"A"} | u lab.acc {"_id":"B"} | i lab.ord {"_id":1}
-- 08-limits B: 20 document × 1 MB trong một transaction
commit OK sau 1158 ms
entry ts 1791993049:9 · 15 lệnh · 15.0 MB · partialTxn true · prevOpTime 0:0
entry ts 1791993049:10 · 5 lệnh · 5.0 MB · partialTxn false · prevOpTime 1791993049:9
Mỗi entry ≤ 16 MB; transaction to hơn thành chuỗi entry nối bằng prevOpTime, entry cuối không có partialTxn. Secondary chờ đủ chuỗi mới áp, nên không bao giờ áp nửa
transaction. Giới hạn kích thước thực tế là cache: transaction bị huỷ khi phần dirty của nó vượt 75 % tổng dirty — 20 document × 1 MB commit được, còn 20 400 document × 1 KB thì chết với lỗi 388
(bài 6); không có con số MB cố định (chưa đo ngưỡng chung).
3.3 Snapshot: transaction đọc thấy gì
README repl, mục "Read Concern Behavior Within Transactions": local và majority hiện không khác nhau — đọc "untimestamped" trên snapshot mới nhất lúc transaction bắt
đầu; snapshot thì đọc tại all_durable timestamp (hoặc atClusterTime). Đọc trong transaction là "speculative": commit w:"majority" chờ cả dữ liệu đã đọc thành majority
(transaction chỉ đọc thì ghi một noop để chờ). Mặc định readConcern trong transaction vẫn là local (log ghi "provenance":"implicitDefault").
-- 08-basics D
T1 đọc A: 69
(ngoài transaction: A.bal = 500, xoá B) — T1 đọc lại A: 69, T1 đọc B: {"_id":"B","bal":30}
T1 updateOne A (A đã đổi sau snapshot): LỖI 112 WriteConflict · labels ["TransientTransactionError"] · …
T1 nhận lỗi sau 4 ms
-- 08-basics D2: transaction chỉ đọc, dữ liệu đổi giữa chừng → commit vẫn OK
Ba hệ quả: đọc lặp lại cho cùng kết quả (kể cả document đã bị xoá ngoài kia — "stale read" mà manual nhắc); ghi lên document đã đổi sau snapshot thì WriteConflict (nên không có lost update); còn transaction chỉ đọc thì không bao giờ xung đột — nó có thể ra quyết định trên dữ liệu cũ. Đó là gốc của write skew (mục 5.1).
3.4 WriteConflict: người ghi đầu thắng, người sau bị huỷ ngay
Storage README: WiredTiger ném WriteConflictException khi "more than one operation stages an uncommitted write to the same document". Ngoài transaction, lệnh nằm trong vòng
writeConflictRetry (chờ tăng dần, có trần) nên client không thấy gì; trong transaction thì không: "multi-document transactions … continue to return a WriteConflict to the client".
-- 08-basics C: T1 đã sửa A (chưa commit), T2 sửa A
T2 updateOne A (T1 đang giữ): LỖI 112 WriteConflict · labels ["TransientTransactionError"] · Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction.
T2 nhận lỗi sau 19 ms (không chờ T1)
T2 lệnh kế tiếp trong cùng transaction: LỖI 251 NoSuchTransaction · labels ["TransientTransactionError"] · Transaction with { txnNumber: 1 } has been aborted.
T1 commitTransaction: OK
-- 08-wait: T1 giữ document 1 s / 5 s; updateOne thường xong sau 964–972 / 4962 ms; findOne 2–22 ms (bản cũ). Có maxTimeMS 500:
updateOne maxTimeMS 500: 508 ms → LỖI 50 MaxTimeMSExpired []: Plan executor error during update :: caused by :: operation exceeded time limit
112 WriteConflict sau 19 ms, không chờ; cả T2 bị huỷ, lệnh kế tiếp
của nó nhận 251 NoSuchTransaction; driver chờ backoff rồi chạy lại cả T2. Lệnh updateOne thường (W) thì ngược lại: không lỗi mà chờ — server retry nội bộ
(+13/+101/+216 writeConflicts) tới khi T1 commit: 70 ms, 964–972 ms, 4 962 ms cho T1 giữ 0,1 / 1 / 5 s. Đọc ngoài không chờ (2–22 ms) và thấy bản cũ.Thiết kế này đổi gì lấy gì: không có hàng đợi khoá dòng, không có deadlock giữa các transaction (không ai chờ ai) — đổi lại mọi xung đột là một lần huỷ, và chi phí chạy lại do client trả. PostgreSQL chọn ngược lại: người đến sau chờ người trước commit rồi mới biết thắng thua (mục 4).
3.5 Ai chạy lại: nhãn lỗi và withTransaction
| Nhãn | Nghĩa | Gặp khi | Phải làm |
|---|---|---|---|
TransientTransactionError | Transaction đã huỷ, không để lại gì | WriteConflict 112, NoSuchTransaction 251, LockTimeout 24, lỗi mạng giữa transaction | Chạy lại cả transaction |
UnknownTransactionCommitResult | Commit có thể đã thành | Lỗi mạng, mất primary, lỗi write concern lúc commit | Chỉ gửi lại commitTransaction (cùng txnNumber); server trả kết quả cũ từ config.transactions |
| (không nhãn) | Không chạy lại được | TooManyOpenTransactions 509, OperationNotSupportedInTransaction 263, quá cache 388 | Ném ra cho app |
Hàm tiện ích của driver làm đúng bảng trên. Node 7.7.0 withTransaction và PHP MongoDB\with_transaction (thư viện 2.5.0): lặp tới 120 s, nghỉ
ngẫu nhiên × min(5 ms × 1,5^lần, 500 ms) giữa các lần; Node gặp lỗi không phải MongoError trong callback thì abort và ném luôn (không chạy lại). laravel-mongodb 5.11
transaction($cb, $attempts = 1) bọc with_transaction nhưng tự đếm lượt: mặc định 1 lượt → WriteConflict không được chạy lại mà ném thẳng ra app (mục 5.2),
và có một lỗi ở đường commit (mục 5.5).
3.6 Causal consistency: đọc ở secondary vẫn thấy ghi của mình
Mỗi phản hồi mang operationTime và $clusterTime. Session có causalConsistency nhớ mốc lớn nhất, lệnh đọc sau gửi kèm afterClusterTime; secondary chờ
tới khi đã áp oplog qua mốc đó rồi mới đọc. Hai service khác nhau thì phải chuyển mốc như một "token" (advanceOperationTime/advanceClusterTime). Lab 08-causal: ghi
w:"majority" qua replica set, đọc ngay ở r2 (nối thẳng), 500 lần mỗi kiểu, chạy 2 lần:
readConcern local không session : không thấy 10/500 (lần 2: 9/500) · đọc p50 2.51 ms
readConcern local causal (afterClusterTime): không thấy 0/500 (lần 2: 0/500) · đọc p50 1.97 ms
readConcern majority không session : không thấy 67/500 (lần 2: 64/500) · đọc p50 1.81 ms
readConcern majority causal (afterClusterTime): không thấy 0/500 (lần 2: 0/500) · đọc p50 2.03 ms
Đây là cách chữa ca 10/500 của bài 7: không phải majority (còn tệ hơn: 64–67/500, snapshot majority của secondary đi sau) mà là session; độ trễ đọc
không tăng thấy được. Manual chỉ hứa đủ 4 đảm bảo (read your writes, monotonic reads/writes, writes follow reads) qua failover khi dùng cả
majority read lẫn write concern — ca failover chưa verify ở lab này.
3.7 Sharded cluster: two-phase commit
README sessions and transactions: mongos chọn atClusterTime ở lệnh đầu, shard đầu tiên làm coordinator. Chỉ một shard bị ghi → commit thẳng; nhiều shard bị ghi → two-phase commit:
coordinator ghi danh sách participant (chờ majority), gửi prepareTransaction, ghi quyết định vào config.transaction_coordinators, rồi gửi commit. Document của transaction đã
prepare chặn lệnh đọc/ghi khác đụng tới nó (prepare conflict) tới khi có quyết định; lệnh đọc snapshot/causal chờ, đọc thường thấy bản trước transaction. Phần này để
bài 9 — lab bài này chỉ chạy replica set.
4. Đảm bảo và giới hạn
| Thứ | Giá trị / hành vi | Nguồn |
|---|---|---|
| Atomic 1 document | Luôn có, gồm cả index và entry oplog; ghi đụng nhau thì server tự retry | storage README, lab atomic |
| Atomic nhiều document | Chỉ trong transaction. updateMany/insertMany ngoài transaction: từng document riêng, người khác thấy nửa chừng | lab isolation (5.8) |
| Durability | Theo write concern của commit — mặc định lấy của client, từ 5.0 là majority; commit w:1 có thể bị rollback khi failover (bài 7) | manual, bài 7 |
| Thời gian sống | 60 s (transactionLifetimeLimitSeconds), bị huỷ thật sau 65–69 s (bài 6) và 67,5 s (lab 5.6) | getParameter, lab |
| Chờ khoá | 5 ms (maxTransactionLockRequestTimeoutMillis) rồi LockTimeout — có nhãn TransientTransactionError | lab wait C |
| Số transaction mở | 10 000 (maxConcurrentMultiDocumentTransactions, có ở cả 8.0.32) → 509 TooManyOpenTransactions, không có nhãn retry | lab limits, release notes |
| Kích thước | Không giới hạn tổng từ 4.2; mỗi entry oplog ≤ 16 MB; trần thật là cache (lỗi 388) | lab limits, bài 6 |
| DDL | Tạo collection, index trên collection mới (4.4+): được. Index trên collection có dữ liệu, drop, lệnh count: lỗi 263 | lab basics F |
So với khung lý thuyết (Berenson et al. 1995 định nghĩa snapshot isolation và write skew;
PostgreSQL theo SSI cho SERIALIZABLE). Cùng kịch bản, lab 08-basics, 08-skew, 08-pg:
| Hiện tượng | MongoDB transaction | PostgreSQL REPEATABLE READ | PostgreSQL SERIALIZABLE |
|---|---|---|---|
| Dirty read, non-repeatable read, phantom | Không | Không | Không |
| Lost update (cùng document/dòng) | Không — người sau WriteConflict ngay (19 ms) | Không — người sau chờ người trước commit (1 720 ms) rồi lỗi could not serialize access due to concurrent update | Như RR |
| Write skew (đếm 29 rồi cùng insert) | Lọt: 31/30 | Lọt: 31/30 | Chặn: 30/30, một bên lỗi khi commit |
5. Failure modes
5.1 Write skew: khoá học 30 chỗ có 31 học viên
courses.C1 ($inc taken có điều kiện taken < 30) → T2 bị WriteConflict, chạy lại trên snapshot mới, thấy hết chỗ.-- 08-basics E: 2 session xen kẽ trong mongosh
T1 đếm 29, T2 đếm 29 → cả hai thấy còn chỗ
T1 commit: OK
T2 commit: OK
số học viên sau cùng: 31 / 30
-- 08-skew: Node, 50 client cùng lúc, còn 10 chỗ, 20 vòng × 3 lần (2 client, còn 1 chỗ: 193/200 vòng ra 31)
txn-count: vượt sức chứa 20/20 vòng · cao nhất 70/30 · ghi danh thành công TB 48.0–48.6/vòng · callback ×1.00 · 116–157 ms/vòng
txn-lock : vượt sức chứa 0/20 vòng · cao nhất 30/30 · callback ×4.17–4.70 · writeConflicts +3167…+3703 · 357–526 ms/vòng
single : vượt sức chứa 0/20 vòng · cao nhất 30/30 · callback ×1.00 · writeConflicts +326…+424 (server tự retry) · 31–39 ms/vòng
-- 08-pg: PostgreSQL 18.4 (pgtest.parallel: đếm → pg_sleep(1) → insert)
REPEATABLE READ: cả hai COMMIT → 31
SERIALIZABLE: ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt. → 30
Cả 50 client đếm trên snapshot trước khi ai commit, nên gần như ai cũng insert được. PostgreSQL REPEATABLE READ cũng là snapshot isolation nên lọt y hệt; SERIALIZABLE (SSI) theo dõi phụ thuộc đọc–ghi và huỷ một bên lúc commit — MongoDB không có mức đó. Cách chữa, theo thứ tự nên chọn:
- Một lệnh update có điều kiện trên document đại diện cho invariant (
coursesgiữtakenvà danh sách học viên): đúng 30, không transaction, 31–39 ms/vòng. - Materialize conflict trong transaction: giữ logic cũ nhưng thêm một lệnh ghi lên document chung (
$inccó điều kiện, hoặc$setmột field giả — MongoDB không cóSELECT … FOR UPDATE). Đúng 30, nhưng callback chạy ×4,2–4,7 lần và chậm hơn ~10 lần (357–526 ms/vòng). - Unique index cho invariant dạng "không trùng" (một người ghi danh một lần:
{course: 1, user: 1}unique). Không giải được bài sức chứa.
Bẫy: (1) "đếm rồi insert" qua code review và qua test đơn luồng, chỉ nổ dưới tải. (2) Document đại diện thành document nóng (5.2). (3) Mọi đường ghi phải đi qua nó: một
script import ghi thẳng enrollments là bộ đếm lệch, nổ ở release sau — cần job đối soát. (4) Lab ép mọi client xuất phát cùng lúc; ngoài đời hiếm hơn nhưng không bằng 0.
5.2 Document nóng trong transaction: bão chạy lại
Lab 08-conflict, 20 client × 10 s, "mua hàng" = trừ tồn kho (có điều kiện) + tạo đơn, chạy 3 lần:
| Kiểu | đơn/s | callback / đơn | writeConflicts (server) | p50 / p99 |
|---|---|---|---|---|
| transaction, cùng 1 sản phẩm | 59 / 61 / 72 | ×4,21–4,33 | +1 933…+2 308 | 105–162 ms / 2,6–3,3 s |
| transaction, mỗi client 1 sản phẩm | 266–433 | ×1,00 | 0 | 35–48 ms / 210–380 ms |
| không transaction (2 lệnh rời), cùng 1 sản phẩm | 288–381 | ×1,00 | +1 410…+2 215 (server tự retry) | 44–58 ms / 169–222 ms |
| không transaction, mỗi client 1 sản phẩm | 375–410 | ×1,00 | 0 | 39–42 ms / 162–385 ms |
Cùng lượng xung đột, nhưng ngoài transaction server chạy lại một lệnh (vài µs), còn trong transaction client chạy lại cả gói qua mạng kèm backoff → throughput còn ~1/5, p99 ×15.
withTransaction giấu hết: không có lỗi nào, chỉ thấy p99. laravel-mongodb mặc định 1 lượt thì ngược lại — lỗi ném thẳng ra app: lab 10 process × 5 s, 1 118/1 623 lần gọi
(69 %) nhận BulkWriteException code 112. Chữa: đưa ghi nóng ra khỏi transaction (thống kê cập nhật sau, từ outbox/change stream), chia bộ đếm thành nhiều document, hoặc thiết kế lại thành
lệnh 1 document. Đặt lệnh ghi nóng cuối transaction để giữ nó ngắn nhất — hợp lý theo cơ chế, chưa đo.
5.3 Transaction dài: write khác phải chờ, DDL làm cả collection đứng
Write thường chờ transaction đang giữ document (Hình 1): transaction mở 5 s là mọi lệnh ghi document đó chờ 5 s, mỗi lệnh chờ vẫn giữ ticket (bài 6). Tệ hơn khi có DDL
xếp hàng. Lab 08-wait C (số đo trong Hình 3; T1 commit ở +4 118 ms, createIndex xong ở +5 915 ms):
transaction mới insert Y: 16 ms → LỖI 24 LockTimeout ["TransientTransactionError"]: Unable to acquire IX lock on '{Collection : lab.wt_y}' within 5ms. …
createIndex cần khoá X nên chờ sau IX của T1; mọi yêu cầu IX đến sau xếp sau X: transaction mới
chỉ chờ 5 ms rồi LockTimeout (có nhãn TransientTransactionError → withTransaction chạy lại liên tục tới 120 s), insert thường chờ 3 151 ms. Đọc là lock-free nên không bị
ảnh hưởng.Phát hiện: $currentOp có lệnh waitingForLock và một transaction timeOpenMicros lớn; app log đầy LockTimeout. Chữa: transaction
ngắn; maxTimeMS cho lệnh ghi (lab B: 508 ms → MaxTimeMSExpired thay vì treo); kiểm transaction đang mở trước khi build index/đổi schema; tăng maxTransactionLockRequestTimeoutMillis
thì đỡ LockTimeout nhưng manual cảnh báo nó làm deadlock lâu bị phát hiện hơn.
5.4 Side effect trong callback chạy N lần
-- 08-sidefx (Node withTransaction, 20 client × 5 s, cuối callback $inc thống kê ngày — document nóng)
inline 20 client × 5 s: đơn 161 · callback chạy 714 lần (1 đơn tối đa 17 lần) · email đã gửi 714 (thừa 553)
outbox 20 client × 5 s: đơn 175 · callback chạy 864 lần (1 đơn tối đa 19 lần) · email trong outbox 175 (khớp đơn: true)
after 20 client × 5 s: đơn 346 · callback chạy 1390 lần (1 đơn tối đa 17 lần) · email đã gửi 346 (thừa 0)
-- 08-laravel-attempts (laravel-mongodb transaction(), 10 process × 5 s; DB::afterCommit trong callback)
attempts=1 · … trả về OK 505 lần, ném lỗi 1118 lần {"BulkWriteException 112":1118}
đơn trong DB 505 · … callback chạy 1623 lần · email trong callback 1623 · email afterCommit 505 (id khác nhau 505, id không có trong DB 0)
attempts=5 · … trả về OK 497 lần, ném lỗi 72 lần {"BulkWriteException 112":72}
đơn trong DB 497 · … callback chạy 1444 lần · email trong callback 1444 · email afterCommit 1084 (id khác nhau 1084, id không có trong DB 587)
Ba bẫy, cái sau nổ trễ hơn: (1) side effect chạy theo số lần chạy lại (1 đơn tối đa 20 lần); laravel-mongodb 1 lượt: 1 118 lần gọi đã ném lỗi mà email vẫn đi. (2) Gửi sau khi
withTransaction trả về thì đủ số, nhưng process chết giữa commit và gửi là mất (at-most-once). (3) DB::afterCommit đúng khi attempts=1 (505 = 505) nhưng sai khi
attempts > 1: 1 084 email cho 497 đơn, 587 cho đơn không tồn tại — mỗi lượt gọi transactionsManager->begin(), lượt hỏng không gọi rollback(), nên
callback của nó chạy khi lượt cuối commit (đọc source). Cách đúng: outbox trong cùng transaction + publisher (change stream, bài 10) + consumer idempotent
theo orderId: at-least-once, trùng thì bỏ.
5.5 laravel-mongodb báo thành công cho transaction đã mất
Lab 08-laravel-lost: callback không lỗi gì, nhưng server huỷ transaction trước lúc commit — (a) callback chạy quá transactionLifetimeLimitSeconds (lab đặt 2 s, callback ngủ 8 s như
gọi API thanh toán chậm); (b) primary step down giữa chừng (failover thật).
-- expire
[driver] commitTransaction lỗi 251: Transaction with { txnNumber: 1 } has been aborted. · labels ["TransientTransactionError"]
transaction() TRẢ VỀ BÌNH THƯỜNG sau 8.1 s: "callback trả về OK"
callback chạy 1 lần · event: ["TransactionRolledBack","TransactionCommitted"] · afterCommit: (không chạy)
document trong DB: []
-- stepdown
[driver] commitTransaction lỗi 10107: not primary · labels ["RetryableWriteError"]
[driver] commitTransaction lỗi 251: Given transaction number 1 on session … · labels ["TransientTransactionError"]
transaction() TRẢ VỀ BÌNH THƯỜNG sau 1.1 s: "callback trả về OK"
document trong DB: []
sequenceDiagram participant L as laravel-mongodb transaction() participant W as MongoDB with_transaction participant S as mongod L->>W: callback bọc, attemptsLeft = 1 W->>S: lần 1 chạy callback (attemptsLeft về 0), insert trong transaction Note over S: transaction bị huỷ: quá 60 s hoặc step down W->>S: commitTransaction S-->>W: 251 NoSuchTransaction, nhãn TransientTransactionError W->>L: theo spec, gọi lại callback L->>L: attemptsLeft = -1 nên abortTransaction() rồi return, KHÔNG ném lỗi W-->>L: session không còn trong transaction, coi như callback tự abort, return L->>L: $throwable rỗng (lỗi xảy ra ở commit) nên handleCommitState(), bắn TransactionCommitted L-->>L: trả kết quả lần 1 như thành công
with_transaction làm đúng spec; lỗi ở lớp bọc ManagesTransactions::transaction() của laravel-mongodb 5.11.0: hết lượt thì abort rồi return, chỉ ném lại lỗi đến từ
callback — lỗi từ commit bị nuốt. attempts > 1 chỉ dời ca này sang lượt cuối (và làm hỏng afterCommit, 5.4). Không có exception; dấu vết duy nhất là
TransactionRolledBack và TransactionCommitted cùng bắn. Chưa thấy issue GitHub nào tả ca này (tìm 2026-10-14). Thay bằng hàm tự viết theo bảng nhãn ở 3.5, dùng
beginTransaction/commit/rollBack của package để query builder vẫn tự gắn session:
function txn(Closure $cb, int $maxSec = 20): mixed {
$c = DB::connection('mongodb'); $t0 = microtime(true);
$late = fn () => microtime(true) - $t0 > $maxSec;
for (;;) {
$c->beginTransaction();
try { $r = $cb(); }
catch (Throwable $e) {
$c->rollBack();
if ($e instanceof MongoDB\Driver\Exception\RuntimeException && $e->hasErrorLabel('TransientTransactionError') && ! $late()) { usleep(random_int(1, 50) * 1000); continue; }
throw $e;
}
for (;;) {
try { $c->commit(); return $r; }
catch (MongoDB\Driver\Exception\RuntimeException $e) {
if ($e->hasErrorLabel('UnknownTransactionCommitResult') && ! $late()) continue; // chỉ commit lại
$c->rollBack();
if ($e->hasErrorLabel('TransientTransactionError') && ! $late()) continue 2; // chạy lại cả gói
throw $e;
}
}
}
}
-- 08-laravel-wrap: cùng 3 kịch bản với txn()
txn() tự viết · 10 process × 5 s: trả về OK 717 lần, ném lỗi 0 lần [] · … email afterCommit 717 (… id không có trong DB 0)
stepdown: callback chạy 2 lần · event: ["TransactionRolledBack","TransactionCommitted"] · afterCommit: afterCommit order-2 · document trong DB: ["order-2"]
expire: txn() NÉM LỖI sau 24.0 s: CommandException 251: Transaction with { txnNumber: 3 } has been aborted. · document trong DB: []
5.6 Quên truyền session: lệnh chạy ngoài transaction, hoặc tự chặn chính mình
-- 08-session (Node): insert đơn có session, insert audit QUÊN session, rồi throw
sau abort: ss_order 0 · ss_audit 1 ← lệnh quên session không bị rollback
mongoose transactionAsyncLocalStorage=false: sau rollback còn order 0 · audit 1
mongoose transactionAsyncLocalStorage=true: sau rollback còn order 0 · audit 0
-- 08-laravel-raw: trong DB::connection('mongodb')->transaction(), rồi throw
còn lại sau rollback: ["C getCollection()->insertOne","D table()->raw(fn)"] ← builder và DB::table() được gắn session
-- selfblock: transaction trừ A.bal, rồi cộng A.points nhưng quên session (CÙNG document)
+0.2 s transaction đã trừ A; giờ cộng điểm thưởng vào A nhưng quên { session }
+67.5 s lệnh quên session xong
+67.5 s commit lỗi: 251 NoSuchTransaction ["TransientTransactionError"]: Transaction with { txnNumber: 2 } has been aborted.
A = {"_id":"A","bal":100,"points":1}
-- loop: như trên nhưng trong withTransaction
+167.9 s withTransaction ném sau 2 lần chạy callback: 251 NoSuchTransaction … · A = {"_id":"A","bal":100,"points":2}
Lệnh quên session là một lệnh thường. Đụng document mà chính transaction vừa ghi thì nó chờ transaction, còn transaction chờ nó (callback đang await) — một deadlock mà server không
thấy, chỉ được gỡ khi transaction hết 60 s. withTransaction chạy lại thì kẹt lần nữa: request treo 168 s, cộng điểm 2 lần, tiền không trừ. laravel-mongodb gắn session cho query builder và
DB::table(), nhưng không cho getCollection() và raw(). Chữa: Mongoose bật transactionAsyncLocalStorage; Node thuần thì lint/review; Laravel
tránh raw() trong closure hoặc truyền ['session' => $conn->getSession()].
5.7 Primary chết đúng lúc commit
Lab 08-failover: 8 worker chạy transaction liên tục (insert đơn với _id cố định + $inc bộ đếm), giây thứ 8 docker kill primary, 3 lần:
[lần1] app: OK 4214 · ném lỗi 7 (trong đó 4 thật ra ĐÃ commit) · callback chạy > 1 lần: 1 · 1 transaction lâu nhất 11499 ms
[lần1] commitTransaction gửi > 1 lần: 0 transaction (tối đa 1 lần) · DB: đơn 4218, tổng bộ đếm 4218 → KHỚP, không trùng, không mất
withTransaction ném 2 × MongoNetworkError []: read ECONNRESET (+ 5 × "connection N to …:27017 closed")
[lần2] … ném lỗi 7 (3 ĐÃ commit) · commit gửi lại: 0 · [lần3] … ném lỗi 7 (5 ĐÃ commit) · commit gửi lại: 1
All-or-nothing giữ đúng: số đơn luôn bằng tổng bộ đếm. Nhưng "lỗi mạng lúc commit → UnknownTransactionCommitResult → commit lại" gần như không xảy ra: 0–1 commit được gửi lại mỗi lần;
7 transaction ném MongoNetworkError không nhãn, 3–6 cái trong đó đã commit. Source Node 7.7.0 (sdam/server.js): lỗi trên connection của pool đã bị clear được trả
thẳng, không gắn RetryableWriteError, nên commit không được thử lại (suy ra từ source, chưa debug từng request). "Lỗi" không có nghĩa "chưa commit": app tự chạy lại phải idempotent —
_id xác định trước (request id), lần chạy lại gặp E11000 là biết đã xong. Lần chạy đầu còn gặp 7 × WriteConflict: Caused by :: Collection namespace 'lab.fo_ord' is already
in use. — 8 transaction cùng tạo ngầm một collection; tạo collection trong migration.
5.8 updateMany không isolated
-- 08-isolation: đổi v 0 → 1 cho 20 000 document, reader đếm {v: 1} liên tục
updateMany 20000 document, ghi mất 730 ms · reader đếm 35 lần · thấy 30 giá trị khác nhau · giá trị nửa chừng: 28 (vd 644, 2311, 2757, 3757, 4460 … 19393)
transaction 20000 document, ghi mất 1074 ms · reader đếm 39 lần · thấy 2 giá trị khác nhau · giá trị nửa chừng: 0 (chỉ thấy 0 hoặc 20000)
updateMany là 20 000 lệnh 1 document liên tiếp: người đọc thấy nửa chừng; bị ngắt thì dừng giữa chừng, không rollback (bài 2, mục 5.3), không được
retryable writes cứu (bài 7). Transaction cho đúng 0 hoặc 20 000 nhưng bị trần 60 s và cache; tập lớn hơn thì ghi phiên bản mới rồi đổi một con trỏ (1 document) sang nó.
5.9 Chạm giới hạn: TooManyOpenTransactions, DDL
-- 08-limits A: maxConcurrentMultiDocumentTransactions hạ xuống 3 lúc đang chạy
transaction 4: 509 TooManyOpenTransactions · labels [] · cannot start a new multi-document transaction; there are already 3 open transactions, which meets or exceeds the limit of 3
insert thường (không transaction) lúc đã đủ 3: OK
-- 08-basics F: DDL trong transaction
createIndex trên ddl (collection đã có dữ liệu): LỖI 263 OperationNotSupportedInTransaction · Cannot create new indexes on existing collection lab.ddl in a multi-document transaction.
drop collection ddl: LỖI 263 OperationNotSupportedInTransaction · Cannot run 'drop' in a multi-document transaction.
count trong transaction (lệnh count cũ): LỖI 263 … Cannot run 'count' in a multi-document transaction. Please see http://dochub.mongodb.org/core/transaction-count …
509 không có nhãn nên withTransaction ném ngay; app nên coi nó là lỗi quá tải. Release notes ghi "Starting in MongoDB 9.0", thực tế đã có từ 8.0.30 (SERVER-108215): lab 8.0.32 cho cùng lỗi.
Chạm trần thường do transaction rò (đường lỗi quên abort) hoặc mở lâu. count cũ bị cấm, countDocuments (aggregate) thì chạy được — chính nó dùng ở lab write skew.
6. Vận hành trong production
| Nhìn vào | Lab thấy gì | Dùng để |
|---|---|---|
serverStatus().transactions | Transaction mở rồi ngồi chờ API: currentOpen 1 · currentActive 0 · currentInactive 1 | Tỉ lệ abort (totalAborted/totalStarted), transaction "ngồi không" |
metrics.operation.writeConflicts | +3 167…+3 703 mỗi lượt lab skew; cả retry nội bộ lẫn WriteConflict trong transaction | Document nóng (so cùng lúc với p99) |
$currentOp + idleSessions: true | type idleSession · timeOpenMicros 1518941 · timeInactiveMicros 1518747 · expiryTime = bắt đầu + 60 s | Tìm transaction dài, ai đang giữ khoá trước DDL |
Log id 51802 "transaction" (component TXN) | terminationCause committed · durationMillis 1556 · timeInactiveMicros 1555973 · "readConcern":{"level":"local","provenance":"implicitDefault"} | Transaction chậm hơn slowms, bị huỷ (aborted), readConcern thật |
| 3 tham số ở mục 4 | Đổi được lúc chạy bằng setParameter (lab: 10 000 → 3, 60 → 2) | Hãm transaction rò, ngắt transaction treo |
3 giờ sáng: p99 vọt + writeConflicts tăng, không lỗi → document nóng (5.2). Ghi vào một collection treo + LockTimeout → DDL xếp sau transaction dài (5.3): tìm nó trong
$currentOp, huỷ bằng killSessions (chưa chạy trong lab). TooManyOpenTransactions → transaction rò hoặc treo (5.6, 5.9). History store phình → transaction mở lâu
(bài 6). Số liệu nghiệp vụ sai mà không lỗi nào → write skew (5.1), quên session (5.6), laravel-mongodb nuốt lỗi commit (5.5) — khó nhất, cần job đối soát. Transaction không
thêm hạ tầng; chi phí nằm ở CPU cho các lần chạy lại và ở kỷ luật code.
7. So sánh và ma trận quyết định
Giá của transaction khi không có xung đột — lab 08-cost, "chuyển 1 đơn vị" giữa 2 document, 8 client × 5 s mỗi kiểu, 3 lần (máy dùng chung, load 5–7: chỉ so tỉ lệ):
| Cách | w:1 (lần chuyển/s) | w:"majority" (lần chuyển/s) |
|---|---|---|
| 2 lệnh rời, không transaction | 723–864 (p50 6,4–6,9 ms) | 87–183 (p50 35–70 ms) |
| transaction 2 lệnh | 628–748 (p50 7,8–8,1 ms) | 91–188 (p50 31–44 ms) |
| 1 document, 2 field, 1 lệnh | 1 746–1 954 (p50 2,3–2,8 ms) | 116–346 (p50 16–33 ms) |
Với w:1, transaction thêm một vòng commit, mất ~15 % so với 2 lệnh rời; gộp vào 1 document nhanh gấp 2,0–2,6 lần. Với majority, transaction chỉ chờ đa số một lần lúc commit
nên ngang hoặc hơn 2 lệnh rời (2/3 lượt); số majority dao động mạnh trên máy này.
| Trục | 1 lệnh / 1 document | MongoDB transaction | PostgreSQL REPEATABLE READ | PostgreSQL SERIALIZABLE | Saga / 2PC tự viết |
|---|---|---|---|---|---|
| Phạm vi atomic | 1 document | Nhiều document, collection, shard | Nhiều dòng/bảng | Nhiều dòng/bảng | Nhiều service, có bù trừ |
| Write skew | Không có (invariant trong 1 document) | Lọt | Lọt | Chặn | Tự lo |
| Đụng nhau | Server tự retry | Lỗi ngay, client chạy lại cả gói | Chờ rồi lỗi | Chờ / lỗi lúc commit | Tự lo |
| Độ phức tạp code | Thấp, cần mô hình đúng | Trung bình: retry, idempotent, session | Thấp, quen thuộc | Thấp + retry 40001 | Cao |
| Tuyển người | Dễ | Ít người hiểu nhãn lỗi | Dễ | Ít người dùng thật | Khó |
Dùng transaction khi: invariant thật sự chạm ≥ 2 document và không gom được (chuyển tiền giữa 2 ví, trừ kho + tạo đơn + outbox), cần đọc nhất quán qua nhiều collection, transaction ngắn và ít tranh chấp. Tránh khi: invariant nằm gọn trong 1 document (dùng update có điều kiện), invariant kiểu "đếm rồi chặn" (write skew — materialize hoặc chuyển về 1 document), document nóng, batch lớn hay chạy lâu, trong callback có I/O ra ngoài, hoặc nghiệp vụ cần serializable thật — lúc đó PostgreSQL SERIALIZABLE là công cụ đúng hơn.
8. Góc nhìn tech lead: tác động tổ chức
- Quy tắc thiết kế (ghi vào ADR): invariant nằm trong 1 document là mặc định; transaction phải có lý do viết ra (invariant nào, chạm document nào). Ngân sách: < 100 ms, ≤ 100 document, không HTTP/email/queue trong callback, side effect qua outbox.
- Một hàm transaction dùng chung cho cả team (như
txn()ở 5.5): retry theo nhãn, giới hạn thời gian, đếm số lần chạy lại thành metric, log khi lỗi commit không rõ kết quả. Với Laravel: không dùngtransaction()nguyên bản của laravel-mongodb 5.11 cho dữ liệu tiền/kho; pin version và có test failover trước khi nâng. - Test đua trong CI cho từng invariant quan trọng (50 client, như lab skew) và một test failover (kill primary giữa tải). Test đơn luồng không bắt được 5.1, 5.5, 5.7.
- Onboarding: dev từ SQL quen
BEGIN … COMMITbọc mọi thứ vàSELECT … FOR UPDATE; dạy 3 mẫu thay thế: update có điều kiện, materialize conflict, outbox. Review checklist: mọi lệnh cósession? callback có side effect? có đếm-rồi-ghi? - Lock-in và exit strategy: API transaction theo spec chung của driver, semantics gần PostgreSQL RR, nên code dựa trên update có điều kiện chuyển sang SQL dễ
(
UPDATE … WHERE seats > 0). Chiều ngược lại nguy hiểm hơn: code dựa vào SERIALIZABLE của PostgreSQL chuyển sang MongoDB sẽ lọt write skew mà không lỗi nào báo. Giữ logic invariant ở tầng repository, kèm test đua — đó là thứ mang theo được khi đổi DB. - Rủi ro dài hạn: lỗi nằm ở đường hiếm của ORM/driver (failover lúc commit, quá 60 s), không lộ lúc bình thường → job đối soát số liệu nghiệp vụ định kỳ.
9. Anti-patterns và bài học thực tế
- Bọc mọi request trong transaction — tăng xung đột, giữ snapshot, không thêm an toàn cho thao tác 1 document.
- Gọi API ngoài trong callback — gửi trùng (5.4), giữ transaction lâu, chạm 60 s (5.5).
- "Đếm rồi insert" để giữ giới hạn — write skew (5.1).
- Bộ đếm thống kê trong transaction — biến mọi transaction thành tranh chấp một document (5.2).
- Nuốt lỗi trong callback rồi đi tiếp — server đã huỷ transaction, lệnh sau nhận
251 NoSuchTransaction(lab basics C). - Quên session / dùng
raw()trong closure — chạy ngoài transaction, có thể tự chặn 60 s (5.6). - Tin rằng lỗi = chưa commit — 3–6 transaction mỗi lần failover đã commit nhưng app nhận lỗi (5.7).
- Chở batch lớn bằng transaction — lỗi 388, 60 s, chặn write khác; dùng lô + idempotent.
Jepsen MongoDB 4.2.6 (jepsen.io, 2020-05-15; sharded cluster 2 shard × 3 node, workload list-append): "even at the strongest levels
of read and write concern, it failed to preserve snapshot isolation" — thấy read skew, cyclic information flow, G-single, G2; với mặc định thì transaction lấy write concern của client
(w:1 thời đó) và read concern local → mất write đã ack, đọc dữ liệu của transaction bị huỷ; transaction "ignore the retryWrites setting, and retry regardless". MongoDB quy phần lớn
anomaly cho SERVER-48307 (transaction ghi 1 shard, đọc shard khác, retry commit báo sai), sửa ở 4.2.8 / 4.4.0-rc7. Từ đó: 5.0 đổi mặc định
sang w:"majority", nhưng readConcern trong transaction vẫn mặc định local (log lab: implicitDefault) — lời khuyên của Jepsen "đặt read/write concern rõ cho từng transaction"
vẫn đúng. Jepsen chưa kiểm lại các bản 5.x–9.x (chưa verify độ an toàn hiện tại ở quy mô đó).
10. Câu hỏi phụ interviewer hay hỏi tiếp
- Snapshot isolation khác serializable chỗ nào? Ví dụ?
- SI chỉ bắt xung đột ghi–ghi trên cùng document. Hai transaction đọc chung điều kiện rồi ghi document khác nhau (đếm 29 rồi cùng insert) đều commit → 31/30. Serializable (PostgreSQL SSI) theo dõi cả phụ thuộc đọc–ghi và huỷ một bên.
- Vì sao ngoài transaction server tự retry WriteConflict còn trong transaction thì không?
- Lệnh đơn chạy lại được từ đầu. Trong transaction, các lệnh trước đã đọc trên snapshot cũ; chạy lại một lệnh với snapshot mới sẽ trộn hai thời điểm → phải huỷ cả gói, client chạy lại.
TransientTransactionErrorvàUnknownTransactionCommitResultkhác gì?- Cái đầu: transaction chắc chắn đã huỷ, chạy lại cả gói. Cái sau: commit có thể đã thành, chỉ gửi lại
commitTransaction; chạy lại cả gói có thể làm 2 lần. - MongoDB có
SELECT … FOR UPDATEkhông? - Không. Ghi lên document trước (update có điều kiện, hoặc
$setfield giả) để "giữ" nó; hoặcfindOneAndUpdate. - Transaction đang mở có chặn đọc, chặn ghi không?
- Không chặn đọc. Ghi thường lên document nó giữ thì chờ; ghi trong transaction khác thì WriteConflict ngay; DDL xếp sau nó thì chặn ghi cả collection.
- Đọc ở secondary ngay sau khi ghi thì sao?
- Có thể hụt (lab 9–10/500, đọc majority còn 64–67/500). Dùng causal session (0/500) hoặc đọc primary.
- Primary chết lúc commit thì sao?
- Transaction là all-or-nothing, nhưng app có thể nhận lỗi cho transaction đã commit (lab 3–6 mỗi lần). Chạy lại phải idempotent theo khoá xác định trước.
Phụ lục: lab đã chạy
Môi trường: MongoDB 9.0.2, rs0 = tmp-mdb-r1 (priority 2), r2, r3, mỗi node --memory 1g, cache 0,25 GB; MongoDB 8.0.32 (tmp-mdb-80, 1 node) để so;
Node 22 + mongodb 7.7.0 + mongoose 9.10.4; PHP 8.3 + ext-mongodb 2.5.3 + mongodb/mongodb 2.5.0 + laravel-mongodb 5.11.0 (Laravel 13.34); PostgreSQL 18.4 (my-test-pgdb,
bảng tmp_mdb_*, đã drop). Chạy lại:
cd labs/mongodb
./08-transaction.sh # tất cả (~25 phút), hoặc từng phần:
./08-transaction.sh basics|atomic|skew|pg|conflict|wait|sidefx|laravel|session|causal|isolation|cost|limits|v80|failover
source ./lib.sh && mdb_down $RS_NODES tmp-mdb-80 # dọn
Output: out/08-*.txt — mỗi phần một file cùng tên (08-laravel-*: attempts, lost, raw, wrap; 08-v80*: 8.0.32; 08-failover-run1: lần chưa tạo collection trước).
Script: 08-transaction.sh, js/08-*.js, node/08-*.mjs, laravel/08-*.php, sql/08-skew-pg.py (dùng parallel() của pgtest.py).
Bẫy khi dựng lab: sed -i lên file đang được tee -a ghi → output rơi vào inode đã xoá; php_lab mặc định nối tmp-mdb-1, phải export
MONGODB_URI của rs0; Laravel 13 query builder trả stdClass; nhãn lỗi trong APM của ext-mongodb phải đọc ở getReply()->errorLabels.
Chưa verify: transaction xuyên shard (2PC, prepare conflict — bài 9); causal consistency qua failover; killSessions để huỷ transaction treo; ngưỡng cache chung cho kích thước transaction;
Atlas; Jepsen trên bản mới; driver khác Node/PHP (Java, Go, Python) ở ca failover lúc commit; laravel-mongodb bản sau 5.11.0.