Transaction không chỉ là bọc nhiều câu SQL trong BEGIN và COMMIT. Khi nhiều request cùng đọc và sửa dữ liệu, ứng dụng phải chọn isolation level phù hợp, khóa đúng hàng, giữ transaction ngắn và chuẩn bị retry các lỗi concurrency có chủ đích.
ACID không tự động bảo vệ mọi business rule
PostgreSQL bảo đảm atomicity và consistency ở mức constraint/transaction, nhưng database không tự hiểu quy tắc “không bán vượt tồn kho” hay “tổng số ghế không vượt giới hạn” nếu ứng dụng chỉ đọc rồi ghi bằng hai câu rời rạc. Hai transaction có thể cùng đọc một trạng thái hợp lệ và cùng ra quyết định xung đột.
Constraint bảo vệ invariant đơn giản; lock hoặc isolation cao hơn bảo vệ quyết định dựa trên dữ liệu đang thay đổi.
MVCC: đọc và ghi đồng thời
PostgreSQL dùng Multi-Version Concurrency Control. Mỗi statement/transaction đọc một snapshot phù hợp, trong khi update tạo row version mới. Vì vậy reader thường không chặn writer và writer không chặn reader như mô hình khóa đọc đơn giản.
MVCC tăng concurrency nhưng không loại bỏ conflict giữa hai writer. Row version cũ cũng phải được VACUUM thu dọn; transaction mở quá lâu giữ snapshot cũ và có thể làm bloat tăng.
1. Transaction đúng cấu trúc
BEGIN;
UPDATE accounts
SET balance = balance - 500
WHERE id = 10 AND balance >= 500;
UPDATE accounts
SET balance = balance + 500
WHERE id = 20;
COMMIT;
Ứng dụng phải kiểm tra số hàng ảnh hưởng của câu trừ tiền; nếu là 0, rollback vì không đủ số dư hoặc account không tồn tại. Dùng phép update nguyên tử tốt hơn đọc balance về ứng dụng rồi ghi giá trị mới.
Không gọi API, gửi email hoặc chờ input của người dùng khi đang giữ transaction. Thực hiện side effect sau commit, hoặc dùng transactional outbox để liên kết ghi database với message đáng tin cậy.
2. Read Committed: mặc định và phù hợp phần lớn CRUD
Ở Read Committed, mỗi statement thấy snapshot tại lúc statement bắt đầu. Hai câu SELECT trong cùng transaction có thể thấy kết quả khác nếu transaction khác commit ở giữa.
Mức này phù hợp khi mỗi thao tác ghi có thể diễn đạt nguyên tử bằng UPDATE ... WHERE, UPSERT, unique/check constraint hoặc row lock rõ ràng. Nó không đủ khi business rule dựa trên nhiều lần đọc mà không khóa.
3. Repeatable Read: một snapshot ổn định
Repeatable Read cho transaction nhìn snapshot ổn định từ truy vấn đầu tiên. Nó hữu ích cho báo cáo nhiều câu cần cùng thời điểm và logic phức tạp, nhưng update xung đột có thể phát sinh lỗi serialization. Ứng dụng phải sẵn sàng chạy lại toàn bộ transaction.
Không dùng isolation cao như một cách tránh thiết kế concurrency. Transaction càng dài càng giữ snapshot lâu, tăng khả năng conflict và ảnh hưởng vacuum.
4. Serializable: kết quả như chạy tuần tự
Serializable của PostgreSQL dùng Serializable Snapshot Isolation để chỉ cho phép commit khi kết quả tương đương một thứ tự tuần tự hợp lệ. Database có thể hủy một transaction với SQLSTATE 40001 khi phát hiện anomaly có thể xảy ra.
Đây là lựa chọn mạnh cho invariant liên quan nhiều hàng hoặc predicate, nhưng hợp đồng là application phải retry. Không chỉ chạy lại câu SQL thất bại; phải chạy lại toàn bộ callback transaction, gồm mọi quyết định dựa trên dữ liệu đã đọc.
5. SELECT FOR UPDATE và các row lock
BEGIN;
SELECT id, stock
FROM products
WHERE id = 42
FOR UPDATE;
UPDATE products
SET stock = stock - 1
WHERE id = 42 AND stock > 0;
COMMIT;
FOR UPDATE ngăn transaction khác update, delete hoặc lấy lock xung đột trên hàng tới khi transaction kết thúc. Dùng khi cần đọc trạng thái, thực hiện logic trong ứng dụng rồi ghi lại. Nếu có thể dùng một câu UPDATE ... WHERE stock > 0 RETURNING ..., cách đó thường ngắn và ít lock hơn.
FOR NO KEY UPDATE: lock yếu hơn khi không thay khóa liên quan foreign key.FOR SHAREvàFOR KEY SHARE: bảo vệ các trường hợp đọc/tham chiếu cụ thể.NOWAIT: lỗi ngay thay vì chờ.SKIP LOCKED: bỏ hàng đang khóa, hữu ích cho worker queue nhưng không cho truy vấn tổng quát vì tạo view không nhất quán.
6. Deadlock hình thành như thế nào?
Transaction A khóa account 10 rồi chờ account 20; transaction B đã khóa account 20 rồi chờ account 10. PostgreSQL phát hiện vòng chờ và abort một transaction với SQLSTATE 40P01. Không được phụ thuộc transaction nào sẽ bị chọn.
Biện pháp phòng ngừa tốt nhất là khóa nhiều object theo cùng thứ tự:
SELECT id
FROM accounts
WHERE id IN (10, 20)
ORDER BY id
FOR UPDATE;
Sau đó mới cập nhật. Transaction phải ngắn và lấy lock mạnh nhất cần dùng từ đầu. Deadlock vẫn có thể xảy ra, nên code production cần retry giới hạn.
7. Retry đúng cách với backoff
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
return runWholeTransaction();
} catch (DatabaseException $e) {
if (! in_array($e->sqlState(), ['40001', '40P01'], true)) {
throw $e;
}
if ($attempt === 3) {
throw $e;
}
usleep(random_int(20_000, 100_000) * $attempt);
}
}
Retry toàn bộ transaction với số lần hữu hạn và jitter. Callback phải an toàn để chạy lại: không gửi email, charge payment hoặc publish message không idempotent trước commit. Unique violation 23505 đôi khi do race nhưng cũng có thể là lỗi dữ liệu cố định; không retry mù quáng.
8. Timeout để thất bại có kiểm soát
SET LOCAL lock_timeout = '2s';
SET LOCAL statement_timeout = '10s';
SET LOCAL idle_in_transaction_session_timeout = '30s';
lock_timeout giới hạn thời gian chờ lock; statement_timeout giới hạn statement; idle_in_transaction_session_timeout xử lý session mở transaction rồi bỏ quên. Đặt theo SLA và workload, không dùng một giá trị cho mọi truy vấn.
SET LOCAL chỉ có hiệu lực trong transaction hiện tại. Khi timeout, transaction có thể ở trạng thái aborted và cần rollback trước khi connection được trả về pool.
9. Tìm session đang block production
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocker.pid AS blocker_pid,
blocker.query AS blocker_query,
now() - blocker.xact_start AS blocker_age
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocker
ON blocker.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;
pg_locks cho cái nhìn toàn cluster về lock đã cấp và đang chờ; kết hợp pg_stat_activity để thấy query, user, state và transaction age. Tìm session idle in transaction vì nó có thể giữ lock và snapshot dù không chạy câu lệnh.
Không vội pg_terminate_backend trên production. Xác định blocker, tác động rollback, owner và khả năng ứng dụng retry. Sau sự cố, sửa code hoặc timeout thay vì chỉ kill session.
10. Constraint và idempotency trước lock thủ công
Ưu tiên để database bảo vệ invariant bằng UNIQUE, CHECK, FOREIGN KEY, exclusion constraint và atomic DML. Ví dụ idempotency key có unique index giúp hai request trùng không tạo hai payment record.
Advisory lock phù hợp cho tài nguyên logic không ánh xạ trực tiếp thành row, nhưng key phải ổn định và lock transaction-level thường an toàn hơn session-level. Advisory lock là cooperative: mọi code path phải tuân thủ cùng quy ước.
Anti-pattern thường gặp
- Đọc số dư/tồn kho rồi update không điều kiện.
- Giữ transaction trong khi gọi HTTP hoặc chờ queue.
- Retry riêng statement thay vì toàn transaction.
- Lock các hàng theo thứ tự khác nhau giữa code path.
- Dùng
SKIP LOCKEDcho truy vấn cần kết quả đầy đủ. - Để session
idle in transactionqua connection pool. - Nâng mọi transaction lên Serializable nhưng không xử lý
40001.
Checklist production
- Business invariant có constraint ở database khi biểu diễn được.
- Transaction ngắn, không chứa network side effect.
- Isolation level được chọn theo anomaly cần ngăn.
- Lock nhiều row theo thứ tự nhất quán.
40001và40P01được retry toàn transaction có giới hạn.- Side effect dùng idempotency/outbox.
- Timeout và connection-pool behavior được cấu hình.
- Dashboard theo dõi lock wait, deadlock và transaction age.
Kết luận
Concurrency đúng trong PostgreSQL là sự kết hợp của atomic SQL, constraint, MVCC, isolation và lock có chủ đích. Read Committed đáp ứng phần lớn CRUD nếu statement được thiết kế tốt; row lock xử lý read-modify-write; Serializable bảo vệ invariant phức tạp với yêu cầu retry. Transaction ngắn, thứ tự lock nhất quán và quan sát production tốt giúp deadlock trở thành lỗi có thể kiểm soát thay vì sự cố bí ẩn.




Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.