Write Conflicts (P1/3): Ba câu hỏi cho mọi lệnh ghi lỗi

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

Bài này là cuốn sổ tay cho ứng dụng khi một lệnh ghi lỗi.

Phần đầu có bảng phân loại lỗi. Phần sau có lab double charge.

Bài có 3 Part:

  • P1 (Part này): Ba câu hỏi cho mọi lệnh ghi lỗi.
  • P2: Idempotency và chiến lược retry.
  • P3: Hot document và checklist production.

Ba bài của Phần Transactions dừng ở cùng một chỗ. Bài Transactions & Atomicity: transaction đến sau bị huỷ trong 1 ms, vòng retry biến "huỷ" thành "thành công". Bài Isolation & Snapshot: document chốt (guard) chặn write skew bằng cách tuần tự hoá một ca trực qua một document. Bài Durability & Consistency: một lệnh ghi timeout có thể đã chạy, và retry bằng _id mới cho ra hai đơn hàng.

Cả ba hỏi cùng một câu: lệnh ghi lỗi thì ai làm lại, và làm lại có an toàn không? Bài này gom chúng thành một cuốn sổ tay cho ứng dụng: bảng phân loại lỗi, một lab làm double charge rồi sửa nó, một lab so các cách chờ giữa các lần retry, và một lab về hot document (document mà nhiều client cùng ghi) kèm những cách giảm tranh chấp, kể cả những cách giúp ít hơn bạn tưởng.

Bài này nằm ở đâu

  • Cần biết trước: Transactions & Atomicity (error label TransientTransactionError / UnknownTransactionCommitResult, Callback API và Core API, vòng retry trên ví bị nhiều client cùng ghi), Isolation & Snapshot (first writer wins, write skew, document chốt), Durability & Consistency (WriteConcernTimeout là "chưa biết", retryable writes), Document Model (ghi một document là atomic)
  • Giới thiệu: bảng "lỗi gì, ai retry, có an toàn không", hai cơ chế retry và vùng chúng không phủ, idempotency (khoá nguyên tử với hiệu ứng, bẫy "insert-or-ignore" trong transaction), chiến lược retry (giới hạn, backoff, jitter, side effect), hot document và cái giá của từng cách giảm tranh chấp, so với PostgreSQL, các con số cần theo dõi
  • Không dạy ở đây: vì sao hai lệnh ghi cùng document xung đột ở tầng lưu trữ (bài WiredTiger MVCC); lock, latch, ticket (bài Concurrency). Bài này chỉ nói ứng dụng thấy gì và phải làm gì
  • Dẫn tới: Concurrency: Locks, Latches & Tickets, rồi Phần WiredTiger (WiredTiger Overview)

Môi trường lab: MongoDB 8.3.11 (image mongo:8), replica set 3 node rs19: ba container mongo-rs19-a / -b / -c trên một máy Apple M4, mỗi container 1 CPU, 1 GB RAM, WiredTiger cache 0,25 GB, database lab19; a là primary. Client là 1 đến 8 container mongosh chạy song song. Server và client chung một máy, nên throughput và latency là cận dưới và nhiễu (cùng cấu hình chạy hai lần có thể lệch tới 2 lần). Con số ổn định là số lần thử mỗi thao tác và bộ đếm của server, nên bài dựa vào chúng là chính; chỗ nào có nhiều lượt đo, bài ghi khoảng min–max. Mọi số đều có file output thô kèm theo.

Nhãn: [tài liệu] theo tài liệu chính thức (bản "current" lúc viết là 9.0, lab chạy 8.3.11); [quan sát] đo trong lab; [suy luận] rút ra từ quan sát, không kiểm tra riêng; [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

Một lệnh ghi lỗi thuộc một trong ba loại: chưa chạy gì (làm lại an toàn), đã chạy rồi (làm lại là chạy hai lần), hoặc không biết (timeout). MongoDB có hai cơ chế tự làm lại: driver gửi lại một lệnh ghi lẻ và server nhận ra lệnh đã chạy (retryable write), và ứng dụng chạy lại cả transaction vừa bị huỷ (lỗi mang error label TransientTransactionError). Mỗi cơ chế an toàn vì một lý do riêng, và có những khe hở cả hai không phủ: updateMany, timeout sau khi lần retry cũng lỗi, side effect ngoài database.

Ở khe hở đó, thứ cứu bạn là idempotency: chạy hai lần cho kết quả như một lần. Còn hot document là chuyện khác: nhiều người ghi cùng một document thì phải xếp hàng, và hàng đó hiện ra thành lệnh bị làm lại, transaction bị huỷ, hoặc CPU bị đốt vô ích. Giảm tranh chấp là đổi thiết kế, không phải retry cho khéo hơn.

Hình dung trước: quầy thu ngân và tờ phiếu

Cửa hàng có nhiều quầy thu ngân và một cuốn sổ công nợ dùng chung. Khách Lan mua 100.000đ, thu ngân bấm "Thanh toán", màn hình quay vòng rồi báo "Lỗi kết nối". Lệnh có thể chưa tới máy chủ (bấm lại là đúng), đã ghi sổ nhưng lời xác nhận bị mất (bấm lại là trừ hai lần), hoặc đang chạy dở. Màn hình không phân biệt được ba trường hợp đó.

Cửa hàng xử lý theo hai cách. Một: mỗi lần bấm máy in một tờ phiếu mang số duy nhất; kế toán thấy số phiếu đã ghi thì bỏ qua. Hai: hai thu ngân cùng sửa một dòng sổ; người sửa sau được báo "dòng vừa bị đổi, đọc lại rồi làm lại từ đầu", và không được vá số cũ vì phép tính của họ dựa trên số đã lỗi thời.

Cửa hàng                                    MongoDB
─────────────────────────────────────────   ─────────────────────────────────────────
màn hình báo lỗi, không biết đã ghi chưa    timeout, WriteConcernTimeout: "chưa biết"
số phiếu duy nhất trên mỗi lần thanh toán   khoá idempotency (requestId, _id suy từ request)
kế toán thấy số phiếu đã ghi → bỏ qua       unique index, lọc "chưa áp dụng" trong lệnh ghi
"dòng vừa bị đổi, làm lại từ đầu"           WriteConflict → TransientTransactionError
nhiều thu ngân tranh một dòng sổ            hot document
mở thêm sổ phụ, cuối ngày cộng lại          sharded counter / bucket, đọc = cộng k phần

[hình dung] Đây chỉ là cách hình dung. Khoá idempotency do client sinh ra và phải lặp lại y nguyên ở lần gửi lại, và việc "đã ghi chưa" được kiểm ngay trong lệnh ghi (unique index, điều kiện trong filter), không bằng một lần đọc riêng. Cơ chế phát hiện xung đột bên dưới cũng không giống "một dòng sổ bị giữ": đó là chuyện của bài WiredTiger MVCC và bài Concurrency.

Ba câu hỏi cho mọi lệnh ghi lỗi

Trước khi viết catch, hỏi: (1) Lỗi gì? (mã, errorLabels, lệnh có nằm trong transaction không). (2) Ai retry? (server, driver, hay ứng dụng). (3) An toàn không? (lệnh có thể đã chạy rồi không; nếu có, chạy hai lần thì sao). Câu thứ ba quyết định tất cả.

Bảng phân loại

Cột "Nguồn": [lab] là thí nghiệm của bài này, [bài trước] là lab đã đọc, [tài liệu] là chưa tự kiểm.

Tình huốngỨng dụng thấyAi retry, retry gìAn toàn khôngNguồn
Hai transaction cùng sửa một document (hoặc primary step down giữa transaction)WriteConflict (112) + TransientTransactionErrorỨng dụng (hoặc Callback API), cả transactionAn toàn: transaction đã huỷ, chưa áp dụng gì[bài trước], [lab]: số abort = số WriteConflict của server
Hai lệnh ghi lẻ (ngoài transaction) cùng sửa một documentKhông gì cả, chỉ chậm hơn (từ 9.0 có giới hạn, xem dưới)Server, bên trongỨng dụng không phải lo (ở 8.3.11)[lab]: 0 lỗi, server đếm 2.171–2.853 WriteConflict/5 s
Lỗi mạng, failover trên insertOne, insertMany, updateOne, replaceOne, deleteOne, findAndModify/findOneAnd…, bulkWrite chỉ gồm lệnh một documentThường không thấy gìDriver, một lầnAn toàn: server nhận ra lệnh đã chạy[tài liệu], [bài trước], [lab]
Cùng lỗi đó trên updateMany, deleteMany, bulk có lệnh nhiều document, hoặc w: 0Lỗi (lab: MongoNetworkTimeoutError)Không aiKhông biết; chỉ an toàn nếu lệnh idempotent[tài liệu], [lab]: báo lỗi mà 3/3 document đã +1
commitTransaction → UnknownTransactionCommitResultLỗi có error label đóDriver thử lại commit một lần; sau đó Callback API, hoặc ứng dụng nếu dùng Core API: chỉ commitAn toàn nếu commit lại đúng transaction đó trong cùng session; chạy lại cả transaction thì không[tài liệu], [bài trước]
DuplicateKey (11000)Lỗi không có error labelKhông retry: đó là câu trả lời "đã có rồi"Retry y nguyên cho cùng lỗi (lab: 5/5 lần)[lab]
WriteConcernTimeout, socket timeout sau khi lần retry cũng lỗiLỗi không có error labelỨng dụngKhông biết; chỉ an toàn nếu idempotent[bài trước]
Lỗi nghiệp vụ (modifiedCount: 0, hết hàng) hoặc TransactionTooLargeForCache (388)Không có error labelKhông retryRetry không giúp, hoặc gần như chắc chắn hỏng lại[bài trước], [tài liệu]

Hai dòng cần đọc kỹ

Dòng "lệnh ghi lẻ cùng sửa một document". [quan sát] Tám client $inc một document trong 5 giây: ứng dụng nhận 0 lỗi, nhưng metrics.operation.writeConflicts của server tăng 2.171 và 2.853 (hai lượt). [chi tiết cài đặt] Server bắt WriteConflict bên trong và chạy lại lệnh. Tài liệu 9.0 khớp với điều này và thêm một giới hạn: metrics.operation.writeConflictRetryWaiters (số lệnh đang thử lại vì write conflict) và writeConflictRetryLimitHit (số lệnh đã trả lỗi WriteConflictRetryLimitExceeded, mã 512, vì vượt "giới hạn thử lại thích nghi"), cả hai New in 9.0. Nghĩa là từ 9.0 một lệnh ghi lẻ trên một hot document có thể nhận lỗi; lab chạy 8.3.11, không có hai trường này, nên không thử.

Còn lệnh ghi lẻ đụng một transaction đang mở đã sửa cùng document thì tài liệu (trang Production Considerations) nói hai kiểu: mục "In-progress Transactions and Write Conflicts" viết lệnh ngoài chờ tới khi transaction kết thúc, còn ví dụ lockId cùng trang viết lệnh ngoài nhận write conflict error. Lab của bài Transactions & Atomicity thấy nó chờ (3 giây), không thấy lỗi. Viết code xử lý được cả hai: chờ có giới hạn (timeout), và xử lý lỗi nếu nó tới.

Dòng updateMany. Bài Durability chưa đo dòng này, và nó là cái bẫy lớn nhất của retryable writes. Dựng giống lab "lệnh đã chạy, câu trả lời không về" của bài Durability & Consistency: chặn hai secondary, ghi w: "majority" với socketTimeoutMS=1500, mở lại secondary sau 1,8 giây, retryWrites=true (mặc định). Ba document { n: 0 }, mỗi lệnh là { $inc: { n: 1 } } (e1-lostreply.out):

LệnhỨng dụng thấyn của 3 documentretriedCommandsCount
updateOne trên _id: 1Thành công sau 1.907 ms[1, 0, 0]0 → 1
updateMany trên cả 3MongoNetworkTimeoutError sau 1.509 ms[1, 1, 1]: đã áp dụng đủKhông đổi
bulkWrite hai updateOneThành công sau 1.929 ms[1, 1, 0]1 → 2
bulkWrite một updateOne + một updateManyMongoBulkWriteError sau 1.513 ms[2, 1, 1]: mỗi lệnh chạy một lầnKhông đổi

[quan sát] Lệnh trong danh sách retryable thì driver gửi lại và server không chạy lần hai. Lệnh ngoài danh sách thì lỗi lọt ra ứng dụng trong khi dữ liệu đã đổi. Lỗi updateMany còn mang error label RetryableWriteError: đừng đọc error label đó là "cứ retry đi", nó là tín hiệu cho driver, mà driver đã không retry lệnh này. Tự gửi lại updateMany { $inc } ở đây là cộng hai lần.

Hai cơ chế retry, hai việc khác nhau

RETRYABLE WRITE (driver)                      TRANSACTION RETRY (ứng dụng)
────────────────────────────────────          ────────────────────────────────────
lỗi: mạng, không thấy primary                 lỗi: WriteConflict, LockTimeout, quá hạn,
                                                    step down giữa transaction
gửi lại:  ĐÚNG MỘT lệnh ghi lẻ                chạy lại: CẢ transaction (đọc lại, tính lại)
vì sao an toàn:                               vì sao an toàn:
  lệnh có thể ĐÃ chạy; server nhớ lệnh          transaction đã bị huỷ; CHƯA có gì
  của session, không chạy lần hai               áp dụng, nên không có gì để chạy hai lần
  [chi tiết cài đặt: lsid + số thứ tự]
không phủ: updateMany, deleteMany, w:0,       không phủ: side effect ngoài database,
  lệnh trong transaction, retry cũng lỗi        lỗi không có error label, thời gian bị kéo dài

[tài liệu] Lệnh bên trong transaction không bao giờ được driver retry riêng lẻ, bất kể retryWrites; riêng commitTransaction và abortTransaction được thử lại một lần. Điều hay bị hiểu nhầm: hai cơ chế an toàn vì hai lý do khác nhau. Retryable write an toàn vì server nhớ lệnh đã chạy; transaction retry an toàn vì transaction chưa áp dụng gì. Mất lý do thì mất an toàn: updateMany không được server nhớ, và side effect (email, API thanh toán) không nằm trong transaction nên huỷ transaction không huỷ được nó. Những khe hở đó là của ứng dụng, và công cụ lấp chúng là idempotency.

Cột mốc: Bạn đã có thể phân loại một lỗi ghi bằng ba câu hỏi: lỗi gì, ai retry, và làm lại có an toàn không. Tiếp theo: Idempotency và chiến lược retry.

Hỏi & đáp

Một service gọi updateMany({ status: "pending" }, { $inc: { retries: 1 } }) với retryWrites=true (mặc định). Mạng đứt đúng lúc primary đã áp dụng lệnh nhưng lời đáp không về, và ứng dụng nhận MongoNetworkTimeoutError. Điều nào đúng?

  1. Driver đã tự gửi lại một lần và server không chạy lần hai, nên dữ liệu đúng; lỗi chỉ để ghi log

    Đó là hành vi của lệnh một document trong danh sách retryable (updateOne, bulkWrite đơn document), không phải của updateMany. Lab: updateMany báo lỗi và retriedCommandsCount không đổi. Xem mục "Hai dòng cần đọc kỹ".

  2. Lệnh có thể đã chạy xong, driver không retry: gửi lại là cộng hai lần

    Lab: sau lỗi, cả 3 document đã n = 1; gửi lại là n = 2. updateMany không thuộc danh sách retryable và server không có bản ghi để nhận ra lệnh đã chạy. Cách an toàn là làm lệnh idempotent (ví dụ $set giá trị tuyệt đối hoặc lọc bằng khoá). Xem mục "Hai dòng cần đọc kỹ"; cách làm lệnh idempotent nằm ở phần Idempotency và chiến lược retry.

  3. Lệnh bị huỷ nguyên khối vì lỗi mạng, nên chưa document nào bị đổi và gửi lại là an toàn

    updateMany không atomic như một khối, và cũng không có cơ chế huỷ khi mất lời đáp: lab thấy cả 3 document đã đổi. Xem mục "Hai dòng cần đọc kỹ".

  4. Error label RetryableWriteError trên lỗi nghĩa là ứng dụng cứ gửi lại là được, driver sẽ lo phần còn lại

    Nhãn đó là tín hiệu cho driver, mà driver đã không retry lệnh này. Gửi lại một $inc trên nhiều document là chạy hai lần. Xem mục "Hai dòng cần đọc kỹ".

Hai nhân viên kho cùng cập nhật một dòng "tổng hàng tồn" trên một bảng tính chung. Người sửa sau được báo "dòng vừa bị đổi". Quy trình nào đúng nhất?

  1. Đọc lại dòng mới, tính lại từ đầu, ghi lại, kèm mã phiếu duy nhất để làm lại nhiều lần vẫn chỉ tính một

    Làm lại cả phép tính trên số mới tương ứng "chạy lại cả transaction"; mã phiếu duy nhất tương ứng khoá idempotency, để lần gửi lại không thành lần ghi thứ hai. Xem mục "Ý chính"; khoá idempotency nằm ở phần Idempotency và chiến lược retry.

  2. Người đó tự cộng phần chênh của mình vào số cũ mà mình đã đọc từ trước rồi ghi lên, vì phần chênh vẫn đúng

    Đó là vá số cũ: nếu người khác lại vừa đổi thêm, kết quả sai mà không ai biết (lost update). Phép tính phải làm lại trên số mới. Xem mục "Hình dung trước: quầy thu ngân và tờ phiếu"; cách làm lại trên số mới nằm ở phần Idempotency và chiến lược retry.

  3. Ghi đè lên dòng bằng số của mình, vì người đến sau là cập nhật mới nhất

    Ghi đè bỏ mất thay đổi của người đầu. "Đến sau" không có nghĩa "đúng hơn". Xem mục "Hình dung trước: quầy thu ngân và tờ phiếu".

  4. Huỷ cập nhật và báo lỗi cho người sửa, vì xung đột nghĩa là số liệu đã hỏng

    Xung đột chỉ cần một bên làm lại từ số mới; bỏ hẳn nghĩa là mất việc không cần thiết (lab: không retry thì 43–55% lần chuyển thất bại dù không có gì hỏng). Xem mục "Bảng phân loại".