Replica Set & Oplog (P2/3): Oplog, idempotent và oplog window

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

Ở phần trước: replica set có một primary nhận ghi; các secondary tự lấy oplog về rồi áp dụng theo batch, bất đồng bộ. Khi mọi thứ khoẻ, trong lab một lần ghi tới secondary trong vài mili giây.

Ý chính

Oplog là một collection, local.oplog.rs, thuộc loại capped collection: kích thước cố định, ghi đầy thì entry cũ nhất bị bỏ đi để lấy chỗ cho entry mới. Mỗi entry mô tả một thay đổi dữ liệu (hoặc một nhóm thay đổi, như ta sẽ thấy). Secondary đọc entry theo thứ tự rồi áp dụng.

Hai tính chất quyết định mọi thứ trong phần này. Một: mỗi entry idempotent, áp dụng một lần hay nhiều lần đều ra cùng kết quả, nên secondary có thể áp dụng lại một đoạn oplog mà không sợ sai. Hai: oplog có kích thước hữu hạn, nên thời gian nó "nhớ" được là hữu hạn. Khoảng thời gian đó là oplog window; secondary vắng mặt lâu hơn window thì không tự bắt kịp được nữa.

Hình dung trước: cuốn sổ ghi thay đổi có số trang cố định

Nhắc lại hình ảnh chuỗi cửa hàng: trụ sở ghi mọi thay đổi vào một cuốn sổ để các chi nhánh đến chép. Cuốn sổ chỉ có 100 trang. Viết hết trang 100 thì quay lại ghi đè trang 1. Mỗi dòng ghi kết quả, không ghi thay đổi: "mặt hàng 17: còn 6 chai", chứ không phải "bán 5 chai". Nhờ vậy chi nhánh lỡ chép một dòng hai lần cũng không sai: mặt hàng 17 vẫn còn 6 chai. Nhưng nếu một chi nhánh nghỉ lâu tới mức trụ sở đã ghi đè quá trang chi nhánh đọc dở, chi nhánh không còn cách nào chép tiếp: nó phải chép lại cả sổ cái gốc.

Sổ ghi thay đổi                             oplog
─────────────────────────────────────       ───────────────────────────────────
100 trang, ghi đè từ đầu                    capped collection
dòng "mặt hàng 17: còn 6 chai"              entry chứa giá trị mới, không phải phần cộng
chi nhánh chép lại một dòng cũng không sai  idempotent
trụ sở ghi hết 100 trang trong bao lâu      oplog window
chi nhánh nghỉ quá lâu, phải chép lại sổ    initial sync

[hình dung] Khác thực tế ở một chỗ: entry của MongoDB không phải lúc nào cũng ghi "kết quả cuối" cho cả document; với $set và $push nó ghi phần khác biệt (diff), nhưng khác biệt đó vẫn được viết sao cho áp dụng lại không đổi kết quả.

Một entry trông thế nào

[quan sát] e3-oplog-entries.out. Entry của insertOne({_id: 1, customer: "an", status: "new", qty: 1, tags: ["a"]}) (rút gọn, bỏ UUID):

{ op: "i", ns: "lab25.orders", ui: <UUID của collection>,
  o:  { _id: 1, customer: "an", status: "new", qty: 1, tags: ["a"] },
  o2: { _id: 1 },
  ts: Timestamp(1791598408, 5), t: 1, v: 2,
  lsid: {...}, txnNumber: 1, stmtId: 0, prevOpTime: { ts: Timestamp(0, 0), t: -1 } }
TrườngNghĩa
tsTimestamp của entry (giây và một bộ đếm); tăng dần trong oplog, là "vị trí" mà rs.status() báo
tTerm: số lần election tại thời điểm ghi
vPhiên bản định dạng entry (2)
opLoại: i insert, u update, d delete, c command, n no-op
nsNamespace database.collection
o, o2o là nội dung của thay đổi; o2 cho biết document nào (với update và insert là { _id })
lsid, txnNumber, stmtId, prevOpTimeCó khi lệnh chạy trong session (retryable write, transaction). mongosh dùng session ngầm nên mọi entry ghi dữ liệu ở lab này có chúng; cách driver dùng chúng để retry nằm ở bài Retryable và chọn mức

Mỗi entry còn có wall (giờ đồng hồ của máy ghi; script lab bỏ field này khi in). [quan sát] Khi không có gì để ghi, primary vẫn thêm một entry op: "n" với o: { msg: "periodic noop" }; ba entry gần nhất cách nhau đúng 10 giây. [suy luận] Nhờ đó oplog và rs.status() luôn có một mốc mới để so, kể cả khi không ai ghi.

Từng loại lệnh thành entry gì

[quan sát] Cùng một document, lần lượt các lệnh (o rút gọn; diff là phần khác biệt; $v: 2 là phiên bản định dạng của diff):

Lệnhopo trong entry
$set: { status: "paid" }u{ $v: 2, diff: { u: { status: "paid" } } }
$inc: { qty: 5 } (qty đang là 1)u{ $v: 2, diff: { u: { qty: 6 } } }
$push: { tags: "b" }u{ $v: 2, diff: { stags: { a: true, u1: "b" } } }
$unset: { customer: "" }u{ $v: 2, diff: { d: { customer: false } } }
replaceOneucả document mới
upsert tạo document mớiicả document
deleteOned{ _id: 77 }
$set ra đúng giá trị cũ(không có)không có entry nào
createIndexcstartIndexBuild, rồi commitIndexBuild
dropc{ drop: "many" }

Có ba điều đáng nhìn. $inc ghi giá trị sau cùng (6), không ghi "cộng 5". Với $push entry chỉ mô tả sự khác biệt của mảng tags (một sub-diff stags, a: true báo đây là mảng, u1: "b" là phần tử ở vị trí 1). Còn lệnh không đổi gì thì không để lại dấu vết [tài liệu]: write không sửa dữ liệu hay lỗi thì không tạo entry.

Đây là phần "diff $v: 2 trong oplog" mà bài Document Model và bài WiredTiger MVCC: update chain nhắc tới. [chi tiết cài đặt] Định dạng diff và các ký hiệu u, d, s là cách server đang làm, không được manual cam kết. Cùng ý "chỉ ghi phần đổi" cũng có ở update chain trong cache, nhưng đó là chỗ khác, định dạng khác.

Idempotent: áp dụng lại cũng không sai

[quan sát] e5-idempotent-diff.out. Tôi lấy đúng entry của $inc: { qty: 5 } (document ở qty: 6) rồi chạy lại nó ba lần bằng lệnh applyOps:

oplog entry o: {"$v":2,"diff":{"u":{"qty":6}}}   | doc now: {"_id":1,"qty":6}
applyOps replay #1 -> doc: {"_id":1,"qty":6}
applyOps replay #2 -> doc: {"_id":1,"qty":6}
applyOps replay #3 -> doc: {"_id":1,"qty":6}
hai lệnh $inc: 5 thật nữa            -> doc: {"_id":1,"qty":16}

Áp dụng lại entry không đổi gì; chạy lại chính lệnh $inc thì cộng dồn. [tài liệu] Manual viết: mỗi thao tác trong oplog là idempotent, cho cùng kết quả dù áp dụng một hay nhiều lần. Điều này cần thiết vì một đoạn oplog có thể bị áp dụng lại, ví dụ sau crash hoặc trong initial sync khi đoạn oplog chồng lên đoạn dữ liệu đã chép [suy luận].

[quan sát] Entry cũng không phình theo kích thước document. Cập nhật một field ở document 1 KB, 100 KB, 1 MB và 8 MB cho entry 322 đến 336 byte, hầu hết là phần cố định (lsid, ui, ns, ts...). Còn replaceOne một document 1 MB là một entry 1.009.229 byte: cả document được ghi. Hệ quả cho thiết kế: ghi từng field bằng $set rẻ hơn thay cả document khi document to.

Một lệnh, bao nhiêu entry

[tài liệu] Manual nói oplog phải chuyển lệnh multi-update thành từng thao tác riêng để giữ idempotent, nên updateMany có thể ăn nhiều chỗ trong oplog mà dữ liệu không tăng. [quan sát] e3c-grouping.out đếm entry (và thao tác bên trong) cho các lệnh nhiều document:

Lệnh3 document1001.00020.000
insertMany1 entry1240
updateMany ($inc)31001.00020.000
deleteMany({})1101002.000

Trong lab, với update một document là một entry. Với insert và delete, server gom nhiều thao tác vào một entry applyOps (op: "c", ns: "admin.$cmd"): khoảng 500 insert hoặc 10 delete mỗi entry. [tài liệu] Release notes 8.0 chỉ nói bulk insert ngoài transaction "typically" được gom thành một entry. [chi tiết cài đặt] Các con số 500, 10 và "mỗi update một entry" là quan sát trên 8.3.11, không có trang manual nào cam kết, và có thể đổi giữa các bản. Số thao tác bên trong vẫn bằng số document: 20.000 thao tác cho 20.000 document.

Cái này trả lời lời hứa ở bài CRUD: ghi và xoá: mỗi document bị xoá vẫn là một thao tác mà secondary phải chạy lại, dù server có thể gom mười thao tác vào một entry. Cộng byte của entry: 20.000 delete ≈ 1.830.000 byte (khoảng 92 byte mỗi delete), 20.000 update $inc ≈ 3.380.000 byte (khoảng 169 byte mỗi update), nhân tuyến tính cho các số lớn hơn [suy luận]. Một triệu document xoá hay sửa một lần là khoảng 87 đến 161 MB oplog trong một lúc: lý do nên chia thành đợt nhỏ.

TTL. Lời hứa ở bài Specialized Indexes: trên replica set chỉ primary chạy TTL. [quan sát] e4-ttl.out: collection có TTL index expireAfterSeconds: 0, 50 document hết hạn sau 3 giây. Primary xoá sau khoảng 51 giây chờ ([tài liệu] TTL monitor chạy mỗi 60 giây): metrics.ttl trên a đi từ passes: 2, deletedDocuments: 0 lên passes: 3, deletedDocuments: 50; trên b và c vẫn passes: 0, deletedDocuments: 0. Vậy mà 1,5 giây sau, cả ba node đều đếm 0 document: oplog của a và b có cùng 6 entry applyOps chứa 50 thao tác d, cùng ts đầu tiên. Secondary không tự xoá; nó nhận các lệnh xoá qua oplog.

Transaction và index build trong oplog

Transaction. Một transaction commit thành entry applyOps, trong đó có các thao tác của nó. [quan sát] Transaction hai lần $inc (trừ 10 ở tài khoản A, cộng 10 ở B) thành một entry op: "c", ns: "admin.$cmd", o.applyOps có hai thao tác bên trong, mỗi cái là diff với giá trị cuối (bal: 90 và bal: 110), chứ không phải ±10. Transaction đủ lớn thì chia thành nhiều entry: [quan sát] e13-bigtxn.out: 40 insert × 0,5 MB thành hai entry, 16.503.809 byte (33 thao tác, partialTxn: true) và 3.501.006 byte (7 thao tác, count: 40), nối với nhau bằng prevOpTime. Đây là điều bài Transactions: lỗi, retry và giới hạn nhắc: giới hạn 16 MB áp cho mỗi entry, không cho cả transaction.

Index build. Lời hứa ở bài Index Fundamentals: trên replica set index được build trên mọi node và chỉ commit khi đủ commit quorum. Oplog mang hai entry c: startIndexBuild, rồi commitIndexBuild. [quan sát] e12-index-build.out: tôi docker pause c, rồi createIndex trên 200.000 document. Sau 20 giây oplog mới có startIndexBuild, chưa có commitIndexBuild, $currentOp vẫn thấy lệnh chạy; lệnh chỉ trả về sau khi tôi unpause c (tổng 23.280 ms), và 12 giây sau khi unpause oplog đã có commitIndexBuild. Với commit quorum mặc định (votingMembers, mọi node mang dữ liệu có quyền bầu), một node như vậy mất liên lạc làm lệnh build treo, đúng như lời hứa.

Journal không phải oplog

Hai thứ này hay bị nhầm vì cùng là "log ghi thao tác". Chúng không thay nhau (xem thêm Crash recovery):

JournalOplog
Nằm ởthư mục journal/ của WiredTigercollection local.oplog.rs
Ghithay đổi ở mức WiredTiger của riêng nodethay đổi ở mức MongoDB, như bảng trên
Ai đọcchỉ mongod đó, khi recovery sau crashsecondary, change stream
Có ở standalonecókhông
Để làm gìmột node sống sót qua crashcác node khác có cùng dữ liệu

Kích thước oplog và oplog window

Oplog window là khoảng cách giữa timestamp mới nhất và cũ nhất trong oplog [tài liệu]. Secondary mất kết nối chỉ có thể dùng replication để bắt kịp nếu quay lại trong window. rs.printReplicationInfo() báo kích thước oplog và window (log length start to end). [tài liệu] Kích thước mặc định là 5% dung lượng đĩa còn trống, tối thiểu 990 MB, tối đa 50 GB. [quan sát] Lúc lab mới dựng, log của mongod ghi Creating replication oplog với oplogSizeMB: 14152, và maxSizeMB của oplog là 14.152. MB trong phần này là đơn vị MongoDB báo, tức 1.048.576 byte.

Window không phải một hằng số: nó bằng kích thước chia tốc độ ghi vào oplog. [quan sát] e7a-resize-rate.out và e7b-window.out: replSetResizeOplog giảm oplog về 990 MB trên cả ba node mà không cần restart (thử 100 MB bị từ chối: size phải ≥ 990). Rồi đo số byte entry mới mỗi giây (tăng của size oplog, hoặc tổng $bsonSize của entry mới) và tính window cho 990 MB. Document 940 byte:

TảiTốc độ vào oplogWindow cho 990 MB
insertOne 50 lệnh/giây0,058 MB/skhoảng 17.056 giây (4,7 giờ)
insertOne 200 lệnh/giây0,232 MB/skhoảng 4.265 giây (1,2 giờ)
insertMany 200 document, 4 luồng, 33 giây71,4 MB/skhoảng 14 giây
insertMany 200 document, 1 luồng, 20 giây80,9 MB/skhoảng 12 giây

Cùng một oplog, window đi từ vài giờ xuống khoảng 12 giây tuỳ tải. Cột window là phép tính (990 MB chia tốc độ), không phải window đo trực tiếp. Mỗi insertOne ở lab tốn khoảng 1.217 byte oplog cho document 940 byte; insertMany chỉ khoảng 1.038 byte mỗi document vì gom entry. Mỗi con số là của lab. Window đo bằng thời gian, nên lúc im lặng nó cứ dài ra (e7c-settle.out: 178 lên 288 giây trong 2 phút gần như không ghi) và co lại ngay khi có đợt ghi dày. Một window dài lúc rảnh không nói gì về window ở giờ cao điểm.

Oplog cũng không luôn đúng kích thước cấu hình. [tài liệu] Manual viết: khác các capped collection khác, oplog có thể lớn hơn giới hạn cấu hình để tránh xoá majority commit point (điểm mà đa số node đã có). [quan sát] Sau đợt ghi 33 giây, oplog đo được 2.411 MB (cấu hình 990) và về 987,85 MB khoảng 20 giây sau; sau đợt ghi 20 giây kế tiếp nó đứng ở 1.639 MB suốt hơn 2 phút đo liên tục, và chỉ về 998 MB ở lần đo vài phút sau đó. Log của c có dòng Truncating the oplog xoá một lần 133 entry (27.691.459 byte): oplog bị cắt theo từng khối, không theo từng entry [chi tiết cài đặt]. Tôi không giải thích được vì sao lần hai nó giữ lâu như vậy: tôi không đo majority commit point lúc đó, nên không biết lý do của manual có áp dụng ở đây hay không.

Khi secondary bị stale

Manual gọi một secondary là stale khi nó tụt lại xa tới mức entry tiếp theo nó cần đã bị xoá khỏi oplog của nguồn. [quan sát] e8-stale.out, e8-c-repl-log.txt. Tôi docker pause c (oplog đã về 998 MB), ghi liên tục 45,3 giây vào a (2.660.400 document, w: 1), rồi unpause c. Oplog của a giờ bắt đầu ở ts giây 1791599238; entry cuối c lấy được là giây 1791599226, kém 12 giây. Log của c ghi chuyển sang RECOVERING chưa đầy một giây sau khi unpause (rs.status() trên a thấy sau khoảng 3 giây; trích, rút gọn):

"Oplog fetcher returned error": "TooStaleToSyncFromSource: we are too stale to sync from the sync source's oplog.
  our last optime fetched: { ts: Timestamp(1791599226, 45), t: 1 }.
  sync source's first optime: { ts: Timestamp(1791599238, 24), t: 1 }"
"Oplog fetcher discovered we are too stale to sync from sync source. Denylisting sync source"   (a, 60000 ms)
"We are too stale to use candidate as a sync source. Denylisting this sync source"             (b, 1 phút)
"Too stale to catch up. Entering maintenance mode."  → Replica set state transition RECOVERING

c ở RECOVERING suốt khoảng 30 giây tôi theo dõi, không tự bắt kịp. [tài liệu] Cách chữa là xoá dữ liệu của node và cho nó chạy initial sync (chép lại toàn bộ dữ liệu từ một node khác; xem phần về initial sync). Chọn kích thước oplog vì vậy là chọn "node được vắng mặt bao lâu, ở tốc độ ghi tệ nhất": window nên dài hơn lần bảo trì hay sự cố mà bạn muốn chịu được, cộng thời gian initial sync nếu có. replSetResizeOplog đổi kích thước không cần restart và có tham số minRetentionHours để giữ entry tối thiểu theo giờ.

Cột mốc: Bạn đã có thể đọc một entry trong local.oplog.rs, giải thích vì sao entry idempotent, tính oplog window từ kích thước và tốc độ ghi, và nhận ra một secondary bị stale. Tiếp theo: Replication lag, flow control và initial sync.

Hỏi & đáp

Entry cho $inc: { qty: 5 } (qty đang là 1) trong oplog là { $v: 2, diff: { u: { qty: 6 } } }, không phải "cộng 5". Vì sao?

  1. Vì primary không biết giá trị cũ của qty khi ghi entry

    Primary tính ra 6 chính từ giá trị cũ (1) cộng 5, nên nó biết. Xem mục "Idempotent: áp dụng lại cũng không sai".

  2. Để entry ngắn hơn: ghi giá trị cuối tốn ít byte hơn ghi phép cộng

    Giá trị cuối (6) không ngắn hơn phần cộng (5); lý do là idempotent: áp dụng lại entry không cộng thêm lần nữa. Xem mục "Idempotent: áp dụng lại cũng không sai".

  3. Để secondary áp dụng lại entry bao nhiêu lần cũng ra qty = 6

    Đúng: replay 3 lần bằng applyOps vẫn ra 6, trong khi chạy lại $inc thì cộng dồn tới 16. Xem mục "Idempotent: áp dụng lại cũng không sai".

  4. Vì $v: 2 làm mọi update thành phép gán

    $v: 2 là phiên bản định dạng của diff. $push vẫn ghi dạng stags: { a: true, u1: "b" }, không phải phép gán cả mảng. Xem mục "Từng loại lệnh thành entry gì".

Oplog 990 MB, workload ghi liên tục 80 MB mỗi giây vào oplog. Một secondary bị tắt 1 phút để bảo trì rồi bật lại. Điều gì nhiều khả năng xảy ra?

  1. Nó tự bắt kịp, vì oplog là một collection nên mọi entry vẫn còn đó cho nó

    Oplog là capped collection: entry cũ bị bỏ đi. Xem mục "Kích thước oplog và oplog window".

  2. Nó bắt kịp, vì entry idempotent nên chỉ cần áp dụng lại từ đầu oplog

    Idempotent chỉ nói áp dụng lại một entry còn tồn tại thì an toàn; entry đã bị bỏ thì không còn gì để áp dụng. Xem mục "Idempotent: áp dụng lại cũng không sai".

  3. Primary giữ riêng một đoạn oplog cho node vắng mặt tới khi nó về

    Không có cơ chế đó: oplog cắt theo kích thước (và minRetentionHours nếu đặt), không chờ node vắng mặt; lab: c thành stale. Xem mục "Khi secondary bị stale".

  4. Window chỉ khoảng 12 giây, nên node nhiều khả năng phải initial sync

    990 MB chia 80 MB/s là khoảng 12 giây, như lab (80,9 MB/s cho window khoảng 12 giây). Node vắng 60 giây thì entry nó cần đã bị xoá khỏi oplog, như c ở lab. Xem mục "Khi secondary bị stale".

Một cuốn sổ ghi thay đổi có 100 trang, ghi hết thì ghi đè từ trang đầu. Mỗi ngày trụ sở dùng hết 40 trang. Một chi nhánh nghỉ 3 ngày rồi quay lại. Điều nào đúng?

  1. Chi nhánh đọc tiếp từ trang nó đọc dở, vì sổ vẫn còn đó

    Sau 3 ngày trụ sở đã dùng 120 trang, nên trang chi nhánh đọc dở đã bị ghi đè. Xem mục "Hình dung trước: cuốn sổ ghi thay đổi có số trang cố định".

  2. Chi nhánh chép tiếp được vì mỗi dòng ghi kết quả, không ghi thay đổi

    Dòng ghi kết quả giúp chép lại một dòng không sai, nhưng không giúp khi các dòng cần chép đã không còn. Xem mục "Hình dung trước: cuốn sổ ghi thay đổi có số trang cố định".

  3. Chi nhánh phải chép lại cả sổ cái gốc, vì trang cần đọc đã bị ghi đè

    Đúng: cuốn sổ chỉ nhớ khoảng 2,5 ngày, ngắn hơn 3 ngày nghỉ, như một secondary vắng lâu hơn oplog window. Xem mục "Hình dung trước: cuốn sổ ghi thay đổi có số trang cố định".

  4. Trụ sở sẽ tự ngừng ghi đè cho tới khi chi nhánh về

    Sổ chỉ có số trang cố định và không biết chi nhánh nào còn thiếu. Xem mục "Hình dung trước: cuốn sổ ghi thay đổi có số trang cố định".