Isolation (P3/3): Write skew; so với PostgreSQL

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

Ở 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.

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ối

Khô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 skewGiá
"không được trùng" (phòng, email, số đơn)unique indexthê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ệndocument chung phải nhỏ
luật trải nhiều document, không biểu diễn đượccùng ghi một document chốt (guard) trong transactiontuầ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ới rmw, 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.3PostgreSQL 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ántự đòi: read concern "snapshot" hoặc transactionmặ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úclệ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òngtransaction đế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 skewxảy ra trong transaction snapshot; phải tự thiết kế để chặnxảy ra ở Read Committed và Repeatable Read; Serializable chặn (40001)
Có mức "serializable" khôngkhông có trong tài liệu transactioncó (SSI, predicate lock)
Ràng buộc unique chặn phantomcó (lab: WriteConflict khi hai transaction đua nhau, DuplicateKey ngoài transaction)có (23505)
Lỗi để retryerror label TransientTransactionErrorSQLSTATE 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ùng find hoặc aggregate với "snapshot", hoặc transaction.
  • Tin majority là snapshot. majority hứ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 find khô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?

  1. Chuyển sang w: "majority" cho cả hai transaction

    Write concern nói về độ bền của lệnh ghi, không làm hai insert vào document khác nhau xung đột. Lab ghi bằng majority vẫn ra 2 đặt chỗ. Xem mục "Cách 3".

  2. Chỉ cần retry khi gặp WriteConflict

    Không có WriteConflict nào để retry: hai document mới không đụng nhau nên cả hai commit. Chính vì không có xung đột nên mới phải có thứ chặn. Xem mục "Cách 3".

  3. Dùng read concern "snapshot" cho transaction

    Họ đã dùng snapshot rồi: write skew/phantom chính là điều snapshot cho phép. Snapshot không làm hai transaction nhìn thấy nhau. Xem mục "Hai bác sĩ cùng xin nghỉ".

  4. Tạo unique index { room: 1, slot: 1 }

    Ràng buộc nằm ở database thì không phụ thuộc ai đọc gì. Lab: có unique index thì người thứ hai bị chặn (trong lịch xen kẽ của lab là WriteConflict, lần làm lại thấy chỗ đã có và từ chối; insert ngoài transaction là DuplicateKey). Không có thì 2 đặt chỗ. Xem mục "Cách 3".

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?

  1. Cả hai đều bắt giao dịch đến sau chờ giao dịch đầu commit rồi chạy tiếp trên dữ liệu mới, ứng dụng không thấy lỗi

    Chỉ PostgreSQL Read Committed làm vậy (chờ 1006 ms rồi ok). Transaction MongoDB không xếp hàng: bên đến sau bị WriteConflict ngay. Xem mục "Hai transaction cùng sửa một dòng".

  2. Cả hai đều huỷ giao dịch đến sau gần như ngay lập tức, và trả về cùng một mã lỗi để ứng dụng retry

    PostgreSQL Read Committed không huỷ mà chờ; ngay cả Repeatable Read cũng chờ ~1 giây rồi mới 40001. Mã lỗi cũng khác (40001 và error label TransientTransactionError). Xem mục "Bảng so sánh".

  3. PostgreSQL (Read Committed): bên đến sau chờ bên kia commit rồi áp dụng lên bản mới; MongoDB: bên đến sau bị WriteConflict

    Đúng: hai cách giải xung đột, cùng đẩy một phần việc về ứng dụng. PostgreSQL chặn để chờ (lab ~1 giây), MongoDB huỷ ngay và gắn error label TransientTransactionError để làm lại. Với delta, PostgreSQL cho kết quả đúng 950000. Xem mục "Hai transaction cùng sửa một dòng"; cơ chế first writer wins nằm ở phần Isolation là gì; snapshot trong transaction.

  4. PostgreSQL huỷ giao dịch đến sau ngay còn MongoDB cho nó chờ, vì MongoDB có lock cấp document

    Ngược lại. Cả hai đều khoá bản ghi đang được sửa; chỗ khác là chính sách khi gặp bản ghi bị khoá: MongoDB huỷ transaction đến sau, PostgreSQL (Read Committed) chờ. Xem mục "Bảng so sánh".

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?

  1. Không thể xảy ra: snapshot isolation tương đương chạy lần lượt

    Snapshot isolation không tương đương serializable. Lab: cả MongoDB lẫn PostgreSQL (Read Committed và Repeatable Read) để lại 0 người trực. Chỉ PostgreSQL Serializable chặn. Xem mục "Hai bác sĩ cùng xin nghỉ".

  2. Không thể xảy ra: MongoDB abort mọi transaction đọc cùng dữ liệu

    MongoDB chỉ abort khi hai transaction ghi cùng một document. Ở đây mỗi bên sửa một document riêng nên không có xung đột. Xem mục "Hai bác sĩ cùng xin nghỉ".

  3. Chỉ xảy ra khi dùng read concern "local"

    Lab chạy với "snapshot", mức mạnh nhất, và vẫn ra 0 người trực. Xem mục "Hai bác sĩ cùng xin nghỉ".

  4. Xảy ra được: đây chính là write skew

    Hai quyết định đúng trên snapshot riêng nhưng sai khi ghép lại: lab MongoDB ra đang trực = []. Chặn bằng cách buộc hai transaction va nhau (cùng ghi một document chốt), đưa luật vào một document, hoặc ràng buộc ở database. Xem mục "Anomaly còn lại: write skew".

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?

  1. Mỗi người quyết định trên ảnh đã cũ; cần một tờ phiếu chung mà cả hai phải ghi vào

    Mỗi quyết định đúng trên ảnh của mình, sai khi ghép lại. Cách chặn: cho hai người cùng ghi vào một tờ chung (guard), hoặc đặt luật vào chính tờ ghi (roster một document), hoặc để ràng buộc ở database. Xem mục "Hai bác sĩ cùng xin nghỉ".

  2. Người sau đã ghi đè lên quyết định của người trước; cần khoá cả bảng khi ghi

    Không ai ghi đè ai: mỗi người sửa một dòng khác nhau, và cả hai quyết định đều còn nguyên. Đó là write skew, không phải lost update; cái sai chỉ hiện ra khi ghép hai quyết định. Xem mục "Hai bác sĩ cùng xin nghỉ".

  3. Mỗi người chụp lại ảnh ngay trước khi ghi quyết định của mình

    Chụp lại chỉ thu hẹp khe hở chứ không đóng nó: giữa lúc chụp và lúc ghi vẫn có người xen vào, và trong transaction snapshot thì ảnh không chụp lại được. Xem mục "Cách 1".

  4. Có người thứ ba kiểm tra lại bảng sau khi cả hai đã ghi xong

    Kiểm tra sau chỉ phát hiện sự cố khi ca đã rỗng chứ không ngăn nó. Ngăn là việc của ràng buộc hoặc một chỗ ghi chung trước khi commit. Xem mục "Cách 2" và "Cách 3".

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