ACID trong PostgreSQL: điều gì được bảo đảm, điều gì không? Cơ bản
Thời gian đọc ước tính: 7 phút
Vấn đề
Phần tiêu đề “Vấn đề”A có 100, B có 50. Chuyển 30 từ A sang B cần hai lệnh: trừ A, cộng B.
Nếu mỗi lệnh tự commit và máy chủ app chết giữa hai lệnh, database giữ A = 70, B = 50: tổng tiền từ 150 còn 120. Hai UPDATE đều hợp lệ; lỗi nằm ở chỗ một thao tác nghiệp vụ bị tách thành hai commit.
Gói hai lệnh vào một transaction (một nhóm câu SQL cùng commit hoặc cùng rollback) là bước đầu. ACID là bốn câu hỏi cần trả lời tiếp, không phải công tắc làm mọi logic tự đúng.
ACID trong một bảng
Phần tiêu đề “ACID trong một bảng”| Chữ | Câu hỏi trong ví dụ chuyển tiền | PostgreSQL bảo đảm | PostgreSQL không bảo đảm |
|---|---|---|---|
| A Atomicity | Trừ A xong mà cộng B lỗi thì sao? | Các lệnh trong cùng transaction cùng commit hoặc cùng rollback | HTTP call, email, message đã gửi ra ngoài |
| C Consistency | Sau commit, luật nghiệp vụ còn đúng? | Constraint đã khai báo (CHECK, UNIQUE, FK, EXCLUDE) |
Luật chưa khai báo, ví dụ “trừ bao nhiêu phải cộng bấy nhiêu” |
| I Isolation | Request khác thấy gì khi đang chạy? | Không đọc dữ liệu chưa commit | Mọi race khác ở Read Committed (xem isolation levels) |
| D Durability | Báo commit xong, mất điện thì sao? | Phục hồi từ WAL ở local (cấu hình mặc định) | Replica đã nhận chưa, client đã nhận response chưa |
Từng chữ, ngắn gọn
Phần tiêu đề “Từng chữ, ngắn gọn”A: ranh giới nằm ở COMMIT
Phần tiêu đề “A: ranh giới nằm ở COMMIT”BEGINmở transaction;COMMIThoặcROLLBACKđóng nó.- Không có
BEGIN, mỗi câu lệnh là một transaction riêng (autocommit). HaiUPDATEliên tiếp không atomic với nhau. Driver và ORM hay bật autocommit ngầm, nên kiểm tra transaction boundary thật sự trong code. - Sau một lỗi bên trong transaction, mọi lệnh tiếp theo bị từ chối với SQLSTATE
25P02(in_failed_sql_transaction) cho đến khiROLLBACK. Đừng bắt lỗi rồi chạy tiếp trong cùng transaction (trừ khi dùng savepoint).
C: database chỉ giữ luật mà nó biết
Phần tiêu đề “C: database chỉ giữ luật mà nó biết”CHECK (balance >= 0)chặn số dư âm. Nó không biết “tổng A + B phải giữ nguyên”: trừ 30 rồi cộng 20 vẫn commit bình thường.UPDATE ... WHERE balance >= 30khi A chỉ còn 10 không báo lỗi, chỉ trả 0 row. App phải kiểm tra row count vàROLLBACK; nếu vẫn cộng B rồi commit, tiền được tạo ra từ không khí.- Luật trải qua nhiều row: dùng SQL atomic,
EXCLUDE/UNIQUE, lock hoặc Serializable (xem anomaly).
I: không thấy dữ liệu dở dang không có nghĩa không có race
Phần tiêu đề “I: không thấy dữ liệu dở dang không có nghĩa không có race”- Mặc định Read Committed: mỗi
SELECTlấy snapshot mới tại lúc câu lệnh bắt đầu. Session khác không thấy A = 70 khi chưa commit. - Nhưng hai
SELECTtrong cùng transaction có thể thấy hai trạng thái khác nhau, và “đọc số dư, kiểm tra trong code, rồiUPDATE” vẫn race được. Đặt điều kiện vào chính câuUPDATErồi kiểm traRETURNING; cần giữ ổn định qua nhiều bước thì dùng row-level lock.
D: commit bền không có nghĩa mọi bản sao đã có dữ liệu
Phần tiêu đề “D: commit bền không có nghĩa mọi bản sao đã có dữ liệu”PostgreSQL ghi write-ahead log (WAL) trước khi tin vào page dữ liệu. Với synchronous_commit = on (mặc định) và fsync = on, commit chỉ được báo thành công sau khi WAL được flush ở local; crash sau đó vẫn phục hồi được từ WAL.
| Cấu hình | Rủi ro |
|---|---|
synchronous_commit = off |
Mất vài transaction cuối sau crash, cửa sổ tối đa 3 x wal_writer_delay (mặc định 200ms, tức 600ms). Database vẫn nhất quán |
fsync = off |
Có thể hỏng dữ liệu không phục hồi được. Không bao giờ tắt trên dữ liệu thật |
remote_write / remote_apply |
Chờ replica đồng bộ; cần cấu hình synchronous_standby_names, đổi lấy latency |
Crash ở đâu thì kết quả là gì?
Phần tiêu đề “Crash ở đâu thì kết quả là gì?”| Thời điểm lỗi | Trạng thái database | Client biết gì | Cách xử lý |
|---|---|---|---|
Sau UPDATE #1, trước COMMIT (app hoặc connection chết) |
Rollback toàn bộ: A = 100, B = 50 | Lỗi kết nối | Retry an toàn |
Trong lúc gửi COMMIT, mất kết nối trước khi nhận ack |
Không biết: có thể đã commit, có thể chưa | Lỗi kết nối, kết quả mơ hồ | Idempotency key, tra lại trạng thái trước khi retry |
Sau COMMIT, trước khi client nhận response |
Đã bền (WAL flushed) | Timeout dù đã thành công | Cùng idempotency key để lấy kết quả cũ |
Server crash sau ack, synchronous_commit = on |
Phục hồi từ WAL, không mất | n/a | Không cần làm gì |
Dòng thứ hai là dòng tốn tiền nhất trong production: retry mù với mã yêu cầu mới sẽ chuyển tiền hai lần.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”BEGINỨng dụng mở transaction. Số dư ban đầu là A = 100, B = 50; chưa có thay đổi nào để session khác nhìn thấy.
| Mốc | Session chuyển tiền thấy | SELECT mới từ session khác thấy |
|---|---|---|
Trước BEGIN |
A = 100, B = 50 | A = 100, B = 50 |
| Đã trừ A, chưa cộng B | A = 70, B = 50 | A = 100, B = 50 |
| Đã cộng B, chưa commit | A = 70, B = 80 | A = 100, B = 50 |
Sau COMMIT |
A = 70, B = 80 | A = 70, B = 80 |
Bảng giả định không có transaction thứ ba đụng hai tài khoản. Một SELECT đã bắt đầu trước commit giữ snapshot cũ của nó.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Chạy demo một session (bảng TEMP, không để lại gì):
docker compose -f examples/docker-compose.yml up -d postgresdocker compose -f examples/docker-compose.yml exec -T postgres psql -U hub -d hub < examples/src/postgres-acid/acid-demo.sqlBEGIN;UPDATE acid_accounts SET balance = balance - 30 WHERE account_id = 1 AND balance >= 30;UPDATE acid_accounts SET balance = balance + 30 WHERE account_id = 2;ROLLBACK; -- lượt hai đổi thành COMMITKết quả cần thấy: sau rollback vẫn 100 và 50, sau commit là 70 và 80. Muốn quan sát isolation, mở hai psql trên bảng thường (bảng TEMP là riêng của từng session).
Khi ACID chưa đủ
Phần tiêu đề “Khi ACID chưa đủ”Checklist cho một thao tác tiền bạc hoặc tồn kho:
- Điều kiện nghiệp vụ nằm trong câu ghi (
WHERE balance >= 30) và code kiểm tra row count. - Invariant biểu diễn được thì khai báo constraint, đừng tin mọi service kiểm tra giống nhau.
- Mọi yêu cầu có idempotency key lưu cùng transaction.
- Side effect ngoài database đi qua outbox hoặc cơ chế idempotent sau commit.
- Transaction ngắn: không gọi mạng chậm khi đang giữ lock.
- Retry toàn bộ transaction khi gặp
40001hoặc40P01.
Trade-offs
Phần tiêu đề “Trade-offs”Điều transaction giúp được
- Gom nhiều thay đổi SQL vào một điểm commit hoặc rollback.
- Constraint chặn trạng thái sai đã khai báo, bất kể service nào ghi.
- WAL cho phép phục hồi thay đổi đã commit.
Chi phí và ranh giới
- Transaction mở lâu giữ lock và cản dọn dẹp version cũ.
- Isolation mạnh hơn kéo theo retry; Read Committed mặc định không loại mọi race.
- Side effect ngoài PostgreSQL, replica và kết quả
COMMITmơ hồ cần cơ chế riêng.
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: (1) Lệnh trừ cập nhật 0 row nhưng lệnh cộng vẫn chạy: chữ nào trong ACID không cứu logic đó? (2) Vì sao không nên gửi lại yêu cầu chuyển tiền với mã mới khi mất kết nối ngay sau COMMIT? (3) Với synchronous_commit = off, database có bị hỏng sau crash không?