WiredTiger MVCC (P1/4): Bốn tầng transaction và timestamp

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

Bài này trả lời một câu: khi một document đổi, các version cũ của nó nằm ở đâu, và ai được thấy version nào. Phần này dựng bốn tầng transaction và timestamp; các phần sau đi vào update chain và history store, ba lab về snapshot bị giữ và so với PostgreSQL.

Ba bài trước nhắc tới "nhiều version của cùng một document" mà chưa chỉ chúng nằm đâu. Bài Isolation & Snapshot dựng mô hình "timestamp cộng nhiều version" rồi dừng ở đó. Bài Write Conflicts, Retry & Idempotency cho thấy hai lệnh ghi vào cùng một document thì một bên phải làm lại, nhưng chưa nói vì sao đơn vị xung đột là document chứ không phải field.

Bài này là bài về cơ chế: một lệnh updateOne đi qua mấy tầng transaction, version mới nằm ở đâu trên page, người đọc chọn version nào, version nào được giữ khi page được ghi xuống đĩa, và khi nào nó bị xoá hẳn. Đọc thấy gì (các mức read concern, write skew) vẫn thuộc bài Isolation & Snapshot; bài này không dạy lại.

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

  • Cần biết trước: Isolation & Snapshot (mô hình timestamp cộng nhiều version, first-writer-wins, minSnapshotHistoryWindowInSeconds, SnapshotTooOld), Transactions & Atomicity (vòng đời, giới hạn 60 giây), Write Conflicts, Retry & Idempotency (WriteConflict), WiredTiger & Compression (page trong RAM và trên đĩa, reconciliation, file .wt)
  • Giới thiệu: bốn tầng transaction từ ứng dụng xuống WiredTiger; read timestamp, commit timestamp, oldest timestamp, stable timestamp và pinned timestamp (quan sát được bằng serverStatus và $currentOp); update chain trên page; cách phát hiện xung đột; update dạng delta so với diff trong oplog; history store (WiredTigerHS.wt) và các bộ đếm của nó; lab giữ một snapshot mở trong lúc dữ liệu liên tục đổi; so sánh với MVCC của PostgreSQL
  • Không dạy ở đây: cache và eviction (bài Cache, Eviction & Working Set, chỉ có bản xem trước ở đây); journal và checkpoint (bài Journal & Checkpoint); lock, latch và ticket (bài Concurrency); stable timestamp và majority commit point trong replica set (bài Replica Set & Oplog và bài Elections, Failover & Majority, chỉ có bản xem trước ở đây)
  • Dẫn tới: Concurrency: Locks, Latches & Tickets

Môi trường lab: MongoDB 8.3.11 trong Docker (mongo:8), WiredTiger 12.0.0 (cùng bản với bài WiredTiger & Compression). Transaction và read concern "snapshot" cần replica set, nên mỗi lab chạy trên replica set một node (--replSet, rồi rs.initiate()), trong container riêng, volume mới, 2 CPU, 2 GB RAM, WiredTiger cache 1 GB (lab áp lực cache: 1 GB RAM, cache 0,25 GB). Host Apple M4; máy ảo Docker chạy chung với lab của các bài khác và vài container không liên quan, nên thời gian dao động mạnh và mọi số về độ trễ chỉ là tham khảo. Mọi số đo đều đo thật, có file output thô kèm theo (thư mục lab lab23/). Để transaction được giữ mở đủ lâu, lab đặt transactionLifetimeLimitSeconds lên 3600 (chỉ trong lab); Lab 3 hạ nó xuống 10 giây để xem giới hạn này hoạt động. minSnapshotHistoryWindowInSeconds cũng được đổi ở vài lab (ghi rõ tại chỗ).

Giới hạn của lab: không tách riêng được chi phí của update chain dài khỏi dao động của máy (Lab 2 ở phần sau (Ba lab về snapshot bị giữ) nói rõ chỗ nào tách được). Và ở quy mô một máy, giữ snapshot mở không làm độ trễ update hay cache xấu đi theo cách đo được; tác động đo được nằm ở history store và các bộ đếm timestamp (Lab 1).

Nhãn: [tài liệu] theo tài liệu chính thức (manual MongoDB hoặc tài liệu WiredTiger 12.0.0); [quan sát] đo trong lab này (8.3.11); [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 sửa một document, WiredTiger không ghi đè giá trị cũ. Nó thêm một version (phiên bản) mới vào đầu một danh sách các version của document đó (gọi là update chain, nằm trên page đang ở trong cache). Mỗi transaction đọc dữ liệu qua một snapshot và một read timestamp: với mỗi document, nó đi từ version mới nhất xuống cho tới version đầu tiên nó được phép thấy.

Khi page được ghi xuống đĩa (lúc bị evict, tức là bỏ khỏi cache để lấy chỗ, hoặc lúc checkpoint), WiredTiger ghi version mới nhất vào file dữ liệu và chuyển các version cũ hơn mà có thể còn ai đó cần sang một file riêng gọi là history store (WiredTigerHS.wt). Version nào chắc chắn không còn ai cần (đã bị thay từ trước oldest timestamp, và không transaction nào đang đọc ở mốc cũ hơn) thì bị xoá. Hệ quả: một snapshot bị giữ mở lâu, hoặc một cửa sổ minSnapshotHistoryWindowInSeconds rộng, buộc WiredTiger giữ nhiều version hơn. Lab sẽ cho thấy cái giá đó cụ thể ở đâu và ở đâu thì chưa thấy.

Hình dung trước: thư viện giữ mọi ấn bản

Một thư viện có cuốn "Quy định nội bộ", cập nhật liên tục. Mỗi lần sửa, thư viện không tẩy chữ trên cuốn cũ; họ in một ấn bản mới, đóng dấu thời điểm in, và xếp lên đầu chồng. Ai đến mượn sẽ nhận ấn bản mới nhất tại thời điểm họ bước vào, và chị Hà cầm một ấn bản từ sáng sẽ đọc ấn bản đó đến hết ngày dù thư viện đã in thêm hàng chục bản.

Thư viện không thể giữ mọi ấn bản mãi. Thủ thư làm hai việc: chồng ấn bản gần nhất để ngay trên bàn (ai cũng với tới), còn ấn bản cũ hơn thì chuyển xuống kho ở tầng hầm. Và họ chỉ vứt một ấn bản khi không còn người đọc nào cầm ấn bản đó hoặc cũ hơn. Một người mượn ấn bản sáng rồi ngồi lì cả tuần thì cả chồng ấn bản từ sáng đến giờ phải ở lại.

Thư viện                                     WiredTiger
─────────────────────────────────────        ──────────────────────────────────────────
mỗi ấn bản có dấu thời điểm in               mỗi version có timestamp (và transaction id)
chồng ấn bản trên bàn, mới nhất ở trên       update chain trên page trong cache
nhận ấn bản mới nhất lúc bước vào            read timestamp của transaction
chuyển ấn bản cũ xuống tầng hầm              reconciliation chuyển version cũ sang history store
vứt ấn bản không ai cầm hoặc cũ hơn          xoá version cũ hơn oldest timestamp và mọi read timestamp
người mượn ngồi lì cả tuần                   transaction bị giữ mở

[hình dung] Đây chỉ là cách hình dung. WiredTiger không có thủ thư đi chuyển sách: version được chuyển xuống history store trong lúc ghi page xuống đĩa, và bị xoá khi page của history store được ghi lại hoặc checkpoint chạy.

Bốn tầng transaction

Một lệnh ghi hay một transaction của ứng dụng đi qua bốn tầng trước khi chạm tới dữ liệu:

Ứng dụng:      withTransaction(...)  hoặc  một lệnh updateOne đơn lẻ
                     │
                     ▼
MongoDB:       transaction của session (readConcern, txnNumber, retry)
                     │   [bài Transactions & Atomicity]
                     ▼
WiredTiger:    một transaction của WiredTiger  =  transaction id
                     │                            + snapshot (danh sách transaction đang chạy)
                     │                            + read timestamp (và commit timestamp lúc ghi)
                     ▼
Trong cache:   page của B-tree  →  với mỗi key, một update chain
                     │              (version mới nhất ở đầu)
                     ▼  evict / checkpoint (reconciliation)
Trên đĩa:      file .wt  = version mới nhất đã commit
               WiredTigerHS.wt  = các version cũ hơn mà ai đó có thể còn cần

[tài liệu] Theo tài liệu WiredTiger, mọi thao tác đọc ghi đều chạy trong một transaction của WiredTiger; nếu không ai bắt đầu transaction tường minh thì WiredTiger tự tạo một cái cho riêng lệnh đó. Một lệnh updateOne ngoài transaction của MongoDB vẫn là một transaction của WiredTiger, chỉ ngắn. Mặc định WiredTiger chạy ở snapshot isolation; mỗi transaction có một transaction id duy nhất (cấp trước lần ghi đầu tiên) và ghi id đó vào mọi thao tác nó làm. Snapshot của transaction gồm danh sách id của các transaction đang chạy lúc snapshot được lấy; một update bị coi là không thấy được nếu id của nó nằm trong danh sách đó hoặc lớn hơn id lớn nhất snapshot biết. Đây là cách engine giấu thay đổi chưa commit: ai có snapshot lập khi A còn chạy thì bỏ qua update mang id của A, đi tiếp xuống version cũ hơn.

[tài liệu] Ngoài id, WiredTiger còn có timestamp: số 64 bit do ứng dụng của WiredTiger tự quản lý (ở đây ứng dụng đó là mongod). Một update chỉ thấy được khi cả transaction id lẫn timestamp của nó đều thấy được với người đọc.

Các timestamp mà MongoDB dùng

Trong mongod, các timestamp của WiredTiger chính là Timestamp(t, i) của MongoDB (t là giây, i là bộ đếm trong giây): WiredTiger lưu chúng thành một số 64 bit bằng (t << 32) | i. Năm cái cần biết:

TimestampNghĩaAi đặt
read timestamptransaction này chỉ thấy các update có timestamp ≤ nó; cố định cả đời transactionlúc transaction lấy snapshot
commit timestamptimestamp gắn vào các update của transaction lúc nó ghimongod, lúc ghi
oldest timestampđiểm xa nhất trong quá khứ mà người đọc mới còn được phép bắt đầu; đọc trước điểm này là lỗimongod, đi theo sau stable timestamp một khoảng bằng minSnapshotHistoryWindowInSeconds
stable timestampranh giới giữa phần dữ liệu đã cố định, bền và phần còn tạm (phần sau stable có thể bị bỏ khi recovery hoặc rollback to stable)mongod (xem đoạn về majority commit point bên dưới)
pinned timestampmin(oldest timestamp, read timestamp của mọi transaction đang chạy); mốc thật để quyết định xoá version nàoWiredTiger tự tính

Một reader ở đây là bất kỳ transaction nào của WiredTiger đang đọc. [tài liệu] Tài liệu timestamp của WiredTiger 12.0.0: oldest timestamp là "điểm sớm nhất mà ứng dụng có thể bắt đầu transaction đọc mới"; pinned timestamp là "cận dưới thật" cho mọi thao tác xoá dữ liệu đã cũ. Ở lớp MongoDB, minSnapshotHistoryWindowInSeconds (mặc định 300 giây, từ 5.0) là thời gian tối thiểu storage engine giữ snapshot history (các version cũ để đọc lại ở mốc trong quá khứ), và manual nói nó nằm ở file WiredTigerHS.wt.

Mối quan hệ giữa stable timestamp và majority commit point (điểm mà đa số node của replica set đã có) là chuyện của bài Replica Set & Oplog và bài Elections, Failover & Majority. [quan sát] Ở lab một node dưới đây, stable timestamp trùng đúng với lastCommittedOpTime. [suy luận] Với nhiều node, stable timestamp không thể vượt majority commit point; tôi chưa kiểm chứng điều đó ở bài này.

Quan sát chúng

Nguồn là serverStatus().wiredTiger.transaction (các trường transaction global ... timestamp), replSetGetStatus().optimes, và $currentOp cho transaction đang mở. Script e6-timestamps.js đặt minSnapshotHistoryWindowInSeconds = 30 (chỉ trong lab), mở một transaction snapshot, rồi để client khác update 40 giây. Mỗi dòng dưới đây đã đổi từ số 64 bit sang dạng giây|bộ đếm (e6-timestamps.out):

                              (1) chưa ai đọc    (2) vừa mở snapshot   (3) 40 s sau, snapshot còn mở   (4) sau khi giải phóng
global oldest timestamp       1791550045|2       1791550045|2          1791550084|1                    1791550084|1
global stable timestamp       1791550075|2       1791550075|2          1791550114|1                    1791550114|1
global pinned timestamp       1791550045|2       1791550045|2          1791550075|2   <- bị giữ        1791550084|1
read timestamp of the oldest
  active reader               0                  1791550075|2          1791550075|2                    0
replSetGetStatus
  lastCommittedOpTime.ts      1791550075|2       1791550075|2          1791550114|1                    1791550114|1
// $currentOp, session đang rảnh nhưng giữ transaction:
{ type: 'idleSession',
  transaction: { parameters: { txnNumber: Long('2'), autocommit: false,
                               readConcern: { level: 'snapshot', provenance: 'clientSupplied' } },
                 readTimestamp: Timestamp({ t: 1791550075, i: 2 }), timeOpenMicros: Long('18345') } }

Đọc bảng này từ trên xuống thì cả cơ chế hiện ra:

  1. [quan sát] Read timestamp của transaction (cột 2) là 1791550075|2, đúng bằng stable timestamp và lastCommittedOpTime lúc đó (ở một node, lệnh ghi mới nhất cũng ở mốc này). [tài liệu] Manual: read concern "snapshot" đọc dữ liệu majority-committed, không phải "bây giờ"; vì vậy ở bài Isolation & Snapshot cursor "snapshot" bị SnapshotTooOld khi không có lệnh ghi nào trong cả cửa sổ.
  2. [quan sát] Oldest timestamp luôn đi sau stable timestamp đúng 30 giây (45 so với 75; 84 so với 114): đó là minSnapshotHistoryWindowInSeconds đang đặt. Với giá trị mặc định 300, khoảng cách là 5 phút.
  3. [quan sát] Trong cột 3, oldest timestamp đã tiến tới ...084, nhưng pinned timestamp ở lại ...075|2, đúng bằng read timestamp của transaction đang mở. Mọi version mới hơn pinned timestamp phải được giữ. Cột 4: giải phóng transaction thì pinned timestamp nhảy lên bằng oldest timestamp.
  4. [quan sát] $currentOp cho thấy transaction đang mở và read timestamp của nó: cách tìm ai đang giữ pinned timestamp.

Cột mốc: Bạn đã có thể kể bốn tầng transaction, nói mỗi timestamp đánh dấu mốc nào, và giải thích vì sao một snapshot cũ cần các version cũ còn sống. Tiếp theo: Update chain và history store.

Hỏi & đáp

Một thư viện giữ mọi ấn bản của cuốn quy định nội bộ. Chị Hà mượn ấn bản sáng nay và ngồi đọc cả tuần. Thư viện in thêm hàng chục ấn bản. Họ có thể bỏ ấn bản nào?

  1. Tất cả ấn bản trừ bản mới nhất, vì người mượn mới chỉ cần bản mới nhất

    Chị Hà vẫn đang đọc bản sáng nay; bỏ nó thì chị mất bản đang đọc. WiredTiger cũng vậy: mọi version từ read timestamp của người đọc cũ nhất trở về sau phải ở lại. Xem mục "Các timestamp mà MongoDB dùng".

  2. Không bỏ được ấn bản nào cho đến khi chị Hà trả sách

    Ấn bản cũ hơn bản chị đang cầm không ai cần nữa; chỉ phần từ bản của chị trở về sau mới bị giữ. Tương ứng: pinned timestamp bị giữ ở read timestamp của reader cũ nhất. Xem mục "Quan sát chúng".

  3. Ấn bản được in trong tuần này, vì chúng chưa ai mượn

    Ngược lại: bản mới là bản người mượn mới sẽ nhận. Thứ có thể bỏ luôn nằm ở phía cũ, không bao giờ ở phía mới hơn người đọc cũ nhất. Xem bảng ở "Hình dung trước".

  4. Mọi ấn bản cũ hơn bản mà chị Hà đang cầm trên tay

    Như oldest và pinned timestamp: chỉ ấn bản cũ hơn người đọc cũ nhất mới vô dụng. Mọi ấn bản từ bản của chị Hà trở về sau phải ở lại. Xem mục "Hình dung trước: thư viện giữ mọi ấn bản" và bảng timestamp.

Oldest timestamp của một node đang ở mốc 84 và stable timestamp ở mốc 114 (tính bằng giây). Một transaction snapshot mở từ trước, read timestamp 75, vẫn chưa đóng. Pinned timestamp là bao nhiêu?

  1. 75: pinned là min của oldest timestamp và read timestamp của mọi transaction đang chạy

    Đúng. Ở bảng quan sát, oldest tiến tới 84 nhưng pinned timestamp ở lại 75, đúng bằng read timestamp của transaction đang mở; mọi version mới hơn mốc đó phải được giữ. Xem mục "Quan sát chúng".

  2. 84: pinned luôn bằng oldest timestamp, nên transaction đang mở không làm version nào bị giữ thêm

    Oldest timestamp chỉ là điểm xa nhất mà người đọc mới còn được bắt đầu. Mốc thật để quyết định xoá version là pinned timestamp, và nó bị kéo lùi bởi reader cũ hơn. Xem mục "Các timestamp mà MongoDB dùng".

  3. 114: pinned đi theo stable timestamp, tức mốc đã bền

    Stable timestamp là ranh giới giữa dữ liệu đã cố định và phần còn tạm, không phải mốc xoá version cũ. Xem mục "Các timestamp mà MongoDB dùng".

  4. 0: có reader đang mở thì WiredTiger không tính pinned timestamp nữa

    Pinned timestamp luôn được WiredTiger tự tính theo công thức min; ở lab nó đúng bằng read timestamp của transaction. Xem mục "Quan sát chúng".