Write Conflicts (P2/3): Idempotency và chiến lược retry

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

Ở phần trước: mỗi lỗi ghi là một trong ba loại: chưa chạy gì, đã chạy rồi, hoặc không biết. Câu hỏi chạy hai lần thì sao quyết định mọi thứ. Driver tự gửi lại một lệnh ghi lẻ, còn retry transaction là việc của ứng dụng.

Idempotency: chạy hai lần cũng như một lần

Idempotent nghĩa là chạy một thao tác hai (hay mười) lần cho kết quả như chạy một lần. Nút "tầng 5" của thang máy là idempotent; nút "thêm một ly cà phê vào đơn" thì không.

Lệnh nào tự idempotent

IdempotentKhông idempotent
$set: { status: "paid" } (giá trị tuyệt đối)$inc: { balance: -1000 }
$addToSet, $max, $min$push
upsert theo khoá tự nhiêninsertOne với _id mới sinh ở mỗi lần gọi
insertOne với _id do request quyết địnhupdateMany có $inc
deleteOne theo _idfindOneAndUpdate lấy "việc tiếp theo"

Hai chú ý. Một: insertOne với _id cố định idempotent về dữ liệu, nhưng lần hai ném DuplicateKey và ứng dụng phải coi đó là "đã làm rồi". Hai: $set giá trị tuyệt đối chỉ an toàn nếu không ai đổi giá trị giữa hai lần, nếu không lần hai ghi đè (lost update của bài Document Model); thêm điều kiện vào filter hoặc dùng khoá idempotency.

Lab 1: bấm nút thanh toán hai lần

Tài khoản A có 1.000.000đ; có 200 yêu cầu req-0 … req-199, mỗi cái 1.000đ. Số dư đúng sau khi xử lý là 800.000đ: mỗi yêu cầu trừ đúng một lần. Một "đồng hồ đo" charged.<i> nằm trong cùng $inc đếm yêu cầu i bị trừ mấy lần (chỉ để đo).

Lỗi được mô phỏng bằng PRNG có seed: sau mỗi bước có 5% "mất kết quả" (timeout sau khi lệnh đã chạy, hoặc tiến trình chết đúng chỗ đó), và client gửi lại cùng requestId, tối đa 4 lần thử mỗi yêu cầu. Đây không phải mất mạng thật (đã làm ở bài Durability & Consistency); mục đích là tách câu hỏi "thiết kế có chịu được lần gửi lại không". Hai điều kiện: một client gửi 200 yêu cầu, và hai client cùng gửi cùng 200 yêu cầu (bấm đúp, hai instance nhận cùng message). Năm cách viết (e2-idem.out, e2-idem-run2.out):

// A. ngây thơ
acct.updateOne({ _id: "A" }, { $inc: { balance: -1000 } })

// B. check-then-act
if (pay.findOne({ _id: req })) return "dup"
acct.updateOne({ _id: "A" }, { $inc: { balance: -1000 } })
pay.insertOne({ _id: req })

// C. ghi khoá trước, trừ tiền sau (hai lệnh rời nhau)
try { pay.insertOne({ _id: req }) } catch (e) { if (e.code === 11000) return "dup" }
acct.updateOne({ _id: "A" }, { $inc: { balance: -1000 } })

// D. khoá và tiền trong CÙNG MỘT transaction; khoá là lệnh đầu tiên
session.withTransaction(() => {
  sd.pay.insertOne({ _id: req })                       // trùng → 11000 → transaction huỷ
  sd.acct.updateOne({ _id: "A" }, { $inc: { balance: -1000 } })
}, { writeConcern: { w: "majority" } })

// E. khoá nằm trong CHÍNH document tiền, điều kiện nằm trong filter
acct.updateOne({ _id: "A", applied: { $ne: req } },
               { $inc: { balance: -1000 }, $push: { applied: req } })   // modifiedCount 0 → "dup"

[quan sát] Với một client, hai lượt cho kết quả giống hệt (seed cố định). Với hai client, cách hai tiến trình đan xen đổi giữa các lượt, nên số lần "mất kết quả" ở D, E và số dư ở B lệch đôi chút; kết luận thì không đổi:

Cách viếtMột client: số dư (đúng = 800.000)thừa / thiếuHai client: số dưthừa / thiếu
A. ngây thơ $inc790.0009 yêu cầu bị trừ 2–3 lần / 0582.000200 bị trừ ≥2 lần / 0
B. check-then-act786.00011 bị trừ 2–4 lần / 0592.000–593.000199 bị trừ ≥2 lần / 0
C. khoá rồi mới trừ814.0000 / 14 không bị trừ813.0000 / 13 không bị trừ
D. khoá + tiền, một transaction800.0000 / 0800.0000 / 0
E. khoá trong document, một lệnh ghi800.0000 / 0800.0000 / 0
  • A sai hiển nhiên: không có khoá thì không ai nhận ra lần gửi lại.
  • B trông có khoá nhưng "kiểm tra" và "hành động" là hai bước. Một client: mất kết quả sau $inc và trước khi ghi phiếu, nên lần gửi lại không thấy phiếu và trừ tiếp. Hai client: cả hai cùng thấy "chưa có phiếu" rồi cùng trừ (199/200). Con số này cao vì hai tiến trình xuất phát cùng lúc và đi cùng thứ tự yêu cầu, gần như song song từng bước; [suy luận] bấm đúp thật sẽ trùng ít hơn, nhưng mỗi lần trùng vẫn trừ hai lần theo đúng cơ chế này. insertOne phiếu của người đến sau còn ném DuplicateKey 185 và 191 lần (hai lượt), sau khi đã trừ tiền.
  • C sửa đúng race (unique index trên _id là trọng tài) nhưng tạo lỗi ngược: khoá ghi trước tiền, nên chết giữa hai lệnh thì phiếu có mà tiền chưa trừ, và lần gửi lại thấy phiếu rồi bỏ qua: thiếu 13–14 yêu cầu.
  • D và E đúng ở cả hai điều kiện vì khoá và hiệu ứng cùng nguyên tử: cùng transaction (D) hoặc cùng một lệnh ghi lên một document (E).

Quy tắc: khoá idempotency phải được ghi nguyên tử với hiệu ứng nó bảo vệ. Ghi khoá trước thì sót hiệu ứng, ghi sau thì sót khoá, kiểm tra bằng một lần đọc riêng thì bị race.

D dùng được khi hiệu ứng nằm ở nhiều document, nhưng mang cái giá của transaction (thêm round-trip, và chính nó có thể bị WriteConflict). E rẻ hơn và không cần transaction, nhưng mảng applied lớn dần và hiệu ứng phải nằm trong một document.

Bẫy: insert-or-ignore bên trong transaction

Cách C bắt 11000 ngay tại chỗ insert rồi xử lý tiếp ("insert-or-ignore"). Ngoài transaction thì ổn; trong transaction nó là bẫy. [quan sát] (e1-dupkey.out, rút gọn tên lỗi):

outside txn, insert duplicate:                 code 11000  labels []
inside txn, insert duplicate (committed key):  code 11000  labels []
commit after the failed insert:                code 251 NoSuchTransaction  labels ["TransientTransactionError"]
5 reruns of the whole transaction -> DuplicateKey 5 times

caught duplicate, carry on:                    code 11000  labels []
next statement:                                code 251 NoSuchTransaction  labels ["TransientTransactionError"]
balance A: 100000

DuplicateKey trong transaction đã huỷ transaction (số dư không bị trừ). Nếu code bắt lỗi rồi đi tiếp, hoặc trả về để commit, lệnh sau hay lệnh commit nhận NoSuchTransaction kèm error label TransientTransactionError. [suy luận] Vòng retry chạy theo error label (kể cả withTransaction) sẽ chạy lại, lại gặp khoá trùng, lại huỷ, quay cho tới khi hết giới hạn của vòng retry; lab không chạy vòng đó, chỉ thấy năm lần chạy lại đều gặp lại DuplicateKey. Cách đúng như D: insert khoá là lệnh đầu tiên, không bắt lỗi trong callback; để 11000 thoát ra khỏi withTransaction, rồi ở ngoài coi nó là "đã xử lý" (và đọc lại kết quả cũ để trả cho client).

Giá của khoá idempotency

Khoá idempotency
   ├── ✓ chạy lại bao nhiêu lần cũng một hiệu ứng, kể cả lần gửi trùng đồng thời
   ├── ✗ thêm một document (hoặc phần tử mảng) cho mỗi request
   ├── ✗ phải quyết định giữ bao lâu: quá ngắn thì lần gửi muộn lọt, quá dài thì phình
   └── ✗ cùng khoá nhưng nội dung khác, và muốn trả đúng phản hồi lần đầu, cần lưu thêm

[suy luận] (lab không đo): giữ khoá bằng TTL index lâu hơn khoảng client còn có thể gửi lại (bài Specialized Indexes); lưu mã băm payload để phát hiện "cùng khoá, khác nội dung".

Chiến lược retry

Idempotency trả lời "retry có an toàn không". Phần này trả lời "retry thế nào":

1. Retry theo ERROR LABEL và MÃ, không theo "có lỗi". Không có error label → báo lỗi
   (trừ khi lệnh idempotent và lỗi là timeout).
2. Retry CẢ transaction, gồm phần đọc và tính: số đã đọc ở lần trước đã lỗi thời.
3. Giới hạn cả số lần LẪN tổng thời gian (deadline). Hết giới hạn: trả lỗi hoặc đẩy vào hàng đợi.
4. Chờ giữa các lần: lũy thừa, có jitter, có trần.
5. Side effect (email, gọi API) nằm SAU commit, không nằm trong vòng retry.
6. Retry ở MỘT tầng: driver, framework, ứng dụng cùng retry 3 lần là 27 lần chạy.

Lab 2: không chờ, chờ lũy thừa, có jitter

Tải của bài Transactions & Atomicity: chuyển tiền giữa 5 ví, mỗi lần là transaction hai document, w: "majority". Mới ở bài này: 8 tiến trình × 300 lần = 2.400 lần chuyển mỗi lượt, 3 lượt cho mỗi cách, và so cách chờ. Tổng tiền 5 ví luôn đúng 50.000.000 (e3-retry.out, tổng hợp bằng e3-agg.py):

Cách retryThất bại / 2.400Lần thử mỗi lần chuyểnp99 (ms)Transaction bị abort
imm: thử ngay, không giới hạn02,83–2,9479–974.403–4.660
expo: chờ min(200, 5·2^(k−1)) ms sau lần hỏng thứ k01,57–1,65125–2011.380–1.553
jitter: chờ ngẫu nhiên trong [0, min(200, 5·2^(k−1))] ms01,75–1,96128–2831.808–2.300
bounded: tối đa 5 lần, thử ngay321–384 (13–16%)2,54–2,6661–1004.024–4.370
bounded + jitter: tối đa 5 lần111–131 (4,6–5,5%)2,03–2,10109–1302.573–2.762

(p99 tính cho cả lần chuyển, từ lần thử đầu tới khi xong hoặc bỏ cuộc, gồm thời gian chờ.)

  • Backoff cắt việc thừa, không thêm throughput. Thử ngay tốn 2,8–2,9 lần thử mỗi lần chuyển, có chờ còn 1,6–2,0: server huỷ ít hơn 51–70% transaction (so từng lượt). Nhưng thời gian chạy hết 2.400 lần chuyển vẫn nằm trong 4,7–8,1 giây ở cả ba cách không giới hạn, chênh giữa các lượt của cùng một cách còn lớn hơn chênh giữa các cách. [suy luận] Giới hạn là các transaction phải nối đuôi nhau trên 5 ví (cộng với máy dùng chung), không phải lần thử thừa.
  • Backoff làm đuôi chậm hơn. p99 tăng từ 79–97 lên 125–283 ms vì có lần chuyển bị chọn chờ lâu (độ trễ tối đa 563–1.774 ms với expo, 730–1.121 ms với jitter, so với 156–186 ms khi thử ngay). [suy luận] Giá trị của backoff nằm ở lúc server quá tải: bớt đập cửa để hàng đợi có cơ hội tan. Lab này không đẩy server tới mức đó.
  • Jitter không cho thấy lợi ích ở lab này: expo (1,57–1,65 lần thử) còn ít hơn jitter (1,75–1,96). [suy luận] Jitter sinh ra để phá sự đồng bộ giữa hàng trăm client cùng chờ rồi cùng quay lại; 8 tiến trình mongosh khó tạo ra đàn đồng bộ đó. Đọc là "lab này không đo được lợi ích của nó", không phải "vô dụng".
  • Giới hạn buộc có đường lui. Thử tối đa 5 lần không chờ: 13–16% lần chuyển bỏ cuộc dù không có lỗi thật nào; thêm jitter còn 4,6–5,5%. Bỏ cuộc rồi thì trả lỗi, hoặc ghi yêu cầu vào hàng đợi, và cách sau chỉ làm được nếu yêu cầu idempotent.
const delay = Math.random() * Math.min(200, 5 * Math.pow(2, k - 1));   // mẫu "jitter"

Side effect, và transaction mở lâu

[quan sát] 8 tiến trình × 150 lần chuyển (1.200 lần) bằng session.withTransaction() trên 5 ví bị nhiều client cùng ghi, đặt một "cuộc gọi bên ngoài" ở ba chỗ (e8-sideeffect.out):

ở DÒNG ĐẦU callback (ví dụ gọi API ủy quyền trước khi ghi)   2.763 lần gọi / 1.200 lần chuyển
ở DÒNG CUỐI callback (sau hai lệnh ghi, trước commit)        1.200 lần gọi / 1.200 lần chuyển
SAU khi withTransaction trả về                               1.200 lần gọi / 1.200 lần chuyển

Cuộc gọi ở dòng đầu chạy 2,3 lần cho mỗi lần chuyển, và có lần thuộc về transaction chưa bao giờ commit. Ở dòng cuối thì không nhân lên, vì xung đột nổ ra ở lệnh ghi và ném lỗi trước khi tới dòng cuối. Đừng coi đó là an toàn: [suy luận] lỗi ở bước commit (primary đổi giữa chừng) vẫn có thể chạy lại callback sau khi side effect đã đi. Chỉ chỗ "sau khi withTransaction trả về" là đúng.

Còn độ dài transaction: cùng workload, 8 tiến trình × 150 lần chuyển, jitter, thêm sleep bên trong transaction giữa hai lệnh ghi (mô phỏng bước xử lý chậm) (e6-scope.out):

Thêm trong transactionLần thử mỗi lần chuyểnp99 (ms)Chạy xong 1.200 lần chuyển
0 ms1,88–1,91243–2813,6–4,0 s
5 ms2,41–2,56704–7088,8–8,9 s
20 ms3,79–3,931.666–1.74028,1–29,2 s

Transaction mở càng lâu thì khoảng thời gian có thể va chạm với transaction khác càng dài: thêm 20 ms làm số lần thử gấp đôi và thời gian chạy gấp 7–8 lần, và retry không chữa được vì chỉ lặp lại đoạn chậm. Mọi việc không cần database (gọi API, tính nặng, chờ người dùng) nằm trước khi mở hoặc sau khi commit.

Cột mốc: Bạn đã có thể viết một thao tác ghi idempotent bằng một requestId gửi lại y nguyên, và chọn cách chờ giữa các lần retry. Tiếp theo: Hot document và checklist production.

Hỏi & đáp

Trong một transaction chuyển tiền, lệnh updateOne thứ hai nhận WriteConflict (code 112, error label TransientTransactionError). Cách xử lý nào đúng?

  1. Bắt lỗi rồi gửi lại riêng lệnh updateOne thứ hai trong cùng transaction, vì lệnh đầu đã thành công

    Lỗi này đã huỷ cả transaction: lệnh tiếp theo trong cùng transaction nhận NoSuchTransaction, và lệnh đầu cũng không còn hiệu lực. Xem phần Ba câu hỏi cho mọi lệnh ghi lỗi (hai cơ chế retry).

  2. Bật retryWrites=true để driver tự gửi lại lệnh đó

    retryWrites không áp dụng cho lệnh bên trong transaction: tài liệu nói lệnh trong transaction không được retry riêng lẻ. Xem phần Ba câu hỏi cho mọi lệnh ghi lỗi (hai cơ chế retry).

  3. Huỷ transaction, chờ một khoảng ngẫu nhiên có trần, rồi chạy lại cả transaction từ đầu, với giới hạn số lần và deadline

    Transaction đã bị huỷ nên chưa có gì áp dụng, chạy lại là an toàn; phải đọc lại và tính lại vì số cũ đã lỗi thời. Giới hạn và chờ để không đập cửa liên tục: lab cho 2,8–2,9 lần thử mỗi lần chuyển khi thử ngay, 1,6–2,0 khi có backoff. Xem mục "Chiến lược retry".

  4. Coi như thất bại thật và báo lỗi cho người dùng, vì xung đột nghĩa là dữ liệu đang sai

    WriteConflict trong transaction là xung đột tạm thời, không phải dữ liệu sai: transaction đã huỷ nên dữ liệu vẫn nguyên, và lần chạy lại trên số mới thường thành công. Lab của bài Transactions & Atomicity: không retry thì 43–55% lần chuyển thất bại dù chẳng có gì hỏng. Xem phần Ba câu hỏi cho mọi lệnh ghi lỗi (bảng phân loại).

Bạn muốn xử lý trùng request thanh toán bằng một collection payments có unique index trên requestId. Cách nào chắc chắn không để lọt một khoản bị trừ thừa hoặc thiếu khi tiến trình có thể chết ở bất kỳ bước nào?

  1. Ghi phiếu vào payments trước, nếu thành công thì mới trừ tiền ở lệnh ghi thứ hai

    Chết giữa hai lệnh thì phiếu đã có mà tiền chưa trừ, và lần gửi lại thấy phiếu rồi bỏ qua. Lab: 13–14/200 yêu cầu không bao giờ bị trừ. Xem mục "Lab 1: bấm nút thanh toán hai lần".

  2. Đọc payments xem đã có requestId chưa; chưa có thì trừ tiền rồi ghi phiếu

    Kiểm tra và hành động là hai bước: hai request cùng thấy "chưa có" rồi cùng trừ (lab: 199/200 bị trừ thừa), và chết sau khi trừ mà trước khi ghi phiếu cũng cho trừ thừa. Xem mục "Lab 1: bấm nút thanh toán hai lần".

  3. Trong một transaction, insert phiếu và bắt 11000 để trả "đã xử lý"; không trùng thì trừ tiền

    Trong transaction, 11000 đã huỷ transaction: trả về bình thường thì lệnh commit nhận NoSuchTransaction kèm error label TransientTransactionError (lab), và vòng retry chạy lại, lại gặp khoá trùng. Phải để 11000 thoát ra ngoài transaction rồi mới coi là đã xử lý. Xem mục "Bẫy: insert-or-ignore bên trong transaction".

  4. Ghi phiếu và trừ tiền trong cùng một transaction, với phiếu là lệnh ghi đầu tiên của nó

    Chết ở giữa thì transaction huỷ, không phiếu cũng không tiền; chết sau commit thì lần gửi lại gặp 11000 và coi là đã xử lý (phiếu phải là lệnh đầu tiên, không bắt lỗi trong callback). Lab (cách D): 200/200 đúng cả với một và hai client. Xem mục "Lab 1: bấm nút thanh toán hai lần".

Vòng retry transaction của bạn đang thử lại ngay lập tức mỗi khi gặp TransientTransactionError. Bạn đổi sang chờ lũy thừa có trần và jitter. Theo lab, điều nào có nhiều khả năng xảy ra nhất trên workload hot document (ví bị nhiều client cùng ghi)?

  1. Số transaction bị abort giảm và p99 cũng giảm, vì bớt đập cửa nên mọi người xong nhanh hơn

    Bớt việc thừa là đúng, nhưng p99 tăng từ 79–97 lên 128–283 ms vì một số lần chuyển bị chọn chờ lâu (độ trễ tối đa 730–1.121 ms với jitter). Xem mục "Lab 2: không chờ, chờ lũy thừa, có jitter".

  2. Throughput tăng rõ rệt vì server không còn phải xử lý các lần thử thừa

    Tổng thời gian chạy 2.400 lần chuyển vẫn quanh 4,7–8,1 s ở mọi cách: theo suy luận của bài, giới hạn là các transaction phải nối đuôi nhau trên 5 ví, không phải lần thử thừa. Xem mục "Lab 2: không chờ, chờ lũy thừa, có jitter".

  3. Không đổi gì: số lần thử chỉ phụ thuộc vào số ví, không phụ thuộc vào cách chờ

    Cùng 5 ví, số lần thử giảm rõ khi có chờ (2,83–2,94 xuống 1,75–1,96 với jitter). Xem mục "Lab 2: không chờ, chờ lũy thừa, có jitter".

  4. Số abort giảm rõ, nhưng độ trễ đuôi tăng và tổng thời gian chạy gần như không đổi

    Lab, jitter so với thử ngay: lần thử mỗi lần chuyển 1,75–1,96 so với 2,83–2,94; abort 1.808–2.300 so với 4.403–4.660; p99 128–283 so với 79–97 ms; thời gian xong của cả hai đều trong khoảng 4,7–8,1 s. Xem mục "Lab 2: không chờ, chờ lũy thừa, có jitter".