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

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

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.

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
  • BEGIN mở transaction; COMMIT hoặc ROLLBACK đóng nó.
  • Không có BEGIN, mỗi câu lệnh là một transaction riêng (autocommit). Hai UPDATE liê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 khi ROLLBACK. Đừng bắt lỗi rồi chạy tiếp trong cùng transaction (trừ khi dùng savepoint).
  • 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 >= 30 khi 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 SELECT lấ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 SELECT trong 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ồi UPDATE” vẫn race được. Đặt điều kiện vào chính câu UPDATE rồi kiểm tra RETURNING; 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
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.

Một transaction chuyển 30: trạng thái trước và sau COMMIT
Một transaction chuyển 30: trạng thái trước và sau COMMITBước 1/5: Ứ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.Ứng dụngPostgreSQLSession khácBEGIN
Ứng dụng → PostgreSQLBEGIN
Bước 1/5

Ứ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ó.

Chạy demo một session (bảng TEMP, không để lại gì):

Terminal window
docker compose -f examples/docker-compose.yml up -d postgres
docker compose -f examples/docker-compose.yml exec -T postgres psql -U hub -d hub < examples/src/postgres-acid/acid-demo.sql
BEGIN;
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 COMMIT

Kế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).

Checklist cho một thao tác tiền bạc hoặc tồn kho:

  1. Điều kiện nghiệp vụ nằm trong câu ghi (WHERE balance >= 30) và code kiểm tra row count.
  2. Invariant biểu diễn được thì khai báo constraint, đừng tin mọi service kiểm tra giống nhau.
  3. Mọi yêu cầu có idempotency key lưu cùng transaction.
  4. Side effect ngoài database đi qua outbox hoặc cơ chế idempotent sau commit.
  5. Transaction ngắn: không gọi mạng chậm khi đang giữ lock.
  6. Retry toàn bộ transaction khi gặp 40001 hoặc 40P01.

Đ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ả COMMIT mơ hồ cần cơ chế riêng.

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?