Journal (P1/3): Đường đi một lần ghi và journal

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

Bài này trả lời một câu hỏi: một thay đổi chỉ mới nằm trong RAM thì sống sót thế nào khi mất điện?

Phần sau có group commit.

Bài có 3 Part:

  • P1 (Part này): Đường đi một lần ghi và journal.
  • P2: j: true, checkpoint và hai lab.
  • P3: Crash recovery; chỉnh gì, đừng chỉnh gì.

Mấy bài trước đã hứa bốn lần mà chưa giải thích. Bài Durability & Consistency nói j: true hứa lệnh ghi "đã nằm trong journal trên đĩa" và cái giá của nó tuỳ đĩa. Bài Mental Model nói WiredTiger ghi checkpoint mỗi 60 giây và phát lại journal sau crash. Bài Schema Patterns & Evolution thấy một $inc 4 byte làm checkpoint ghi 10.920.698 byte. Bài WiredTiger & Compression hỏi: thay đổi chỉ nằm trong RAM sống sót qua mất điện bằng cách nào?

Bài này trả lời bằng thí nghiệm: nhìn thư mục journal, đo j:true với 1 đến 64 client, kill -9 một mongod đang chịu tải rồi đếm lệnh ghi còn sống, đọc log recovery thật, và xem một checkpoint ghi bao nhiêu byte sau một $inc.

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

Môi trường lab: MongoDB 8.3.11 trong Docker (image mongo:8, WiredTiger 12.0.0 theo WiredTiger.turtle), mongosh 2.12.0. Mọi mongod là standalone, mỗi cái 2 CPU, 2 GB RAM, WiredTiger cache 1 GB, một volume riêng; client chạy trong container khác. Host Apple M4 với Docker Desktop; volume nằm trên ổ ảo /dev/vda1 (ext4) của máy ảo Docker. Tôi không biết fsync trong máy ảo có chạm tới SSD của Mac hay dừng ở lớp ảo hoá, nên các con số j: true là của lab này, không phải của đĩa production. Host còn chạy nhiều container không liên quan và một lab khác (load average khoảng 11 ở một lần đọc uptime), nên thời gian dao động. Script và output thô nằm trong lab22/ kèm INDEX.md.

Cần nói thẳng: (1) thử $inc trên document lớn, đôi khi một lệnh trong 100 lệnh ghi vào journal cỡ 1 MB thay vì vài chục byte; tôi không điều tra nguyên nhân (xem mục "Journal: ghi gì, ở đâu, khi nào xuống đĩa"). (2) Thử nghiệm kill dùng docker kill (SIGKILL tới mongod), không phải mất điện; Lab 4 nói rõ nó chứng minh và không chứng minh gì.

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

Ý chính

Khi bạn ghi một document, MongoDB không sửa file dữ liệu ngay. Nó sửa page trong RAM (cache), và đồng thời ghi một dòng mô tả thao tác vào cuối một file tuần tự gọi là journal (về mặt kỹ thuật là một write-ahead log: log được ghi trước, file dữ liệu được sửa sau). Ghi nối vào cuối một file thì rẻ; sửa rải rác nhiều page trong nhiều file .wt thì đắt. Vì vậy dòng journal được ghi ngay, còn page thì để đó.

Cứ khoảng 60 giây, WiredTiger làm một checkpoint: ghi mọi page đã sửa xuống file dữ liệu, thành một bản nhất quán, rồi mới cho phép bỏ những đoạn journal cũ. Nếu mongod chết giữa hai checkpoint, lúc khởi động lại nó tìm checkpoint cuối cùng trong file dữ liệu rồi phát lại (replay) phần journal sau đó. Chỗ còn lại để mất dữ liệu là khoảng từ lúc lệnh ghi được xác nhận tới lúc dòng journal của nó xuống đĩa; j: true đóng khoảng đó bằng cách chờ.

Hình dung trước: cuốn sổ ghi chép của cửa hàng

Một cửa hàng tạp hoá có sổ cái: gọn gàng, chia theo từng mặt hàng, mỗi mặt hàng một trang. Sửa sổ cái mỗi lần bán hàng thì rất chậm: phải lật tới đúng trang, tẩy, viết lại. Vì vậy chủ tiệm làm hai việc. Mỗi lần bán, ông ghi một dòng ngắn vào cuốn sổ ghi chép hằng ngày ("10:02 bán 2 chai nước, mặt hàng 17") rồi quên chuyện sổ cái đi. Cứ mỗi giờ, ông ngồi lại chỉnh sổ cái theo các giao dịch trong giờ qua, đóng dấu "sổ cái đúng tới đây", rồi xé những trang sổ ghi chép đã chỉnh xong.

Một đêm mất điện, hàng hoá trên quầy (bộ nhớ làm việc) chẳng còn dấu vết nào. Sáng hôm sau ông mở sổ cái, đọc dấu "đúng tới đây", rồi làm lại từng dòng của sổ ghi chép kể từ dấu đó. Không giao dịch nào đã vào sổ ghi chép bị mất.

Cửa hàng                                    MongoDB / WiredTiger
─────────────────────────────────────       ──────────────────────────────────────────────
quầy hàng, bàn làm việc                     cache: page trong RAM, một số đã bị sửa
một lần bán hàng                            một lệnh ghi của client
một dòng ngắn trong sổ ghi chép             một record trong journal (thao tác, không phải page)
sổ cái                                      các file .wt
chỉnh sổ cái mỗi giờ, đóng dấu              checkpoint (mặc định mỗi 60 giây)
xé các trang sổ ghi chép đã chỉnh xong      journal file cũ bị xoá sau khi checkpoint xong
sáng hôm sau đọc lại sổ ghi chép            recovery: replay journal sau checkpoint cuối

[hình dung] Hình ảnh này khác thực tế ở bốn chỗ. Một: dòng viết vào sổ ghi chép trước hết nằm trong bộ đệm RAM của mongod, rồi tới bộ đệm của hệ điều hành, rồi mới tới đĩa; chữ "đã ghi" có ba nghĩa, và đó là toàn bộ chuyện của j: true. Hai: checkpoint không viết lại cả sổ cái, chỉ các page đã sửa, nhưng viết lại cả page. Ba: sổ ghi chép chỉ của một cửa hàng; chuyện sao chép sang cửa hàng khác (oplog) là cơ chế riêng. Bốn: mất điện thật còn làm mất cả những gì hệ điều hành chưa kịp đẩy xuống đĩa, điều lab không mô phỏng được.

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

Client: insertOne({ ... }, { writeConcern: { w: 1, j: true } })
   │
   ▼
mongod (query layer)
   │
   ▼
WiredTiger
   ├─① sửa page trong cache ─────────────▶ page bị đánh dấu "dirty" (đã sửa, chưa ghi xuống file)
   │
   ├─② viết một record vào bộ đệm của journal (trong RAM của mongod)
   │         │
   │         ▼   (gộp với record của các thread khác, rồi write() xuống OS)
   │      page cache của hệ điều hành
   │         │
   │         ▼   fsync(): chờ thiết bị lưu trữ xác nhận
   │      file journal/WiredTigerLog.NNNNNNNNNN trên đĩa
   │         │
   │         └──▶ nếu j: true, client chỉ nhận acknowledged SAU bước này
   │
   └─③ khoảng mỗi 60 giây: checkpoint
             reconciliation các page dirty ─▶ ghi vào file .wt (khối mới, không đè khối cũ)
             ─▶ cập nhật metadata ─▶ journal file cũ không còn cần nữa

Tuỳ mongod hay cả máy chết ở mũi tên nào, phần sống sót khác nhau:

Lúc mọi thứ dừng đột ngộtThay đổi còn không?Vì sao
Sau ①, record journal vẫn trong bộ đệm của mongodMất (cả khi chỉ mongod bị kill)RAM của tiến trình biến mất cùng tiến trình [tài liệu]: manual viết "trong lúc record journal còn nằm trong bộ đệm của WiredTiger, cập nhật có thể mất sau khi mongod bị tắt đột ngột"
Record đã write() xuống hệ điều hành, chưa fsyncCòn nếu chỉ mongod chết; mất nếu cả máy mất điệnPage cache của OS sống sót khi một tiến trình bị kill, nhưng không sống sót khi mất điện [suy luận] (hiểu biết chung về hệ điều hành; lab không đo được vế mất điện)
Sau fsync của journalCòn, kể cả khi mất điện (nếu thiết bị lưu trữ giữ lời hứa của fsync)Đây là điều j: true chờ [tài liệu]
Sau checkpointCòn trong file dữ liệu; journal trước đó không cần nữaCheckpoint là điểm "sổ cái đúng tới đây" [tài liệu]

Hai dòng đầu là lý do w: 1 không có j có một cửa sổ mất dữ liệu nhỏ (Lab 4 đo nó); dòng thứ hai cũng là lý do lab kill không chứng minh được mọi thứ về mất điện.

Journal: ghi gì, ở đâu, khi nào xuống đĩa

Ghi gì: thao tác, không phải page

[tài liệu] WiredTiger tạo một record journal cho mỗi lệnh ghi do client khởi tạo, gồm cả thay đổi nội bộ do nó gây ra: cập nhật một document làm đổi index vẫn chỉ có một record. Mỗi record có header 16 byte và kích thước là bội của 128 byte.

[quan sát] Đo (e0b-ops-not-pages.out): chèn 5.000 document (273 byte BSON, hex ngẫu nhiên 200 ký tự để không nén được) và đọc bộ đếm wiredTiger.log trước và sau.

Payload journal mỗi insertByte thực ghi mỗi insertSố record cho 5.000 insert
Collection chỉ có index _id316,7 byte384,8 byte5.009
Thêm 3 index phụ364,4 byte384,0 byte5.000

Payload lớn hơn document một chút (khoá, header), và ba index phụ chỉ làm payload tăng khoảng 48 byte: thay đổi của index nằm trong cùng record. Byte thực ghi khoảng 384 = 3 × 128 mỗi insert: record được làm tròn lên bội của 128.

Quan trọng hơn là $inc trên document lớn. Tôi chạy $inc lên một field số của một document 10.897.823 byte (mảng 70.000 phần tử, cùng dạng với document 10,9 MB của bài Schema Patterns) 100 lần, đo journal sau từng lệnh: cả 100 lệnh ghi 62 byte payload và 128 byte thực ghi (e3b-inc-checkpoint.out, document 10.068.933 byte: 63 byte). Journal ghi thao tác ("cộng 1 vào field này của document kia"), không ghi page chứa document.

Một điều tôi không giải thích được: ở document 7,3 MB, một trong 100 lệnh $inc ghi khoảng 1,26 MB vào journal còn 99 lệnh kia 62 byte (e0b-odd-runs.out); ở e3b, 100 lệnh $inc trên document 8 KB tổng cộng 64.158 byte thay vì khoảng 6.400. [suy luận] Có vẻ thỉnh thoảng WiredTiger ghi cả giá trị thay vì chỉ thao tác; tôi không kiểm chứng. Cái đã chắc: phần lớn lệnh chỉ ghi vài chục byte, rất ít so với checkpoint (Lab 3).

Vì journal chứa thao tác, nó cũng nén được như dữ liệu. [tài liệu] Mặc định mongod nén journal bằng snappy (storage.wiredTiger.engineConfig.journalCompressor, các giá trị none, snappy, zlib, zstd); record từ 128 byte trở xuống không được nén. Log khởi động của lab xác nhận (e0a-anatomy.out):

"Opening WiredTiger" ... log=(enabled=true,remove=true,path=journal,compressor=snappy) ...

[quan sát] e0d-journal-compression.out: cùng 5.000 insert, cùng 229 byte BSON mỗi document, chỉ khác nội dung.

Chuỗi lặp: payload 98,5 byte mỗi insert, 5.004 trên 5.009 record được nén (1.347.483 → 491.389 byte, còn 36%). Hex ngẫu nhiên: 269,5 byte mỗi insert, chỉ 4 record được nén.

Dữ liệu không nén được thì WiredTiger thử rồi bỏ.

Nằm ở đâu: thư mục journal/

[tài liệu] Journal nằm ở thư mục con journal của dbPath, các file tên WiredTigerLog.<số thứ tự>, mỗi file tối đa khoảng 100 MB; khi vượt giới hạn, WiredTiger tạo file mới. Journal file được cấp phát sẵn (pre-allocate) để lúc chuyển file thread của client không phải chờ tạo file. File cũ chỉ được giữ khi còn cần để recovery từ checkpoint cuối.

[quan sát] Thư mục của container mongo-jrn22-main ngay sau khi khởi động (e0a-anatomy.out):

-rw------- 1 mongodb mongodb 104857600 Oct  9 09:50 WiredTigerLog.0000000001
-rw------- 1 mongodb mongodb 104857600 Oct  9 09:46 WiredTigerPreplog.0000000001

Hai file đều dài đúng 100 MiB (104.857.600 byte) dù mới có vài KB dữ liệu: ls cho kích thước cấp phát, không phải lượng journal đã dùng. WiredTigerPreplog.* là file chuẩn bị sẵn cho lần chuyển file kế tiếp.

Khi nào xuống đĩa

[tài liệu] Trang Journaling của manual nói WiredTiger gom record vào bộ đệm trong RAM (record tới 128 kB đều được đệm) và đẩy xuống đĩa (sync) khi một trong các điều kiện sau xảy ra:

  1. Một lệnh ghi có hoặc ngầm có j: true (w: "majority" ngầm có j: true khi writeConcernMajorityJournalDefault là true, mặc định);
  2. Với secondary, sau mỗi batch áp dụng oplog;
  3. Mỗi 100 ms (storage.journal.commitIntervalMs, mặc định 100, khoảng 1 đến 500);
  4. Khi tạo file journal mới (khoảng mỗi 100 MB).

Điều (3) là cửa sổ của w: 1: nếu không ai đòi j: true, record của một lệnh ghi có thể đợi tới khoảng một chu kỳ này mới được sync xuống đĩa. [quan sát] Server tự khai báo đúng giá trị đó: getParameter trả journalCommitInterval: 100 và syncdelay: 60 (e0a-anatomy.out). Tần suất cũng hiện ra trong bộ đếm: khi mongod rảnh, log flush operations tăng 99 lần trong 10 giây; khi 1 đến 64 client ghi w: 1 liên tục, log sync operations chỉ khoảng 37 đến 40 lần trong 4 giây (e1b-concurrency.out), tức khoảng 10 lần mỗi giây, bất kể có bao nhiêu lệnh ghi.

Phiên bản. [tài liệu] Từ MongoDB 6.1 journaling luôn bật: tuỳ chọn storage.journal.enabled và cờ --journal, --nojournal đã bị gỡ. "Tắt journal để chạy nhanh" không còn là lựa chọn. [tài liệu] Ngày nay j: true đòi mọi node được đếm trong w ghi journal xong; chi tiết nằm ở bài Durability & Consistency, bài này không nhắc lại.

Cột mốc: Bạn đã có thể chỉ ra journal nằm ở đâu, mô tả đường đi của một lần ghi từ cache tới đĩa, và nói vì sao journal ghi tuần tự. Tiếp theo: Checkpoint và j: true.

Hỏi & đáp

Một mongod standalone đang chạy bình thường, không crash. Journal được dùng để làm gì lúc đó?

  1. Để secondary đọc và áp dụng lại các thao tác của primary

    Đó là việc của oplog, một collection trong replica set. Journal là file của WiredTiger mà chỉ mongod đó đọc, và chỉ khi recovery. Về khác biệt giữa journal và oplog, xem Crash recovery.

  2. Để phục vụ các lần đọc khi page không có trong cache

    Đọc dữ liệu đi qua file .wt, không qua journal. Journal chỉ được ghi nối vào cuối, và không được đọc lúc chạy bình thường. Xem mục "Đường đi của một lần ghi".

  3. Chỉ được ghi; nó được đọc lúc khởi động lại, để replay phần sau checkpoint cuối

    Đúng với manual: recovery tìm checkpoint cuối rồi áp dụng lại journal sau đó. Trong lab, sau docker kill log ghi Recovering log 2 through 3 và replay 223 ms. Xem Crash recovery.

  4. Là nơi lưu dữ liệu chính; các file .wt chỉ là bản sao để đọc cho nhanh

    Ngược lại: file .wt là nơi dữ liệu nằm lâu dài, journal chỉ giữ thao tác từ checkpoint gần nhất và bị xoá sau checkpoint (lab: file 3 biến mất sau lần checkpoint kế tiếp). Xem Checkpoint và j: true.

Bạn muốn tránh mất lệnh ghi khi mất điện và định đặt storage.journal.commitIntervalMs xuống 1 thay vì dùng j: true. Điều nào đúng?

  1. Đúng, interval 1 ms nghĩa là mọi lệnh ghi được fsync trước khi trả lời, không cần j: true

    Interval là chu kỳ nền, không phải "chờ trước khi trả lời": client vẫn nhận ack mà không đợi lần sync kế tiếp. Xem mục "Journal: ghi gì, ở đâu, khi nào xuống đĩa".

  2. Sai vì giá trị nhỏ nhất cho phép là 100 ms

    Manual cho phép từ 1 đến 500 ms (mặc định 100). Vấn đề nằm ở chỗ khác. Xem mục "Journal: ghi gì, ở đâu, khi nào xuống đĩa".

  3. Đúng và còn làm mọi lệnh ghi nhanh hơn, vì sync thường xuyên hơn thì mỗi lần sync nhẹ hơn

    Manual nói ngược lại: giá trị thấp đổi bằng hiệu năng đĩa (nhiều lần sync hơn). Về việc chỉnh interval, xem Crash recovery.

  4. Không đủ: interval chỉ thu hẹp cửa sổ, j: true mới làm lệnh ghi chờ journal xuống đĩa

    Manual: giá trị thấp làm journal bền hơn, đổi bằng hiệu năng đĩa; client vẫn nhận ack trước lần sync kế tiếp. Chỉ j: true bắt lệnh ghi chờ sync. Lab không đo được tác dụng thật của interval. Về việc chỉnh interval, xem Crash recovery.