Failover (P3/3): Ứng dụng nhìn thấy gì; read preference, timeout và retry

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

Ở 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.

Môi trường lab: MongoDB 8.3.11 trong Docker (mongo:8), replica set 3 node mongo-fo26-a/-b/-c, Node.js driver 7.7.0 (mặc định retryWrites và retryReads bậ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à ENOTFOUND thay vì ECONNREFUSED hay timeout như ở mạng thật. Script và output thô nằm trong lab26/. 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ừ đâuKhi không có primary
primary (mặc định)Chỉ primaryLỗi (sau serverSelectionTimeoutMS)
primaryPreferredPrimary, nếu không có thì secondaryĐọc secondary
secondaryChỉ secondaryVẫn đọc secondary
secondaryPreferredSecondary; nếu không có secondary phù hợp thì primaryVẫn đọc secondary
nearestMember ngẫu nhiên có độ trễ không hơn member gần nhất quá localThresholdMS (mặc định 15 ms), primary hay secondaryKhô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ìnhNode trả lời (300 lần)
primary, primaryPreferreda: 300
secondaryb: 162, c: 138
secondaryPreferredb: 156, c: 144
nearesta: 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 + tagDriver từ chối: Primary read preference cannot be combined with tags
secondary + maxStalenessSeconds: 30Driver 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 concernThao tác chậm nhất, 3 lần
primary / local, primary / majority10,2 đến 10,6 giây
primaryPreferred / local5 đến 18 ms
secondary / local10 đến 25 ms
secondaryPreferred / local, secondaryPreferred / majority6 đến 28 ms
nearest / local8 đế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 clientKill primaryPartition
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: false1 lỗi MongoNetworkError sau 29 đến 93 ms; thao tác kế tiếp chờ 9,9 đến 10,3 giâyKhông đo
serverSelectionTimeoutMS: 30003 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âyKhông đo
timeoutMS: 15000Không đo1 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

  1. Giữ retryWrites và retryReads bậ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).
  2. Đặt serverSelectionTimeoutMS lớ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.
  3. Đặt deadline cho cả thao tác (timeoutMS, hoặc socketTimeoutMS cộng wtimeout): không có nó, partition trong lab giữ client 36 giây.
  4. Làm lệnh ghi idempotent (ví dụ _id do 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).
  5. Dùng w: "majority" cho dữ liệu không được mất, cộng với wtimeout hoặc timeoutMS; nhớ WriteConcernTimeout không có nghĩa là lệnh không được ghi.
  6. 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: secondary lỗi, secondaryPreferred rơi về primary).
  7. 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).
  8. 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 WriteConcernTimeout là 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.
  • Để serverSelectionTimeoutMS mặc định mà không có deadline cho cả thao tác: partition giữ client 36 giây không lỗi.
  • Đặt serverSelectionTimeoutMS ngắ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 maxStalenessSeconds dưới 90: secondary im 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 kill là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?

  1. Driver từ chối: ngưỡng nhỏ nhất là 90 giây

    Lab: maxStalenessSeconds: 30 báo must be at least 90 seconds ngay khi tạo read preference. Xem mục "Read preference: năm chế độ, tag set và độ cũ".

  2. Chỉ các secondary cũ hơn 30 giây bị loại; còn lại đọc bình thường

    Ngưỡng dưới 90 giây không được chấp nhận. Lab chỉ chạy với 90: c (cũ 134 giây) bị loại. Xem mục "Read preference: năm chế độ, tag set và độ cũ".

  3. Mọi lệnh đọc rơi về primary

    Rơi về primary là hành vi của secondaryPreferred khi không secondary nào phù hợp (lab: tag {dc: "zz"}), không phải của một ngưỡng không hợp lệ. Xem mục "Read preference: năm chế độ, tag set và độ cũ".

  4. Nó đổi read concern thành majority cho các lệnh đọc

    Read preference và read concern độc lập với nhau. Xem mục "Read preference: năm chế độ, tag set và độ cũ".

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?

  1. Ghi w: 1 vẫn chạy vì node còn dữ liệu đầy đủ

    Node đã thành secondary, mọi lệnh ghi lỗi MongoServerSelectionError sau 3 giây. Xem mục "Mất đa số: ghi và đọc ra sao".

  2. Đọc bằng secondaryPreferred, trả dữ liệu cũ tới commit point cuối cùng (với majority)

    Lab: đọc secondaryPreferred chạy 1 đến 5 ms; majority đứng ở 29 trong khi local tăng lên 41. Xem mục "Mất đa số: ghi và đọc ra sao".

  3. Đọc primary chạy vì node cuối cùng là primary gần nhất

    Không còn primary, đọc primary lỗi sau 3 giây. Xem mục "Mất đa số: ghi và đọc ra sao".

  4. Không thao tác nào: cụm tự khoá khi mất đa số

    Đọc từ secondary không cần primary và vẫn chạy. Xem mục "Mất đa số: ghi và đọc ra sao".

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

Chủ đề

Bạn thấy bài này thế nào?