Isolation (P2/3): Read concern và đọc ngoài transaction
Ở 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.
- Cần đọc trước: Isolation là gì; snapshot trong transaction
- Dẫn tới: Write skew; so với PostgreSQL
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ất | có | 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à maxTimeMS | không | khô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 đọc | p50 | p95 |
|---|---|---|
find + read concern "local" | 0,137 ms | 0,189 ms |
find + read concern "available" | 0,131 ms | 0,164 ms |
find + read concern "majority" | 0,131 ms | 0,169 ms |
find + read concern "snapshot" | 0,134 ms | 0,154 ms |
find + read concern "linearizable" | 0,555 ms | 1,000 ms |
transaction snapshot chỉ đọc (start, find, commit) | 0,660 ms | 0,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ớiw: 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ộngbalance, đếm số_idtrả về và số_idkhá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 đọc | Lượt có document trùng | Lượt có document sót | Lượt có tổng sai | Số document trùng / sót (cộng 30 lượt) | Lệch tổng tối đa |
|---|---|---|---|---|---|
A local, sort balance, nghỉ 5 ms | 30, 30, 27 / 30 | 30, 30, 29 / 30 | 30 / 30 ở cả ba | 180 / 208, 132 / 114, 138 / 134 | 6.466, 6.279, 8.415 |
A0 local, sort balance, không nghỉ | 15, 19, 16 / 30 | 17, 21, 15 / 30 | 30 / 30 ở cả ba | 21 / 28, 32 / 40, 27 / 25 | 3.935, 5.506, 3.127 |
B local, sort _id | 0 | 0 | 30 / 30 ở cả ba | 0 / 0 | 484, 820, 474 |
M majority, sort balance (một đợt) | 28 / 30 | 29 / 30 | 30 / 30 | 105 / 95 | 6.281 |
C snapshot, sort balance | 0 | 0 | 0 | 0 / 0 | 0 |
D transaction snapshot, sort balance | 0 | 0 | 0 | 0 / 0 | 0 |
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
getMorecursor vẫn giải phóng snapshot (không còn giữ nó). - B: sort theo
_idhết trùng và sót, nhưng tổng vẫn sai 30 / 30 lần._idkhô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:
majoritykhô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).majorityhứ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ả
findngoà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?
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ì?