Isolation (P3/3): Write skew; so với PostgreSQL
Ở phần trước: read concern nói dữ liệu đã an toàn đến đâu, và chỉ snapshot cho một thời điểm duy nhất. Cursor đọc ngoài transaction có thể đọc trùng hoặc sót.
- Cần đọc trước: Read concern và đọc ngoài transaction
- Dẫn tới: Durability & Consistency, bài tiếp theo. Phần này là phần cuối của bài về isolation.
Anomaly còn lại: write skew
Hai bác sĩ cùng xin nghỉ
Một ca trực luôn phải có ít nhất một bác sĩ. Đang có An và Bình. Cả hai cùng xin nghỉ. Mỗi yêu cầu chạy trong một transaction:
// mỗi transaction (snapshot): chỉ cho nghỉ nếu còn người khác trực
const n = oncall.countDocuments({ ward: "w3", onCall: true });
if (n >= 2) oncall.updateOne({ _id: me }, { $set: { onCall: false } });Mỗi transaction đọc cùng một snapshot (cả hai thấy 2 người), rồi sửa document của riêng mình (An sửa an, Bình sửa binh). Hai document khác nhau thì không có write conflict. Cả hai commit.
[quan sát] Lịch xen kẽ: T1 và T2 cùng mở, cùng đọc, rồi lần lượt ghi và commit:
== 1. Write skew: hai transaction snapshot, mỗi bên sửa document RIÊNG ==
T1 (An): thấy 2 người đang trực
T2 (Bình): thấy 2 người đang trực
T1: an xin nghỉ, commit ok
T2: binh xin nghỉ, commit ok
kết quả: đang trực = [] (luật: ít nhất 1)Không ai làm gì sai: mỗi transaction đều đúng luật trên snapshot của mình, cái sai chỉ hiện ra khi ghép hai quyết định lại. Đó là write skew: hai transaction đọc chung một tập dữ liệu, ghi hai chỗ khác nhau, và kết quả không tương đương thứ tự chạy lần lượt nào (An chạy trước thì Bình thấy 1 người và bị từ chối).
T1 T2
│ đọc: 2 người trực │ đọc: 2 người trực
│ │
├── ghi an.onCall=false ├── ghi binh.onCall=false (hai document khác nhau)
▼ ▼
commit ✓ commit ✓ → 0 người trực ✗Snapshot isolation thuần không chặn điều này, ở database nào cũng vậy. Tài liệu transaction của MongoDB không có mức "serializable": read concern "snapshot" là mức mạnh nhất.
Đây là một lịch xen kẽ hợp lệ do script ép ra, không phải cuộc đua ngẫu nhiên; lab không đo tần suất của nó trong tải thật. Chỉ cần nó có thể xảy ra là đủ để phải thiết kế tránh.
Cách 1: buộc hai transaction va vào nhau
Nếu cả hai cùng ghi một document chung, MongoDB sẽ phát hiện xung đột: người ghi sau bị abort. Thêm một document "chốt" (guard) cho mỗi ca trực, và cho mọi transaction $inc nó trước khi đọc:
guard.updateOne({ _id: "w3" }, { $inc: { v: 1 } }); // ghi chung: buộc hai transaction va nhau
const n = oncall.countDocuments({ ward: "w3", onCall: true });
if (n >= 2) oncall.updateOne({ _id: me }, { $set: { onCall: false } });[quan sát]
== 2. Sửa bằng cách cùng ghi một document 'guard' ==
T1 (An): thấy 2 người đang trực
T2 (Bình): lỗi WriteConflict ["TransientTransactionError"]
T1: an xin nghỉ, commit ok
Bình làm lại cả transaction:
T3 (Bình, lần 2): thấy 1 người đang trực
T3: chỉ còn 1 người trực, từ chối cho nghỉ
kết quả: đang trực = ["binh"]Người đến sau bị WriteConflict, làm lại, và lần này thấy 1 người trực nên từ chối: luật được giữ. Đổi thứ tự (T2 đọc trước, T1 commit, rồi T2 mới ghi guard) cũng cho WriteConflict ở T2 và kết quả y hệt. [tài liệu] Mục "In-progress Transactions and Stale Reads" của trang Production Considerations đưa ra chính kỹ thuật này: dùng findOneAndUpdate sửa một field để transaction "lấy lock" trên document và bảo đảm bản đọc không cũ.
Giá: mọi transaction của ca trực đó bị tuần tự hoá qua một hot document (document mà nhiều client cùng ghi). Hợp khi tranh chấp thấp và luật quan trọng (bài Write Conflicts bàn việc giảm tranh chấp).
Cách 2: đưa luật vào một document
Luật "còn ít nhất một người" nằm gọn trong một document nếu danh sách người trực nằm chung một document. Lệnh ghi một document là atomic và có điều kiện trong filter, nên không cần transaction:
// roster: { _id: "w3", onCall: ["an", "binh"] }
roster.updateOne({ _id: "w3", "onCall.1": { $exists: true }, onCall: me },
{ $pull: { onCall: me } }) // chỉ khớp khi còn ít nhất 2 phần tử[quan sát] An xin nghỉ: modified 1 | Bình xin nghỉ: modified 0 | roster = ["binh"]. Người thứ hai không khớp filter và không sửa gì: luật nằm trong chính lệnh ghi. Giá: danh sách nằm chung một document, nên phải đủ nhỏ và đủ ít người sửa (bài Data Modeling về document phình vô hạn).
Cách 3: unique index cho luật "không được trùng"
Write skew khó thấy hơn khi chỗ cần ghi chưa tồn tại: kiểm tra "chưa có ai đặt" rồi mới insert (write skew qua phantom). Hai người đặt cùng phòng R1 lúc 09:00. Cả hai đọc snapshot, cả hai thấy 0 chỗ đặt, mỗi người insert một document mới. Hai document mới không đụng nhau, nên không có write conflict.
[quan sát] Hai transaction snapshot, collection bookings (không có ràng buộc) và bookings_u (có unique index { room: 1, slot: 1 }):
-- collection bookings (không có unique index)
X thấy 0 đặt chỗ, Y thấy 0 đặt chỗ
X: insert + commit ok
Y: insert + commit ok
kết quả: 2 đặt chỗ cho R1 09:00
-- collection bookings_u (unique index {room, slot})
X thấy 0 đặt chỗ, Y thấy 0 đặt chỗ
X: insert + commit ok
Y: lỗi WriteConflict code 112 ["TransientTransactionError"]
kết quả: 1 đặt chỗ cho R1 09:00
-- bookings_u, insert thường (ngoài transaction) khi đã có chỗ:
lỗi code 11000
-- bookings_u, Y làm lại cả transaction sau WriteConflict:
Y (lần 2) thấy 1 đặt chỗ -> từ chốiKhông có unique index thì hai đặt chỗ cùng tồn tại. Có unique index thì database giữ luật: người thứ hai bị chặn. [quan sát] Ở lịch xen kẽ này (X commit sau khi Y đã có snapshot), Y nhận WriteConflict chứ không phải DuplicateKey; làm lại thì Y thấy chỗ đã có và từ chối. Một insert ngoài transaction khi chỗ đã có thì báo DuplicateKey (code 11000). [suy luận] Key của X mới hơn snapshot của Y, nên lần chèn trùng bị xử lý như một xung đột ghi; nếu X đã commit trước snapshot của Y thì nhiều khả năng Y nhận DuplicateKey, lab không thử trường hợp đó. Ứng dụng nên xử lý được cả hai mã lỗi.
Bài học chung: ràng buộc nằm ở database thì không phụ thuộc vào ai đọc gì.
| Luật cần giữ | Cách chặn write skew | Giá |
|---|---|---|
| "không được trùng" (phòng, email, số đơn) | unique index | thêm chi phí index (bài Specialized Indexes) |
| "ít nhất / nhiều nhất N" trên một nhóm nhỏ | đưa nhóm vào một document, ghi có điều kiện | document chung phải nhỏ |
| luật trải nhiều document, không biểu diễn được | cùng ghi một document chốt (guard) trong transaction | tuần tự hoá qua một hot document, phải retry |
So với PostgreSQL
Cùng các kịch bản chạy trên PostgreSQL 18.6 (mặc định read committed) bằng hai connection xen kẽ như lab MongoDB. Không quy đổi "mức này của MongoDB bằng mức kia của PostgreSQL": ta so hành vi đo được.
Write skew: ba mức isolation
READ COMMITTED thấy 2,2 | T1 commit ok | T2 commit ok | còn trực: []
REPEATABLE READ thấy 2,2 | T1 commit ok | T2 commit ok | còn trực: []
SERIALIZABLE thấy 2,2 | T1 commit ok
| T2 lỗi SerializationFailure SQLSTATE 40001:
could not serialize access due to read/write dependencies among transactions
| còn trực: ['binh'][quan sát] Write skew xảy ra ở Read Committed và cả Repeatable Read (snapshot isolation): không ai trực. Chỉ Serializable chặn nó: giao dịch thứ hai bị từ chối ở bước commit với SQLSTATE 40001, và ứng dụng làm lại. Đó là điều MongoDB không có: không có một mức isolation nào của transaction để "đòi" database tự phát hiện write skew. Ở MongoDB, chặn nó là việc của thiết kế (ba cách ở trên).
[tài liệu PostgreSQL] Serializable dùng predicate lock; loại lock này không chặn ai, chỉ dùng để phát hiện phụ thuộc giữa các giao dịch đồng thời. Giao dịch bị loại thì ứng dụng retry toàn bộ.
Hai transaction cùng sửa một dòng
Bài Transactions đã so cách hai bên xử lý xung đột ghi ở Read Committed; ở đây thêm Repeatable Read và trường hợp đọc rồi ghi. T1 sửa dòng và giữ 1 giây rồi commit. T2 (mở trước, đã đọc dòng đó) cũng sửa dòng ấy. delta là balance = balance + 50000 (tính ở phía server), rmw là đọc rồi ghi giá trị tính ở ứng dụng (như lost update):
READ COMMITTED delta T2 chờ 1006 ms -> ok | balance cuối 950000 (đúng)
READ COMMITTED rmw T2 chờ 1006 ms -> ok | balance cuối 1050000 (lost update)
REPEATABLE READ delta T2 chờ 1006 ms -> 40001 could not serialize access due to concurrent update
REPEATABLE READ rmw T2 chờ 1008 ms -> 40001 could not serialize access due to concurrent update
| balance cuối 900000 (chỉ có lệnh của T1)[quan sát] Ở cả hai mức, T2 đứng chờ đúng bằng thời gian T1 giữ dòng (khoảng 1 giây). Transaction MongoDB thì không chờ: gặp document mà transaction khác đang giữ, nó nhận WriteConflict ngay (thí nghiệm guard ở trên); gặp document đã bị sửa sau snapshot, nó bị huỷ sau 1 ms (mục "Đọc xong rồi ghi"). [tài liệu] Ngược lại, một lệnh ghi ngoài transaction gặp document đang bị transaction giữ thì chờ tới khi transaction kết thúc. Sau khi T1 commit:
- Read Committed: T2 chạy tiếp. Với
delta, PostgreSQL áp dụng lệnh lên phiên bản mới của dòng: kết quả đúng. Vớirmw, T2 ghi đè giá trị cũ mà ứng dụng tự tính: lost update, y hệt MongoDB ngoài transaction. - Repeatable Read: T2 chờ rồi bị
40001. Giống MongoDB ở chỗ người đến sau phải làm lại, khác ở chỗ PostgreSQL bắt chờ trước rồi mới báo, còn MongoDB báo ngay.
Phantom với unique constraint
REPEATABLE READ bookings thấy 0,0 | X: ok | Y: ok (2 ms) | 2 đặt chỗ
REPEATABLE READ bookings_u thấy 0,0 | X: ok | Y: 23505 UniqueViolation (1 ms) | 1 đặt chỗ
SERIALIZABLE bookings thấy 0,0 | X: ok | Y: 40001 SerializationFailure | 1 đặt chỗ
SERIALIZABLE bookings_u thấy 0,0 | X: ok | Y: 40001 SerializationFailure | 1 đặt chỗ[quan sát] Có unique constraint thì Y bị chặn (23505; MongoDB trong transaction báo WriteConflict). Không có thì Repeatable Read cho cả hai đặt chỗ và chỉ Serializable cứu được. Cùng bài học: ràng buộc ở database là hàng rào chắc nhất.
Bảng so sánh
| MongoDB 8.3 | PostgreSQL 18 | |
|---|---|---|
Một câu SELECT/find đơn lẻ (không transaction) có nhìn một thời điểm duy nhất? | không: cursor trộn nhiều thời điểm (lab: tổng sai 30/30 lượt) | có: mặc định Read Committed vẫn cho mỗi câu lệnh một snapshot riêng |
| Muốn đọc nhất quán | tự đòi: read concern "snapshot" hoặc transaction | mặc định đã có cho từng câu; nhiều câu cùng snapshot thì Repeatable Read trở lên |
| Snapshot được chụp lúc | lệnh đầu tiên của transaction (lab) | [tài liệu] Repeatable Read: lệnh đầu tiên không phải điều khiển transaction; Read Committed: đầu mỗi câu lệnh |
| Hai transaction cùng sửa một dòng | transaction đến sau bị huỷ ngay (WriteConflict, lab: 1 ms) | Read Committed: bên đến sau chờ, rồi chạy tiếp; Repeatable Read: chờ, rồi 40001 |
| Write skew | xảy ra trong transaction snapshot; phải tự thiết kế để chặn | xảy ra ở Read Committed và Repeatable Read; Serializable chặn (40001) |
| Có mức "serializable" không | không có trong tài liệu transaction | có (SSI, predicate lock) |
| Ràng buộc unique chặn phantom | có (lab: WriteConflict khi hai transaction đua nhau, DuplicateKey ngoài transaction) | có (23505) |
| Lỗi để retry | error label TransientTransactionError | SQLSTATE 40001, 40P01 |
[tài liệu PostgreSQL] Bảng mức isolation của PostgreSQL: Repeatable Read không cho phantom read (chặt hơn tiêu chuẩn SQL yêu cầu) nhưng vẫn cho serialization anomaly (write skew); Read Uncommitted hoạt động y hệt Read Committed.
Cách đọc: về hành vi quan sát, transaction snapshot của MongoDB gần Repeatable Read của PostgreSQL (cùng write skew, cùng first-writer-wins, cùng snapshot ổn định), nhưng không bằng nhau: khác ở cách xử lý xung đột (huỷ ngay thay vì chờ), ở việc không có Serializable, và ở chỗ ngoài transaction MongoDB không có snapshot theo từng lệnh như Read Committed. Tên mức không nói được gì về hành vi; phải đo hoặc đọc đúng tài liệu của đúng phiên bản.
Hai bên không "đúng/sai": PostgreSQL mặc định an toàn hơn cho một câu lệnh đơn lẻ và có Serializable, đổi lại chi phí theo dõi phụ thuộc. MongoDB mặc định rẻ cho thao tác một document, cho snapshot khi đòi, và giao việc chặn write skew cho thiết kế. So sánh tổng thể ở bài Anti-patterns & MongoDB vs PostgreSQL.
Những lỗi thường gặp
- Dùng cursor thường để tính con số cần chính xác. Lab: tổng sai 30/30 lượt khi có người ghi, ngay cả khi sort theo
_id. Dùngfindhoặcaggregatevới"snapshot", hoặc transaction. - Tin
majoritylà snapshot.majorityhứa dữ liệu đã an toàn, không hứa một thời điểm duy nhất (lab: 28/30 lượt trùng, 29/30 lượt sót). - Giữ snapshot quá lâu. Quá hạn: 60 giây trong transaction, 300 giây mặc định ngoài transaction.
- Đọc rồi ghi giá trị tính ở app, ngoài transaction. Lost update: lab mất 100.000đ mà không báo lỗi. Dùng
$inc/toán tử cập nhật, hoặc làm trong transaction. - Nghĩ transaction snapshot là serializable. Lab: hai bác sĩ cùng nghỉ, ca trực rỗng. Chặn bằng unique index, document chung hoặc
guard. - Đặt read concern cho từng lệnh trong transaction. Nó đặt ở cấp transaction; tài liệu khuyên không đặt riêng lẻ.
- Giả định hành vi PostgreSQL. Ở MongoDB transaction đến sau không chờ mà bị huỷ, một
findkhông tự có snapshot, và không có mức Serializable.
Cột mốc: Bạn đã nhận ra write skew, chọn được một trong ba cách chặn nó, và so được hành vi của MongoDB với PostgreSQL. Bài Isolation & Snapshot khép lại ở đây.
Hỏi & đáp
Hệ thống đặt phòng: transaction (snapshot) kiểm tra "chưa có ai đặt R1 lúc 09:00" rồi insert. Hai người đặt cùng lúc và cả hai commit thành công, ra hai đặt chỗ. Cách sửa nào đúng nhất?
Hai giao dịch cùng sửa một bản ghi; giao dịch đầu chưa commit, giao dịch hai đến sau. Lab đo ở PostgreSQL (Read Committed) và MongoDB (transaction). Điều nào đúng?
Hai transaction cùng đọc một snapshot (đang có 2 bác sĩ trực), mỗi bên cho một bác sĩ khác nhau nghỉ, cả hai commit. Điều gì đúng?
Hai trưởng ca cùng nhìn bản chụp bảng trực lúc 9:00 (đang có 2 người), mỗi người cho một người khác nhau nghỉ, và bảng thật còn 0 người. Vì sao, và làm gì để tránh?
Nếu bỏ hết thuật ngữ: khi nhiều người cùng làm việc trên một cuốn sổ đang được ghi, người đọc có hai lựa chọn. Lật từng trang thì thấy trang này lúc này, trang kia lúc khác, nên con số có thể sai mà không ai báo. Hoặc chụp ảnh cả cuốn sổ một lần rồi chỉ nhìn ảnh: con số luôn đúng với một thời điểm, nhưng ảnh có hạn dùng, và hai người cùng nhìn một ảnh có thể đưa ra hai quyết định mà ghép lại thì sai. Muốn chặn cái sau, đừng trông vào ảnh: phải buộc họ cùng ghi vào một chỗ, hoặc để luật nằm ngay trên sổ.
Bài tiếp theo
Bài này trả lời "ai thấy gì, ở thời điểm nào". Còn "dữ liệu đó an toàn đến đâu"? Lệnh ghi w: 1 ở phần trước (Read concern và đọc ngoài transaction) nhận ack sau 1 ms, trước khi bất kỳ secondary nào có nó; nếu primary sập ngay sau đó thì sao? majority nghĩa là gì khi một node mất, và vì sao một lệnh đọc secondary có thể không thấy lệnh ghi của chính mình vừa làm?
Durability & Consistency trả lời những câu đó: write concern (w, j, wtimeout), read concern majority đi cùng write concern ra sao, causal consistency qua session, và cách retryable write/read hoạt động. Còn cơ chế bên dưới (journal, majority commit point lúc failover) là chuyện của bài Journal & Checkpoint và bài Elections, Failover & Majority.
Tài liệu tham khảo
- Read Isolation, Consistency, and Recency (read uncommitted, non-point-in-time reads, cursor snapshot, causal consistency)
- Read Concern (local, available, majority, snapshot, linearizable; transaction-level read concern)
- Read Concern "snapshot" (outside transactions, atClusterTime, minSnapshotHistoryWindowInSeconds)
- Read Concern "snapshot", bản 8.0
- Transactions (read concern in transactions, snapshot isolation on sharded clusters)
- Production Considerations (in-progress transactions and write conflicts, stale reads, findOneAndUpdate to take a lock)
- Server Parameters (minSnapshotHistoryWindowInSeconds)
- PostgreSQL: Transaction Isolation (Read Committed, Repeatable Read, Serializable, bảng bất thường theo mức)
- PostgreSQL: Serialization Failure Handling (40001, retry)