Transactions (P3/3): Cái giá: đo latency; so với PostgreSQL

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

Ở phần trước: khi gặp TransientTransactionError thì làm lại cả gói, còn với UnknownTransactionCommitResult thì chỉ chạy lại commit. Giới hạn chính là 60 giây, 5 ms chờ lock và 16 MB mỗi oplog entry.

Cái giá: đo latency và throughput

Thí nghiệm

Bốn kiểu thao tác, mỗi kiểu với w: 1 và w: "majority":

  • 1 doc: một updateOne trừ kho (có điều kiện stock >= 1), không transaction.
  • txn 2 doc: transaction gồm insert đơn + trừ kho.
  • txn 3 doc: transaction gồm insert đơn + trừ kho + insert sổ cái.
  • 3 lệnh rời: ba lệnh của "txn 3 doc", gửi lần lượt không transaction, mỗi lệnh mang write concern riêng. Cùng công việc, không atomic: mốc để so.

SKU chọn ngẫu nhiên trong 10.000. Một client tuần tự, đo bằng performance.now() quanh từng thao tác (và quanh từng lệnh bên trong); 100 lần warm-up rồi 1.000 lần đo mỗi ô; 7 lượt (riêng "3 lệnh rời": 3 lượt). Bảng ghi median của các lượt:

Thao tácw: 1 p50w: 1 p95w: 1 ops/sw: "majority" p50w: "majority" p95w: "majority" ops/s
1 doc0,22 ms0,60 ms3.5740,63 ms1,11 ms1.112
txn 2 doc0,52 ms1,03 ms1.6960,91 ms1,52 ms916
txn 3 doc0,66 ms1,13 ms1.4601,02 ms1,73 ms857
3 lệnh rời (không atomic)0,54 ms1,39 ms1.4532,04 ms3,32 ms360

Phân rã thời gian trung bình của từng lệnh trong transaction (ms, median các lượt):

                    insert đơn   trừ kho   insert sổ cái   commit
txn 3 doc, w:1         0,19        0,19        0,16         0,13
txn 3 doc, majority    0,19        0,19        0,13         0,66
txn 2 doc, w:1         0,21        0,22         –           0,15
txn 2 doc, majority    0,19        0,18         –           0,68

Throughput với 8 client song song, mỗi ô chạy 5 giây, median của 4 lượt (số transaction phải làm lại vì xung đột: 0–10 mỗi ô, trên khoảng 6.500–11.500 transaction):

Thao tácw: 1 ops/sw: "majority" ops/s
1 doc6.2102.185
txn 2 doc2.2571.405
txn 3 doc1.7671.312

Thời gian đi đâu

Round-trip. [quan sát] Một ping tới primary mất 0,11–0,13 ms. "1 doc" là một round-trip, "txn 3 doc" là bốn (ba lệnh và commit), và mỗi lệnh trong transaction chỉ mất 0,13–0,22 ms: phần lớn là đi và về. Với w: 1, transaction 3 document chậm hơn lệnh đơn lẻ khoảng 3 lần ở p50 (0,66 so với 0,22 ms), thấp hơn tỉ lệ số round-trip (bốn so với một) một chút. Trên production, round-trip app ↔ database thường lớn hơn 0,12 ms nhiều (ví dụ minh hoạ: 0,5–1 ms trong cùng một vùng cloud), nên số lệnh trong một transaction là thứ đáng đếm.

Commit. [quan sát] Với w: 1, commit mất 0,13–0,15 ms, xấp xỉ một round-trip. [chi tiết cài đặt, không đo riêng] Lúc commit, server ghi entry applyOps vào oplog và cập nhật trạng thái session trong config.transactions (lab đọc được state: "committed", txnNum: 2): phần sổ sách mà lệnh ghi đơn lẻ ngoài session không có.

Chờ majority. [quan sát] Commit với w: "majority" mất 0,66–0,68 ms thay vì 0,13–0,15: khoảng 0,5 ms chờ thêm; lệnh đơn lẻ cũng vậy (0,22 → 0,63 ms). [hình dung] Đó là thời gian một secondary lấy entry mới từ oplog, ghi nó, rồi báo tiến độ về primary; lab không đo riêng từng bước, và mạng giữa ba node gần bằng không. Khi các node ở những availability zone khác nhau, mỗi lần chờ cộng thêm ít nhất một vòng mạng giữa chúng.

Điều bất ngờ: với majority, transaction rẻ hơn ba lệnh rời. [quan sát] Ba lệnh rời với w: "majority" mất 2,04 ms ở p50, vì mỗi lệnh tự chờ majority (trung bình 0,91–0,94 ms mỗi lệnh). Cùng ba lệnh trong một transaction chỉ còn 1,02 ms: các lệnh bên trong không chờ gì, chỉ commit chờ majority, một lần. Với w: 1, hai cách gần như bằng nhau (0,54 so với 0,66 ms). Khi ứng dụng vốn ghi với majority (mặc định từ 5.0), cái giá round-trip của transaction được bù bằng việc gộp ba lần chờ thành một.

Throughput. [quan sát] Với 8 client và w: 1, lệnh đơn lẻ đạt 6.210 ops/s, transaction 3 document đạt 1.767 transaction/s (khoảng 5.300 document được ghi mỗi giây). Vài mẫu docker stats (lấy thô, mỗi mẫu mất khoảng 2 giây) trong lúc chạy các ô w: 1 cho thấy primary ở mức 92–101% của CPU duy nhất của nó. Suy luận (không đo riêng): giới hạn là CPU của primary, và mỗi transaction tốn nhiều lệnh, nhiều sổ sách hơn trên CPU đó. Với w: "majority", khoảng cách hẹp lại (2.185 so với 1.312), hợp với việc một phần thời gian của mỗi client là chờ secondary chứ không dùng CPU primary.

Đọc con số này thế nào

Multi-document transaction
   │
   ├── ✓ tất cả hoặc không gì cả; người khác không thấy trạng thái nửa vời
   ├── ✓ chỉ chờ majority một lần, lúc commit
   ├── ✗ mỗi lệnh là một round-trip, cộng một round-trip commit
   ├── ✗ sổ sách ở server (session, applyOps, config.transactions)
   ├── ✗ xung đột là abort cả gói, ứng dụng phải làm lại
   └── ✗ giữ lock trên document đã sửa cho tới khi commit: lệnh khác phải chờ

Đừng đọc bảng thành "transaction chậm gấp 3, tránh nó". Đọc thành: một document có điều kiện trong filter là đường rẻ nhất, nên mô hình hoá để nghiệp vụ bị ghi nhiều nhất rơi vào đó; khi nghiệp vụ thật sự trải nhiều document, transaction là cách đúng, và chi phí của nó tỉ lệ với số round-trip và thời gian nó mở.

So với PostgreSQL

MongoDBPostgreSQL
Mặc định, không mở transactionmỗi lệnh ghi atomic trên một document; updateMany không atomic như một khốiautocommit: mỗi câu lệnh là một transaction, kể cả UPDATE chạm triệu dòng
Mở transactionsession.startTransaction(), mọi lệnh phải mang sessionBEGIN ... COMMIT / ROLLBACK trên cùng một connection
Cần hạ tầng gìreplica set hoặc sharded cluster; standalone không hỗ trợmột server là đủ
Hai transaction cùng sửa một bản ghitransaction đến sau nhận WriteConflict ngay (lab: 1 ms), phải làm lạiRead Committed: người đến sau chờ, rồi áp dụng lên phiên bản mới; Repeatable Read: chờ, rồi lỗi could not serialize access nếu bên kia commit
Lỗi giữa chừngtransaction bị abort; mọi lệnh sau nhận NoSuchTransactiontransaction vào trạng thái aborted; có SAVEPOINT / ROLLBACK TO để huỷ một phần
Retryerror label TransientTransactionError / UnknownTransactionCommitResult; Callback API tự retrykhông tự retry; app retry theo SQLSTATE 40001, 40P01
Thời gian sốngmặc định 60 s (transactionLifetimeLimitSeconds)mặc định không giới hạn; transaction_timeout (từ 17) và idle_in_transaction_session_timeout đều mặc định 0 (tắt)

[tài liệu PostgreSQL] PostgreSQL "coi mọi câu lệnh SQL như được thực thi trong một transaction": không có BEGIN thì mỗi câu có BEGIN và COMMIT ngầm bao quanh. Trang BEGIN thêm: nhiều câu lệnh trong một transaction block chạy nhanh hơn, vì bắt đầu và commit tốn CPU và đĩa. Lab thấy điều tương tự ở MongoDB với majority: ba lệnh trong transaction (1,02 ms) nhanh hơn ba lệnh rời (2,04 ms).

Khác biệt đáng nhớ nhất là dòng xung đột. Ở PostgreSQL Read Committed, giao dịch sau đứng chờ rồi chạy tiếp, ứng dụng không thấy lỗi; ở MongoDB, giao dịch sau bị huỷ ngay, và không có vòng retry thì gần một nửa số lần chuyển tiền trong lab thất bại. Không phải bên nào kém hơn: hai cách giải xung đột, mỗi cách đẩy một phần việc về ứng dụng. Mức isolation nào ứng với hành vi nào là việc của bài Isolation & Snapshot; so sánh tổng thể ở bài Anti-patterns & MongoDB vs PostgreSQL.

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

  • Dùng transaction cho thứ một document làm được. Một updateOne có điều kiện: 0,22 ms; transaction 3 document: 0,66 ms và có thể bị abort.
  • Không có vòng retry. Lab mất 43–55% số lần chuyển tiền trên ví bị nhiều client cùng ghi. Dùng withTransaction, hoặc retry theo error label.
  • Chạy lại cả gói khi gặp UnknownTransactionCommitResult. Transaction có thể đã commit: trừ tiền hai lần. Chỉ chạy lại commit.
  • Side effect trong callback. Mỗi lần retry là một email, một lần gọi API thanh toán nữa.
  • Giữ transaction mở trong lúc chờ thứ khác. Chặn lệnh ghi khác vào document đã chạm (lab: 3 giây), quá 60 giây thì bị abort.
  • Quên truyền session cho một lệnh. Lệnh đó chạy ngoài transaction, không bị rollback.
  • Một transaction cho cả lô import. Lab dừng ở 28–53 MB với TransactionTooLargeForCache, không retry được.
  • createIndex trên collection đang có transaction dài. Mọi transaction mới trên collection đó bị LockTimeout sau 5 ms chờ lock (lab: bị từ chối sau 13 ms).

Cột mốc: Bạn đã đo được cái giá của transaction so với một lệnh ghi và ba lệnh rời, và biết so sánh cách transaction làm việc ở MongoDB với PostgreSQL. Bài transaction khép lại ở đây.

Hỏi & đáp

Một service chỉ cần trừ kho một SKU bằng một updateOne có điều kiện stock >= 1. Đồng nghiệp đề nghị bọc nó trong transaction "cho chắc". Với w: 1, lab cho thấy điều gì?

  1. Không cần: một lệnh một document có điều kiện đã atomic, và là đường rẻ nhất

    Đúng. Lệnh đơn lẻ mất 0,22 ms; transaction 2 document đã 0,52 ms, transaction 3 document 0,66 ms (bốn round-trip: ba lệnh và commit) và còn có thể bị abort vì xung đột. Transaction dành cho nghiệp vụ thật sự trải nhiều document. Xem mục "Đọc con số này thế nào".

  2. Nên bọc, vì với w: "majority" transaction nhanh hơn lệnh đơn lẻ

    Cái được lợi là so với ba lệnh rời: 1,02 ms so với 2,04 ms, vì chỉ chờ majority một lần. So với một lệnh đơn lẻ thì transaction vẫn chậm hơn (0,63 ms so với 0,91 ms cho transaction 2 document). Xem mục "Thời gian đi đâu".

  3. Không, vì transaction luôn chậm gấp ba nên phải tránh trong mọi trường hợp

    Đừng đọc bảng thành "transaction chậm gấp 3, tránh nó": khi nghiệp vụ thật sự trải nhiều document thì transaction là cách đúng, và chi phí tỉ lệ với số round-trip và thời gian nó mở. Xem mục "Đọc con số này thế nào".

  4. Nên bọc, vì transaction chỉ thêm một round-trip nên chênh lệch không đáng kể

    Mỗi lệnh trong transaction vẫn là một round-trip (0,13–0,22 ms), cộng một round-trip commit. Với w: 1, transaction 2 document đã chậm hơn lệnh đơn lẻ hơn hai lần (0,52 so với 0,22 ms). Xem mục "Thời gian đi đâu".

Lab đo với w: "majority": ba lệnh ghi rời (đơn, kho, sổ cái) mất 2,04 ms ở p50, cùng ba lệnh đó trong một transaction chỉ mất 1,02 ms. Vì sao?

  1. Transaction bỏ qua write concern, nên không phải chờ secondary nào

    Commit của transaction vẫn chờ majority: 0,66 ms so với 0,13 ms khi w: 1. Transaction chỉ dời việc chờ về một chỗ. Xem phân rã thời gian ở mục "Thí nghiệm".

  2. Transaction gửi ba lệnh trong một round-trip duy nhất

    Mỗi lệnh trong transaction vẫn là một round-trip riêng (0,13–0,19 ms mỗi lệnh), cộng một round-trip commit: bốn tất cả. Xem mục "Thời gian đi đâu".

  3. Ba lệnh rời mỗi lệnh tự chờ majority; transaction chỉ chờ một lần ở commit

    Đúng. Mỗi lệnh rời mất trung bình 0,91–0,94 ms vì tự chờ secondary; các lệnh trong transaction không chờ gì, chỉ commit chờ majority (0,66 ms). Với w: 1, hai cách gần như bằng nhau (0,54 so với 0,66 ms). Xem mục "Thời gian đi đâu".

  4. Ba node chung một máy nên số đo majority không có ý nghĩa

    Mạng gần bằng 0 làm số tuyệt đối là cận dưới, nhưng so sánh vẫn đúng: ba lần chờ so với một lần chờ. Mạng chậm hơn chỉ làm khoảng cách lớn hơn. Xem khung Môi trường lab ở Một gói hai kết cục; replica set; vòng đời.

Nếu bỏ hết thuật ngữ: khi một việc phải sửa nhiều cuốn sổ cùng lúc, hãy viết nháp tất cả rồi chép vào một lần, hoặc xé nháp. Hai người không được cùng viết nháp một trang: người đến sau làm lại. Nếu không chắc lần chép đã xong chưa, hỏi lại đúng lần chép đó, đừng viết nháp mới. Và đừng cầm nháp quá lâu, vì trong lúc đó không ai khác sửa được trang ấy.

Bài tiếp theo

Bài này trả lời "tất cả hoặc không gì cả" từ phía ghi. Còn chiều đọc? Bên trong một transaction, người khác commit giữa chừng thì ta có thấy không? Hai transaction mỗi bên đọc một ví rồi ghi ví kia, cả hai cùng commit được, có sai không? Và đọc trên một snapshot có chặn được chuyện đọc trùng, đọc sót mà bài Query Execution Engine để lại?

Isolation & Snapshot trả lời những câu đó: snapshot isolation, MVCC như một mô hình để hình dung, các mức read concern, những bất thường vẫn có thể xảy ra (write skew), và PostgreSQL đặt các mức isolation của nó ở đâu so với MongoDB.

Tài liệu tham khảo