Transactions (P2/3): Lỗi, retry và giới hạn

10 phút đọcSeries: MongoDB: từ gốc đến internals

Ở 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.

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: TransientTransactionError thì chạy lại cả transaction, UnknownTransactionCommitResult thì 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ếtChuyển thành công / 300 (mỗi tiến trình)Số lần thử transaction / 300Thời gian mỗi tiến trìnhTổng tiền 5 ví sau lượt
core (không retry)135–1723001,15–1,21 s50.000.000
loop (retry tự viết)300549–6082,0–2,2 s50.000.000
cb (withTransaction)300433–4651,6–2,2 s50.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, withTransaction khoảng 1,5 lần (433–465), vì 5 ví cho 4 tiến trình là tranh chấp cực đoan. Vì sao withTransaction cầ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 ms

Mộ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 MB

Transaction 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: 2

Giớ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                              → OperationNotSupportedInTransaction

Sau 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ì?

  1. B chờ A commit xong rồi tự chạy tiếp trên số dư mới, ứng dụng không thấy lỗi

    Đó là hành vi của PostgreSQL Read Committed, và của một lệnh ghi ngoài transaction trong MongoDB (lab: chờ 3 s). Transaction đến sau thì không chờ. Xem mục "TransientTransactionError: làm lại cả gói".

  2. B ghi đè lên thay đổi của A, ai commit sau thì thắng

    Không có ghi đè: transaction đã sửa document trước giữ nó cho tới khi kết thúc. Tổng tiền 5 ví trong lab luôn đúng 50.000.000. Xem mục "Vòng retry: đo trên ví bị nhiều client cùng ghi".

  3. B thành công, còn A bị huỷ lúc commit vì B mới hơn

    Ngược lại: A commit bình thường ("A commit ok"), người bị huỷ là người đến sau. Xem output Ca 1 ở mục "TransientTransactionError".

  4. B bị abort sau khoảng 1 ms với WriteConflict, error label TransientTransactionError

    Đúng. Transaction đến sau không xếp hàng, nó bị abort ngay và được gắn error label để ứng dụng chạy lại từ đầu; commit của B sau đó trả NoSuchTransaction. Xem Ca 1 ở mục "TransientTransactionError: làm lại cả gói".

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ì?

  1. Gọi lại commitTransaction trên cùng session, không chạy lại các lệnh

    Kết quả không xác định: lab cho thấy primary đã áp dụng transaction (số dư đã đổi) nhưng chưa chứng minh được majority. Commit lại thành công sau 4 ms và tiền chỉ chuyển một lần. Xem mục "UnknownTransactionCommitResult: không biết đã commit chưa".

  2. Abort rồi chạy lại toàn bộ transaction từ đầu, như mọi lỗi tạm thời khác

    Transaction có thể đã commit trên primary; chạy lại cả gói là một transaction mới, trừ tiền lần nữa. Chạy lại cả gói chỉ dành cho TransientTransactionError. Xem bảng error label ở mục "UnknownTransactionCommitResult".

  3. Coi như chuyển tiền thất bại và ghi một bút toán hoàn tiền

    Không có gì để hoàn: lab đọc trên primary thấy tiền đã chuyển, và commit lại thì thành công. Coi lỗi này là thất bại sẽ làm sổ sách lệch. Xem mục "UnknownTransactionCommitResult".

  4. Bật retryWrites=true để driver retry các lệnh ghi bên trong transaction

    Tài liệu nói từng lệnh ghi bên trong transaction không được retry riêng lẻ, bất kể retryWrites. Thứ được retry ở đây là commit. Xem mục "TransientTransactionError: làm lại cả gói" và "Ranh giới với bài sau".

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?

  1. Commit vẫn thành công, vì giới hạn 60 giây chỉ áp cho lúc transaction không làm gì

    Giới hạn tính thời gian sống của transaction, không phân biệt bận hay rỗi. Lab với giới hạn 5 s: lệnh tiếp theo sau 12 s nhận NoSuchTransaction, log ghi transaction bị abort vì quá transactionLifetimeLimitSeconds. Xem mục "Thời gian sống: 60 giây".

  2. Bị abort, nhưng chạy lại ngay là xong vì lỗi mang error label tạm thời

    Lỗi đúng là mang TransientTransactionError, nhưng chạy lại thì lại gọi API 70 giây và lại quá hạn. Xem đoạn về error label ở mục "Thời gian sống: 60 giây".

  3. Server tự gia hạn thêm 60 giây vì transaction vẫn còn lệnh đang chạy

    Không có gia hạn: tiến trình nền abortExpiredTransactions abort transaction quá hạn. Xem log trong mục "Thời gian sống: 60 giây".

  4. Bị server abort sau khoảng 60 giây, và suốt lúc đó ví bị chặn ghi

    Đúng. Mặc định transaction phải xong dưới 60 giây; trong lúc mở, lệnh ghi khác vào ví đó phải chờ (lab: 3 s khi transaction giữ 3 s). Cách sửa: gọi API trước hoặc sau transaction, giữ transaction ngắn. Xem mục "Thời gian sống: 60 giây" và Ca 3 ở mục "TransientTransactionError".

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?

  1. Tăng maxTransactionLockRequestTimeoutMillis để transaction chờ lâu hơn

    Tham số đó là thời gian chờ lock (ví dụ sau một lệnh DDL), không phải write conflict: transaction đến sau bị abort ngay chứ không chờ. Xem mục "Chờ lock: 5 mili giây".

  2. Retry cả transaction khi có error label TransientTransactionError

    Đúng. Vòng retry tự viết và withTransaction đều chuyển đủ 300/300, tổng tiền vẫn 50.000.000; cái giá là 433–608 lần thử thay vì 300. Giảm tranh chấp trên hot document là việc của bài Write Conflicts. Xem mục "Vòng retry: đo trên ví bị nhiều client cùng ghi".

  3. Chỉ retry lệnh updateOne bị lỗi rồi commit tiếp

    Sau lỗi, transaction đã bị abort: mọi lệnh sau, kể cả commit, nhận NoSuchTransaction. Phải làm lại cả gói. Xem phần Một gói hai kết cục; replica set; vòng đời, chỗ nói một lệnh lỗi là cả gói bị huỷ.

  4. Bật retryWrites trong chuỗi kết nối

    Lệnh ghi bên trong transaction không được retry riêng lẻ, bất kể retryWrites. Xem mục "TransientTransactionError: làm lại cả gói".