Viết transaction trong code: giữ dữ liệu đúng khi nhiều request chạy cùng lúc Trung cấp
Thời gian đọc ước tính: 24 phút
Vấn đề
Phần tiêu đề “Vấn đề”Flash sale: 10 sản phẩm, 50 người bấm “Mua” trong cùng một giây. Đoạn code “đọc stock, nếu còn thì trừ” chạy trong BEGIN ... COMMIT hẳn hoi, vậy mà demo bên dưới tạo ra 50 đơn hàng cho 10 sản phẩm, không một dòng log lỗi.
Có BEGIN không có nghĩa là đúng. Transaction chỉ bảo đảm tất cả hoặc không gì cả (ACID). Nó không tự chống race giữa các request. Bài này là phần “viết code thế nào”: lý thuyết anomaly và isolation nằm ở Các anomaly và Isolation levels.
Mọi ví dụ dùng pg (node-postgres) với TypeScript, PostgreSQL 17, mức mặc định Read Committed.
Chọn pattern nào? (tóm tắt trước)
Phần tiêu đề “Chọn pattern nào? (tóm tắt trước)”Câu hỏi đầu tiên khi review: đoạn này có đọc rồi ghi dựa trên cái vừa đọc không? Nếu có, chọn một dòng trong bảng.
| Bài toán đọc-sửa-ghi | Pattern mặc định | Vì sao |
|---|---|---|
| Trừ/cộng số lượng có điều kiện (stock, quota, số dư) | Atomic conditional UPDATE ... WHERE ... RETURNING + kiểm tra rowCount |
Một câu, database tự kiểm tra lại điều kiện sau khi chờ lock |
| Phải đọc, chạy logic app, rồi mới quyết định ghi | SELECT ... FOR UPDATE trong transaction ngắn |
Khoá row trước khi đọc, request khác phải xếp hàng |
| Người dùng sửa form vài phút rồi bấm Lưu | Version column (optimistic) | Không giữ lock trong lúc người dùng suy nghĩ |
| Không được tạo trùng (đơn theo request id, email) | UNIQUE + INSERT ... ON CONFLICT |
Database cưỡng chế, kể cả khi hai request tới cùng lúc |
| Khoá nhiều row trong một transaction | ORDER BY id FOR UPDATE |
Thứ tự khoá cố định thì không có vòng chờ |
| Invariant trên nhiều row, không biểu diễn được bằng constraint | Serializable + retry | Xem Isolation levels |
| Gọi API ngoài, gửi email, publish event | Ra ngoài transaction, qua outbox | Transaction không rollback được HTTP call |
Kết quả đo: 50 request tranh 10 sản phẩm
Phần tiêu đề “Kết quả đo: 50 request tranh 10 sản phẩm”Script examples/src/transaction-patterns/buy-race.ts chạy 5 cách viết trên cùng bảng product(stock = 10), pool 20 connection, mỗi request có 5 ms “logic app” giữa đọc và ghi. Kết quả một lần chạy trên PostgreSQL 17.11:
| Cách viết | Request báo “mua được” | Đơn trong DB | Stock cuối | Bán vượt |
|---|---|---|---|---|
(a) Naive: SELECT rồi SET stock = <giá trị đã tính> |
50 | 50 | 9 | 40 |
(b) Atomic UPDATE ... WHERE stock >= 1 |
10 | 10 | 0 | 0 |
(c) SELECT ... FOR UPDATE rồi quyết định |
10 | 10 | 0 | 0 |
| (e) Version column + retry | 10 | 10 | 0 | 0 (189 lần retry) |
(d) Như (c) nhưng dùng pool.query |
39 (+11 lỗi lock timeout) | 39 | 8 | 29 |
Ba điểm đáng nhớ:
- Naive sai một cách im lặng: stock cuối là 9, nhìn dashboard không ai nghi ngờ.
- Optimistic tốn 189 lần retry cho 50 request trên một hot row. Đúng, nhưng sai công cụ cho flash sale.
- Bug
pool.querykhông ổn định: 4 lần chạy cho bán vượt 24, 40, 37 và 29. Bug kiểu này qua được test đơn luồng.
Output đầy đủ ở phần demo.
Ranh giới transaction: một thao tác nghiệp vụ, ở service layer
Phần tiêu đề “Ranh giới transaction: một thao tác nghiệp vụ, ở service layer”Quy tắc: một use case = một transaction, mở ở service, không ở controller, không ở repository.
- Repository mở transaction riêng cho từng hàm thì
createOrdervàdecreaseStockcommit rời nhau. Crash ở giữa là mất nhất quán. - Controller mở transaction thì transaction kéo dài qua validate, serialize, đôi khi cả HTTP call.
Viết một helper duy nhất, mọi service dùng nó. Đây là đúng mẫu node-postgres khuyến nghị: một client từ pool.connect(), release() trong finally.
import { Pool, PoolClient } from 'pg';
export async function withTransaction<T>( pool: Pool, fn: (client: PoolClient) => Promise<T>,): Promise<T> { const client = await pool.connect(); try { await client.query('BEGIN'); const result = await fn(client); await client.query('COMMIT'); return result; } catch (err) { await client.query('ROLLBACK').catch(() => {}); // connection hỏng thì ROLLBACK cũng lỗi throw err; } finally { client.release(); }}Service chỉ còn logic nghiệp vụ, mọi câu SQL đi qua cùng một client:
async placeOrder(cmd: PlaceOrder) { return withTransaction(this.pool, async (tx) => { await this.stockRepo.decrease(tx, cmd.productId, cmd.qty); // repo nhận tx, không tự mở const order = await this.orderRepo.insert(tx, cmd); await this.outboxRepo.add(tx, 'order.placed', order); // event đi cùng transaction return order; });}Giữ transaction ngắn: không có network call bên trong
Phần tiêu đề “Giữ transaction ngắn: không có network call bên trong”// TRƯỚC: lock stock bị giữ suốt thời gian gọi cổng thanh toán (có khi vài giây)await withTransaction(pool, async (tx) => { await decreaseStock(tx, productId, 1); await paymentGateway.charge(card, amount); // HTTP bên trong transaction await insertOrder(tx, ...);});
// SAU: transaction chỉ chứa câu SQL; side effect đi qua outbox, xử lý sau commitconst order = await withTransaction(pool, async (tx) => { await decreaseStock(tx, productId, 1); const order = await insertOrder(tx, { status: 'pending_payment' }); await addOutbox(tx, 'payment.requested', order); return order;});Ba lý do HTTP call không được nằm trong transaction:
- Lock bị giữ theo độ trễ của bên thứ ba. Cổng thanh toán chậm 3 giây, mọi request mua cùng sản phẩm xếp hàng 3 giây.
- Rollback không thu hồi được tiền đã charge.
INSERTlỗi sau khi charge thành công thì khách mất tiền, không có đơn. - Retry lặp side effect. Wrapper retry
40001/40P01chạy lại cả hàm, nghĩa là charge hai lần.
Cách đẩy event ra sau commit: Transactional outbox.
Lỗi kinh điển của node-postgres: transaction bằng pool.query
Phần tiêu đề “Lỗi kinh điển của node-postgres: transaction bằng pool.query”Tài liệu node-postgres nói thẳng: mọi câu trong một transaction phải chạy trên cùng một client, và “Do not use transactions with the pool.query method.” pool.query mượn một connection rảnh bất kỳ, chạy một câu, rồi trả lại.
// SAI: mỗi dòng có thể chạy trên một connection khácawait pool.query('BEGIN');const { rows } = await pool.query('SELECT stock FROM product WHERE id = $1 FOR UPDATE', [id]);await pool.query('UPDATE product SET stock = $2 WHERE id = $1', [id, rows[0].stock - 1]);await pool.query('COMMIT');
// ĐÚNG: withTransaction ở trên, một client cho cả transactionawait withTransaction(pool, async (tx) => { /* tx.query(...) */ });pool.query("BEGIN") lấy connection #1, mở transaction, rồi trả connection về pool. Transaction vẫn mở trên #1.
Hậu quả đo được trong demo (d):
FOR UPDATErơi vào connection khác vớiBEGIN. Nếu connection đó không có transaction, câu chạy ở autocommit và nhả lock ngay: bán vượt 29.- 30 lần
WARNING: there is already a transaction in progressvà 24 lầnthere is no transaction in progress. Đây là dấu hiệu nhận biết trong log PostgreSQL. - 11 request lỗi
55P03(lock timeout): khiFOR UPDATErơi vào connection đang cóBEGIN“mồ côi” của request khác, lock bị giữ mãi vì không ai commit trên connection đó. Không đặtlock_timeoutthì các request này treo vô hạn.
Code dễ dính bug này nhất: hàm repository nhận pool thay vì client. Quy ước giúp tránh: repository luôn nhận tham số kiểu PoolClient (hoặc một interface Queryable), service quyết định đó là transaction nào.
Pattern 1: atomic conditional UPDATE (mặc định)
Phần tiêu đề “Pattern 1: atomic conditional UPDATE (mặc định)”Đưa điều kiện nghiệp vụ vào WHERE, để PostgreSQL vừa kiểm tra vừa ghi trong một câu:
// TRƯỚC: đọc vào app, so sánh trong app, ghi hằng sốconst { rows } = await tx.query('SELECT stock FROM product WHERE id = $1', [id]);if (rows[0].stock < qty) throw new SoldOut();await tx.query('UPDATE product SET stock = $2 WHERE id = $1', [id, rows[0].stock - qty]);
// SAU: một câu, kiểm tra rowCountconst res = await tx.query( 'UPDATE product SET stock = stock - $2 WHERE id = $1 AND stock >= $2 RETURNING stock', [id, qty],);if (res.rowCount === 0) throw new SoldOut(); // không khớp điều kiện: hết hàng (hoặc id sai)const remaining = res.rows[0].stock;Naive: A và B cùng đọc stock = 1. Cả hai đều kết luận "còn hàng" trong bộ nhớ của app.
Vì sao nó đúng ở Read Committed: request sau chờ row lock, rồi đánh giá lại WHERE trên phiên bản mới của row (chi tiết ở Isolation levels). stock >= $2 lúc đó sai, câu cập nhật 0 row.
Ghi chú khi dùng:
- Luôn kiểm tra
rowCount. Quên kiểm tra thì code vẫn tạo đơn dù không trừ được stock. rowCount = 0gộp hai nghĩa: hết hàng hoặc không có sản phẩm. Cần phân biệt thì đọc lại sau khi biết thất bại.- Thêm
CHECK (stock >= 0)làm lưới an toàn cuối: code sai ở đâu đó thì nhận lỗi23514thay vì stock âm.
Pattern 2: SELECT ... FOR UPDATE khi phải đọc rồi mới quyết định
Phần tiêu đề “Pattern 2: SELECT ... FOR UPDATE khi phải đọc rồi mới quyết định”Dùng khi quyết định cần logic app không viết được trong WHERE: tính giá theo hạng thành viên, kiểm tra nhiều cột, gọi rule engine nội bộ.
await withTransaction(pool, async (tx) => { const { rows } = await tx.query( 'SELECT stock, price, status FROM product WHERE id = $1 FOR UPDATE', [id], ); const p = rows[0]; if (!p || p.status !== 'active' || p.stock < qty) throw new CannotBuy(); const total = pricing.compute(p.price, qty, customer); // logic thuần, không I/O await tx.query('UPDATE product SET stock = stock - $2 WHERE id = $1', [id, qty]); await tx.query('INSERT INTO purchase (...) VALUES (...)', [...]);});- Lock giữ từ
FOR UPDATEđếnCOMMIT. Mọi thứ giữa hai điểm đó phải nhanh và không có I/O ngoài database. - Chỉ khoá row cần sửa. Không cần đổi khoá chính/khoá ngoại thì
FOR NO KEY UPDATEnhẹ hơn (xem Row-level locks). - Demo (c) cho kết quả đúng như atomic UPDATE, nhưng tốn thêm một round-trip và giữ lock lâu hơn. Viết được bằng một câu thì chọn Pattern 1.
Pattern 3: optimistic locking bằng version column
Phần tiêu đề “Pattern 3: optimistic locking bằng version column”Dùng khi giữa lúc đọc và lúc ghi có con người hoặc khoảng thời gian dài: form sửa sản phẩm, admin duyệt đơn. Không giữ lock, phát hiện xung đột lúc ghi.
// Lúc mở form: trả version cho client// SELECT id, name, price, version FROM product WHERE id = $1
// Lúc bấm Lưu: client gửi lại version đã thấyconst res = await pool.query( `UPDATE product SET name = $2, price = $3, version = version + 1 WHERE id = $1 AND version = $4`, [id, name, price, expectedVersion],);if (res.rowCount === 0) throw new ConflictError('Dữ liệu đã bị người khác sửa, tải lại trang');- Một câu
UPDATEkhông cầnBEGIN: tự nó đã atomic, dùngpool.queryở đây là đúng. - Với API, trả
409 Conflict(hoặc412nếu dùngIf-Match/ETag) và để người dùng quyết định. Đừng tự retry ghi đè: mất ý nghĩa của optimistic lock. - Không dùng cho hot row. Demo (e): 50 request trên một sản phẩm tốn 189 lần retry. Contention cao thì chọn Pattern 1 hoặc 2.
Pattern 4: UNIQUE + INSERT ... ON CONFLICT cho idempotency
Phần tiêu đề “Pattern 4: UNIQUE + INSERT ... ON CONFLICT cho idempotency”Client retry khi timeout, người dùng bấm hai lần, message broker giao lại. Chống trùng bằng SELECT trước rồi INSERT là race: hai request cùng thấy “chưa có”. Để database cưỡng chế:
// Schema: purchase.request_id text NOT NULL UNIQUEconst res = await tx.query( `INSERT INTO purchase (product_id, request_id, qty) VALUES ($1, $2, $3) ON CONFLICT (request_id) DO NOTHING RETURNING id`, [productId, requestId, qty],);if (res.rowCount === 0) { // Đã xử lý trước đó: trả lại kết quả cũ, KHÔNG trừ stock lần nữa return findPurchaseByRequestId(tx, requestId);}await decreaseStock(tx, productId, qty);- Với
DO NOTHING, row bị bỏ qua không xuất hiện trongRETURNINGvà không được tính vàorowCount. - Đặt
INSERT ... ON CONFLICTtrước các thao tác có tác dụng phụ trong cùng transaction, như ví dụ trên. - Không có
ON CONFLICTthì câuINSERTtrùng ném23505và làm hỏng cả transaction (xem phần savepoint). - Bảng inbox và retention cho consumer: Consumer idempotent.
Pattern 5: khoá nhiều row theo thứ tự cố định
Phần tiêu đề “Pattern 5: khoá nhiều row theo thứ tự cố định”Chuyển tiền A→B và B→A cùng lúc, mỗi bên khoá row của mình trước rồi chờ row kia: deadlock. Cách chữa rẻ nhất là khoá theo cùng một thứ tự ở mọi code path:
// TRƯỚC: thứ tự khoá phụ thuộc chiều chuyển tiềnawait tx.query('SELECT 1 FROM account WHERE id = $1 FOR UPDATE', [fromId]);await tx.query('SELECT 1 FROM account WHERE id = $1 FOR UPDATE', [toId]);
// SAU: luôn khoá theo id tăng dần, một câuawait tx.query( 'SELECT id, balance FROM account WHERE id = ANY($1) ORDER BY id FOR UPDATE', [[fromId, toId]],);Cùng nguyên tắc cho batch update: sort danh sách id trong app trước khi chạy vòng UPDATE. Cơ chế phát hiện deadlock, cách đọc log 40P01 và các mẫu deadlock khác: Deadlock.
Retry: chỉ cho 40001 và 40P01, và chạy lại cả hàm
Phần tiêu đề “Retry: chỉ cho 40001 và 40P01, và chạy lại cả hàm”Ở Read Committed với các pattern trên, 40001 hiếm; 40P01 vẫn có thể xảy ra. Ở Repeatable Read/Serializable, retry là bắt buộc. Wrapper withRetry đầy đủ nằm ở Isolation levels → Cách retry đúng; ở đây chỉ nhắc ba luật khi ghép vào withTransaction:
- Retry bao quanh cả
withTransaction, không bao quanh từng câu. Phần đọc phải chạy lại trên snapshot mới. - Hàm truyền vào phải chạy lại được an toàn: không HTTP, không email, không biến đếm bên ngoài bị cộng dồn qua mỗi lần thử.
- Chỉ các mã đã phân loại:
40001(serialization_failure),40P01(deadlock_detected).23505(unique_violation) hay55P03(lock_not_available) là tín hiệu nghiệp vụ hoặc quá tải, không phải lý do retry mù.
Timeout: chặn transaction treo trước khi nó chặn cả hệ thống
Phần tiêu đề “Timeout: chặn transaction treo trước khi nó chặn cả hệ thống”Trong PostgreSQL 17, cả bốn tham số dưới đây mặc định là 0 (tắt). Không đặt gì nghĩa là một request treo có thể giữ lock vô thời hạn.
| Tham số | Khi vượt | Gợi ý cho API OLTP |
|---|---|---|
lock_timeout |
Huỷ câu lệnh đang chờ lock, lỗi 55P03. Tính riêng cho mỗi lần chờ |
Vài giây; nhỏ hơn timeout của HTTP request |
statement_timeout |
Huỷ câu lệnh chạy quá lâu, lỗi 57014 |
Theo p99 của query chậm nhất hợp lệ, cộng biên |
idle_in_transaction_session_timeout |
Đóng session đang mở transaction mà không gửi lệnh | Chặn bug quên COMMIT/release() giữ lock và cản VACUUM |
transaction_timeout (mới trong PostgreSQL 17) |
Đóng session khi một transaction kéo dài quá mức | Giới hạn tổng thời gian transaction |
Đặt cho role của ứng dụng hoặc trong cấu hình pool, không đặt toàn cục trong postgresql.conf (tài liệu PostgreSQL khuyên vậy vì nó ảnh hưởng mọi session, gồm cả migration và job bảo trì):
const pool = new Pool({ connectionString, max: 20, // mặc định của pg.Pool là 10 lock_timeout: 3_000, statement_timeout: 10_000, idle_in_transaction_session_timeout: 30_000,});-- Hoặc theo roleALTER ROLE app_user SET lock_timeout = '3s';-- Nới riêng cho một transaction đặc biệt (chỉ có hiệu lực tới COMMIT/ROLLBACK)SET LOCAL statement_timeout = '60s';Lưu ý: nếu statement_timeout khác 0 mà lock_timeout lớn hơn hoặc bằng nó, lock_timeout vô nghĩa vì statement_timeout luôn bắn trước.
Savepoint: khi một lỗi không được phép huỷ cả transaction
Phần tiêu đề “Savepoint: khi một lỗi không được phép huỷ cả transaction”Trong PostgreSQL, một câu lỗi trong transaction làm hỏng cả transaction. Mọi câu sau đó bị từ chối với current transaction is aborted, commands ignored until end of transaction block.
Nguy hiểm hơn: nếu code nuốt lỗi rồi vẫn gọi COMMIT, node-postgres không ném lỗi. Demo (f) đo được:
=== (f) nuốt lỗi trong transaction rồi COMMIT === lỗi bị nuốt: 23505 COMMIT không ném lỗi, command tag = ROLLBACK stock sau "commit": 10 (UPDATE đã bị rollback)Hàm trả về “thành công”, nhưng không có gì được ghi. Khi thật sự muốn bỏ qua một câu lỗi và đi tiếp, dùng savepoint:
await tx.query('SAVEPOINT try_coupon');try { await tx.query('INSERT INTO coupon_usage (coupon_id, user_id) VALUES ($1, $2)', [couponId, userId]); await tx.query('RELEASE SAVEPOINT try_coupon');} catch (err: any) { if (err.code !== '23505') throw err; // chỉ bỏ qua lỗi đã biết await tx.query('ROLLBACK TO SAVEPOINT try_coupon'); // transaction dùng tiếp được}- Savepoint dùng cho ngoại lệ, không cho vòng lặp hàng nghìn row: mỗi savepoint tạo một subtransaction có chi phí riêng.
- Chống trùng thì
ON CONFLICT DO NOTHINGgọn hơn savepoint. - Quy tắc chung: lỗi trong
fnphải ném ra tớiwithTransactionđể nóROLLBACK. Khôngtry/catchnuốt lỗi bên trong mà không có savepoint.
ORM: cùng nguyên tắc, khác cú pháp
Phần tiêu đề “ORM: cùng nguyên tắc, khác cú pháp”Mọi ORM đều có cùng bẫy với pool.query: query bên trong transaction phải đi qua đối tượng transaction được truyền vào callback, không qua client/manager toàn cục.
| Thư viện | API transaction | Bẫy cần nhớ |
|---|---|---|
| node-postgres | pool.connect() + BEGIN/COMMIT/ROLLBACK + release() |
Không dùng pool.query cho transaction |
| Prisma ORM 7 (bản ổn định hiện tại) | prisma.$transaction(async (tx) => ...) |
Query trong callback phải dùng tx, không dùng prisma |
| Prisma ORM 8 (đang release candidate, kiểm tra 18/10/2026) | db.transaction(async (tx) => ...), bỏ $transaction |
Không có option timeout; tài liệu gợi ý dùng timeout phía PostgreSQL. Kiểm tra lại khi bản 8 phát hành chính thức |
| TypeORM | dataSource.transaction(async (manager) => ...), hoặc QueryRunner với startTransaction/commitTransaction/rollbackTransaction/release |
Tài liệu nhấn mạnh: luôn dùng manager được truyền vào, không dùng entity manager toàn cục |
| Knex | knex.transaction(async (trx) => { await trx('product')... }); handler ném lỗi thì rollback. Isolation level qua option isolationLevel (read committed, repeatable read, serializable…) |
Trong handler phải dùng trx('bảng') hoặc .transacting(trx). Lỡ gọi knex('bảng') là câu đó chạy ngoài transaction, trên connection khác của pool: đúng bẫy pool.query ở trên |
| Kysely | db.transaction().execute(async (trx) => ...); callback ném lỗi thì rollback, xong thì commit. Isolation: db.transaction().setIsolationLevel('serializable').execute(...) |
Mọi query trong callback đi qua trx, không qua db |
Đừng giả định ORM tự retry 40001/40P01: tài liệu Prisma ghi rõ là không, retry vẫn là việc của bạn. Các pattern 1 đến 5 viết bằng raw SQL qua tx vẫn là cách rõ ràng nhất.
Demo đã chạy
Phần tiêu đề “Demo đã chạy”docker compose -f examples/docker-compose.yml up -d postgresnpx --prefix examples ts-node examples/src/transaction-patterns/buy-race.tsScript tạo database riêng scratch_tx, chạy 50 request đồng thời cho mỗi cách viết, rồi xoá database. Output thật (một lần chạy):
PostgreSQL 17.11, 50 request đồng thời, stock = 10, pool max = 20
=== (a) naive: SELECT rồi UPDATE stock = giá trị đã tính === request trả "bought": 50 request trả "sold_out": 0 request lỗi: 0 stock cuối (DB): 9 đơn hàng trong DB: 50 bán vượt (đơn - 10): 40 connection treo "idle in transaction" lúc kết thúc: 0 thời gian: 40 ms
=== (b) atomic: UPDATE ... WHERE stock >= 1 RETURNING === request trả "bought": 10 request trả "sold_out": 40 request lỗi: 0 stock cuối (DB): 0 đơn hàng trong DB: 10 bán vượt (đơn - 10): 0 connection treo "idle in transaction" lúc kết thúc: 0 thời gian: 89 ms
=== (c) SELECT ... FOR UPDATE rồi quyết định === request trả "bought": 10 request trả "sold_out": 40 request lỗi: 0 stock cuối (DB): 0 đơn hàng trong DB: 10 bán vượt (đơn - 10): 0 connection treo "idle in transaction" lúc kết thúc: 0 thời gian: 82 ms
=== (e) optimistic: version column + retry === request trả "bought": 10 request trả "sold_out": 40 request lỗi: 0 stock cuối (DB): 0 đơn hàng trong DB: 10 bán vượt (đơn - 10): 0 connection treo "idle in transaction" lúc kết thúc: 0 thời gian: 71 ms số lần retry vì version đổi: 189
=== (d) BUG: (c) nhưng dùng pool.query cho BEGIN/.../COMMIT === request trả "bought": 39 request trả "sold_out": 0 request lỗi: 11 stock cuối (DB): 8 đơn hàng trong DB: 39 bán vượt (đơn - 10): 29 connection treo "idle in transaction" lúc kết thúc: 1 thời gian: 4050 ms notice/error x30: WARNING: there is already a transaction in progress notice/error x24: WARNING: there is no transaction in progress notice/error x11: ERROR 55P03 canceling statement due to lock timeoutĐọc kết quả:
- (a) stock cuối 9 dù bán 50 đơn: mỗi request ghi đè hằng số tính từ giá trị cũ, đúng lịch lost update.
- (b), (c), (e) đều đúng. Thời gian không so sánh được ở quy mô này; khác biệt thật nằm ở số round-trip, thời gian giữ lock và số retry.
- (d) dao động mạnh giữa các lần chạy (3 lần chạy khác: bán vượt 24, 40, 37). Lần này 11 request chỉ dừng được nhờ
lock_timeout = 2000đặt riêng cho kịch bản; bỏ nó đi thì script treo.
Khi nào áp dụng / ngoại lệ
Phần tiêu đề “Khi nào áp dụng / ngoại lệ”Áp dụng cho mọi code ghi dữ liệu mà kết quả phụ thuộc vào trạng thái hiện tại: tồn kho, số dư, quota, trạng thái đơn, ghế, slot lịch hẹn.
Ngoại lệ hợp lý:
- Một câu
INSERT/UPDATEđơn lẻ, không phụ thuộc giá trị đọc trước: không cầnBEGIN, autocommit đã atomic. - Dữ liệu chỉ một writer (job batch chạy đơn luồng có lock cấp job): race không xảy ra, nhưng hãy ghi rõ giả định đó trong code.
- Bộ đếm xấp xỉ (lượt xem) chấp nhận sai số: dùng
SET views = views + 1là đủ, không cần lock. - Quy trình kéo dài nhiều service: một transaction database không bao được; dùng outbox, idempotency và saga, không dùng distributed transaction tự chế.
Checklist production
Phần tiêu đề “Checklist production”- Mọi transaction đi qua một helper (
withTransaction) dùngpool.connect()vàrelease()trongfinally. - Pool cấu hình
lock_timeout,statement_timeout,idle_in_transaction_session_timeout(mặc định PostgreSQL đều tắt). - Có constraint làm lưới an toàn:
CHECK (stock >= 0),UNIQUE (request_id), khoá ngoại. - Wrapper retry chỉ cho
40001/40P01, có giới hạn lần thử, jitter và metric tỷ lệ retry. - Không có HTTP/email/publish trong transaction; side effect đi qua outbox.
- Alert khi có session
idle in transactionlâu hoặc khi log PostgreSQL xuất hiệnthere is no transaction in progress. - Test concurrency thật: N request song song trên một row, assert số đơn và stock cuối (như script ở trên), chạy trong CI với PostgreSQL thật.
Checklist code review
Phần tiêu đề “Checklist code review”- Có
pool.query('BEGIN')hoặc hàm repository nhậnPoolnhưng được gọi trong transaction? Chặn merge. - Có đọc giá trị rồi ghi lại hằng số tính trong app (
SET x = $1) không? Đổi sangSET x = x - $1 WHERE ...hoặcFOR UPDATE. - Sau
UPDATEcó điều kiện, code có kiểm trarowCountkhông? - Có
awaitnetwork call,sleep, hay vòng lặp gọi service khác giữaBEGINvàCOMMITkhông? - Có
try/catchnuốt lỗi bên trong callback transaction mà không có savepoint không? - Khoá nhiều row: có
ORDER BY idhoặc sort id trước khi lặp không? - Chống trùng bằng
SELECTrồiINSERT? Đổi sangUNIQUE+ON CONFLICT. - Transaction bắt đầu ở service layer, không ở controller và không ở từng hàm repository?
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Request B chạy
UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock >= 1trong lúc A đang giữ row và sắp commit stock = 0. B nhận kết quả gì, và vì sao không cần retry? - Một hàm repository
findById(pool, id)được gọi bên trongwithTransaction. Nó có thấy dữ liệu chưa commit của transaction hiện tại không? Có giữ lock của transaction đó không? - Vì sao không nên dùng version column cho flash sale, dù kết quả vẫn đúng?
- Code bắt lỗi
23505trong transaction rồi đi tiếp vàCOMMIT. Không có savepoint thì chuyện gì xảy ra với các câu đã chạy trước đó? - Đặt
statement_timeout = 5svàlock_timeout = 10s.lock_timeoutcó tác dụng gì không?