Durability (P3/3): Retryable, chọn mức theo dữ liệu

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

Ở phần trước: muốn đọc lại lệnh ghi của mình thì cả ghi và đọc phải dùng majority, còn causal consistency giúp đọc từ secondary thấy lệnh ghi đó. Với w: 1, lab 4a không thấy document ở 498 trên 500 lần đọc.

Retryable writes và retryable reads

Mạng có một kiểu lỗi đặc biệt khó xử lý: lệnh ghi đã chạy nhưng câu trả lời không về. Ứng dụng nhận lỗi timeout, và không biết có nên gửi lại. Gửi lại thì có thể ghi hai lần; không gửi thì có thể mất.

Cách MongoDB giải bài toán này

[tài liệu] Retryable writes để driver thử lại đúng một lần một số lệnh ghi sau lỗi mạng hoặc khi không tìm thấy primary khoẻ. Cần replica set hoặc sharded cluster (không phải standalone) với WiredTiger; driver tương thích MongoDB 4.2 trở lên và mongosh bật sẵn (retryWrites=true). Được retry (khi write concern có ack, tức không phải w: 0): insertOne, insertMany, updateOne, replaceOne, deleteOne, họ findAndModify, và bulkWrite chỉ gồm các thao tác trên một document. Không được retry: updateMany, deleteMany, w: 0, và các lệnh ghi bên trong transaction (riêng commitTransaction và abortTransaction thì driver retry một lần). Driver chờ tối đa serverSelectionTimeoutMS để tìm primary mới; failover lâu hơn thì lệnh ghi lỗi. Đặt timeoutMS thì driver có thể retry nhiều lần cho tới khi hết giờ.

Làm sao khỏi ghi hai lần? [chi tiết cài đặt] (mức khái niệm) Mỗi lệnh ghi retryable mang ID session (lsid) và số thứ tự của lệnh trong session. Server nhớ những lệnh nào của session đã thực thi; gặp lại đúng lệnh đó, nó không chạy lại mà trả kết quả lần đầu. Bằng chứng gián tiếp ở lab 5b: bộ đếm serverStatus().transactions.retriedCommandsCount tăng đúng 1 mỗi lần.

[tài liệu] Từ MongoDB 6.1, nếu cả lần đầu và lần retry đều lỗi mà không có lệnh ghi nào được thực hiện, lỗi mang error label NoWritesPerformed. Nếu client không phản hồi lâu hơn localLogicalSessionTimeoutMinutes, lệnh ghi có thể được áp dụng lại khi client tỉnh: hiếm nhưng có thật.

Retryable reads đơn giản hơn vì đọc không đổi dữ liệu. [tài liệu] Driver tương thích server 6.0 trở lên bật sẵn (retryReads=false để tắt) và retry một lần các lệnh như find, aggregate (không có $out/$merge), count, countDocuments, estimatedDocumentCount, distinct, listDatabases, listCollections, listIndexes và change stream; không retry getMore, mapReduce và lệnh gọi qua runCommand chung. mongosh không hỗ trợ retryable reads, nên bài này không thí nghiệm được chúng.

Lab 5a: step down giữa dòng lệnh ghi

Một client ghi liên tục 8 giây (mỗi vòng: insertOne và updateOne { $inc } lên một bộ đếm, cả hai w: "majority"), trong khi một client khác chạy rs.stepDown(30) trên primary khoảng giây thứ ba. Hai lần chạy cho mỗi cấu hình (e5a-stepdown.sh, e5a-stepdown.out, e5a-stepdown-run2.out):

retryWritesSố vòngLỗi ứng dụng nhìn thấyDocument / bộ đếm so với số lần được xác nhận
true (lần 1)4.93604.936 document, bộ đếm 4.936: khớp
true (lần 2)4.90604.906 document, bộ đếm 4.906: khớp
false (lần 1)4.4931: InterruptedDueToReplStateChange (code 11602) ở một $incbộ đếm 4.493 trong khi chỉ 4.492 lần $inc được xác nhận: lệnh báo lỗi vẫn được áp dụng
false (lần 2)4.0651: PrimarySteppedDown (code 189) ở một insertOne4.064 document: lệnh báo lỗi không có trong dữ liệu

[quan sát] Khi tắt retry, mỗi lần chạy ứng dụng thấy đúng một lỗi, cả hai đều tới dưới dạng MongoWriteConcernError không có error label, nhưng hậu quả ngược nhau: một lệnh ghi báo lỗi mà vẫn được áp dụng, một lệnh ghi báo lỗi mà không có trong dữ liệu. Chỉ nhìn lỗi, ứng dụng không phân biệt được. Khi bật retry (mặc định), 0 lỗi lọt ra ứng dụng ở cả hai lần, và số document, bộ đếm đều khớp số lần được xác nhận.

Lab không nhìn thấy trực tiếp lần retry nào ở 5a. retriedCommandsCount bằng 0 trên cả ba node (e5a2-counters.out), nhưng bộ đếm này chỉ tăng khi server gặp lại một lệnh đã chạy; một lần retry chạy lệnh lần đầu thì không được đếm. [suy luận] Hai lần tắt retry đều có một lệnh trúng lúc step down, nên nhiều khả năng ở hai lần bật retry driver cũng đã retry âm thầm, và lần retry đó không gặp lại lệnh nào đã chạy. Mỗi cấu hình chỉ 2 lần chạy: đủ để thấy hình dạng, chưa đủ để nói tần suất.

Lab 5b: lệnh đã chạy, câu trả lời không về

Đây là trường hợp khó thật. Cách dựng: chặn hai secondary, ghi w: "majority" với socketTimeoutMS=1500. Primary áp dụng lệnh ghi ngay rồi chờ đa số; client hết giờ socket sau 1,5 giây; 1,8 giây sau khi client bắt đầu gửi thì tôi mở lại hai secondary. Mỗi trường hợp reset collection trước (e5b.sh, e5b-timeout-retry.js, e5b-timeout-retry.out):

LệnhretryWritesỨng dụng nhìn thấyDữ liệu sau đóNếu ứng dụng tự gửi lại
insertOne (đơn mới)truethành công, sau 1.945 ms1 đơn; retriedCommandsCount +1không cần
insertOnefalseMongoNetworkTimeoutError sau 1.509 ms1 đơn (đã có)2 đơn paid
updateOne { $inc: { n: 1 } }truethành công, sau 1.949 msn = 1; retriedCommandsCount +1không cần
updateOne { $inc: { n: 1 } }falseMongoNetworkTimeoutError sau 1.506 msn = 1 (đã cộng)n = 2

[quan sát] Khi retryWrites=false, ứng dụng nhận lỗi timeout dù lệnh ghi đã có trong dữ liệu. Ứng dụng tự "thử lại cho chắc" bằng _id mới thì ra hai đơn hàng; với $inc thì bộ đếm cộng hai lần. Khi retryWrites=true, ứng dụng nhận thành công sau ~1,95 giây; dữ liệu có 1 đơn, n = 1, và retriedCommandsCount trên primary tăng 1: server đã gặp lại một lệnh đã chạy và không thực thi lần hai. [suy luận] Driver gửi lại khi socket hết giờ ở 1,5 giây, và lần retry chờ majority tới khi secondary mở lại (1,8 giây); lab không ghi thời điểm từng bước.

[suy luận] Retryable writes giảm chứ không loại bỏ rủi ro: chúng chỉ phủ lỗi trong một lần retry, chỉ cho các lệnh retry được (không có updateMany, deleteMany), và chỉ khi driver còn tìm được primary trong serverSelectionTimeoutMS. Khi cả lần retry cũng lỗi, ứng dụng quay về tình huống "không biết lệnh ghi đã chạy chưa" của lab 2. Ở đó mới cần idempotency: thiết kế lệnh ghi sao cho chạy hai lần cho kết quả như một lần (_id do client sinh ra, khoá idempotency, $set thay vì $inc, unique index). Phần đó là của bài Write Conflicts, Retry & Idempotency, nơi cũng so sánh retry của lệnh ghi lẻ với retry cả transaction (TransientTransactionError).

So với PostgreSQL

PostgreSQL gom phần lớn những gì bài này gọi là write concern vào một tham số synchronous_commit, đi kèm danh sách standby synchronous_standby_names. Phần này không chạy PostgreSQL: mọi nhận định lấy từ trang cấu hình WAL và replication của tài liệu PostgreSQL hiện hành, mang nhãn [tài liệu PostgreSQL], không phải số đo.

synchronous_commitCommit chờGần nhất trong MongoDB (lỏng, không tương đương)
offkhông chờ gì; trễ tới 3 lần wal_writer_delay (mặc định 200 ms). Crash có thể mất vài giao dịch cuối nhưng database vẫn nhất quánw: 1
localWAL đã flush xuống đĩa cục bộw: 1, j: true
remote_writestandby đã nhận và ghi vào file system (chưa chắc xuống đĩa)w: 2 không đặt j (bản sao mới ở bộ nhớ)
on (mặc định)flush cục bộ và, nếu có standby đồng bộ, standby đã nhận và flushw: "majority"
remote_applystandby đã áp dụng, nên truy vấn trên standby thấy ngaykhông có; MongoDB giải bài toán đó ở phía đọc bằng causal consistency

[tài liệu PostgreSQL] Nếu synchronous_standby_names rỗng, remote_apply, remote_write và local đều chỉ còn là flush cục bộ như on. Danh sách standby có thể theo ưu tiên FIRST n (...) hoặc theo quorum ANY n (...); ánh xạ sang majority chỉ lỏng vì MongoDB đếm member của replica set, PostgreSQL đếm standby có tên trong danh sách.

Hai khác biệt cần nhớ. Một: mặc định. PostgreSQL mặc định on nhưng chưa cấu hình standby đồng bộ thì chỉ đợi đĩa cục bộ, không đợi bản sao nào; MongoDB từ 5.0 đợi đa số (trừ ngoại lệ arbiter). Hai: PostgreSQL không có tham số tương đương read concern cho standby, nên "đọc thấy gì" ở standby là việc của remote_apply và của ứng dụng. Cả hai đều cho đổi mức theo từng giao dịch (SET LOCAL synchronous_commit TO OFF; write concern theo từng lệnh ghi). Đây là so sánh khái niệm, không phải so sánh tốc độ.

Chọn mức theo loại dữ liệu

Câu hỏi quyết định: nếu lệnh ghi này đã được xác nhận mà vẫn biến mất sau một failover, hậu quả là gì?

Loại dữ liệuGợi ýGiáPhải đi kèm
Sổ cái, thanh toán, đơn hàngw: "majority" (mặc định), retryWrites=true, có wtimeout hoặc timeoutMSlatency cao hơn (lab: 0,62 so với 0,22 ms ở mạng gần 0); timeout hoặc treo khi mất đa sốidempotency key để retry an toàn; WriteConcernTimeout xử lý như "chưa biết"
Cần đọc lại lệnh ghi của mình qua secondaryghi majority, đọc majority trong session causalConsistency: trueđọc chờ secondary bắt kịpmaxTimeMS; mang mốc thời gian theo request
Event log, click, metric: mất vài giây cuối chấp nhận đượcw: 1mất lệnh chưa tới secondary khi failover (lab 3)chấp nhận rõ ràng; vẫn giữ retryWrites=true
Dữ liệu giống cache, dựng lại đượcw: 1như trêncó đường dựng lại; tránh w: 0 vì mất luôn thông báo lỗi và không retry được

Hai nguyên tắc. Đừng hạ mức để "nhanh hơn" khi chưa đo: ở mạng gần bằng 0, majority chỉ đắt hơn w: 1 khoảng 0,4 ms (lab 1); số thật của bạn nằm ở RTT giữa các node. Đừng nâng thành w: 3: nó đổi khả dụng lấy độ bền (mất một node là không ghi được), trong khi majority chịu được mất một node mà vẫn ghi. Còn w: 1, j: true chỉ chống tắt đột ngột một node, vẫn mất khi failover.

Những lỗi thường gặp

  • Tin acknowledged: true là "đã an toàn". Lab 3: w: 1 xác nhận sau 4–5 ms và mất ở 3/3 lần chạy. Lời hứa nằm ở write concern, không ở phản hồi.
  • Coi WriteConcernTimeout là thất bại. Lab 2: lệnh ghi nằm trên primary rồi tới nơi; lab 3: cũng có thể bị rollback. Trạng thái là chưa biết.
  • Retry bằng _id mới hoặc $inc mà không có idempotency. Lab 5b: một timeout, hai đơn hàng, bộ đếm cộng hai lần.
  • Nghĩ j: true là majority. Journal chống tắt đột ngột một node, không chống failover.
  • Đọc majority ngay sau w: 1 rồi tưởng sẽ thấy. Lab 4a: 498/500 lần không thấy. Muốn đọc lại lệnh ghi của mình, ghi và đọc phải cùng majority.
  • Đọc secondary mà không có session nhân quả. Lab 4b: 0/30 thấy lệnh ghi của mình; session nhân quả chờ ~1 giây và thấy 30/30.
  • Mô phỏng mất mạng bằng docker pause. Trong lab 3, lệnh ghi vẫn tới secondary dù chúng bị đóng băng, nên suýt kết luận sai. Hãy cắt mạng thật.
  • Đặt w: 3 để "chắc ăn", hoặc quên arbiter. Mất một node là lệnh ghi treo. Với P-S-A, mặc định ngầm rơi về w: 1 và majority có thể không bao giờ xong khi mất node mang dữ liệu.

Cột mốc: Bạn đã có thể chọn write concern và cách retry cho từng loại dữ liệu, và biết khi nào retryable write đã đủ, khi nào cần idempotency key. Bài Durability & Consistency khép lại ở đây.

Hỏi & đáp

Một lệnh insertOne bị timeout socket sau khi primary đã áp dụng nó (secondary tạm chậm), retryWrites=true. Điều gì xảy ra?

  1. Driver gửi lại đúng lệnh đó; server nhận ra lệnh đã chạy nên không áp dụng lần hai, còn 1 document

    Lab 5b: ứng dụng nhận thành công sau 1.945 ms, có 1 đơn, và retriedCommandsCount tăng 1. Server nhớ lệnh nào của session đã thực thi. Xem mục "Lab 5b: lệnh đã chạy, câu trả lời không về".

  2. Driver thực thi lại lệnh như mới, nên có thể có 2 document nếu _id được server sinh

    Lab 5b: có 1 document. Retry dùng lại cùng định danh lệnh nên server không áp dụng lần hai. Bản trùng chỉ xuất hiện khi chính ứng dụng gửi lại bằng _id mới (lab 5b, retryWrites=false rồi tự gửi lại: 2 đơn). Xem mục "Lab 5b".

  3. Ứng dụng luôn nhận lỗi timeout vì retry chỉ áp dụng cho lỗi NotWritablePrimary

    Retry áp dụng cho lỗi mạng (kể cả timeout) lẫn lúc không tìm thấy primary khoẻ. Lab 5b: timeout, rồi thành công. Xem mục "Cách MongoDB giải bài toán này".

  4. Driver retry nhiều lần cho tới khi thành công, nên không cần quan tâm tới idempotency

    Mặc định chỉ một lần retry (trừ khi đặt timeoutMS). Khi cả lần retry cũng lỗi, ứng dụng quay về tình huống "không biết lệnh đã chạy chưa" và cần idempotency. Xem mục "Retryable writes và retryable reads".

Chọn write concern cho hai bảng: nhật ký click của người dùng (hàng chục nghìn bản ghi mỗi giây) và sổ cái thanh toán. Cách nào hợp lý?

  1. w: 0 cho click để ghi nhanh nhất, majority cho sổ cái

    w: 0 không chờ xác nhận nên mất luôn thông báo lỗi và không retry được. Với click, w: 1 đã rẻ (lab 1: 0,215 ms) mà vẫn báo lỗi khi ghi hỏng. Xem mục "Chọn mức theo loại dữ liệu".

  2. w: 1, j: true cho sổ cái vì journal trên đĩa là đủ

    Journal chống tắt đột ngột một node, không chống failover: lệnh ghi vẫn có thể bị rollback. Với sổ cái cần majority. Xem phần Write concern và ba lab đầu (mục về j).

  3. w: 3 cho sổ cái, vì cần chắc chắn cả ba node đều có

    Mất một node là mọi lệnh ghi sổ cái treo; majority bảo vệ như nhau trước một node mất mà vẫn ghi được. Xem mục "Chọn mức theo loại dữ liệu".

  4. w: 1 cho click, majority cho sổ cái thanh toán

    Mất vài click cuối khi failover chấp nhận được, mất giao dịch thanh toán thì không. Lab 1: 0,215 so với 0,618 ms; lab 3: w: 1 mất ở 3/3 trial. Xem mục "Chọn mức theo loại dữ liệu".

Nếu bỏ hết thuật ngữ: khi bạn nhờ người khác lưu một thứ quan trọng, câu "xong rồi" chỉ có nghĩa là họ đã làm đúng việc bạn nhờ, không hơn. Nhờ "nhận thư" thì họ nhận; muốn thư không mất khi một nơi cháy, phải nhờ "đưa tới ít nhất hai nơi". Nếu người đưa tin mất liên lạc ngay lúc bạn không biết thư đã đi chưa, thì đừng viết lại một lá thư mới: hãy hỏi lại đúng lá thư đó, hoặc viết sao cho đọc hai lần cũng chỉ có một việc xảy ra. Và nếu bạn muốn đọc lại chính điều mình vừa gửi từ một chi nhánh, hãy mang theo biên nhận: chi nhánh nào chưa nhận tới số biên nhận đó thì phải đợi.

Bài tiếp theo

Bài này trả lời "lệnh ghi đã bền tới đâu" và "đọc lại lệnh ghi của mình bằng cách nào". Nhưng nó cũng để lại một vòng lặp mở: khi một lệnh ghi lỗi, ứng dụng làm gì? WriteConflict trong transaction, WriteConcernTimeout, timeout mạng, PrimarySteppedDown: lỗi nào retry được, retry bằng cách nào, và làm sao để chạy hai lần cũng không gây hại?

Write Conflicts, Retry & Idempotency trả lời những câu đó: vòng WriteConflict → abort → retry, sự khác nhau giữa retryable write (driver lo) và transaction retry (ứng dụng lo), cái gì phải idempotent, và cách giảm tranh chấp trên một hot document (document mà nhiều client cùng ghi). Còn journal giữ lời hứa j: true thế nào, và majority commit point nhích ra sao khi failover, là chuyện của bài Journal & Checkpoint và bài Elections, Failover & Majority.

Tài liệu tham khảo