Write Conflicts (P2/3): Idempotency và chiến lược retry
Ở 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.
- Cần đọc trước: Ba câu hỏi cho mọi lệnh ghi lỗi
- Dẫn tới: Hot document và checklist production
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
| Idempotent | Không idempotent |
|---|---|
$set: { status: "paid" } (giá trị tuyệt đối) | $inc: { balance: -1000 } |
$addToSet, $max, $min | $push |
| upsert theo khoá tự nhiên | insertOne với _id mới sinh ở mỗi lần gọi |
insertOne với _id do request quyết định | updateMany có $inc |
deleteOne theo _id | findOneAndUpdate 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ết | Một client: số dư (đúng = 800.000) | thừa / thiếu | Hai client: số dư | thừa / thiếu |
|---|---|---|---|---|
A. ngây thơ $inc | 790.000 | 9 yêu cầu bị trừ 2–3 lần / 0 | 582.000 | 200 bị trừ ≥2 lần / 0 |
| B. check-then-act | 786.000 | 11 bị trừ 2–4 lần / 0 | 592.000–593.000 | 199 bị trừ ≥2 lần / 0 |
| C. khoá rồi mới trừ | 814.000 | 0 / 14 không bị trừ | 813.000 | 0 / 13 không bị trừ |
| D. khoá + tiền, một transaction | 800.000 | 0 / 0 | 800.000 | 0 / 0 |
| E. khoá trong document, một lệnh ghi | 800.000 | 0 / 0 | 800.000 | 0 / 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
$incvà 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.insertOnephiếu của người đến sau còn némDuplicateKey185 và 191 lần (hai lượt), sau khi đã trừ tiền. - C sửa đúng race (unique index trên
_idlà 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: 100000DuplicateKey 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 retry | Thất bại / 2.400 | Lần thử mỗi lần chuyển | p99 (ms) | Transaction bị abort |
|---|---|---|---|---|
| imm: thử ngay, không giới hạn | 0 | 2,83–2,94 | 79–97 | 4.403–4.660 |
expo: chờ min(200, 5·2^(k−1)) ms sau lần hỏng thứ k | 0 | 1,57–1,65 | 125–201 | 1.380–1.553 |
jitter: chờ ngẫu nhiên trong [0, min(200, 5·2^(k−1))] ms | 0 | 1,75–1,96 | 128–283 | 1.808–2.300 |
| bounded: tối đa 5 lần, thử ngay | 321–384 (13–16%) | 2,54–2,66 | 61–100 | 4.024–4.370 |
| bounded + jitter: tối đa 5 lần | 111–131 (4,6–5,5%) | 2,03–2,10 | 109–130 | 2.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ớijitter, 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ơnjitter(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ểnCuộ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 transaction | Lần thử mỗi lần chuyển | p99 (ms) | Chạy xong 1.200 lần chuyển |
|---|---|---|---|
| 0 ms | 1,88–1,91 | 243–281 | 3,6–4,0 s |
| 5 ms | 2,41–2,56 | 704–708 | 8,8–8,9 s |
| 20 ms | 3,79–3,93 | 1.666–1.740 | 28,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?
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?
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)?