Durability (P3/3): Retryable, chọn mức theo dữ liệu
Ở 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.
- Cần đọc trước: Lab majority và phía đọc
- Dẫn tới: Write Conflicts, Retry & Idempotency, bài tiếp theo. Phần này là phần cuối của bài Durability & Consistency.
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):
retryWrites | Số vòng | Lỗi ứng dụng nhìn thấy | Document / bộ đếm so với số lần được xác nhận |
|---|---|---|---|
true (lần 1) | 4.936 | 0 | 4.936 document, bộ đếm 4.936: khớp |
true (lần 2) | 4.906 | 0 | 4.906 document, bộ đếm 4.906: khớp |
false (lần 1) | 4.493 | 1: InterruptedDueToReplStateChange (code 11602) ở một $inc | bộ đế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.065 | 1: PrimarySteppedDown (code 189) ở một insertOne | 4.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ệnh | retryWrites | Ứng dụng nhìn thấy | Dữ liệu sau đó | Nếu ứng dụng tự gửi lại |
|---|---|---|---|---|
insertOne (đơn mới) | true | thành công, sau 1.945 ms | 1 đơn; retriedCommandsCount +1 | không cần |
insertOne | false | MongoNetworkTimeoutError sau 1.509 ms | 1 đơn (đã có) | 2 đơn paid |
updateOne { $inc: { n: 1 } } | true | thành công, sau 1.949 ms | n = 1; retriedCommandsCount +1 | không cần |
updateOne { $inc: { n: 1 } } | false | MongoNetworkTimeoutError sau 1.506 ms | n = 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_commit | Commit chờ | Gần nhất trong MongoDB (lỏng, không tương đương) |
|---|---|---|
off | khô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án | w: 1 |
local | WAL đã flush xuống đĩa cục bộ | w: 1, j: true |
remote_write | standby đã 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à flush | w: "majority" |
remote_apply | standby đã áp dụng, nên truy vấn trên standby thấy ngay | khô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ệu | Gợi ý | Giá | Phải đi kèm |
|---|---|---|---|
| Sổ cái, thanh toán, đơn hàng | w: "majority" (mặc định), retryWrites=true, có wtimeout hoặc timeoutMS | latency 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 secondary | ghi majority, đọc majority trong session causalConsistency: true | đọc chờ secondary bắt kịp | maxTimeMS; mang mốc thời gian theo request |
| Event log, click, metric: mất vài giây cuối chấp nhận được | w: 1 | mấ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 được | w: 1 | như trên | có đườ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: truelà "đã an toàn". Lab 3:w: 1xá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
WriteConcernTimeoutlà 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
_idmới hoặc$incmà không có idempotency. Lab 5b: một timeout, hai đơn hàng, bộ đếm cộng hai lần. - Nghĩ
j: truelà majority. Journal chống tắt đột ngột một node, không chống failover. - Đọc
majorityngay sauw: 1rồ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ùngmajority. - Đọ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: 1vàmajoritycó 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?
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ý?
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
- Write Concern (w, j, wtimeout, implicit default and the arbiter exception, acknowledgment behavior, writeConcernMajorityJournalDefault)
- Read Concern "majority" (guarantee, performance, majority commit point example, read your own writes)
- Causal Consistency and Read and Write Concerns (four guarantees, table of combinations, network partition scenarios)
- Retryable Writes (operations, retryWrites default, once-only retry, NoWritesPerformed, failover period)
- Retryable Reads (operations, unsupported operations, mongosh does not support them)
- Replica Set Configuration (writeConcernMajorityJournalDefault, settings.getLastErrorDefaults, members[n].secondaryDelaySecs)
- Rollbacks During Replica Set Failover (rollback data files, avoiding rollbacks, w: 1 and journaling)
- Read Isolation, Consistency, and Recency (read uncommitted, causal consistency)
- PostgreSQL: Write Ahead Log settings (synchronous_commit, wal_writer_delay)
- PostgreSQL: Replication settings (synchronous_standby_names, FIRST and ANY)