Write Conflicts (P3/3): Hot document và checklist production

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

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

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 ghiSố documentops/s, 1 ngườiops/s, 8 ngườiLần thử mỗi thao tác (8 người)writeConflicts của server (8 người)
inc1790–9921.492–1.5201,02.171–2.853 (ứng dụng thấy 0 lỗi)
inc16699–7751.302–1.6631,0126–168
rmw1765–844564–6582,95–2,961.572–1.906
rmw16695–827560–1.1311,1256–176
txn1738–772242–4012,83–2,902.295–3.668 (= số transaction bị abort)
txn16805–809686–1.1801,10334–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.

  1. $inc lẻ 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,38 WriteConflict mỗ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.
  2. Đọc-sửa-ghi ở app và transaction trả giá thật. Cùng 8 người trên một document, rmw còn 564–658 ops/s và txn cò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.
  3. $inc ở server là cách rẻ nhất biểu diễn "đọc, tính, ghi". inc và rmw là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áchThời gian xong (2 lượt)Lệnh ghi vào databasewriteConflicts của serverTổng đúng 16.000?
Một document $inc (mốc)9,0–10,3 s16.0004.941–5.080có
k = 8 document con, chọn ngẫu nhiên10,2–11,1 s16.000543–573có
k = 2 document con11,0–11,9 s16.0002.861–2.938có
Gộp ở app: mỗi 100 lần +1 thành một $inc: 1000,2–0,3 s16031–34có
Append-only: mỗi lần +1 là một insertOne, cộng bằng $group + $merge10,2 s + 25–30 ms để cộng 16.000 sự kiện16.0000có
  • 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): writeConflicts giả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 ghi majority qua 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 (find 8 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), events phì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.
  • $inc thay đọ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ó _id nhỏ 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.

MongoDBPostgreSQL
Hai transaction cùng sửa một bản ghibê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 retrytheo error labeltheo 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"
Deadlockhai 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ệcfindOneAndUpdate 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âubị 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ĩaLab thấy gì
transactions.totalStarted, totalCommitted, totalAbortedtransaction đã bắt đầu, commit, abort từ lúc khởi độngtỉ 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.writeConflictssố lần server gặp WriteConflict, kể cả lần nó tự thử lạitả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, retriedStatementsCountsố 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, currentInactivetransaction đang mở, đang chạy lệnh, đang nhàn rỗinhiều transaction inactive kéo dài: ứng dụng giữ transaction trong khi làm việc khác
metrics.operation.insertFailedDueToDuplicateKeyErrorinsert 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 conflict8.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 server

Những lỗi thường gặp

  • Tin retryWrites=true là xong. Lab: updateMany bị lỗi mạng báo lỗi mà dữ liệu đã đổi đủ 3/3; tự gửi lại là +2. Error label RetryableWriteError khô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 11000 rồi đi tiếp trong transaction. Lệnh sau (hoặc commit) nhận NoSuchTransaction kè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?

  1. Server tự bắt xung đột và chạy lại lệnh bên trong; ứng dụng chỉ thấy chậm, CPU server tốn vào việc thử lại

    Lab: 2.171–2.853 writeConflicts trong khi 0 lỗi lên ứng dụng, khoảng 0,29–0,38 xung đột cho mỗi lệnh ghi; trải ra 16 document còn 0,02. Xem mục "Lab 3: 1 document so với 16 document".

  2. Các lệnh ghi bị mất một phần: bộ đếm này chỉ tăng khi dữ liệu không được áp dụng, nên dữ liệu trên server đang sai

    Ở mọi ô của lab, tổng các bộ đếm bằng đúng số thao tác đã xác nhận: xung đột được thử lại cho tới khi thành công, không mất lệnh nào. Xem mục "Lab 3: 1 document so với 16 document".

  3. Ứng dụng vẫn nhận WriteConflict, nhưng driver retry âm thầm (retryable write) nên log không thấy

    Retryable write chỉ xử lý lỗi mạng và không tìm thấy primary, không xử lý WriteConflict. Lab đếm lỗi lên ứng dụng và nó bằng 0 ở mọi ô inc: xung đột được giải quyết ngay trong server. Xem phần Ba câu hỏi cho mọi lệnh ghi lỗi (bảng phân loại).

  4. Đó là số transaction bị abort: mỗi lệnh ghi lẻ chạy như một transaction ngầm

    Chế độ inc không dùng transaction: transactions.totalAborted không đổi ở các ô đó. Chỉ chế độ txn mới có abort, và khi đó số abort bằng số writeConflicts. Xem mục "Lab 3: 1 document so với 16 document".

Độ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?

  1. Throughput tăng gần k lần vì k document ghi song song không chờ nhau

    Lab không thấy điều đó: bốn cách ghi từng lệnh một đều chạy khoảng 1.350–1.770 lệnh/giây, và k = 8 không xong nhanh hơn một document. Xem mục "Cách giảm tranh chấp".

  2. Xung đột giảm mạnh khi k đủ lớn so với số người ghi, nhưng thời gian chạy không nhanh hơn

    Lab: một document 4.941–5.080 writeConflicts, k = 8 còn 543–573, k = 2 còn 2.861–2.938; thời gian xong 9,0–10,3 s (một document) so với 10,2–11,1 s (k = 8); bài suy luận rằng trần nằm ở đường ghi majority chứ không ở tranh chấp. Bucket thắng khi tranh chấp là thứ giới hạn bạn (như rmw, txn). Xem mục "Cách giảm tranh chấp".

  3. Đọc không đổi giá và luôn trả về một số nhất quán như một ảnh chụp

    Phải đọc k document và cộng; mỗi document đọc ở một thời điểm, nên tổng không phải một snapshot. Ở k = 8 chênh lệch độ trễ nhỏ (0,18–0,22 ms), nhưng đó là cái giá tăng theo k. Xem mục "Cách giảm tranh chấp".

  4. k = 2 là đủ: chia đôi document là chia đôi số người tranh nhau, xung đột gần như hết

    Với 8 người ghi, k = 2 chỉ cắt xung đột khoảng 40% (4.941 xuống 2.938): nhiều người vẫn chọn cùng một document. Cần k lớn hơn số người ghi đồng thời. Xem mục "Cách giảm tranh chấp".

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