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, id lsid, mỗi transaction một txnNumber) · 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 entry applyOps → 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ãn TransientTransactionError → 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

  1. 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 $inc không bao giờ mất.
  2. 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:

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
WriteConflict trong transaction và write thường phải chờ T1 sửa A trong transaction. T2 sửa A: WriteConflict ngay, huỷ, chạy lại sau khi T1 commit. updateOne thường (W) sửa A: chờ, server tự retry tới khi T1 commit. Đọc ngoài thấy bản cũ, không chờ. T1transaction T2transaction Wupdate thường document A bal 70 69 · T1 chưa commit đọc ngoài: vẫn thấy 70, không chờ 69 ✓ T1 commit 169 ✓ W ghi xong 168 ✓ T2 chạy lại xong updateOne A commit ✓ updateOne A WriteConflict ✗ 112 · TransientTransactionError · 19 ms, không chờ T1 abort · chờ backoff chạy lại ✓ updateOne A chờ — server retry nội bộ xong ✓ ngay sau khi T1 commit
Hình 1 — First-writer-wins. T1 ghi A trước (bản chưa commit, viền cam). T2 ghi A trong transaction khác: lỗi 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ãnNghĩaGặp khiPhải làm
TransientTransactionErrorTransaction đã huỷ, không để lại gìWriteConflict 112, NoSuchTransaction 251, LockTimeout 24, lỗi mạng giữa transactionChạy lại cả transaction
UnknownTransactionCommitResultCommit có thể đã thànhLỗi mạng, mất primary, lỗi write concern lúc commitChỉ 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 đượcTooManyOpenTransactions 509, OperationNotSupportedInTransaction 263, quá cache 388Né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 viNguồn
Atomic 1 documentLuôn có, gồm cả index và entry oplog; ghi đụng nhau thì server tự retrystorage README, lab atomic
Atomic nhiều documentChỉ trong transaction. updateMany/insertMany ngoài transaction: từng document riêng, người khác thấy nửa chừnglab isolation (5.8)
DurabilityTheo 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ống60 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 TransientTransactionErrorlab wait C
Số transaction mở10 000 (maxConcurrentMultiDocumentTransactions, có ở cả 8.0.32) → 509 TooManyOpenTransactions, không có nhãn retrylab limits, release notes
Kích thướcKhô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
DDLTạ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 263lab 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ượngMongoDB transactionPostgreSQL REPEATABLE READPostgreSQL SERIALIZABLE
Dirty read, non-repeatable read, phantomKhôngKhôngKhô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 updateNhư RR
Write skew (đếm 29 rồi cùng insert)Lọt: 31/30Lọt: 31/30Chặ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

Write skew dưới snapshot isolation và cách chữa bằng materialize conflict A: T1, T2 cùng đếm 29 trên một snapshot, mỗi bên insert một học viên, cả hai commit: 31. B: cả hai $inc document khoá học; T2 WriteConflict, chạy lại, thấy taken = 30, dừng: đúng 30. A · Đếm rồi insert trong transaction (snapshot isolation) B · Cùng $inc document khoá học trong transaction (materialize conflict) Khoá C1 tối đa 30, đang có 29. T1 và T2 bắt đầu cùng lúc, đọc cùng một snapshot. T1T2 enrollments · khoá C1 đếm → 29 đếm → 29 insert an insert bình commit ✓ commit ✓ $inc taken ✓ cùng document courses.C1 $inc taken WriteConflict ✗ commit ✓ chạy lại → hết chỗ 29 30 30 31 31 / 30 — vượt sức chứa 30 / 30 — đúng sức chứa T1, T2 ghi 2 document khác nhau → không có xung đột nào để phát hiện → 31 học viên. T2 đụng document T1 đang giữ → WriteConflict ngay → chạy lại, thấy taken = 30 → dừng. Lab: 2 phiên xen kẽ 31/30 · 50 client giành 10 chỗ: 20/20 vòng vượt, tới 70/30 · cách B: luôn 30
Hình 2 — Snapshot isolation chỉ phát hiện xung đột khi hai transaction ghi cùng một document. A: mỗi bên insert một document mới, không ai đụng ai → cả hai commit, 31/30. B: bắt cả hai ghi chung document 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:

  1. Một lệnh update có điều kiện trên document đại diện cho invariant (courses giữ taken và danh sách học viên): đúng 30, không transaction, 31–39 ms/vòng.
  2. Materialize conflict trong transaction: giữ logic cũ nhưng thêm một lệnh ghi lên document chung ($inc có điều kiện, hoặc $set mộ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).
  3. 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/scallback / đơnwriteConflicts (server)p50 / p99
transaction, cùng 1 sản phẩm59 / 61 / 72×4,21–4,33+1 933…+2 308105–162 ms / 2,6–3,3 s
transaction, mỗi client 1 sản phẩm266–433×1,00035–48 ms / 210–380 ms
không transaction (2 lệnh rời), cùng 1 sản phẩm288–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ẩm375–410×1,00039–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 xếp hàng sau một transaction dài T1 giữ IX 4,1 s. Giây 0,5: createIndex xin X, chờ T1. Giây 1: transaction mới LockTimeout sau 5 ms; insert thường chờ 3,15 s; find xong 17 ms; insert collection khác 24 ms. T1 commit, createIndex dựng index tới giây 5,9. T1 · transactioncreateIndex transaction mớiinsert thường find (đọc)insert collection khác commit 4,1 s chờ khoá X của collection dựng index LockTimeout sau 5 ms (TransientTransactionError) chờ 3 151 ms✓ ✓ 17 ms — đọc không lấy khoá collection ✓ 24 ms 0 s1 s2 s3 s4 s5 s6 s
Hình 3 — Hàng đợi khoá của collection cấp theo thứ tự xếp hàng (lock manager README). 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

withTransaction chạy lại callback: email gửi 2 lần, outbox chỉ 1 Trái: lần 1 gửi email rồi WriteConflict, rollback nhưng email đã đi; lần 2 gửi lại rồi commit: 1 đơn, 2 email. Phải: email nằm trong outbox của cùng transaction; lần 1 rollback xoá nó, lần 2 commit; publisher gửi đúng 1. Gửi email trong callbackOutbox trong cùng transaction lần 1lần 2lần 1lần 2 insert đơn insert đơn gửi email insert outbox $inc stats $inc stats WriteConflict WriteConflict rollback — nhưng email đã gửi đi rollback cả đơn lẫn outbox insert đơn insert đơn gửi email insert outbox $inc stats $inc stats commit ✓ — 1 đơncommit ✓ — 1 đơn Hộp thư khách hàngHộp thư khách hàng 0 1 2 2 email cho 1 đơn 0 1 publisher (change stream) gửi đúng 1
Hình 4 — Rollback chỉ xoá được thứ nằm trong DB. Email gửi trong callback đi luôn, lần chạy lại gửi thêm. Outbox là một document trong cùng transaction: lần bị huỷ xoá cả nó; chỉ lần commit để lại đúng một bản, publisher gửi sau (bài 10).
-- 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àoLab thấy gìDùng để
serverStatus().transactionsTransaction mở rồi ngồi chờ API: currentOpen 1 · currentActive 0 · currentInactive 1Tỉ 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 transactionDocument nóng (so cùng lúc với p99)
$currentOp + idleSessions: truetype idleSession · timeOpenMicros 1518941 · timeInactiveMicros 1518747 · expiryTime = bắt đầu + 60 sTì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áchw:1 (lần chuyển/s)w:"majority" (lần chuyển/s)
2 lệnh rời, không transaction723–864 (p50 6,4–6,9 ms)87–183 (p50 35–70 ms)
transaction 2 lệnh628–748 (p50 7,8–8,1 ms)91–188 (p50 31–44 ms)
1 document, 2 field, 1 lệnh1 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ục1 lệnh / 1 documentMongoDB transactionPostgreSQL REPEATABLE READPostgreSQL SERIALIZABLESaga / 2PC tự viết
Phạm vi atomic1 documentNhiều document, collection, shardNhiều dòng/bảngNhiều dòng/bảngNhiều service, có bù trừ
Write skewKhông có (invariant trong 1 document)LọtLọtChặnTự lo
Đụng nhauServer tự retryLỗi ngay, client chạy lại cả góiChờ rồi lỗiChờ / lỗi lúc commitTự lo
Độ phức tạp codeThấp, cần mô hình đúngTrung bình: retry, idempotent, sessionThấp, quen thuộcThấp + retry 40001Cao
Tuyển ngườiDễÍt người hiểu nhãn lỗiDễÍt người dùng thậtKhó

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

9. Anti-patterns và bài học thực tế

  1. 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.
  2. Gọi API ngoài trong callback — gửi trùng (5.4), giữ transaction lâu, chạm 60 s (5.5).
  3. "Đếm rồi insert" để giữ giới hạn — write skew (5.1).
  4. Bộ đếm thống kê trong transaction — biến mọi transaction thành tranh chấp một document (5.2).
  5. Nuốt lỗi trong callback rồi đi tiếp — server đã huỷ transaction, lệnh sau nhận 251 NoSuchTransaction (lab basics C).
  6. Quên session / dùng raw() trong closure — chạy ngoài transaction, có thể tự chặn 60 s (5.6).
  7. 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).
  8. 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.
TransientTransactionError và UnknownTransactionCommitResult khá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 UPDATE không?
Không. Ghi lên document trước (update có điều kiện, hoặc $set field giả) để "giữ" nó; hoặc findOneAndUpdate.
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.