Bỏ qua để đến nội dung
Knowledge Hub

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

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.

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

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.query khô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ì createOrder và decreaseStock commit 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 commit
const 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:

  1. 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.
  2. Rollback không thu hồi được tiền đã charge. INSERT lỗi sau khi charge thành công thì khách mất tiền, không có đơn.
  3. Retry lặp side effect. Wrapper retry 40001/40P01 chạ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ác
await 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ả transaction
await withTransaction(pool, async (tx) => { /* tx.query(...) */ });
Một transaction viết bằng pool.query rơi vào ba connection khác nhau
Một transaction viết bằng pool.query rơi vào ba connection khác nhauBước 1/5: pool.query("BEGIN") lấy connection #1, mở transaction, rồi trả connection về pool. Transaction vẫn mở trên #1.thời gian (một request X)tx mồ côi, còn mở …BEGIN (X)mở txConnection #1trong poolConnection #2trong poolConnection #3trong pool
Bước 1/5

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 UPDATE rơi vào connection khác với BEGIN. 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 progress và 24 lần there 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): khi FOR UPDATE rơ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 đặt lock_timeout thì 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 rowCount
const 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;
Stock = 1, hai người mua: naive bán vượt, atomic UPDATE trả sold_out
Stock = 1, hai người mua: naive bán vượt, atomic UPDATE trả sold_outBước 1/7: 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.thời gian (stock ban đầu = 1)SELECT stock→ 1SELECT stock→ 1Request Anaive: đọc rồi ghiRequest Bnaive: đọc rồi ghiRequest Catomic UPDATERequest Datomic UPDATE
Bước 1/7

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 = 0 gộ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ỗi 23514 thay 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 đến COMMIT. 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 UPDATE nhẹ 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ấy
const 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 UPDATE không cần BEGIN: tự nó đã atomic, dùng pool.query ở đây là đúng.
  • Với API, trả 409 Conflict (hoặc 412 nếu dùng If-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 UNIQUE
const 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 trong RETURNING và không được tính vào rowCount.
  • Đặt INSERT ... ON CONFLICT trướ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 CONFLICT thì câu INSERT trùng ném 23505 và 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ền
await 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âu
await 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:

  1. 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.
  2. 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ử.
  3. Chỉ các mã đã phân loại: 40001 (serialization_failure), 40P01 (deadlock_detected). 23505 (unique_violation) hay 55P03 (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 role
ALTER 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 NOTHING gọn hơn savepoint.
  • Quy tắc chung: lỗi trong fn phải ném ra tới withTransaction để nó ROLLBACK. Không try/catch nuốt lỗi bên trong mà không có savepoint.

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.

Terminal window
docker compose -f examples/docker-compose.yml up -d postgres
npx --prefix examples ts-node examples/src/transaction-patterns/buy-race.ts

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

Á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ần BEGIN, 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 + 1 là đủ, 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ế.
  1. Mọi transaction đi qua một helper (withTransaction) dùng pool.connect() và release() trong finally.
  2. Pool cấu hình lock_timeout, statement_timeout, idle_in_transaction_session_timeout (mặc định PostgreSQL đều tắt).
  3. Có constraint làm lưới an toàn: CHECK (stock >= 0), UNIQUE (request_id), khoá ngoại.
  4. Wrapper retry chỉ cho 40001/40P01, có giới hạn lần thử, jitter và metric tỷ lệ retry.
  5. Không có HTTP/email/publish trong transaction; side effect đi qua outbox.
  6. Alert khi có session idle in transaction lâu hoặc khi log PostgreSQL xuất hiện there is no transaction in progress.
  7. 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.
  1. Có pool.query('BEGIN') hoặc hàm repository nhận Pool nhưng được gọi trong transaction? Chặn merge.
  2. Có đọc giá trị rồi ghi lại hằng số tính trong app (SET x = $1) không? Đổi sang SET x = x - $1 WHERE ... hoặc FOR UPDATE.
  3. Sau UPDATE có điều kiện, code có kiểm tra rowCount không?
  4. Có await network call, sleep, hay vòng lặp gọi service khác giữa BEGIN và COMMIT không?
  5. Có try/catch nuốt lỗi bên trong callback transaction mà không có savepoint không?
  6. Khoá nhiều row: có ORDER BY id hoặc sort id trước khi lặp không?
  7. Chống trùng bằng SELECT rồi INSERT? Đổi sang UNIQUE + ON CONFLICT.
  8. Transaction bắt đầu ở service layer, không ở controller và không ở từng hàm repository?
  1. Request B chạy UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock >= 1 trong 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?
  2. Một hàm repository findById(pool, id) được gọi bên trong withTransaction. 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?
  3. Vì sao không nên dùng version column cho flash sale, dù kết quả vẫn đúng?
  4. Code bắt lỗi 23505 trong 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 đó?
  5. Đặt statement_timeout = 5s và lock_timeout = 10s. lock_timeout có tác dụng gì không?