Replica Set & Oplog (P2/3): Oplog, idempotent và oplog window
Ở 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.
- Cần đọc trước: Các thành viên và đường đi của một lần ghi
- Dẫn tới: Replication lag, flow control và initial sync
Ý 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
$setvà$pushnó 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ường | Nghĩa |
|---|---|
ts | Timestamp của entry (giây và một bộ đếm); tăng dần trong oplog, là "vị trí" mà rs.status() báo |
t | Term: số lần election tại thời điểm ghi |
v | Phiên bản định dạng entry (2) |
op | Loại: i insert, u update, d delete, c command, n no-op |
ns | Namespace database.collection |
o, o2 | o 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, prevOpTime | Có 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ệnh | op | o 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 } } } |
replaceOne | u | cả document mới |
| upsert tạo document mới | i | cả document |
deleteOne | d | { _id: 77 } |
$set ra đúng giá trị cũ | (không có) | không có entry nào |
createIndex | c | startIndexBuild, rồi commitIndexBuild |
drop | c | { 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ệnh | 3 document | 100 | 1.000 | 20.000 |
|---|---|---|---|---|
insertMany | 1 entry | 1 | 2 | 40 |
updateMany ($inc) | 3 | 100 | 1.000 | 20.000 |
deleteMany({}) | 1 | 10 | 100 | 2.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):
| Journal | Oplog | |
|---|---|---|
| Nằm ở | thư mục journal/ của WiredTiger | collection local.oplog.rs |
| Ghi | thay đổi ở mức WiredTiger của riêng node | thay đổi ở mức MongoDB, như bảng trên |
| Ai đọc | chỉ mongod đó, khi recovery sau crash | secondary, change stream |
| Có ở standalone | có | không |
| Để làm gì | một node sống sót qua crash | cá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ải | Tốc độ vào oplog | Window cho 990 MB |
|---|---|---|
insertOne 50 lệnh/giây | 0,058 MB/s | khoảng 17.056 giây (4,7 giờ) |
insertOne 200 lệnh/giây | 0,232 MB/s | khoảng 4.265 giây (1,2 giờ) |
insertMany 200 document, 4 luồng, 33 giây | 71,4 MB/s | khoảng 14 giây |
insertMany 200 document, 1 luồng, 20 giây | 80,9 MB/s | khoả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 RECOVERINGc ở 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?
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?
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?