Transactions (P2/3): Lỗi, retry và giới hạn
Ở phần trước: transaction là một gói. Thay đổi chưa commit không ai thấy được, và một lệnh lỗi làm cả gói bị huỷ, nên ứng dụng phải biết khi nào làm lại.
- Cần đọc trước: Một gói hai kết cục; replica set; vòng đời
- Dẫn tới: Cái giá: đo latency; so với PostgreSQL
Lỗi và retry ở mức transaction
TransientTransactionError: làm lại cả gói
[tài liệu] Từng lệnh ghi bên trong transaction không được retry riêng lẻ, bất kể retryWrites. Khi một lệnh gặp lỗi mang error label "TransientTransactionError" (error label là một chuỗi server gắn kèm lỗi để driver biết có nên thử lại hay không; ví dụ primary step down), cả transaction có thể được chạy lại từ đầu. Trang Production Considerations mô tả nguồn hay gặp nhất: nếu một lệnh ghi bên ngoài transaction đã sửa một document mà transaction sau đó định sửa, transaction bị abort vì write conflict; còn nếu transaction đã sửa document đó trước, lệnh ghi bên ngoài chờ tới khi transaction kết thúc.
[quan sát] Ba ca, ba kết cục:
Ca 1: transaction A đã sửa ví t042:u001 (chưa commit); transaction B sửa cùng ví
B bị từ chối sau 1 ms
WriteConflict code 112 labels ["TransientTransactionError"]
B commit: NoSuchTransaction code 251 labels ["TransientTransactionError"]
A commit ok
Ca 2: transaction C đọc ví t042:u002; một updateOne NGOÀI transaction sửa ví đó (xong sau 1 ms);
rồi C sửa ví đó
WriteConflict code 112 labels ["TransientTransactionError"]
Ca 3: transaction A sửa ví t042:u003 rồi giữ 3 s mới commit; một updateOne ngoài transaction
sửa cùng ví
updateOne ngoài transaction chờ 3022 ms | 3005 ms | 3016 ms (3 lần chạy)Transaction đến sau không xếp hàng: nó bị huỷ ngay (1 ms) và được gắn error label "tạm thời, làm lại đi". Lệnh đơn lẻ ngoài transaction thì [tài liệu] chờ tới khi transaction kết thúc (bên trong, server tự thử lại nó, như bài Document Model đã nhắc), và lab thấy nó chờ đúng bằng thời gian transaction kia còn mở. Ca 3 là lời cảnh báo: một transaction mở lâu chặn mọi lệnh ghi khác vào những document nó đã chạm.
Vòng retry: đo trên ví bị nhiều client cùng ghi
Bốn tiến trình mongosh chạy song song, mỗi tiến trình 300 lần chuyển tiền ngẫu nhiên giữa 5 ví của t001 (cố ý dồn tranh chấp), mỗi lần là một transaction 2 document, w: "majority". Ba cách viết:
- core: Core API, không retry: lỗi thì abort và đếm là thất bại.
- loop: Core API, vòng retry theo đúng mẫu của tài liệu:
TransientTransactionErrorthì chạy lại cả transaction,UnknownTransactionCommitResultthì chỉ chạy lại commit. - cb:
session.withTransaction().
// "loop" (trích); commitWithRetry() lặp lại commit khi lỗi có error label UnknownTransactionCommitResult
while (true) {
session.startTransaction({ writeConcern: { w: "majority" } });
try { transfer(from, to, amount); commitWithRetry(); break; }
catch (e) {
try { session.abortTransaction(); } catch (_) {}
if (e.errorLabels?.includes("TransientTransactionError")) continue; // làm lại CẢ GÓI
throw e; // lỗi nghiệp vụ: không retry
}
}[quan sát] Hai lượt, mỗi lượt 4 tiến trình × 300 lần chuyển:
| Cách viết | Chuyển thành công / 300 (mỗi tiến trình) | Số lần thử transaction / 300 | Thời gian mỗi tiến trình | Tổng tiền 5 ví sau lượt |
|---|---|---|---|---|
| core (không retry) | 135–172 | 300 | 1,15–1,21 s | 50.000.000 |
| loop (retry tự viết) | 300 | 549–608 | 2,0–2,2 s | 50.000.000 |
cb (withTransaction) | 300 | 433–465 | 1,6–2,2 s | 50.000.000 |
Ở lượt 2, mọi thất bại của "core" đều là WriteConflict ["TransientTransactionError"]. Số lần thử khớp với serverStatus().transactions.totalStarted của server.
Đọc bảng:
- Không retry thì 43–55% số lần chuyển tiền thất bại, dù chẳng có gì hỏng: chỉ là hai transaction đụng cùng một ví.
- Tổng tiền luôn đúng 50.000.000 ở cả ba cách. Atomicity giữ cho mỗi lần chuyển hoặc trọn vẹn, hoặc không xảy ra; nó không giữ cho lần chuyển thành công. Thành công là việc của vòng retry.
- Retry có giá: vòng tự viết cần gần gấp đôi số transaction (549–608 so với 300) và thời gian,
withTransactionkhoảng 1,5 lần (433–465), vì 5 ví cho 4 tiến trình là tranh chấp cực đoan. Vì saowithTransactioncần ít lần thử hơn thì lab không tìm hiểu, chỉ ghi nhận.
Vì sao một document thành hot document (nhiều client cùng ghi), và giảm tranh chấp bằng cách nào, là việc của bài Write Conflicts, Retry & Idempotency.
UnknownTransactionCommitResult: không biết đã commit chưa
[tài liệu] Commit là một retryable write: driver tự thử lại commit (một lần) kể cả khi retryWrites tắt. Nếu commit gặp lỗi mang error label "UnknownTransactionCommitResult", chỉ commit được chạy lại, không phải cả transaction. Khi retry commitTransaction, driver luôn dùng w: "majority".
Tái hiện được trong lab bằng cách làm cho majority không thể xác nhận: transaction chuyển 100.000đ từ u010 sang u011, w: "majority", wtimeout: 1000; ngay trước commit, mình docker pause cả hai secondary trong 7 giây. Primary không step down trong lúc đó (log của nó chỉ có một lần lên primary, term 1).
04:16:56.580 đã ghi 2 ví, chờ 3 s rồi commit (secondary sẽ bị pause)
04:17:00.599 commit lỗi sau 1018 ms: WriteConcernTimeout code 64
labels ["UnknownTransactionCommitResult"] | waiting for replication timed out
primary vẫn thấy balance u010 = 9900000
04:17:05 unpause hai secondary
04:17:06.614 commit lần 2 ok sau 4 ms
balance u010 = 9900000 u011 = 10100000[quan sát] Đúng là "kết quả không xác định": primary đã áp dụng transaction (đọc trên primary thấy 9.900.000) nhưng chưa chứng minh được majority có nó. Nếu ứng dụng coi lỗi là "chưa chuyển" và chạy lại cả transaction, Lan bị trừ hai lần. Đúng cách là chạy lại commit của chính transaction đó (cùng session, cùng txnNumber): lab commit lại sau 4 ms, tiền chỉ chuyển một lần.
lỗi có error label làm gì
──────────────────────────────────── ──────────────────────────────────────────
TransientTransactionError abort, chạy lại CẢ transaction từ đầu
UnknownTransactionCommitResult chạy lại CHỈ commitTransaction
không có error label (lỗi nghiệp vụ, 388...) abort, báo lỗi; retry không giúp gìRanh giới với bài sau
Retryable write (driver gửi lại một lệnh ghi đơn lẻ) và retry transaction (chạy lại cả gói) là hai cơ chế khác nhau, giải những lỗi khác nhau. So sánh chúng, và câu hỏi "cái gì phải idempotent", là trọng tâm của bài Write Conflicts, Retry & Idempotency.
Giới hạn
Thời gian sống: 60 giây
[tài liệu] Mặc định, một transaction phải chạy dưới một phút. Tham số transactionLifetimeLimitSeconds (mặc định 60, tối thiểu 1) đặt giới hạn này; transaction quá hạn bị một tiến trình nền định kỳ abort (chạy mỗi transactionLifetimeLimitSeconds/2 giây, hoặc ít nhất mỗi 60 giây). Mục đích: giảm áp lực lên WiredTiger cache.
[quan sát] Lab đọc được transactionLifetimeLimitSeconds: 60. Hạ xuống 5 giây (chỉ trong lab), mở transaction, sửa một ví, rồi "quên" nó 12 giây như một app đang chờ API bên ngoài:
sau 12006 ms, lệnh tiếp theo: NoSuchTransaction code 251 labels ["TransientTransactionError"]
| Transaction with { txnNumber: 1 } has been aborted.
commit: NoSuchTransaction labels ["TransientTransactionError"]
balance u020 = 10000000 (thay đổi đã bị bỏ)
ghi ngoài txn vào cùng ví mất 5 ms (không còn bị chặn)
log của primary:
"ctx":"abortExpiredTransactions",
"msg":"Aborting transaction because it has been running for longer than 'transactionLifetimeLimitSeconds'"Để ý error label: transaction quá hạn cũng mang TransientTransactionError, nên vòng retry sẽ chạy lại nó. Nếu chính code trong transaction chậm (gọi API ngoài, xử lý nặng giữa các lệnh), retry chỉ lặp lại cái chậm. Giữ transaction ngắn: chuẩn bị dữ liệu trước, mở, ghi, commit.
Chờ lock: 5 mili giây
[tài liệu] Mặc định, transaction chỉ chờ tối đa 5 ms để lấy lock mà các lệnh bên trong cần (maxTransactionLockRequestTimeoutMillis, mặc định 5); không lấy được thì transaction abort. Tình huống điển hình: một lệnh DDL (ví dụ createIndex, cần lock độc quyền collection) đang chờ sau một transaction đang mở; mọi transaction mới chạm vào collection đó không lấy được lock và abort sau thời gian này.
[quan sát] Ba tiến trình: transaction A sửa một đơn trong orders rồi giữ 4 giây; 300 ms sau, tiến trình B gọi createIndex({ userId: 1 }) trên orders; 1 giây sau, tiến trình C mở transaction B' và insert một đơn mới.
P3: transaction B' bị từ chối sau 13 ms: LockTimeout ["TransientTransactionError"]
| Unable to acquire IX lock on '{Collection : lab16.orders}' within 5ms.
P1: commit transaction A (giữ 4 s)
P2: createIndex xong sau 3785 msMột transaction dài cộng một lệnh DDL là đủ để mọi transaction mới trên collection đó bị huỷ hàng loạt. Tăng maxTransactionLockRequestTimeoutMillis chỉ đổi abort ngay thành chờ lâu hơn, và theo tài liệu có thể làm chậm việc abort những transaction đang deadlock. Các tầng lock, latch, ticket bên dưới là chủ đề của bài Concurrency.
Kích thước: 16 MB cho mỗi oplog entry, không phải cho cả transaction
[tài liệu] MongoDB tạo bao nhiêu oplog entry tuỳ nhu cầu cho các lệnh ghi của một transaction, nên tổng kích thước không còn bị giới hạn 16 MB, nhưng mỗi entry vẫn phải dưới 16 MB (giới hạn BSON). Bản tài liệu 4.4 của cùng trang ghi thay đổi này có từ 4.2; trước đó cả transaction nằm trong một entry.
[quan sát] Một transaction insert 40 document, mỗi cái khoảng 0,5 MB (tổng khoảng 20 MB), commit trong 133 ms. Oplog của session đó:
entry 1 applyOps 32 ops, 15.63 MB partialTxn
entry 2 applyOps 8 ops, 3.91 MB (cuối)
tổng: 2 oplog entry, 19.5 MBTransaction 3 document của bài sinh đúng một entry applyOps chứa cả ba thao tác, kèm lsid và txnNumber:
txn 3 doc → c admin.$cmd applyOps: i lab16.orders, u lab16.products, i lab16.ledger | lsid: true txnNumber: 2Giới hạn thật nằm ở cache. [tài liệu] Transaction quá lớn để vừa WiredTiger cache bị abort với lỗi TransactionTooLargeForCache (từ 6.2; bản cũ hơn trả WriteConflict hoặc TemporarilyUnavailable), và server không tự thử lại vì cache quá nhỏ, thử lại gần như chắc chắn hỏng. [quan sát] Với cache 256 MB, một transaction insert liên tục các document 0,5 MB dừng ở document thứ 106 (khoảng 53 MB) ở lần chạy đầu, thứ 55 (khoảng 28 MB) ở lần sau:
code 388 labels [] | WiredTigerRecordStore::insertRecord -31800: transaction is too large
and will not fit in the storage engine cache (Transaction has the oldest pinned transaction ID)Không có error label nào, nên cả withTransaction lẫn vòng retry theo error label đều không chạy lại: lỗi "đừng thử lại". [tài liệu] Tài liệu nêu mức 20% dirty page (page đã bị sửa trong cache nhưng chưa ghi xuống đĩa) làm ngưỡng bắt đầu giới hạn, khoảng 51 MB với cache 256 MB của lab này. [quan sát] Ở thí nghiệm của bài về cache, transaction thất bại ở khoảng 25 đến 84 MB dirty, nên đừng coi 20% là một đường cắt chính xác. Import hàng chục MB thì chia lô. Dirty page và eviction là chuyện của bài Cache, Eviction & Working Set.
Những thao tác không được phép
[tài liệu] Trong transaction không được: listCollections, listIndexes, các lệnh không phải CRUD như count hay createUser, explain, ghi vào capped collection hay system.*, đụng tới database config/admin/local, và tạo collection hay index tường minh khi read concern khác "local". Được (từ 4.4, trừ cross-shard write transaction): tạo collection, và tạo index trên collection mới, rỗng vừa tạo trong cùng transaction. Đếm thì dùng countDocuments (một $group + $sum).
[quan sát] Mỗi thao tác một transaction riêng, rồi abort:
✗ createIndex trên orders (đã có dữ liệu) → OperationNotSupportedInTransaction
✗ lệnh count → OperationNotSupportedInTransaction
✓ countDocuments (aggregate $group) → 523
✗ listCollections → OperationNotSupportedInTransaction
✓ createCollection mới (read concern local) → ok
✓ createIndex trên collection vừa tạo trong txn → "tenantId_1"
✓ insert vào collection chưa tồn tại → ok (collection được tạo ngầm)
✗ explain một find → OperationNotSupportedInTransactionSau khi abort, refunds và audit_log không tồn tại: tạo collection cũng là một phần của gói.
Read concern, write concern ở mức transaction
[tài liệu] Các lệnh trong transaction dùng read concern, write concern và read preference của transaction, đặt lúc startTransaction (không có thì lấy của session, rồi của client); transaction có thao tác đọc phải dùng read preference primary. Write concern chỉ áp dụng lúc commit; đặt write concern cho từng lệnh bên trong là lỗi. [quan sát] Lệnh insert gửi thẳng kèm writeConcern bị server từ chối (InvalidOptions | writeConcern is not allowed within a multi-statement transaction), còn helper insertOne(..., { writeConcern }) của mongosh thì chạy bình thường. Suy luận (không kiểm tra lệnh gửi đi): mongosh không gửi write concern đó lên server.
Mỗi mức hứa gì với ứng dụng là chủ đề của bài Durability & Consistency và bài Isolation & Snapshot.
Cột mốc: Bạn đã có thể phân biệt TransientTransactionError với UnknownTransactionCommitResult, biết khi gặp mỗi lỗi thì làm lại phần nào, và nêu các giới hạn chính của transaction. Tiếp theo: Cái giá: đo latency; so với PostgreSQL.
Hỏi & đáp
Transaction A đã trừ tiền ví t042:u001 nhưng chưa commit. Transaction B cũng trừ tiền ví đó. Lab cho thấy B gặp gì?
Commit một transaction chuyển tiền với w: "majority", wtimeout: 1000 trả lỗi WriteConcernTimeout có error label UnknownTransactionCommitResult. Ứng dụng nên làm gì?
Một service mở transaction, sửa một ví, rồi gọi API thanh toán bên ngoài mất 70 giây trước khi commit. Điều gì xảy ra?
Bốn tiến trình chuyển tiền giữa 5 ví bằng Core API không retry: 43–55% lần chuyển thất bại với WriteConflict. Sửa thế nào là đúng?