Isolation (P2/3): Read concern và đọc ngoài transaction

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

Ở phần trước: trong transaction, mọi lệnh đọc dùng một snapshot đứng yên, dù người khác commit giữa chừng. Ngoài transaction thì không có lát cắt nào như vậy.

Read concern: mỗi mức hứa gì

Read concern là tham số của lệnh đọc, trả lời câu hỏi "tôi muốn thấy dữ liệu đã an toàn đến mức nào". Nó khác isolation ở chỗ nó nói về độ bền của dữ liệu đọc được, và chỉ riêng "snapshot" mới cho thêm lời hứa về một thời điểm duy nhất.

Mức[tài liệu] hứa gìCó thể rollback?Trong transaction
"local"dữ liệu mới nhất trên node, không đảm bảo đã tới majority. Mặc định khi đọc trên primary (và secondary)cóđược
"available"như local về độ bền; trên sharded cluster có thể trả cả orphaned document, đổi lại độ trễ thấp nhấtcókhông
"majority"dữ liệu đã được đa số node xác nhận, không bị rollback. Có hiệu lực đầy đủ trong transaction chỉ khi commit bằng w: "majority"khôngđược
"snapshot"dữ liệu majority-committed tại một thời điểm duy nhất trong quá khứ gần; trong transaction chỉ có hiệu lực khi commit bằng w: "majority"khôngđược; ngoài transaction chỉ cho find, aggregate và distinct (distinct chỉ trên collection không sharded)
"linearizable"phản ánh mọi lệnh ghi majority đã hoàn tất trước khi đọc bắt đầu; chỉ trên primary, nên dùng filter chọn đúng một document và maxTimeMSkhôngkhông

Hai điều bảng không nói thẳng. [tài liệu] Trong transaction, read concern đặt ở cấp transaction; read concern cấp collection hay database bị bỏ qua, và không nên đặt cho từng lệnh. Và chỉ "snapshot" nói tới một thời điểm: "majority" nói "dữ liệu này an toàn", không nói "dữ liệu này cùng một lúc". Mục "Đọc ngoài transaction" đo điều đó.

Đo cái giá

Một client tuần tự đọc ngẫu nhiên một document theo _id trong 10.000 document, trên primary, hệ thống không có ai ghi. 100 lần warm-up rồi 1.000 lần đo mỗi ô, 7 lượt, bảng ghi median của các lượt:

Cách đọcp50p95
find + read concern "local"0,137 ms0,189 ms
find + read concern "available"0,131 ms0,164 ms
find + read concern "majority"0,131 ms0,169 ms
find + read concern "snapshot"0,134 ms0,154 ms
find + read concern "linearizable"0,555 ms1,000 ms
transaction snapshot chỉ đọc (start, find, commit)0,660 ms0,925 ms

[quan sát] local, available, majority, snapshot không phân biệt được ở quy mô này: 0,13–0,14 ms, chênh nhau nhỏ hơn dao động giữa các lượt (p50 từng lượt của local dao động 0,13–0,18). Tài liệu cũng nói "majority" có hiệu năng tương đương các mức khác. linearizable đắt hơn khoảng bốn lần. Một transaction chỉ-đọc đắt hơn khoảng năm lần, nhưng không phải vì snapshot: một lần đọc "snapshot" ngoài transaction giá bằng local. [suy luận] Phần chênh là lệnh commitTransaction (thêm một lượt đi về server) và việc mở, đóng session trong vòng đo (script tạo session mới cho mỗi lần); lab không tách từng phần.

[suy luận] linearizable chậm hơn có thể vì nó phải xác nhận với đa số node trước khi trả lời; lab không đo từng bước. Hệ thống ở đây không có người ghi và mạng gần bằng không, nên con số chỉ nói rằng snapshot không phải lý do để né read concern. Giá thật của snapshot nằm ở thời gian giữ nó (mục "Cách sửa"), không ở latency một lần đọc.

Mức nào thấy gì: kéo hai node secondary xuống

Để thấy tận mắt local khác majority, ta làm cho "đa số" không xác nhận được. Ghi stock = 100 với w: "majority" (xong, cả ba node có), rồi docker pause cả hai secondary, rồi ghi stock = 90 với w: 1 (primary tự áp dụng), rồi đọc lại bằng từng mức (kết nối thẳng vào primary, chỉ vài giây để primary chưa kịp step down):

ghi stock = 90 với w:1, ack sau 1 ms, modified 1
local         stock = 90   (1 ms)
available     stock = 90   (1 ms)
majority      stock = 100  (0 ms)
snapshot      stock = 100  (0 ms)
linearizable  lỗi MaxTimeMSExpired code 50 sau 2011 ms   (maxTimeMS đặt 2000)

[quan sát] local thấy giá trị mới (đúng là chưa an toàn: nếu primary sập ngay lúc này, 90 có thể bị rollback). majority và snapshot vẫn thấy 100, giá trị cuối cùng mà đa số xác nhận. linearizable không trả lời: nó chờ đa số mà đa số không có mặt, và chỉ maxTimeMS cứu nó khỏi chờ vô hạn (đúng lời khuyên của tài liệu). Sau khi docker unpause hai secondary, đọc majority thấy 90.

Nghĩa là majority có thể cũ hơn những gì primary đã áp dụng. Đọc thấy lệnh ghi của chính mình qua secondary hay qua failover là chuyện causal consistency và write concern, trong bài Durability & Consistency.

Đọc ngoài transaction: cursor không đứng yên

Báo cáo cộng sai mà không báo lỗi

Bài Query Execution Engine tái hiện cursor trả trùng bằng hai lệnh sửa cố tình. Lần này làm cho nó giống production hơn: một người ghi chạy liên tục và một báo cáo đọc cả collection.

  • Collection wallets: 2.000 ví, tổng tiền 2.001.980, index { balance: 1 }.
  • Người ghi: một tiến trình mongosh liên tục chuyển tiền giữa hai ví ngẫu nhiên, mỗi lần là một transaction (hai $inc, commit với w: 1), nên tổng luôn bằng 2.001.980 ở mọi snapshot. Mỗi lần chạy 60 giây, làm được 1.614–1.896 lần chuyển mỗi giây.
  • Người đọc (báo cáo): find().sort({ balance: 1 }).batchSize(100), cộng balance, đếm số _id trả về và số _id khác nhau. 30 lượt mỗi cách đọc. Giữa hai batch báo cáo nghỉ 5 ms (giả lập app xử lý batch, tham số của lab); riêng "A0" không nghỉ.

Các cách đọc:

// A, A0  : find().sort({ balance: 1 }).batchSize(100)                         (local, mặc định)
// B      : find().sort({ _id: 1 }).batchSize(100)                             (sort theo field không đổi)
// M      : find().sort({ balance: 1 }).readConcern("majority")
// C      : find().sort({ balance: 1 }).readConcern("snapshot")                (ngoài transaction)
// D      : cùng find trong transaction, readConcern snapshot

Đo ba đợt, mỗi đợt người đọc làm 30 lượt cho mỗi cách trong lúc người ghi đang chạy (đợt 1 trong lần chạy thứ nhất của người ghi, đợt 2 và 3 trong lần chạy thứ hai). M được đo riêng, trong lần chạy thứ ba, cạnh một lượt A để đối chiếu. Mỗi ô ghi ba số theo ba đợt:

Cách đọcLượt có document trùngLượt có document sótLượt có tổng saiSố document trùng / sót (cộng 30 lượt)Lệch tổng tối đa
A local, sort balance, nghỉ 5 ms30, 30, 27 / 3030, 30, 29 / 3030 / 30 ở cả ba180 / 208, 132 / 114, 138 / 1346.466, 6.279, 8.415
A0 local, sort balance, không nghỉ15, 19, 16 / 3017, 21, 15 / 3030 / 30 ở cả ba21 / 28, 32 / 40, 27 / 253.935, 5.506, 3.127
B local, sort _id0030 / 30 ở cả ba0 / 0484, 820, 474
M majority, sort balance (một đợt)28 / 3029 / 3030 / 30105 / 956.281
C snapshot, sort balance0000 / 00
D transaction snapshot, sort balance0000 / 00

Khi không có người ghi, cả A, A0, B, C, D đều cho 0 trùng, 0 sót, tổng đúng 30 lượt trên 30. Vấn đề chỉ xuất hiện khi có ghi song song.

Đọc bảng

  • A và A0: trùng, sót, tổng sai. Ở A (nghỉ giữa các batch), gần như mọi lượt có document bị trả hai lần và document khác bị bỏ sót, tổng lệch hàng nghìn đồng. Ở A0 (đọc liền) con số nhỏ hơn nhưng vẫn khoảng một nửa số lượt có trùng hoặc sót, và mọi lượt đều sai tổng: app không nghỉ thì khe hở hẹp lại, nhưng giữa hai getMore cursor vẫn giải phóng snapshot (không còn giữ nó).
  • B: sort theo _id hết trùng và sót, nhưng tổng vẫn sai 30 / 30 lần. _id không đổi nên document không "chạy" trong index, nhưng cursor vẫn thấy các ví ở thời điểm khác nhau: ví này trước lần chuyển, ví kia sau. Sort ổn định chỉ giải nửa vấn đề.
  • M: majority không phải snapshot. Số liệu gần y hệt A (lượt A đo cùng lúc: trùng 28/30, sót 29/30, tổng sai 30/30). majority hứa dữ liệu an toàn, không hứa dữ liệu cùng một lúc. Nhầm lẫn này khá phổ biến: "tôi đã dùng majority rồi mà".
  • C và D: không lần nào sai, trong 90 lượt. Cả find ngoài transaction với "snapshot" lẫn transaction cho tổng đúng 2.001.980 mọi lượt, dù người ghi đang chuyển 1.600–1.900 lần mỗi giây. Thời gian trung bình mỗi lượt (ba đợt): A 135–166 ms, C 138–165 ms, D 161–177 ms, phần lớn là 20 lần nghỉ 5 ms của lab: snapshot không làm chậm đáng kể ở quy mô này, nhưng đây không phải benchmark.

[tài liệu] Khớp với trang Read Isolation, Consistency, and Recency: không có cô lập, một read bắt đầu ở t1 có thể thấy bản cập nhật commit ở t2, có thể bỏ sót document khớp bị cập nhật trong lúc đọc, và cursor có thể trả cùng một document nhiều lần nếu thao tác khác đổi field thuộc index mà query đang dùng. Trang đó cũng nói "dùng read isolation để cải thiện tính nhất quán", tức read concern "snapshot".

[chi tiết cài đặt] Cơ chế đã giải ở bài Query Execution Engine: giữa các getMore (và mỗi lần query yield) cursor giải phóng snapshot, rồi đi tiếp từ vị trí cũ trên index hiện tại. Ví bị đổi balance có key mới trong index. Nếu ví đã đọc rồi mà key mới nằm phía trước vị trí cursor, nó bị gặp lại lần nữa (trùng); nếu ví chưa đọc mà key mới bị dời về vùng cursor đã đi qua, nó không bao giờ được gặp (sót).

Cách sửa

Cần một con số nhất quán (báo cáo, đối soát, export)?
   │
   ├── đọc trên một snapshot  → find / aggregate với readConcern "snapshot"
   │                            (hoặc transaction snapshot)
   │       ✓ không trùng, không sót, tổng đúng      ✗ chỉ sống được một khoảng thời gian
   │
   ├── giảm khe hở            → sort theo field không đổi (_id, createdAt)
   │       ✓ hết trùng và sót                       ✗ vẫn đọc dữ liệu của nhiều thời điểm
   │
   └── chịu được sai số       → job idempotent, đọc theo khoảng _id/createdAt đã "đóng"
           (dữ liệu quá khứ không còn bị sửa, như đơn của hôm qua)

Cái giá của snapshot. [tài liệu] Một snapshot read chỉ làm được trong thời gian minSnapshotHistoryWindowInSeconds (mặc định 300 giây, từ 5.0): đọc lâu hơn thì có thể bị dừng. Ngoài transaction, "snapshot" chỉ dùng được cho find, aggregate và distinct (distinct chỉ trên collection không sharded), và nhận thêm tham số atClusterTime để đọc ở một mốc cụ thể; mốc cũ hơn cửa sổ đó thì nhận lỗi SnapshotTooOld. Trong transaction thì còn giới hạn 60 giây của chính transaction.

[quan sát] Hạ minSnapshotHistoryWindowInSeconds xuống 5 giây (chỉ trong lab), ghi một lệnh w: "majority" cho mốc snapshot mới, rồi đọc cursor "snapshot" 2.000 ví theo batch 100 (giữa hai batch có một lệnh ghi nhỏ):

nghỉ 0,1 s / batch: đọc đủ 2000 document sau 2,3 s
nghỉ 8 s / batch  : dừng ở document 200 sau 16,0 s: SnapshotTooOld code 239
                    | Read timestamp Timestamp(1791529060, 8) is older than the oldest available timestamp.

[quan sát] Ở lần chạy đầu, khi chưa có lệnh ghi majority trước khi mở cursor, ngay cả cursor nghỉ 0,1 s cũng dừng ở batch đầu với SnapshotTooOld. [tài liệu] Mốc của "snapshot" là điểm majority-committed gần nhất, không phải "bây giờ". [suy luận] Hệ thống không có lệnh ghi nào trong hơn 5 giây thì mốc đó đã nằm ngoài cửa sổ. Với cửa sổ mặc định 300 giây, chuyện này khó xảy ra.

Snapshot không miễn phí: ứng dụng phải đọc đủ nhanh, hoặc chia việc thành đoạn ngắn (mỗi đoạn một snapshot; giữa các đoạn dữ liệu có thể đổi). Báo cáo chạy 20 phút cần cách khác, như số liệu tính trước (bài Aggregation Pipeline có $merge cho rollup).

Cột mốc: Bạn đã chọn được read concern cho một nhu cầu đọc, biết vì sao cursor đọc ngoài transaction có thể đọc trùng hoặc sót, và biết cách sửa. Tiếp theo: Write skew; so với PostgreSQL.

Hỏi & đáp

Một báo cáo cộng balance của 2.000 ví qua cursor, trong khi một tiến trình khác chuyển tiền giữa các ví (tổng tiền không đổi). Cách nào làm báo cáo luôn ra đúng tổng?

  1. Sort theo _id để document không còn bị dời trong index

    Lab: sort theo _id hết trùng và sót, nhưng tổng vẫn sai 30/30 lượt, vì cursor đọc từng ví ở những thời điểm khác nhau (trước và sau một lần chuyển). Xem mục "Đọc bảng".

  2. Đặt read concern "majority", vì dữ liệu majority đã ổn định

    Lab: majority trùng 28/30 lượt, sót 29/30 lượt, tổng sai 30/30, gần y hệt local. majority hứa dữ liệu an toàn, không hứa một thời điểm duy nhất. Xem mục "Đọc bảng".

  3. Đọc find với "snapshot", đủ nhanh để không quá hạn

    Đúng. Lab: snapshot ngoài transaction và transaction snapshot đều cho tổng đúng 2.001.980 trong 90/90 lượt. Giá phải trả là thời hạn: mặc định 300 giây, lab hạ xuống 5 giây thì SnapshotTooOld. Xem mục "Cách sửa".

  4. Đọc liền không nghỉ giữa các batch để khe hở đủ nhỏ

    Lab (A0): không nghỉ thì số trùng và sót giảm, nhưng vẫn khoảng một nửa số lượt có trùng hoặc sót và 30/30 lượt tổng sai: giữa hai getMore cursor vẫn giải phóng snapshot (không còn giữ nó). Xem mục "Đọc bảng".

Hai secondary tạm mất. Primary vừa nhận một lệnh ghi w: 1 đổi stock từ 100 xuống 90. Đọc stock bằng read concern "majority" trên primary trả gì?

  1. 100: bản cuối mà đa số đã xác nhận

    Mới chỉ primary có giá trị 90, đa số chưa xác nhận. Lab: local thấy 90, majority và snapshot thấy 100, còn linearizable không trả lời (MaxTimeMSExpired sau 2 giây). Xem mục "Mức nào thấy gì".

  2. 90, vì đọc trên primary luôn thấy lệnh ghi mới nhất

    Đó là local. majority chỉ trả dữ liệu đã được đa số xác nhận, nên lệnh ghi w: 1 chưa xác nhận bị giấu. Xem mục "Mức nào thấy gì".

  3. Nó chờ tới khi đa số có 90 rồi mới trả 90

    Đó là hành vi gần với linearizable (và nó chờ vô hạn nếu không có maxTimeMS). majority không chờ: nó trả ngay dữ liệu majority-committed hiện có. Xem mục "Mức nào thấy gì".

  4. Lỗi, vì đọc majority đòi mọi node phải sẵn sàng

    Không đòi: lab trả 100 sau 0 ms. Thứ có thể treo là linearizable. Xem mục "Mức nào thấy gì".