Transactions (P3/3): Cái giá: đo latency; so với PostgreSQL
Ở 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ần đọc trước: Lỗi, retry và giới hạn
- Dẫn tới: Isolation & Snapshot, bài tiếp theo. Phần này là phần cuối của bài về transaction.
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
updateOnetrừ kho (có điều kiệnstock >= 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ác | w: 1 p50 | w: 1 p95 | w: 1 ops/s | w: "majority" p50 | w: "majority" p95 | w: "majority" ops/s |
|---|---|---|---|---|---|---|
| 1 doc | 0,22 ms | 0,60 ms | 3.574 | 0,63 ms | 1,11 ms | 1.112 |
| txn 2 doc | 0,52 ms | 1,03 ms | 1.696 | 0,91 ms | 1,52 ms | 916 |
| txn 3 doc | 0,66 ms | 1,13 ms | 1.460 | 1,02 ms | 1,73 ms | 857 |
| 3 lệnh rời (không atomic) | 0,54 ms | 1,39 ms | 1.453 | 2,04 ms | 3,32 ms | 360 |
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,68Throughput 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ác | w: 1 ops/s | w: "majority" ops/s |
|---|---|---|
| 1 doc | 6.210 | 2.185 |
| txn 2 doc | 2.257 | 1.405 |
| txn 3 doc | 1.767 | 1.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
| MongoDB | PostgreSQL | |
|---|---|---|
| Mặc định, không mở transaction | mỗi lệnh ghi atomic trên một document; updateMany không atomic như một khối | autocommit: mỗi câu lệnh là một transaction, kể cả UPDATE chạm triệu dòng |
| Mở transaction | session.startTransaction(), mọi lệnh phải mang session | BEGIN ... 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 ghi | transaction đến sau nhận WriteConflict ngay (lab: 1 ms), phải làm lại | Read 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ừng | transaction bị abort; mọi lệnh sau nhận NoSuchTransaction | transaction vào trạng thái aborted; có SAVEPOINT / ROLLBACK TO để huỷ một phần |
| Retry | error label TransientTransactionError / UnknownTransactionCommitResult; Callback API tự retry | không tự retry; app retry theo SQLSTATE 40001, 40P01 |
| Thời gian sống | mặ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
updateOnecó đ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. createIndextrên collection đang có transaction dài. Mọi transaction mới trên collection đó bịLockTimeoutsau 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ì?
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?
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
- Transactions (atomicity, sessions, read/write concern, restricted operations, create collections and indexes)
- Production Considerations (availability, runtime limit, oplog size limit, WiredTiger cache, acquiring locks, pending DDL, write conflicts)
- Production Considerations, bản 4.4 ("Starting in version 4.2" cho nhiều oplog entry)
- Handle Transactions in Applications with the Drivers API (Callback vs Core API, TransientTransactionError, UnknownTransactionCommitResult, TransactionTooLargeForCache)
- Release Notes for MongoDB 4.4 (create collections and indexes in transactions)
- Session.withTransaction() (mongosh)
- Server Parameters (transactionLifetimeLimitSeconds, maxTransactionLockRequestTimeoutMillis, transactionTooLargeForCacheThreshold)
- Retryable Writes (commit and abort are retryable; writes inside a transaction are not)
- Replication
- Write Concern ("majority", calculated majority, implicit default)
- PostgreSQL: Transactions (tutorial)
- PostgreSQL: BEGIN
- PostgreSQL: Transaction Isolation (Read Committed, Repeatable Read and concurrent updates)
- PostgreSQL: Serialization Failure Handling (40001, 40P01)
- PostgreSQL: Client Connection Defaults (transaction_timeout, idle_in_transaction_session_timeout)