Write Conflicts (P3/3): Hot document và checklist production
Ở phần trước: kiểm tra rồi mới ghi là hai bước nên có thể trừ tiền hai lần. Unique index trên _id là trọng tài sửa được race, còn retry có chờ thì cắt việc thừa chứ không tăng throughput.
- Cần đọc trước: Idempotency và chiến lược retry
- Dẫn tới: WiredTiger & Compression, bài tiếp theo. Phần này là phần cuối của bài Write Conflicts, Retry & Idempotency.
Hot document
Vì sao tranh chấp xuất hiện
Hình dung cuốn sổ chỉ có một dòng "tổng kho". Ai muốn đổi dòng đó phải xếp hàng; thêm thu ngân không làm sổ nhanh hơn, chỉ làm hàng dài hơn.
[tài liệu + bài trước] Nhiều lệnh ghi chạy đồng thời được nếu chúng sửa những document khác nhau; hai lệnh sửa cùng một document thì một bên thắng, bên kia phải làm lại. Hàng đợi đó có hai hình dạng:
Lệnh ghi lẻ ($inc) Transaction (hoặc đọc-sửa-ghi ở app)
─────────────────────────── ───────────────────────────────────────
va nhau → server thử lại va nhau → bên đến sau bị HUỶ
ứng dụng không thấy lỗi ứng dụng phải làm lại cả gói
cái giá: CPU thử lại cái giá: công việc bị bỏ + làm lại,
tăng theo số người cùng chờVì sao đơn vị xung đột là document là chuyện của bài WiredTiger MVCC; cách locks, latches và tickets xếp hàng các lệnh ghi là chuyện của bài Concurrency.
Lab 3: 1 document so với 16 document
Bài Document Model lấy ví dụ 10.000 lệnh ghi mỗi giây vào một document "tổng kho". Primary 1 CPU của lab, chung máy với client, không tới con số đó (cao nhất ở đây: 1.663 ops/s), nên lab đo hình dạng, không đo độ lớn: tăng số người ghi (1, 2, 4, 8 tiến trình, mỗi lượt 5 giây) lên một counter, rồi lên 16 counter chọn ngẫu nhiên (e4-hot.out, e4-agg.out, e4-attempts.out). Ba cách ghi: inc ($inc lẻ, ngoài transaction); rmw (đọc rồi ghi ở app, kiểm tra phiên bản, không khớp thì đọc lại); txn ($inc trong transaction, vòng retry theo error label). Mỗi ô chạy hai lượt:
| Cách ghi | Số document | ops/s, 1 người | ops/s, 8 người | Lần thử mỗi thao tác (8 người) | writeConflicts của server (8 người) |
|---|---|---|---|---|---|
| inc | 1 | 790–992 | 1.492–1.520 | 1,0 | 2.171–2.853 (ứng dụng thấy 0 lỗi) |
| inc | 16 | 699–775 | 1.302–1.663 | 1,0 | 126–168 |
| rmw | 1 | 765–844 | 564–658 | 2,95–2,96 | 1.572–1.906 |
| rmw | 16 | 695–827 | 560–1.131 | 1,12 | 56–176 |
| txn | 1 | 738–772 | 242–401 | 2,83–2,90 | 2.295–3.668 (= số transaction bị abort) |
| txn | 16 | 805–809 | 686–1.180 | 1,10 | 334–567 |
Trên một document, lần thử mỗi thao tác theo số người ghi (1, 2, 4, 8): rmw 1,0 → 1,81 → 2,23 → 2,96; txn 1,0 → 1,22 → 1,76 → 2,87. Tranh chấp tăng theo số người cùng ghi.
$inclẻ chịu tranh chấp tốt, nhưng không miễn phí. Ở 8 người ghi vào một document, throughput (khoảng 1.500 ops/s) không thua cách trải 16 document và ứng dụng không thấy lỗi. Tranh chấp vẫn có, chỉ là server giấu nó: 0,29–0,38WriteConflictmỗi lệnh ghi (0,02 khi trải 16 document). [suy luận] Mỗi lần đó là một lần server làm lại, tức CPU tiêu vào việc thừa; lab không đo CPU, nhưng ở server đã bận, đó là phần bạn không còn để dành.- Đọc-sửa-ghi ở app và transaction trả giá thật. Cùng 8 người trên một document,
rmwcòn 564–658 ops/s vàtxncòn 242–401 ops/s, thấp hơn một người ghi (738–844), với gần ba lần thử mỗi thao tác. Trải ra 16 document thì lần thử về 1,10–1,12 và throughput hồi lại. $incở server là cách rẻ nhất biểu diễn "đọc, tính, ghi".incvàrmwlàm cùng một việc: một lệnh nguyên tử, so với hai bước ở ứng dụng. Bảng trên là cái giá của bước thứ hai.
Hạn chế: ops/s lệch tới 2 lần giữa hai lượt (txn, 8 người, 1 document: 242 và 401); hãy tin xu hướng.
Cách giảm tranh chấp
Lab đo bốn cách trên cùng một việc: 8 người ghi, mỗi người 2.000 lần "+1" vào một bộ đếm (16.000 lần), so thời gian, số lệnh ghi và writeConflicts của server (e5-remedy.out, e5-agg.out):
| Cách | Thời gian xong (2 lượt) | Lệnh ghi vào database | writeConflicts của server | Tổng đúng 16.000? |
|---|---|---|---|---|
Một document $inc (mốc) | 9,0–10,3 s | 16.000 | 4.941–5.080 | có |
| k = 8 document con, chọn ngẫu nhiên | 10,2–11,1 s | 16.000 | 543–573 | có |
| k = 2 document con | 11,0–11,9 s | 16.000 | 2.861–2.938 | có |
Gộp ở app: mỗi 100 lần +1 thành một $inc: 100 | 0,2–0,3 s | 160 | 31–34 | có |
Append-only: mỗi lần +1 là một insertOne, cộng bằng $group + $merge | 10,2 s + 25–30 ms để cộng 16.000 sự kiện | 16.000 | 0 | có |
- Bucket (chia bộ đếm thành k document, ghi vào một cái ngẫu nhiên, đọc bằng cách cộng k):
writeConflictsgiảm 9 lần (4.941 → 543) ở k = 8, nhưng thời gian không giảm (10,2–11,1 s so với 9,0–10,3 s, thậm chí chậm hơn chút). Bốn cách ghi từng lệnh một đều chạy khoảng 1.350–1.770 lệnh/giây, cùng cỡ với trần ở lab 3 khi trải 16 document. [suy luận] Trần đó nằm ở đường đi của một lệnh ghimajorityqua primary 1 CPU, chung máy với 8 client, không phải ở tranh chấp, nên bỏ tranh chấp không nâng trần. Bucket giảm công việc thừa của server, và chỉ thành throughput khi tranh chấp là thứ giới hạn bạn (nhưrmw,txn). k = 2 chỉ giảm khoảng 40%: chọn k lớn hơn số người ghi. Giá phía đọc: 500 lần đọc cho p50 0,19 ms (một document), 0,18 ms (find8 bucket rồi cộng ở app), 0,22 ms (aggregate) (e5-read.out); rẻ ở k = 8, đắt dần khi k lớn, và tổng gồm các bucket đọc ở nhiều thời điểm, không phải một snapshot (bài Isolation & Snapshot). - Gộp ở app thắng lớn nhất về công việc (160 lệnh thay 16.000) vì giảm số lần chạm vào document. Giá: [suy luận] chết trước khi flush là mất tới 99 lần +1 (lab không giết tiến trình), số hiển thị trễ tới một lô, và flush lại một lô là cộng hai lần nếu lô không có khoá idempotency.
- Append-only không có xung đột nào (
writeConflicts+0) vì mỗi lần +1 là một document mới. Giá: "số hiện tại" chỉ chính xác sau mỗi lần cộng (25–30 ms cho 16.000 sự kiện; hàng trăm triệu sự kiện thì phải cộng tăng dần),eventsphình ra.
Ba cách không phải bucket:
- Thu hẹp transaction. Mục "Side effect, và transaction mở lâu" đã đo: thêm 20 ms trong transaction làm số lần thử gấp đôi. Chuẩn bị trước, mở, ghi, commit. Document chốt của bài Isolation & Snapshot tuần tự hoá mọi transaction của một nhóm qua một hot document: hợp khi tranh chấp thấp và luật quan trọng.
$incthay đọc-sửa-ghi: 1,0 so với 2,96 lần thử ở lab 3. Phép tính nào nói được bằng toán tử cập nhật ($inc,$max, điều kiện trong filter) thì để server làm.- Đổi thứ tự ghi: giúp rất ít ở MongoDB. Với lock của cơ sở dữ liệu quan hệ, ai cũng học "luôn khoá theo cùng một thứ tự để tránh deadlock". [quan sát] Hai ví, 8 tiến trình chuyển qua lại ngẫu nhiên, ghi ví nguồn trước so với luôn ghi ví có
_idnhỏ hơn trước (e6-scope.out): 2,16–2,18 so với 2,00–2,11 lần thử. Thứ tự cố định thấp hơn chút ở cả hai lượt, nhưng hai lượt không đủ để tách nó khỏi nhiễu. [suy luận] Hai transaction cùng sửa một document không chờ nhau (bên đến sau bị huỷ ngay), nên không có vòng chờ để thứ tự phá; xung đột đến từ việc cùng chạm một document. Thứ giảm tranh chấp là chạm ít document dùng chung hơn: 5 ví cho 1,88–1,91 lần thử, 2 ví cho 2,16–2,18.
Mẫu "claim" cho hàng đợi việc. Nhiều worker cùng lấy việc thì dùng một lệnh nguyên tử findOneAndUpdate({ status: "new" }, { $set: { status: "taken", by } }, { sort: { _id: 1 } }), đừng đọc rồi đánh dấu. [quan sát] 4 worker lấy 2.000 việc: mỗi việc được lấy đúng một lần, 0 lỗi ở ứng dụng, mỗi worker 495–506 việc (hai lượt, e7-claim.out, e7-claim-run2.out), trong khi server đếm 2.339–2.548 WriteConflict bên trong. Filter được kiểm lại lúc ghi, nên hai worker không thể cùng lấy một việc; giá là CPU thử lại khi mọi worker cùng nhắm vào việc đầu hàng.
Hot document: chọn cách giảm
├── lệnh là "+1", "+n", "max"? → $inc / toán tử cập nhật (rẻ nhất, đầu tiên)
├── cần số chính xác tức thì? → một document, thu hẹp transaction, chấp nhận hàng đợi
├── chấp nhận số trễ vài giây? → gộp ở app, hoặc append-only + cộng định kỳ
├── nhiều người ghi, đọc ít? → bucket k document (k > số người ghi), đọc = cộng k
└── transaction nhiều document? → ít document dùng chung hơn, ngắn hơn (đổi thứ tự giúp rất ít)So với PostgreSQL
Phần này không chạy PostgreSQL: nhận định lấy từ tài liệu PostgreSQL 18 ("Serialization Failure Handling", "Explicit Locking", SELECT), nhãn [tài liệu PostgreSQL]. So về cách xử lý xung đột, không về tốc độ, và không có chuyện bên nào "tương đương" bên nào.
| MongoDB | PostgreSQL | |
|---|---|---|
| Hai transaction cùng sửa một bản ghi | bên đến sau bị huỷ ngay (WriteConflict) | bên đến sau chờ khoá hàng; Read Committed chạy tiếp sau khi bên kia commit, Repeatable Read lỗi 40001 nếu bên kia đã commit |
| Lỗi nào retry | theo error label | theo SQLSTATE: 40001; tài liệu nói cũng nên cân nhắc 40P01 (deadlock), đôi khi 23505 |
| Retry gì | cả transaction (Callback API làm hộ) | cả transaction, gồm logic chọn câu lệnh và giá trị; "does not offer an automatic retry facility" |
| Deadlock | hai transaction sửa cùng document không chờ nhau (bên đến sau bị huỷ), nên không tạo vòng chờ | tự phát hiện, huỷ một bên; phòng bằng khoá theo thứ tự nhất quán |
| Bảo vệ luật bằng khoá | document chốt ($inc một field để mọi transaction va nhau) | SELECT … FOR UPDATE (và NOWAIT) |
| Nhiều worker lấy việc | findOneAndUpdate có filter trạng thái (lab: 2.000 việc, mỗi việc một lần) | SELECT … FOR UPDATE SKIP LOCKED |
| Giữ transaction mở lâu | bị huỷ sau 60 giây (mặc định) | chờ khoá "indefinitely" nếu không có timeout; tài liệu gọi giữ transaction khi chờ người dùng là "a bad idea" |
PostgreSQL cho chờ, nên ở Read Committed nhiều xung đột biến mất khỏi mắt ứng dụng, đổi lại là deadlock và chờ dài; ở Repeatable Read và Serializable nó cũng huỷ và đòi retry cả transaction. MongoDB huỷ ngay, nên luôn cần vòng retry (không có thì 43–55% lần chuyển ở lab của bài Transactions thất bại), đổi lại không có deadlock và không ai chờ vô hạn. Cả hai cùng một tinh thần: lỗi tạm thời thì retry cả gói, lỗi vĩnh viễn thì không. Tài liệu PostgreSQL nói SKIP LOCKED "cho một cái nhìn không nhất quán" và chỉ hợp cho hàng đợi; findOneAndUpdate không bỏ qua việc đang bị khoá mà để server thử lại: hai mẫu giải cùng bài toán bằng hai cơ chế. So sánh tổng thể ở bài Anti-patterns & MongoDB vs PostgreSQL.
Checklist cho production
Ở ứng dụng (server không làm hộ): mỗi thao tác ghi log số lần thử, mã và error label của lần cuối, tổng thời gian (theo dõi p99 của số lần thử, không phải trung bình); số lần bỏ cuộc (khác 0 là có việc rơi xuống đất, cần đường lui idempotent); số lần khoá idempotency báo "đã xử lý" (tăng vọt nghĩa là client gửi lại nhiều, dấu hiệu timeout hoặc failover phía dưới); thời gian transaction (gần 60 giây là sắp bị abort).
Ở server, các trường sau của db.serverStatus() (trừ dòng cuối) có thật ở 8.3.11 (e0-config.out):
| Trường | Ý nghĩa | Lab thấy gì |
|---|---|---|
transactions.totalStarted, totalCommitted, totalAborted | transaction đã bắt đầu, commit, abort từ lúc khởi động | tỉ lệ abort = Δaborted / Δstarted. Lab 2, lượt 1: thử ngay 66% (4.605/7.005), expo 37% (1.380/3.780), jitter 43% (1.808/4.208) |
metrics.operation.writeConflicts | số lần server gặp WriteConflict, kể cả lần nó tự thử lại | tải chỉ có transaction: bằng số abort (4.605 = 4.605); tải $inc lẻ: +2.171–2.853 khi ứng dụng thấy 0 lỗi. Chỉ báo tranh chấp tốt nhất phía server |
transactions.retriedCommandsCount, retriedStatementsCount | số lệnh ghi retryable đã commit rồi mà server nhận lại (lời đáp bị mất) | +1 cho mỗi updateOne / bulkWrite đơn document được gửi lại; không tăng cho updateMany. Bằng 0 không nghĩa là không có retry (lần retry chạy lệnh lần đầu thì không được đếm) |
transactions.currentOpen, currentActive, currentInactive | transaction đang mở, đang chạy lệnh, đang nhàn rỗi | nhiều transaction inactive kéo dài: ứng dụng giữ transaction trong khi làm việc khác |
metrics.operation.insertFailedDueToDuplicateKeyError | insert hỏng vì trùng khoá | tăng khi khoá idempotency bắt lần gửi trùng |
metrics.operation.writeConflictRetryLimitHit ([tài liệu] New in 9.0) | lệnh trả lỗi 512 vì vượt giới hạn thử lại write conflict | 8.3.11 chưa có; khác 0 là ứng dụng đã nhận lỗi đó |
Các bộ đếm này tích luỹ từ lúc khởi động (về 0 khi mongod khởi động lại), nên chỉ hiệu giữa hai lần lấy mẫu có nghĩa. transactions.abortCause (tách abort theo nguyên nhân) [tài liệu] chỉ có trên mongos, nên trên replica set muốn biết nguyên nhân phải nhìn error label và mã lỗi ở ứng dụng. Gom các chỉ số này thành quy trình chẩn đoán là chủ đề của bài Observe → Diagnose → Tune.
Trước khi ship một đường ghi
□ "Lệnh này chạy hai lần thì sao?" đã có câu trả lời
□ Khoá idempotency ghi NGUYÊN TỬ với hiệu ứng; không bắt 11000 rồi đi tiếp trong transaction
□ Retry theo error label, có giới hạn số lần VÀ deadline, có backoff, chỉ MỘT tầng retry
□ Side effect nằm sau commit; transaction ngắn, không gọi API bên trong
□ Không có updateMany/deleteMany "$inc" nào được gửi lại tự động
□ Có đường lui khi bỏ cuộc, và nó cũng idempotent
□ Dashboard: tỉ lệ abort, p99 số lần thử, số lần bỏ cuộc, writeConflicts của serverNhững lỗi thường gặp
- Tin
retryWrites=truelà xong. Lab:updateManybị lỗi mạng báo lỗi mà dữ liệu đã đổi đủ 3/3; tự gửi lại là +2. Error labelRetryableWriteErrorkhông phải lời mời tự retry. - Check-then-act thay khoá nguyên tử. Lab: 199/200 yêu cầu bị trừ hai lần khi hai client gửi trùng.
- Ghi khoá idempotency tách khỏi hiệu ứng. Khoá trước tiền thì sót tiền (lab: 13–14/200 không bị trừ).
- Bắt
11000rồi đi tiếp trong transaction. Lệnh sau (hoặc commit) nhậnNoSuchTransactionkèm error label "tạm thời", và vòng retry chạy lại chỉ để gặp lại khoá trùng. - Side effect trong callback của
withTransaction. Lab: cuộc gọi ở dòng đầu chạy 2,3 lần mỗi lần chuyển. - Retry không chờ, không giới hạn. Lab: 2,8–2,9 lần thử mỗi lần chuyển, 66% transaction bị abort. Nhưng cũng đừng kỳ vọng backoff làm nhanh hơn: nó bớt việc thừa, p99 tăng (79–97 → 125–283 ms), tổng thời gian không giảm.
- Đọc-sửa-ghi ở app trên hot document. Lab: 2,96 lần thử mỗi thao tác so với 1,0 của
$inc. - Chia bộ đếm k = 2 và tưởng xong. Xung đột chỉ giảm khoảng 40%; k = 8 mới giảm 9 lần.
Cột mốc: Bạn đã có thể nhận ra hot document, đo tranh chấp bằng writeConflicts của server, và dùng checklist production trước khi phát hành. Bài Write Conflicts, Retry & Idempotency khép lại ở đây.
Hỏi & đáp
Tám tiến trình cùng $inc một document (lệnh ghi lẻ, không transaction) trong 5 giây. Ứng dụng không thấy lỗi nào, nhưng metrics.operation.writeConflicts của server tăng vài nghìn. Điều gì đang xảy ra?
Đội bạn chia một bộ đếm hay bị tranh chấp thành k document con, ghi vào một cái ngẫu nhiên và đọc bằng cách cộng k cái. Phát biểu nào đúng với kết quả lab?
Nếu bỏ hết thuật ngữ: khi một việc có thể đã làm xong mà bạn không biết, đừng đoán và đừng làm thêm một lần nữa: dán cho mỗi việc một số phiếu duy nhất để người thực hiện tự nhìn số phiếu trước khi làm, và ghi số phiếu cùng một nét bút với việc, nếu không thì có lúc phiếu có mà việc chưa làm, hoặc ngược lại. Khi hai người tranh một dòng sổ, người đến sau không vá số cũ mà đọc lại, tính lại từ đầu, và chờ một chút rồi mới thử, đừng đứng gõ cửa liên tục. Còn nếu nhiều người luôn tranh một dòng, thì chia dòng đó thành nhiều dòng nhỏ rồi cộng lại khi cần, hoặc gom nhiều lần ghi thành một, đừng chỉ dạy mọi người xếp hàng cho khéo hơn.
Bài tiếp theo
Phần Transactions khép lại với bức tranh nhìn từ ứng dụng: transaction gói thay đổi, snapshot cho nó một lát cắt, write concern nói nó bền tới đâu, và bài này nói khi nó hỏng thì ai làm lại và vì sao một document có thể thành nút thắt. Nhưng mọi chỗ bài này viết "server tự thử lại", "transaction đến sau bị huỷ" đều là cái nhìn từ bên ngoài. Bên trong, lệnh ghi đi qua lock ở nhiều mức, latch ngắn hạn và ticket giới hạn số thao tác chạy đồng thời. Vì sao đôi lúc nhiều client chờ nhau mà không có WriteConflict nào, và ticket cạn trông ra sao, là câu hỏi của Concurrency: Locks, Latches & Tickets.
Sau đó series bước sang Phần WiredTiger, mở bằng bài WiredTiger Overview & Compression: storage engine thật sự lưu document ra sao, và cũng là nơi giữ những "phiên bản" của document, thứ tạo ra xung đột ở bài này (bài WiredTiger MVCC).
Tài liệu tham khảo
- Handle Transactions in Applications with the Drivers API (Callback API vs Core API, TransientTransactionError, UnknownTransactionCommitResult)
- Retryable Writes (retryable operations, commit and abort retry, once-only retry, NoWritesPerformed)
- Atomicity and Transactions (single-document atomicity, updateMany is not atomic as a whole)
- Transactions: Production Considerations (in-progress transactions and write conflicts, the lockId technique)
- Error Codes (112 WriteConflict, 11000 DuplicateKey, 512 WriteConflictRetryLimitExceeded)
- serverStatus (transactions section, metrics.operation.writeConflicts, writeConflictRetryLimitHit new in 9.0)
- PostgreSQL: Serialization Failure Handling (retry 40001 and 40P01, retry the whole transaction)
- PostgreSQL: Explicit Locking (row-level locks, deadlocks, consistent lock order)
- PostgreSQL: SELECT, The Locking Clause (FOR UPDATE, NOWAIT, SKIP LOCKED)
- Số đo của bài:
lab19/INDEX.mdghi từng thí nghiệm, script, file output thô và mục của bài trích nó