Transactions (P1/3): Một gói hai kết cục; replica set; vòng đời
Bài này trả lời hai câu hỏi. Một: khi một nghiệp vụ ghi nhiều document, làm sao để các thay đổi thành một gói? Hai: gói đó tốn bao nhiêu?
Bài có 3 Part:
- P1 (Part này): Một gói hai kết cục; replica set; vòng đời.
- P2: Lỗi, retry và giới hạn.
- P3: Cái giá: đo latency; so với PostgreSQL.
Phần Index & Query khép lại bằng một thí nghiệm khó chịu: một cursor đọc trùng một đơn và bỏ sót một đơn khác, không báo lỗi gì. Bài Query Execution Engine giải thích vì sao: ngoài transaction, một thao tác đọc không nhìn dữ liệu ở một thời điểm duy nhất.
Bài này đi tiếp từ phía ghi. Mọi lệnh ghi ta đo từ đầu series đều chạm một document, và bài Document Model đã cho thấy lệnh như vậy atomic sẵn. Nhưng tạo một đơn hàng thật là ba việc: ghi đơn, trừ kho, ghi sổ cái. Việc thứ hai thất bại thì sao? Và gói ba việc lại thì tốn thêm bao nhiêu?
Bài này nằm ở đâu
- Cần biết trước: Document Model (single-document atomicity, thí nghiệm lost update), Data Modeling (embed hay reference), CRUD & Query Model (
updateManyvàbulkWritekhông atomic như một khối), Query Execution Engine (đọc ngoài transaction không phải snapshot) - Giới thiệu: replica set tối thiểu (primary, secondary, majority), single-document vs multi-document atomicity, session,
startTransaction/commitTransaction/abortTransaction, Callback API vs Core API, error labelTransientTransactionErrorvàUnknownTransactionCommitResult, vòng retry, giới hạn (thời gian sống, chờ lock, kích thước, thao tác bị cấm), cái giá đo bằng latency và throughput - Dẫn tới: Isolation & Snapshot
Môi trường lab: MongoDB 8.3.11 trong Docker (image
mongo:8), một replica set 3 node tênrs16: ba containermongo-rs16-a/-b/-ctrên cùng một máy host Apple M4, mỗi container 1 CPU, 1 GB RAM, WiredTiger cache 0,25 GB, databaselab16. Client là mongosh 2.12.0 trong một container riêng cùng network. Ba node chung một máy nên mạng giữa chúng gần như bằng không (ping client → primary: 0,11–0,13 ms): mọi số đow: "majority"ở đây là cận dưới, vì trên production mỗi lần chờ majority cộng thêm ít nhất một vòng mạng giữa các node. Số nào không đo thì ghi là minh hoạ.
Nhãn: [tài liệu] theo tài liệu chính thức; [quan sát] đo trong lab này (8.3.11); [chi tiết cài đặt] cách server đang làm, không phải cam kết API; [hình dung] mô hình để dễ nhớ.
Ý chính
Transaction là một nhóm thao tác đọc và ghi mà database xử lý như một gói: hoặc mọi thay đổi trong gói cùng có hiệu lực, hoặc không thay đổi nào có hiệu lực. Trong MongoDB, ghi một document vốn đã là một gói như vậy. Multi-document transaction mở rộng điều đó ra nhiều document, nhiều collection, thậm chí nhiều database.
Nó không miễn phí: cần replica set (hoặc sharded cluster), sống trong một session, hết hạn sau 60 giây theo mặc định, có thể bị huỷ giữa chừng vì xung đột và khi đó ứng dụng phải làm lại cả gói, và tốn thêm round-trip cùng một bước commit. Câu hỏi đầu tiên luôn là: dữ liệu này có nằm chung một document được không?
Hình dung trước: chuyển tiền giữa hai ví
Lan chuyển 100.000đ cho Bình. Trên sổ của ví điện tử, đó là hai dòng: trừ 100.000đ ở ví Lan, cộng 100.000đ vào ví Bình.
Kế toán cẩn thận không ghi thẳng vào sổ cái. Chị viết hai dòng lên một tờ nháp mang số phiếu của mình; ai mở sổ cái lúc này vẫn thấy số dư cũ. Viết xong, chị đóng dấu và chép tờ nháp vào sổ một lần. Đang viết dở mà thấy ví Lan không đủ tiền, hoặc mất điện, chị xé tờ nháp: sổ cái chưa từng bị đụng tới. Nếu đồng nghiệp cũng đang viết nháp một phiếu trừ chính ví Lan, hai người không thể cùng đóng dấu: một người phải xé nháp và làm lại trên số dư mới.
Đơn hàng cũng vậy, chỉ là ba dòng: ghi đơn, trừ kho, ghi sổ cái.
Phòng kế toán MongoDB
────────────────────────────────────── ──────────────────────────────────────
số phiếu của kế toán session (lsid) + txnNumber
viết nháp: trừ ví Lan, cộng ví Bình các lệnh ghi bên trong transaction
người khác vẫn thấy số dư cũ thay đổi chưa commit không ai thấy
đóng dấu, chép vào sổ một lần commitTransaction
xé tờ nháp abortTransaction (hoặc server tự abort)
hai người cùng viết nháp một ví write conflict → làm lại cả gói
sổ cái có hai bản sao ở hai phòng khác replica set: secondary chép theo primary[hình dung] Đây chỉ là cách hình dung. MongoDB không có "tờ nháp" riêng: thay đổi của transaction nằm trong storage engine dưới dạng các phiên bản chưa commit, và cách engine giấu chúng khỏi người đọc khác là chủ đề của bài Isolation & Snapshot và bài WiredTiger MVCC.
Mental model: một gói, hai kết cục
startTransaction
│
├── insert đơn hàng ✓
├── trừ kho SKU-007 ✓
└── ghi sổ cái ✓
│
▼
commitTransaction → cả ba cùng hiện ra với mọi người, cùng một lúcstartTransaction
│
├── insert đơn hàng ✓
├── trừ kho SKU-007 ✗ (hết hàng, write conflict, quá hạn...)
└── ghi sổ cái – không chạy
│
▼
abortTransaction → đơn hàng đã insert cũng biến mất; không ai từng thấy nóHai ý giữ suốt bài: gói có thể bị huỷ mà ứng dụng không hề gọi abort (xung đột, quá hạn, một lệnh lỗi), nên ứng dụng phải sẵn sàng làm lại cả gói; và "commit xong" nghĩa là bao nhiêu node đã có dữ liệu thì tuỳ write concern. Vì vậy phải nói về replica set trước.
Replica set tối thiểu
Transaction và write concern đều nói về "bao nhiêu máy đã có dữ liệu", nên cần một bức tranh tối thiểu về replica set. Phần này chỉ đủ dùng cho bài; cơ chế thật nằm ở bài Replica Set & Oplog và bài Elections, Failover & Majority.
app / driver
│ mọi lệnh ghi (và mặc định mọi lệnh đọc)
▼
┌──────────────────┐
│ PRIMARY (a) │ nhận lệnh ghi, ghi vào oplog
└───┬──────────┬───┘
chép oplog │ │ chép oplog
▼ ▼
┌──────────────┐ ┌──────────────┐
│ SECONDARY (b)│ │ SECONDARY (c)│ áp dụng lại các thay đổi
└──────────────┘ └──────────────┘
w: 1 → trả lời khi primary đã áp dụng
w: "majority" → trả lời khi 2 trên 3 node (primary + 1 secondary) đã ghi[tài liệu] Replica set là một nhóm mongod giữ cùng một bộ dữ liệu. Chỉ một node là primary; primary nhận mọi lệnh ghi và ghi mọi thay đổi vào oplog. Secondary chép oplog của primary rồi áp dụng lại, bất đồng bộ. Primary mất thì một secondary được bầu lên thay.
Majority là "quá nửa số node có quyền bầu". [tài liệu] Với 3 node P-S-S, majority là 2: lệnh ghi w: "majority" phải nằm trong oplog của primary và một secondary mới được báo thành công. Với w: 1, primary trả lời ngay khi áp dụng xong, và dữ liệu đó có thể bị rollback nếu primary sập trước khi kịp chép đi. Từ 5.0, write concern mặc định là w: "majority" (trừ một trường hợp có arbiter).
[quan sát] Lab đọc được đúng những điều đó:
mongo-rs16-a:27017 PRIMARY
mongo-rs16-b:27017 SECONDARY
mongo-rs16-c:27017 SECONDARY
writeMajorityCount 2 votingMembersCount 3
defaultWriteConcern { w: 'majority', wtimeout: 0 } (source: implicit)
featureCompatibilityVersion 8.3Vì sao transaction cần replica set? [tài liệu] Trang Production Considerations ghi thẳng: deployment standalone không hỗ trợ transaction; phải là replica set (FCV tối thiểu 4.0) hoặc sharded cluster (4.2). Một transaction đã commit được ghi thành entry trong oplog (lab sẽ cho xem), và oplog chính là thứ secondary chép theo. Oplog, election và rollback hoạt động ra sao là chuyện của Phần Phân tán.
Lab: ba container mongod --replSet rs16 --wiredTigerCacheSizeGB 0.25 --bind_ip_all trên network rs16, rs.initiate() với ba member (mongo-rs16-a priority 2 để nó làm primary). Client kết nối bằng mongodb://mongo-rs16-a,mongo-rs16-b,mongo-rs16-c/lab16?replicaSet=rs16 để driver tự tìm primary.
Setup và dataset
Vẫn là SaaS bán hàng nhiều tenant của cả series, thêm tồn kho, sổ cái và ví:
MongoDB version : 8.3.11 (mongo:8), replica set 3 node, primary = mongo-rs16-a
Hardware : Apple M4 host; mỗi node 1 CPU, 1 GB RAM
Configuration : --wiredTigerCacheSizeGB 0.25
Dataset : lab16.products 10.000 (100 tenant × 100 SKU, stock 1.000.000)
lab16.wallets 10.000 (100 tenant × 100 user, balance 10.000.000đ)
lab16.orders 50.000 (1–3 món mỗi đơn, 9,9 MB)
lab16.ledger 50.000 (một dòng doanh thu cho mỗi đơn, 4,8 MB)
Indexes : _id_; orders { tenantId: 1, createdAt: -1 }; ledger { tenantId: 1, at: -1 }// trích seed.js (PRNG mulberry32 seed 16): _id mang tenantId ở đầu
{ _id: "t042:SKU-007", tenantId: "t042", sku: "SKU-007", price: 250000, stock: 1000000 } // products
{ _id: "t042:u001", tenantId: "t042", balance: 10000000 } // wallets
{ tenantId: "t042", orderId, type: "sale", amount, at } // ledgerMột document hay nhiều document
Khi một document là đủ
[tài liệu] Trang Transactions mở đầu: một thao tác trên một document là atomic, và nhờ embedded document và array, nhiều trường hợp thực tế không cần multi-document transaction. Transaction thường tốn hơn ghi một document, và không nên thay thế cho thiết kế schema tốt.
Trước khi mở transaction, hỏi ba câu:
Những dữ liệu phải đổi CÙNG NHAU có nằm được trong MỘT document không?
│
├── có, và document không phình vô hạn
│ → một lệnh ghi, có điều kiện trong filter. Không cần transaction.
│ Ví dụ: đơn + các order item (embed); kho + đã bán + người mua
│
├── có thể, nếu đổi cách nghĩ về dữ liệu
│ → ví dụ: thay vì trừ kho ở products rồi ghi đơn, ghi đơn kèm
│ trạng thái "chờ trừ kho" và để một job xử lý tuần tự (chấp nhận trễ)
│
└── không: chúng thuộc những thực thể sống độc lập, nhiều nơi cùng sửa
→ multi-document transactionNhánh đầu đã được đo. Bài Document Model: "kiểm tra còn hàng rồi trừ kho" viết thành một updateOne có điều kiện stock: { $gte: 1 } luôn bán đúng 100 cái, còn đọc-tính-ghi ở app bán 168–195 cái từ kho 100. Bài Data Modeling: embed order item vào đơn làm "tạo đơn" thành một lệnh insert, còn mô hình reference ghi 1 + N document và cần transaction nếu phải "tất cả hoặc không gì cả".
Khi thật sự cần transaction
Có những thứ không nhét chung được, và không nên:
- Chuyển tiền giữa hai ví. Hai ví của hai người, mỗi ví bị hàng trăm giao dịch khác sửa. Gộp mọi ví vào một document là tạo ra một document khổng lồ ai cũng tranh nhau.
- Đơn + tồn kho + sổ cái. Kho thuộc về sản phẩm, bị hàng nghìn đơn cùng trừ; sổ cái là nhật ký chỉ thêm; đơn thuộc về khách. Ba vòng đời, ba access pattern.
- Bản chép "cũ một chút là sai" (bài Data Modeling), hay review ghi hai chỗ phải luôn khớp (bài Schema Design Patterns): cập nhật trong cùng transaction với bản gốc. Chấp nhận lệch tạm thời thì lệnh ghi idempotent và retry là đủ.
Câu hỏi để quyết định không phải "có liên quan nhau không", mà là: nếu chỉ một nửa thay đổi xảy ra, nghiệp vụ có sai không, và có ai nhìn thấy cái sai đó không?
Vòng đời của một transaction
Session, start, các lệnh bên trong, commit hoặc abort
[tài liệu] Transaction gắn với một session. Mỗi session chỉ có tối đa một transaction đang mở. Với driver, mỗi lệnh trong transaction phải được truyền session; lệnh quên session sẽ chạy ngoài transaction. Session kết thúc khi transaction còn mở thì transaction bị abort.
Hàm đặt hàng của lab, viết theo kiểu Core API (gọi tường minh từng bước):
function placeOrder(session, tenantId, sku, qty) {
const s = session.getDatabase("lab16"); // mọi lệnh đi qua session này
const p = s.products.findOne({ _id: tenantId + ":" + sku });
const orderId = new ObjectId(), total = qty * p.price;
s.orders.insertOne({ _id: orderId, tenantId, userId: "u001", status: "paid",
items: [{ sku, qty, price: p.price }], total, createdAt: new Date() });
const r = s.products.updateOne({ _id: tenantId + ":" + sku, stock: { $gte: qty } },
{ $inc: { stock: -qty } });
if (r.modifiedCount !== 1) throw new Error("hết hàng: " + sku);
s.ledger.insertOne({ tenantId, orderId, type: "sale", amount: total, at: new Date() });
return orderId;
}
const session = db.getMongo().startSession();
session.startTransaction({ writeConcern: { w: "majority" } });
try {
placeOrder(session, "t042", "SKU-007", 2);
session.commitTransaction();
} catch (e) {
session.abortTransaction();
throw e;
} finally {
session.endSession();
}Đơn được insert trước khi biết kho có đủ không. Ngoài transaction, đó là bug; trong transaction thì vô hại, vì abort xoá luôn đơn.
[quan sát] Đặt stock của t042:SKU-007 về 5. Lượt 1 mua 2 cái và commit; lượt 2 mua 10 cái, hỏng ở bước trừ kho:
trong txn, client khác thấy: order = null stock = 5 ledger = 0
trong txn, chính session thấy stock = 3
sau commit, client khác thấy: order = 1 stock = 3 ledger = 1
lỗi: hết hàng: SKU-007
trước: {"orders":523,"ledger":523} sau abort: {"orders":523,"ledger":523} stock = 3Lượt 2 đã insert một đơn, nhưng sau abort số đơn của t042 vẫn là 523.
Người khác thấy gì trước khi commit
[tài liệu] Trước commit, thay đổi của transaction không hiển thị ra ngoài; commit xong thì cùng hiện ra; abort thì bị bỏ mà chưa từng hiển thị. Dòng đầu output ở trên là vậy: client thứ hai thấy stock = 5 và chưa có đơn, chính session thấy stock = 3. Transaction đọc trên snapshot nào, thấy gì từ những người commit giữa chừng, là nội dung của bài Isolation & Snapshot.
Một lệnh lỗi là cả gói bị huỷ
Lab tình cờ cho thấy điều này: chạy chín thao tác trong cùng một transaction để xem cái nào bị cấm, thao tác đầu bị từ chối, và từ đó mọi thao tác hợp lệ phía sau đều thất bại:
✗ createIndex trên orders (đã có dữ liệu) → OperationNotSupportedInTransaction
✗ lệnh count → OperationNotSupportedInTransaction
✗ countDocuments (aggregate $group) → NoSuchTransaction | Transaction with { txnNumber: 1 } has been aborted.
✗ createCollection mới → NoSuchTransaction | ...has been aborted.
commit: NoSuchTransaction ["TransientTransactionError"][tài liệu + quan sát] Tài liệu nói lỗi phía server (ví dụ DuplicateKeyError) có thể kết thúc transaction dù client chưa gọi abortTransaction(); lab thấy lỗi "không hỗ trợ" cũng vậy. (countDocuments, createCollection chạy riêng thì được, xem mục "Những thao tác không được phép".) Không có "bắt lỗi rồi đi tiếp" trong một transaction: lỗi là làm lại cả gói, hoặc bỏ.
Callback API và Core API
Driver cho hai cách viết transaction.
[tài liệu] Core API: ứng dụng tự gọi startTransaction, commitTransaction, abortTransaction. Nó không tự xử lý TransientTransactionError và UnknownTransactionCommitResult; ứng dụng phải tự viết vòng retry. Callback API (withTransaction): ứng dụng đưa vào một hàm; driver mở transaction, chạy hàm, commit, hoặc abort nếu hàm ném lỗi; nó tự retry cả transaction khi gặp TransientTransactionError và tự retry commit khi gặp UnknownTransactionCommitResult. Lỗi không mang hai error label đó (ví dụ TransactionTooLargeForCache, xem mục "Kích thước") thì Callback API không chạy lại.
// Callback API trong mongosh: Session.withTransaction()
session.withTransaction(() => {
const w = session.getDatabase("lab16").wallets;
const r = w.updateOne({ _id: from, balance: { $gte: amount } }, { $inc: { balance: -amount } });
if (r.modifiedCount !== 1) throw new Error("không đủ số dư"); // ném lỗi → abort, không retry
w.updateOne({ _id: to }, { $inc: { balance: amount } });
}, { writeConcern: { w: "majority" } });Vì hàm có thể chạy nhiều lần, bên trong chỉ nên có thao tác database. Gửi email, gọi API thanh toán, đẩy message thì đặt sau khi withTransaction trả về, nếu không mỗi lần retry là một email nữa.
Cột mốc: Bạn đã có thể mô tả một transaction như một gói, nói thay đổi chưa commit ai thấy được, và biết vì sao một lệnh lỗi huỷ cả gói. Tiếp theo: Lỗi, retry và giới hạn.
Hỏi & đáp
Đơn hàng nhúng sẵn các order item (items) trong một document. Tạo một đơn mới có cần multi-document transaction để "đơn không bao giờ thiếu order item" không?
Kế toán chuyển 100.000đ từ ví Lan sang ví Bình bằng hai dòng ghi sổ. Ghi xong dòng trừ ví Lan thì mất điện. Cách làm việc nào đảm bảo không ai thấy tiền "biến mất"?