Failover (P3/3): Ứng dụng nhìn thấy gì; read preference, timeout và retry
Ở phần trước: majority commit point là điểm mà đa số member đã có; nó đứng yên khi đa số mất. Lệnh w: 1 ở primary bị cô lập vẫn được xác nhận, rồi bị rollback khi primary quay lại. Phần này đứng ở phía client.
- Cần đọc trước: Failover: majority commit point và rollback, Durability: retryable write
- Dẫn tới: Sharding Architecture. Bài về failover khép lại ở đây
Môi trường lab: MongoDB 8.3.11 trong Docker (
mongo:8), replica set 3 nodemongo-fo26-a/-b/-c, Node.js driver 7.7.0 (mặc địnhretryWritesvàretryReadsbật), host Docker Desktop đang bận nên mọi thời gian là cận dưới. Mỗi kịch bản chạy 3 lần, một mẫu nhỏ (bảng báo khoảng min đến max). Một ghi chú vềdocker kill: nó xoá tên container khỏi DNS của Docker, nên lỗi kết nối trong log làENOTFOUNDthay vìECONNREFUSEDhay timeout như ở mạng thật. Script và output thô nằm tronglab26/. Nhãn: [tài liệu], [quan sát], [suy luận] như hai phần trước.
Hình dung trước: tổng đài viên và bảng ca trực
Khách gọi vào đường dây nóng; trước mỗi cuộc gọi, tổng đài viên nhìn bảng ca trực để biết ai đang là trưởng ca, ai đang rảnh, ai vừa vắng. Bảng được cập nhật mỗi vài giây, hoặc ngay khi một cuộc gọi bị cắt giữa chừng. Khi trưởng ca mất, bảng cũ vẫn ghi tên ông ta cho tới khi có người báo; và mỗi loại cuộc gọi có quy định riêng: gọi cần trưởng ca thì phải chờ trưởng ca mới, gọi "ai trả lời cũng được" thì chuyển ngay cho người rảnh.
tổng đài viên = driver
bảng ca trực = topology: trạng thái từng node mà driver theo dõi
gọi cần trưởng ca = ghi, hoặc đọc với read preference primary
gọi ai trả lời cũng được = đọc với secondaryPreferred hay nearestĐây chỉ là cách hình dung: driver không "nhìn bảng" mà chạy một luồng monitor riêng cho mỗi node và chọn node cho từng thao tác.
Ý chính
Khi failover, ứng dụng không thấy "cụm" mà thấy driver của mình. Driver theo dõi trạng thái từng node, chọn node cho mỗi thao tác theo read preference, và chờ tối đa serverSelectionTimeoutMS (mặc định 30 giây) cho tới khi có node phù hợp. Thao tác cần primary phải chờ election; thao tác cho phép đọc secondary thì chạy tiếp. Mọi thứ khác (retry, timeout, idempotency) là cách bạn cấu hình driver và viết code quanh khoảng chờ đó.
Read preference: năm chế độ, tag set và độ cũ
[tài liệu] Read preference chọn từ đâu đọc. Năm chế độ và hành vi khi không có primary:
| Chế độ | Đọc từ đâu | Khi không có primary |
|---|---|---|
primary (mặc định) | Chỉ primary | Lỗi (sau serverSelectionTimeoutMS) |
primaryPreferred | Primary, nếu không có thì secondary | Đọc secondary |
secondary | Chỉ secondary | Vẫn đọc secondary |
secondaryPreferred | Secondary; nếu không có secondary phù hợp thì primary | Vẫn đọc secondary |
nearest | Member ngẫu nhiên có độ trễ không hơn member gần nhất quá localThresholdMS (mặc định 15 ms), primary hay secondary | Không đổi |
Hai núm tinh chỉnh. Tag set chọn member theo nhãn bạn đặt trong cấu hình ({ dc: "hn", rack: "1" }): driver thử các tag set theo thứ tự cho tới khi có member khớp. maxStalenessSeconds loại các secondary mà driver ước lượng là cũ hơn một ngưỡng; [tài liệu] ngưỡng nhỏ nhất là 90 giây. [tài liệu] Mọi chế độ trừ primary có thể trả dữ liệu cũ, và read preference không đổi việc dữ liệu nhìn thấy được trước khi xác nhận; mức "an toàn" là việc của read concern (Isolation: read concern).
Lab 4: node nào trả lời? Tôi đặt nhãn a: dc=hn, b: dc=hn, c: dc=sg, rồi gửi 300 lệnh hello cho mỗi cấu hình read preference và đếm node trả lời (phần A của e9-readpref-run1-delayed-member-hidden.out); a là primary:
| Cấu hình | Node trả lời (300 lần) |
|---|---|
primary, primaryPreferred | a: 300 |
secondary | b: 162, c: 138 |
secondaryPreferred | b: 156, c: 144 |
nearest | a: 100, b: 113, c: 87 |
secondary + tag {dc: "sg"} | c: 300 |
secondary + tag {dc: "zz"} (không member nào có) | Lỗi Server selection timed out after 2000 ms |
secondaryPreferred + tag {dc: "zz"} | a: 300 (rơi về primary) |
secondary + tag list [{dc: "zz"}, {}] | b: 147, c: 153 (tag rỗng khớp mọi secondary) |
primary + tag | Driver từ chối: Primary read preference cannot be combined with tags |
secondary + maxStalenessSeconds: 30 | Driver từ chối: must be at least 90 seconds |
Với ba node trong cùng một máy, độ trễ gần như bằng nhau nên nearest rải đều, kể cả lên primary. Tag không khớp là bẫy: secondary im lặng chờ rồi lỗi, secondaryPreferred im lặng đọc từ primary.
maxStalenessSeconds hoạt động. Để có một secondary cũ thật, tôi khoá oplog applier của c bằng db.fsyncLock() và chờ 130 giây (e9d-staleness.out; lúc đọc, c cũ hơn primary 134 giây). secondary không giới hạn: b 154, c 146. secondary + maxStalenessSeconds: 90: b 300, c không bao giờ. (Lần thử với một member có secondaryDelaySecs thì hỏng, tôi không dùng kết quả: [quan sát] hello không đưa member trễ vào danh sách hosts cho client, nên driver không bao giờ chọn nó, kể cả khi không giới hạn độ cũ.)
Khi primary biến mất: reader nào còn chạy?
Một client đọc mỗi 50 ms ở mỗi tổ hợp read preference và read concern, trong lúc tôi docker kill primary (3 lần, ec-rd-t1.out đến t3.out). Số lệnh đọc mỗi tổ hợp khoảng 670 đến 875, không lỗi nào (driver mặc định tự retry đọc một lần). Cột phải là thao tác chậm nhất:
| Read preference / read concern | Thao tác chậm nhất, 3 lần |
|---|---|
primary / local, primary / majority | 10,2 đến 10,6 giây |
primaryPreferred / local | 5 đến 18 ms |
secondary / local | 10 đến 25 ms |
secondaryPreferred / local, secondaryPreferred / majority | 6 đến 28 ms |
nearest / local | 8 đến 27 ms |
[quan sát] Đọc primary chờ đúng một cuộc election. primaryPreferred không chờ: khi không có primary nó đọc secondary ngay (dữ liệu có thể cũ hơn primary). Các chế độ còn lại gần như không thấy failover. [quan sát] Trong ba lần này, driver đánh dấu primary cũ là Unknown sau 53 đến 73 ms và thấy primary mới sau 10,3 đến 10,7 giây.
Mất đa số: ghi và đọc ra sao
Câu hỏi khác: nếu hai trên ba node chết và không ai đủ phiếu để làm primary thì sao? Tôi docker kill hai secondary và gửi mỗi 0,4 giây một vòng thăm dò (ghi w: 1, ghi w: "majority" với wtimeout: 1500, đọc primary, đọc secondaryPreferred với local và majority; serverSelectionTimeoutMS đặt 3.000; 3 lần, e8-run1.out đến run3.out; e8-summary.txt).
mất hai secondary → khoảng 9,7 đến 10,0 giây sau: primary step down
trong khoảng đó (còn là primary): w:1 ✓ (0 đến 4 ms) đọc primary ✓
w:"majority" ✗ WriteConcernTimeout sau ≈ 1.510 ms
sau khi step down (không còn primary): w:1 ✗ w:"majority" ✗ đọc primary ✗
cùng lỗi MongoServerSelectionError sau 3.000 ms
đọc secondaryPreferred (local, majority) ✓ 1 đến 5 ms[quan sát] Khi mất đa số, cụm trở thành chỉ đọc theo đúng nghĩa: ghi dừng, đọc primary dừng, đọc từ secondary vẫn chạy. Hai chi tiết cho thấy "an toàn" nghĩa là gì. Một: đọc majority qua secondaryPreferred trả 29 (31 ở lần 3) suốt thời gian đó, còn local tăng từ 31 (33 ở lần 3) lên 41: các lệnh w: 1 ở khoảng đầu và các lệnh w: "majority" đã timeout vẫn nằm trên node, nhưng majority không thấy chúng, đúng như commit point đứng yên ở phần trước. Hai: các lệnh w: 1 ở khoảng đầu được xác nhận trong khi cụm không còn đa số. [suy luận] Nếu node này mất trước khi hai node kia sống lại và chép được chúng, hai node kia có thể bầu một primary thiếu các lệnh đó, và khi node này quay về, chúng bị rollback. (Ở lần 1, một lệnh w: 1 sát lúc step down vẫn thành công.)
Timeout và retry quanh một lần failover
Cùng client ghi w: "majority" tuần tự, 45 giây, đổi cấu hình driver (3 đến 5 lần mỗi ô; số ở dạng khoảng):
| Cấu hình client | Kill primary | Partition |
|---|---|---|
Mặc định (retryWrites: true, serverSelectionTimeoutMS 30 giây) | 0 lỗi; thao tác đang chạy chờ 10,1 đến 11,1 giây rồi thành công (một lần 20,6 giây) | 0 lỗi; thao tác chờ 36,3 đến 36,4 giây |
retryWrites: false | 1 lỗi MongoNetworkError sau 29 đến 93 ms; thao tác kế tiếp chờ 9,9 đến 10,3 giây | Không đo |
serverSelectionTimeoutMS: 3000 | 3 lỗi MongoServerSelectionError, mỗi lỗi 3,0 giây; thao tác thứ tư thành công sau 0,8 đến 2,0 giây | Không đo |
timeoutMS: 15000 | Không đo | 1 lỗi MongoOperationTimeoutError sau đúng 15,0 giây |
[quan sát] Ba điều từ bảng. Một, retryable write che hoàn toàn một lần kill: lệnh ghi chỉ chậm. Tắt nó thì đúng một lệnh, lệnh đang chạy lúc node chết, trả lỗi mà ứng dụng không biết lệnh đã được áp dụng hay chưa: đây là tình huống ứng dụng phải retry một cách an toàn (Write Conflicts: idempotency và retry). Hai, serverSelectionTimeoutMS ngắn hơn thời gian election (3 giây so với ~10) làm mọi thao tác trong khoảng đó thất bại thay vì chờ. Ba, partition khác crash: kết nối tới primary cũ không bị đóng mà chỉ im, nên thao tác lâu 36 giây, không lỗi nào, dù primary mới có sau 10 giây. [suy luận] 36 giây đến từ driver, không phải server: timeout 40 giây của kết nối monitor (connectTimeoutMS 30 giây cộng heartbeatFrequencyMS 10 giây) trừ khoảng 4 giây client đã chạy trước partition (phần đầu có chi tiết). timeoutMS cắt nó còn 15 giây.
Một quan sát phụ ở partition với timeoutMS (3 lần): sau lỗi 15 giây, 9, 9 và 7 lệnh w: "majority" liên tiếp mỗi lệnh mất 1,96 đến 2,01 giây trên primary mới (từ giây thứ 16 tới khoảng giây 30 đến 34), dù primary mới đã nhận ghi từ giây thứ 10. Ở lần 1, log của secondary còn lại ghi Unable to forward progress 33 lần trong khoảng giây 10 đến 36 (summary-forward-progress.txt). [suy luận] Secondary đó có vẻ chưa báo được tiến độ chép cho primary mới, nên primary chỉ biết nó đã có dữ liệu qua heartbeat 2 giây một lần; tôi chưa kiểm chứng cơ chế. Bài học thực dụng: ngay sau failover có primary không có nghĩa là w: "majority" đã nhanh trở lại.
Checklist cho ứng dụng chịu được failover
- Giữ
retryWritesvàretryReadsbật (mặc định) và hiểu giới hạn: driver retry một lần; lỗi mà ứng dụng không biết lệnh đã được áp dụng hay chưa là việc của ứng dụng (Durability: retryable write). - Đặt
serverSelectionTimeoutMSlớn hơn thời gian election của bạn (lab: 10 đến 11 giây, đôi khi 20) nếu muốn thao tác chờ qua failover; đặt nhỏ nếu muốn thất bại nhanh và retry ở tầng ứng dụng. - Đặt deadline cho cả thao tác (
timeoutMS, hoặcsocketTimeoutMScộngwtimeout): không có nó, partition trong lab giữ client 36 giây. - Làm lệnh ghi idempotent (ví dụ
_iddo client sinh) để retry sau một lỗi mà bạn không biết lệnh đã áp dụng hay chưa không tạo bản sao (Write Conflicts: idempotency và retry, Write Conflicts). - Dùng
w: "majority"cho dữ liệu không được mất, cộng vớiwtimeouthoặctimeoutMS; nhớWriteConcernTimeoutkhông có nghĩa là lệnh không được ghi. - Chọn read preference theo mức chịu dữ liệu cũ, thêm
maxStalenessSeconds(tối thiểu 90) nếu đọc từ secondary, và kiểm tra tag không bị gõ sai (không khớp:secondarylỗi,secondaryPreferredrơi về primary). - Số member có vote lẻ, trải qua các vùng lỗi khác nhau; mất một datacenter không nên mất đa số. Arbiter chỉ khi hiểu rõ cái giá (xem phần đầu).
- Thử failover thật bằng
rs.stepDown()trên môi trường test và đo từ client;rs.stepDown()cũng là cách bảo trì đúng thay vì tắt node đang là primary.
So với PostgreSQL
[tài liệu] PostgreSQL không có election tích hợp. Manual nói PostgreSQL không cung cấp phần mềm để phát hiện primary hỏng và báo cho standby; việc nâng standby lên là lệnh pg_ctl promote hoặc pg_promote(). Trong thực tế người ta thêm công cụ ngoài: Patroni giữ một khoá "leader" trong kho chia sẻ (etcd, Consul, ZooKeeper, Kubernetes); khoá hết hạn sau ttl (mặc định 30 giây, loop_wait 10, retry_timeout 10) thì failover bắt đầu. Hai hệ thống khác nhau ở chỗ lưu quyết định: MongoDB quyết định trong chính các node dữ liệu, PostgreSQL + Patroni quyết định trong một dịch vụ thứ ba. Tôi không đo PostgreSQL trong bài này nên không so thời gian.
Về dữ liệu: [tài liệu] replication bất đồng bộ của PostgreSQL (mặc định) có thể mất những commit chưa kịp sang standby khi primary sập. synchronous_standby_names với ANY n (...) đặt số standby phải xác nhận (kiểu quorum); Patroni synchronous_mode chỉ cho phép nâng standby chắc chắn có mọi commit đã báo thành công. Các cơ chế này cùng mục đích với w: "majority" nhưng không tương đương, và tôi không so chi tiết ở đây. Khi primary cũ quay lại, PostgreSQL dùng pg_rewind ([tài liệu]: đồng bộ lại một cụm với bản sao của nó sau khi các timeline đã diverge) hoặc dựng lại standby; MongoDB tự rollback và để lại rollback file. Phía client, libpq có target_session_attrs=read-write kết hợp nhiều host để tìm primary; MongoDB đưa việc đó vào driver. Không bên nào "tốt hơn": chúng đặt ranh giới khác nhau giữa database và công cụ vận hành.
Những lỗi thường gặp
- Tin
w: 1đã xác nhận là đã an toàn khi failover: lab rollback ở phần trước mất cả 1.132 đến 1.345 lệnh như vậy. - Coi
WriteConcernTimeoutlà thất bại: lệnh vẫn nằm trên primary, có thể bị rollback, và sẽ bị áp dụng hai lần nếu client gửi lại mà lệnh không idempotent. - Để
serverSelectionTimeoutMSmặc định mà không có deadline cho cả thao tác: partition giữ client 36 giây không lỗi. - Đặt
serverSelectionTimeoutMSngắn hơn một cuộc election rồi ngạc nhiên vì lỗi hàng loạt khi failover. - Gõ sai tag hoặc dùng
maxStalenessSecondsdưới 90:secondaryim lặng lỗi, driver từ chối ngưỡng nhỏ. - Cho là
readConcern: "majority"giới hạn được độ cũ khi đọc secondary: nó đọc dữ liệu đã đa số, nhưng trên một secondary tụt lại thì "đã đa số" vẫn là dữ liệu cũ. - Suy ra production từ số lab: Docker Desktop không có độ trễ mạng thật và
docker killlàm DNS biến mất.
Cột mốc: Bạn đã có thể đọc một lần failover từ phía client (khoảng chờ, lỗi, retry), chọn read preference và timeout cho từng loại thao tác, và nói cluster mất đa số còn chạy được những gì. Bài về failover khép lại ở đây; bài sau là sharding.
Hỏi & đáp
Dashboard báo cáo đọc từ secondary với maxStalenessSeconds. Một đồng nghiệp đặt maxStalenessSeconds=30 để dữ liệu "tươi hơn". Điều gì xảy ra?
Replica set 3 node mất hai node, primary còn lại đã step down. Client dùng serverSelectionTimeoutMS: 3000. Thao tác nào vẫn chạy?
Nếu bỏ hết thuật ngữ: một đường dây nóng có ba người trực và một trưởng ca cầm điện thoại chung. Khi trưởng ca biến mất, hai người còn lại chờ đủ lâu rồi bầu trưởng ca mới; ai gọi vào lúc này phải chờ, hoặc nếu chỉ cần hỏi thông tin thì người nào rảnh cũng trả lời được, nhưng có thể là thông tin chưa mới nhất. Nếu cả hai người kia cùng vắng, người còn lại giữ máy nhưng không dám nhận việc mới. Lời hứa với khách nên dựa vào những dòng sổ đã có đa số chép lại, vì dòng viết trong lúc cô lập có thể bị gạch khi trưởng ca cũ quay về. Và khách nên có một giới hạn thời gian chờ, thay vì chờ vô hạn một đường dây im lặng.
Bài tiếp theo
Một replica set giải quyết một chuyện: primary chết thì node khác lên thay. Nó không giải quyết chuyện dữ liệu lớn hơn sức chứa hoặc tốc độ ghi của một primary, vì mọi lệnh ghi vẫn đi qua một node.
Sharding Architecture trả lời: chia dữ liệu thành nhiều phần, mỗi phần là một replica set riêng, và ai biết phần nào ở đâu (mongos, config server, chunk, balancer). Mỗi shard vẫn dùng đúng cơ chế bầu cử, majority commit point và rollback của bài này.
Tài liệu tham khảo
- MongoDB: Replica Set Elections (heartbeat 2 giây và 10 giây, priority, votes, median 12 giây)
- MongoDB: Rollbacks During Replica Set Failover (rollback file, hai thuật toán,
rollbackTimeLimitSecs) - MongoDB: replSetGetStatus (
optimes,electionCandidateMetrics) - MongoDB: Replica Set Configuration (
electionTimeoutMillis,catchUpTimeoutMillis,heartbeatTimeoutSecs) - MongoDB: replSetStepDown
- MongoDB: Replica Set Member States
- MongoDB: Replica Set Arbiter
- MongoDB: Read Preference (chế độ,
maxStalenessSeconds) và Read Preference Use Cases, Mechanics (localThresholdMS15 ms) - MongoDB: Connection String Options (
serverSelectionTimeoutMS30 giây,heartbeatFrequencyMS10 giây,maxStalenessSecondstối thiểu 90) - MongoDB: hello (
hosts,passives) - MongoDB: Read Concern "majority"
- PostgreSQL: Failover
- PostgreSQL: pg_rewind
- PostgreSQL: Replication settings (
synchronous_standby_names) - PostgreSQL: libpq connection parameters (
target_session_attrs) - Patroni: Dynamic configuration (
ttl,loop_wait,retry_timeout,synchronous_mode) - Patroni: Replication modes