Replica Set & Oplog (P1/3): Các thành viên và đường đi của một lần ghi

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

Ba phần này trả lời một câu hỏi: bạn ghi vào một node, vậy làm sao hai node kia có cùng dữ liệu, và chuyện gì xảy ra khi một node chậm hoặc vắng mặt? Phần này nói về các thành viên của replica set và đường đi của một lần ghi; phần sau mở oplog, đọc từng entry và đo oplog window; phần cuối đo replication lag, nói về flow control và initial sync.

Bài Transactions & Atomicity đã vẽ một replica set tối thiểu và hứa "cơ chế thật nằm ở đây". Vài bài khác cũng để lại lời hứa: TTL chỉ chạy trên primary, mỗi lệnh xoá là một thao tác phải chép đi, diff $v: 2 trong oplog, index build trên secondary. Phần này dựng bức tranh chung; phần sau trả nốt các lời hứa đó.

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

  • Cần biết trước: Durability & Consistency (w: 1, w: "majority", acknowledged), Transactions & Atomicity (replica set tối thiểu)
  • Giới thiệu: replica set để làm gì và không để làm gì; primary, secondary, priority, votes, hidden, delayed, arbiter; các trường của rs.conf() và rs.status(); đường đi của một lần ghi; secondary lấy và áp dụng oplog ra sao; oplog (phần sau); replication lag (phần cuối)
  • Không dạy ở đây: lời hứa của w và read concern (bài Durability & Consistency); cách retryable write hoạt động (bài Retryable và chọn mức); election, failover, majority commit point dịch chuyển ra sao, rollback (bài Elections, Failover & Majority, chỉ có bản xem trước ở đây); sharding. Replica set không phải sharding: mỗi node ở đây giữ toàn bộ dữ liệu
  • Dẫn tới: Oplog, idempotent và oplog window

Môi trường lab: MongoDB 8.3.11 trong Docker (image mongo:8), mongosh 2.12.0 trong container client riêng. Ba container mongo-rep25-a, -b, -c trên một network Docker, --replSet rep25, WiredTiger cache 0,5 GB, 2 CPU, 2 GB RAM mỗi node (nâng lên 3 GB từ lab gây lag ở phần về replication lag, lý do ghi ở đó); a có priority 2 nên thường làm primary. Host là Apple M4 với Docker Desktop, dùng chung với các lab khác, nên thời gian dao động và mọi con số là của lab này. Network trong cùng một máy có độ trễ gần bằng không: số đo replication ở đây là cận dưới so với ba máy thật. Script và output thô nằm trong lab25/ kèm INDEX.md.

Nhãn: [tài liệu] theo tài liệu chính thức (manual "current" lúc viết ghi 9.0, lab là 8.3.11); [quan sát] đo trong lab này; [suy luận] rút ra từ quan sát, chưa kiểm riêng; [chi tiết cài đặt] cách server đang làm, không phải cam kết; [hình dung] mô hình để dễ nhớ.

Ý chính

Replica set là một nhóm mongod giữ cùng một bộ dữ liệu. Chỉ một node là primary và nhận mọi lệnh ghi; các node còn lại là secondary. Mọi thay đổi trên primary được ghi vào oplog (operations log), dưới dạng các entry theo thứ tự. Mỗi secondary tự chọn một node nguồn và mở một cursor trên oplog của node đó; entry mới đi về qua cursor ấy, rồi secondary áp dụng chúng vào dữ liệu của mình theo đúng thứ tự.

Việc chép là bất đồng bộ: primary không chờ secondary mới trả lời client, trừ khi write concern đòi vậy. Vì thế secondary luôn chậm hơn primary một chút. Khoảng chậm đó là replication lag: gần bằng không khi mọi thứ khoẻ, và có thể lên hàng chục giây khi một node yếu. Độ bền của w: "majority", việc đọc phải dữ liệu cũ từ secondary và failover đều dính tới khoảng chậm này.

Hình dung trước: trụ sở và các chi nhánh chép cùng một sổ cái

Một chuỗi cửa hàng có trụ sở và hai chi nhánh. Sổ cái gốc nằm ở trụ sở; mọi giao dịch phải được ghi ở trụ sở trước. Trụ sở ghi mọi thay đổi theo thứ tự vào một cuốn sổ ghi thay đổi ("mặt hàng 17: còn 6 chai", "xoá mặt hàng 40"), và mỗi chi nhánh tự đến lấy những dòng mới rồi chép vào sổ cái của mình. Khách hỏi tồn kho thì chi nhánh trả lời được; nhưng chi nhánh không được tự ghi sổ, mọi thay đổi phải đi qua trụ sở.

Chuỗi cửa hàng                              MongoDB
─────────────────────────────────────       ───────────────────────────────────
trụ sở, giữ sổ cái gốc                      primary
chi nhánh, chép lại sổ cái                  secondary
sổ ghi thay đổi theo thứ tự                 oplog
chi nhánh tự đến lấy dòng mới               secondary lấy oplog về, rồi áp dụng
chi nhánh chậm hơn trụ sở vài dòng          replication lag
trụ sở đóng cửa, chi nhánh nhận làm trụ sở  election (bài về failover)

[hình dung] Hình ảnh này khác thực tế ở ba chỗ. Một: chi nhánh có thể lấy dòng mới từ chi nhánh khác chứ không nhất thiết từ trụ sở (secondary có thể sync từ secondary). Hai: chuyện "ai là trụ sở" do các node tự bầu với nhau, không có ai chỉ định. Ba: các chi nhánh không phải kho lưu trữ. Nếu trụ sở ghi nhầm một dòng thì chi nhánh chép luôn dòng nhầm đó (xem mục "Replica set để làm gì").

Replica set để làm gì, và không để làm gì

Mục đíchReplica set giúp thế nàoGiới hạn
Availability (vẫn chạy khi một node chết)Primary mất thì một secondary được bầu lên thayCần đa số node còn sống; cơ chế nằm ở bài Elections, Failover & Majority
Độ bền qua nhiều máyw: "majority" chờ đa số node có thay đổiChỉ khi ứng dụng đòi, xem bài Durability & Consistency
Đọc từ nhiều nodeGửi lệnh đọc tới secondary (read preference)Secondary có thể trả dữ liệu cũ tới mức replication lag
Bảo trì không dừng dịch vụCập nhật từng node mộtMỗi lần chỉ một node, để không mất đa số

Có ba thứ replica set không làm.

Nó không phải backup. [quan sát] e2b-not-backup.out: một collection 1.000 document có mặt trên cả ba node; chạy deleteMany({}) trên primary, 2 giây sau cả ba node đều đếm ra 0 document. drop() collection cũng vậy: sau 2 giây cả b và c đều không còn collection đó. Một lệnh nhầm trên primary sẽ đi tới mọi bản sao. (Một node áp dụng chậm cố ý, delayed member, thu hẹp được rủi ro này nhưng vẫn không thay backup; xem replication lag.)

Nó không tăng khả năng ghi. [suy luận] Mọi lệnh ghi vẫn đi qua primary và mọi secondary phải áp dụng lại đủ từng thay đổi, nên thêm secondary không làm primary ghi được nhiều hơn. Muốn chia nhỏ dữ liệu và tải ghi ra nhiều máy là chuyện của sharding, một cơ chế khác; mỗi shard lại là một replica set.

Nó không làm đọc từ secondary luôn đúng. Secondary có thể chậm hơn primary tới hàng chục giây khi bị quá tải; phần về replication lag đo điều đó.

Giải phẫu một replica set

Các thành viên và vai trò

[quan sát] e1-anatomy.out: hello() trên a trả setName: 'rep25', isWritablePrimary: true, danh sách hosts có ba node. rs.conf() cho mỗi thành viên các trường này (ví dụ của b):

{ _id: 1, host: 'mongo-rep25-b:27017', arbiterOnly: false, buildIndexes: true,
  hidden: false, priority: 1, tags: {}, secondaryDelaySecs: Long('0'), votes: 1 }

a giống vậy nhưng priority: 2. Phần settings của cấu hình có chainingAllowed: true, heartbeatIntervalMillis: 2000, heartbeatTimeoutSecs: 10, electionTimeoutMillis: 10000, và writeConcernMajorityJournalDefault: true. Mỗi trường quy định một vai trò hoặc một tham số:

TrườngNghĩa
priorityNode nào được ưu tiên làm primary khi có election. priority: 0 là không bao giờ làm primary
votesSố phiếu của node trong election (0 hoặc 1)
arbiterOnlyArbiter chỉ bỏ phiếu, không giữ dữ liệu [tài liệu]
hiddenNode ẩn với ứng dụng; dùng cho báo cáo hoặc backup [tài liệu]; phải có priority: 0
secondaryDelaySecsNode áp dụng thay đổi chậm cố ý chừng ấy giây [tài liệu]; phải có priority: 0 và phải hidden
buildIndexesCó build index trên node này không

[tài liệu] Một replica set có thể có tới 50 member nhưng chỉ 7 member có quyền bầu, cấu hình tối thiểu khuyến nghị là ba node mang dữ liệu. Majority là "quá nửa số node có quyền bầu": ở đây rs.status() trả majorityVoteCount: 2, writeMajorityCount: 2, votingMembersCount: 3, đúng với ba node.

Arbiter, hidden và delayed ta chỉ nhắc ở mức khái niệm; delayed member được đo ở phần về replication lag. Arbiter là cách tiết kiệm (một node không giữ dữ liệu) nhưng đổi lại không thêm bản sao nào, nên w: "majority" có thể khó đạt hơn khi một node dữ liệu mất; cái giá đó nằm ở bài Elections, Failover & Majority.

Đọc rs.status()

rs.status() là lệnh replSetGetStatus. Trong lab, các trường cấp trên cùng có set, term: Long('1'), myState, syncSourceHost, majorityVoteCount, optimes, members. Mỗi members[n] có stateStr (PRIMARY hoặc SECONDARY), health, uptime, optimeDate, lastHeartbeatMessage, configVersion, syncSourceHost, infoMessage.

[quan sát] Hai chỗ đáng nhớ. Với b và c, syncSourceHost là mongo-rep25-a:27017: sync source là node mà một secondary lấy oplog về. Với chính primary, infoMessage là Could not find member to sync from: bình thường, primary không có node nào để sync.

Khối optimes khi rảnh có năm mốc cùng một giá trị (ts: Timestamp({ t: 1791598348, i: 17 }), t: Long('1')). Mỗi mốc là một vị trí trong oplog:

MốcNghĩa
writtenOpTimeEntry cuối đã được ghi vào oplog của node. Có từ 8.0 [tài liệu]
appliedOpTimeEntry cuối đã được áp dụng vào dữ liệu
durableOpTimeEntry cuối đã xuống đĩa bền (journal)
lastCommittedOpTimeĐiểm mà đa số node đã có: majority commit point
readConcernMajorityOpTimeĐiểm mà đọc với readConcern: "majority" nhìn thấy

Chúng là thứ để đo replication lag. Mỗi vị trí gồm ts (timestamp của entry, xem phần về oplog) và t (term, số lần có election). Majority commit point dịch chuyển thế nào khi failover là chuyện của bài Elections, Failover & Majority.

[quan sát] heartbeatIntervalMillis: 2000 nghĩa là các node ping nhau mỗi 2 giây; rs.status() lấy trạng thái của member khác từ heartbeat đó, nên con số của một node khác có thể cũ tới vài giây. optimeDate chỉ có độ phân giải 1 giây (phần giây của timestamp), nên mọi con số lag lấy từ rs.status() làm tròn tới giây.

Đường đi của một lần ghi

Client: insertOne({...}, { writeConcern: { w: "majority" } })
   │
   ▼
PRIMARY (a)
   ├─ ① áp dụng vào collection và index của nó
   ├─ ② ghi một entry mô tả thay đổi vào local.oplog.rs
   │
   │      secondary MỞ cursor trên oplog của nguồn
   │      ◀────────────── getMore dài trên oplog ──────────────┐
   │                                                           │
   ▼                                                           │
SECONDARY (b, c)                                               │
   ├─ ③ OplogFetcher: lấy entry mới về, đưa vào buffer ────────┘
   ├─ ④ writer thread: ghi entry vào oplog của chính nó
   ├─ ⑤ applier thread: áp dụng theo batch, nhiều thread song song
   └─ ⑥ báo vị trí đã tới về cho primary (replSetUpdatePosition)
        │
        ▼
   primary thấy đa số node đã có entry → majority commit point dịch lên
        → client nhận ack (nếu w: "majority")

Sơ đồ là mô hình rút gọn; tên OplogFetcher và cách chia việc giữa các thread là [chi tiết cài đặt] của bản 8.x, có thể đổi giữa các bản. Bốn điều trong sơ đồ có số liệu hoặc tài liệu đi kèm.

Secondary là bên mở kết nối. [quan sát] e6-pull.out: $currentOp trên primary hiện hai thao tác getmore trên local.oplog.rs, appName: "OplogFetcher", maxTimeMS: 5000, mỗi cái đã chạy vài giây: đó là hai secondary đang chờ entry mới ([suy luận] một getMore được giữ mở tới khi có dữ liệu mới hoặc hết 5 giây). [tài liệu] Trang Data Synchronization gọi cách chép này là streaming replication: node nguồn gửi một dòng entry liên tục cho node nhận (tắt được bằng tham số oplogFetcherUsesExhaust, mặc định true). [suy luận] Hai điều khớp nhau: secondary chọn nguồn và mở cursor, còn entry mới chảy về qua cursor đó; primary không cần biết trước secondary nào đang theo. [tài liệu] Với chaining bật (mặc định), một secondary có thể chọn secondary khác làm sync source.

Áp dụng theo batch, song song. [tài liệu] MongoDB áp dụng thay đổi theo batch bằng nhiều thread, gom theo _id của document và áp dụng mỗi nhóm bằng một thread riêng; các thay đổi của cùng một document luôn theo đúng thứ tự gốc. [quan sát] getParameter trên b: replWriterThreadCount: 16 (manual: số thread thực dùng tối đa bằng 4 lần số core, tức 8 ở node 2 CPU này), replBatchLimitOperations: 5000, replBatchLimitBytes: 104857600 (100 MiB). Một lệnh đọc trên secondary (read concern local hoặc majority) đọc từ một snapshot nên không phải chờ batch đang áp dụng [tài liệu].

Ghi vào oplog và áp dụng chạy song song. [tài liệu] Release notes 8.0 viết: từ 8.0, với mỗi batch, một writer thread (thread ghi entry mới vào oplog của chính secondary, bước ④) và một applier thread (thread áp dụng entry vào dữ liệu, bước ⑤) chạy song song; vì thế rs.status() có thêm writtenOpTime bên cạnh appliedOpTime, và metrics.repl.buffer tách thành hai buffer. [quan sát] Trên b: metrics.repl.buffer.write tối đa 268.435.456 byte (256 MiB), metrics.repl.buffer.apply tối đa 104.857.600 byte (100 MiB) và 10.000 entry. Hệ quả cho client nằm ở bài Durability: từ 8.0, w: "majority" được ack khi đa số node đã ghi entry, chưa chắc đã áp dụng, nên đọc ngay từ secondary sau đó có thể chưa thấy (bài Durability: lab majority và phía đọc).

Số liệu của một đợt ghi. [quan sát] Tôi ghi 20.000 document vào primary (200 lệnh insertMany mỗi lệnh 100 document, rồi 20 lệnh updateMany mỗi lệnh 1.000 document, w: 1), đọc metrics.repl của b trước và sau (e6-pull.out):

Bộ đếm trên bTăng
network.ops (entry lấy về)20.201
apply.ops (thao tác đã áp dụng)40.201
apply.batches.num307
write.batches.num348
network.getmores.num682
network.bytes6.088.153

Xong đợt ghi, cả a và b đều có 20.000 document. [suy luận] 20.201 entry là 200 entry applyOps (mỗi insertMany 100 document thành một entry chứa 100 insert bên trong), 20.000 entry update và một lệnh tạo collection. apply.ops lớn hơn 20.000 vì bộ đếm này tính cả 20.000 insert nằm bên trong các entry applyOps: 20.201 + 20.000 = 40.201; phần về oplog cho thấy vì sao một lệnh nhiều document có thể thành ít entry hơn số document. Khoảng 66 entry mỗi batch áp dụng (20.201 / 307) cho thấy batch gom nhiều entry chứ không áp từng cái.

Thêm một phép đo rất thô về "bao lâu thì secondary thấy". [quan sát] e2-roles.out: ghi w: 1 trên a, rồi dò liên tục trên b tới khi thấy; 30 lần cho khoảng 0 đến 6 ms tính từ lúc primary ack tới lúc b đọc được, median 1 ms. Đó là cận trên của một lần chờ nhỏ trong cùng một máy, gồm cả một lượt gọi lệnh đọc, không phải độ trễ replication thật giữa ba máy; phần về replication lag đo khi nó lớn lên.

Vai trò trong thực tế

[quan sát] e2-roles.out: ghi một document với w: "majority" qua kết nối replica set, rồi nối thẳng vào từng node (directConnection):

a  isWritablePrimary=true   find → thấy document   insert → ok
b  secondary=true           find → thấy document   insert → NotWritablePrimary: not primary
c  secondary=true           find → thấy document   insert → NotWritablePrimary: not primary

Secondary đọc được (qua kết nối trực tiếp, ở lab này) và từ chối ghi với NotWritablePrimary. Ứng dụng bình thường dùng URI có replicaSet= để driver tự tìm primary, đúng như bài Transactions & Atomicity đã làm.

Cột mốc: Bạn đã có thể nói replica set không phải backup cũng không phải sharding, đọc các vai trò trong rs.conf(), và vẽ đường đi của một lần ghi từ primary sang secondary. Tiếp theo: Oplog, idempotent và oplog window.

Hỏi & đáp

Một đồng nghiệp chạy nhầm deleteMany({}) trên collection orders của primary. Cậu ấy bảo "không sao, còn hai secondary giữ nguyên bản sao". Điều gì đúng hơn?

  1. Đúng, secondary chỉ chép những gì primary còn, nên chúng giữ bản cũ của dữ liệu đã bị xoá

    Ngược lại: secondary chép chính thao tác xoá. Lab: sau 2 giây cả ba node đếm ra 0 document. Xem mục "Replica set để làm gì, và không để làm gì".

  2. Sai, lệnh xoá cũng được chép sang secondary, nên replica set không thay được backup

    Đúng với lab: deleteMany và drop đều tới cả b và c trong 2 giây. Muốn quay lại phải có backup; một delayed member chỉ thu hẹp rủi ro. Xem mục "Replica set để làm gì, và không để làm gì".

  3. Đúng nếu đặt priority: 0 cho cả hai secondary, vì chúng không bao giờ làm primary

    priority chỉ quyết định node nào được bầu làm primary, không ảnh hưởng việc chép thao tác. Xem mục "Giải phẫu một replica set".

  4. Sai, vì lệnh xoá chỉ được chép khi dùng w: "majority"

    Write concern quyết định client chờ gì trước khi nhận ack, không quyết định thao tác có được chép hay không: mọi thay đổi đều vào oplog và được secondary lấy về. Xem mục "Đường đi của một lần ghi".

Trong $currentOp trên primary có hai thao tác getmore trên local.oplog.rs, appName: "OplogFetcher", đang chạy vài giây. Chúng là gì?

  1. Hai secondary đang chờ entry mới

    Đúng: mỗi secondary giữ một getMore mở trên oplog của sync source. Xem mục "Đường đi của một lần ghi".

  2. Primary đang tự mở kết nối tới từng secondary để gửi oplog

    Ngược lại: các cursor này do secondary mở lên primary (appName: OplogFetcher); entry mới đi về qua chính cursor đó. Xem mục "Đường đi của một lần ghi".

  3. Hai lệnh đọc của ứng dụng đang bị treo chờ lock

    appName là OplogFetcher, một client nội bộ của mongod, không phải ứng dụng. Xem mục "Đường đi của một lần ghi".

  4. Hai applier thread áp dụng thay đổi vào collection của chính primary

    Applier thread nằm trên secondary, và áp dụng vào dữ liệu của secondary chứ không đọc oplog của primary. Xem mục "Đường đi của một lần ghi".

Chuỗi cửa hàng: trụ sở giữ sổ cái gốc, hai chi nhánh tự đến lấy các dòng mới trong sổ ghi thay đổi rồi chép vào sổ cái của mình. Một khách ghé chi nhánh, vừa mua món hàng ở trụ sở 3 giây trước, hỏi còn bao nhiêu. Điều nào đúng?

  1. Chi nhánh luôn trả lời đúng ngay, vì nó cùng một chuỗi với trụ sở

    Chi nhánh chỉ đúng tới lần lấy dòng mới gần nhất. Xem mục "Hình dung trước: trụ sở và các chi nhánh chép cùng một sổ cái".

  2. Chi nhánh tự sửa sổ cái cho khớp rồi báo lại trụ sở

    Chi nhánh không được tự ghi sổ; mọi thay đổi phải đi qua trụ sở. Xem mục "Hình dung trước: trụ sở và các chi nhánh chép cùng một sổ cái".

  3. Chi nhánh phải gọi trụ sở hỏi trước mỗi lần trả lời

    Chi nhánh trả lời từ sổ của mình, không gọi trụ sở mỗi lần. Xem mục "Hình dung trước: trụ sở và các chi nhánh chép cùng một sổ cái".

  4. Chi nhánh có thể trả lời số cũ, vì có thể chưa lấy dòng thay đổi mới

    Đúng: đó là độ trễ giữa trụ sở và chi nhánh, tương ứng với replication lag. Xem mục "Hình dung trước: trụ sở và các chi nhánh chép cùng một sổ cái".