ACID & isolation level trong PostgreSQL

4 câu hỏi~30 phútTừ khóa: ACID, atomicity, isolation level, dirty read, non-repeatable read, phantom read, lost update, write skew, READ COMMITTED, SERIALIZABLE, serialization failure, retry

ACID trong lập trình là gì?

Chương này dùng một project chạy xuyên suốt: shop-app trên PostgreSQL với 4 bảng orders, order_items, products, wallets. ACID là 4 tính chất mà một transaction của database hứa với bạn.

1. Bài toán

Khách đặt hàng trên shop-app. Một lần "đặt hàng" gồm nhiều câu SQL:

BEGIN;
UPDATE wallets  SET balance = balance - 300 WHERE user_id = 7;
UPDATE products SET stock   = stock - 1     WHERE id = 42;
INSERT INTO orders (id, user_id, total, status) VALUES (1001, 7, 300, 'PAID');
INSERT INTO order_items (order_id, product_id, qty, price) VALUES (1001, 42, 1, 300);
COMMIT;

2. Vấn đề

3. Giải pháp: 4 chữ ACID

ChữÝ nghĩaTrên shop-appPostgreSQL làm bằng
AtomicityTất cả hoặc không gì cảTrừ tiền + tạo order cùng thành công hoặc cùng rollbackRollback, MVCC (bài 6.3)
ConsistencyTừ trạng thái hợp lệ sang trạng thái hợp lệCHECK (balance >= 0), FK, UNIQUEConstraint + logic của app
IsolationTransaction chạy song song không giẫm lên nhauHai người mua cùng lúc vẫn ra kết quả đúngMVCC + lock, chọn isolation level
DurabilityCOMMIT rồi thì không mấtMất điện vẫn còn orderWAL + fsync (bài 6.2)

Chú ý: chữ C phần lớn là trách nhiệm của bạn (khai báo constraint, viết logic đúng). Database chỉ đảm bảo không vi phạm các constraint đã khai báo.

4. Isolation level và các anomaly

Isolation "hoàn hảo" (như chạy lần lượt từng transaction) thì chậm. Nên SQL chuẩn định nghĩa nhiều mức, mỗi mức cho phép một số anomaly:

AnomalyMô tảVí dụ shop-app
Dirty readĐọc dữ liệu chưa commit của transaction khácThấy balance đã trừ, rồi transaction kia rollback
Non-repeatable readĐọc 1 row 2 lần ra 2 giá trị khác nhauĐọc stock = 5, lát sau đọc lại = 3
Phantom readChạy lại cùng điều kiện WHERE thì có thêm/mất rowCOUNT(*) FROM orders WHERE user_id=7 ra 2 rồi 3
Lost updateHai transaction đọc-sửa-ghi, cái sau ghi đè cái trướcĐã nói ở bài 3.5 và bài 4.3.10
Write skewHai transaction đọc chung dữ liệu, mỗi bên ghi row khác nhau, kết quả chung vi phạm luậtXem bên dưới

Bảng isolation level trong PostgreSQL (khác chuẩn một chút):

LevelDirty readNon-repeatablePhantomLost updateWrite skew
READ UNCOMMITTEDPostgreSQL coi như READ COMMITTED (không bao giờ dirty read)
READ COMMITTED (mặc định)KhôngCóCóCóCó
REPEATABLE READKhôngKhôngKhông (PG chặn luôn)Không: lỗi serializationCó
SERIALIZABLEKhôngKhôngKhôngKhôngKhông: lỗi serialization

5. Code từng bước

Bước 1: Constraint cho chữ C

ALTER TABLE wallets  ADD CONSTRAINT wallet_balance_non_negative CHECK (balance >= 0);
ALTER TABLE products ADD CONSTRAINT product_stock_non_negative  CHECK (stock >= 0);

Bước 2: Thử anomaly bằng 2 cửa sổ psql (READ COMMITTED)

-- Session A                               -- Session B
BEGIN;
SELECT stock FROM products WHERE id=42; -- 5
                                           BEGIN;
                                           UPDATE products SET stock=3 WHERE id=42;
                                           COMMIT;
SELECT stock FROM products WHERE id=42; -- 3  ← non-repeatable read
COMMIT;

Mỗi câu lệnh trong READ COMMITTED lấy một snapshot mới. Đổi sang BEGIN ISOLATION LEVEL REPEATABLE READ; thì câu thứ hai vẫn ra 5, vì snapshot lấy một lần cho cả transaction.

Bước 3: Write skew. Luật: mỗi user tối đa 1 order PENDING.

-- A và B (REPEATABLE READ) cùng chạy, cùng user 7:
SELECT count(*) FROM orders WHERE user_id=7 AND status='PENDING';  -- 0, cả hai đều thấy 0
INSERT INTO orders(user_id, status, total) VALUES (7, 'PENDING', 100);
COMMIT;   -- cả hai cùng thành công → user 7 có 2 order PENDING

Không ai ghi đè ai, nên REPEATABLE READ không phát hiện. Cách chữa: SERIALIZABLE, hoặc đơn giản hơn là đưa luật vào constraint:

CREATE UNIQUE INDEX one_pending_order_per_user ON orders(user_id) WHERE status = 'PENDING';

Bước 4: Spring Boot chọn isolation + retry

@Service
@RequiredArgsConstructor
public class CheckoutService {
    private final CheckoutTx tx;

    public OrderDto checkout(CheckoutRequest req) {
        for (int attempt = 1; ; attempt++) {
            try {
                return tx.placeOrder(req);              // transaction mới mỗi lần
            } catch (CannotAcquireLockException | ConcurrencyFailureException e) {
                if (attempt == 3) throw e;              // SQLState 40001 / 40P01
            }
        }
    }
}

@Component
class CheckoutTx {
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public OrderDto placeOrder(CheckoutRequest req) { /* đọc, kiểm tra luật, ghi */ }
}

Retry đặt ngoài method @Transactional (lý do giống bài 4.3.10). Spring dịch SQLState 40001 serialization_failure thành một subclass của ConcurrencyFailureException.

Bước 5: Test

curl -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
  -d '{"userId":7,"items":[{"productId":42,"qty":1}]}' &
curl -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
  -d '{"userId":7,"items":[{"productId":42,"qty":1}]}' &
wait
psql -c "SELECT count(*) FROM orders WHERE user_id=7 AND status='PENDING';"   -- phải là 1

6. Cách hoạt động của SERIALIZABLE trong PostgreSQL

PostgreSQL dùng SSI (Serializable Snapshot Isolation):
  - Không khóa khi đọc. Chỉ GHI NHẬN "A đã đọc cái B sắp ghi" (rw-dependency)
  - Thấy vòng phụ thuộc nguy hiểm A → B → A
  - Hủy 1 transaction lúc commit:
      ERROR: could not serialize access due to read/write dependencies
      SQLSTATE 40001
  → App PHẢI retry. Không retry = người dùng thấy lỗi 500.

7. Khi nào dùng level nào

Trước đây mình học atomic thì định nghĩa là: trong lúc thread này update thì thread khác không được thấy trạng thái không đầy đủ của dữ liệu. Nghe có vẻ là isolation nhỉ?

Bạn nhận ra đúng chỗ dễ nhầm. Chữ "atomic" mang hai nghĩa khác nhau tùy ngữ cảnh:

Atomic trong concurrency (Java)Atomicity trong ACID
Câu hỏi nó trả lờiThread khác có thấy trạng thái nửa vời không?Lỗi giữa chừng thì sao?
Đảm bảoThao tác không bị chia nhỏ khi nhìn từ thread khácTất cả hoặc không gì cả, có rollback
Ví dụAtomicInteger.incrementAndGet() (bài 3.4)Trừ tiền + tạo order cùng rollback
Tương đương trong ACIDGần với Isolation hơnA

Vậy định nghĩa bạn học là atomic theo nghĩa concurrency, và đúng là nó gần với isolation trong ACID. Atomicity của ACID thì không nói gì về người khác nhìn thấy gì; nó nói về failure: crash, exception, ROLLBACK. Database cần cả hai: A lo "không nửa vời khi lỗi", I lo "không nửa vời khi chạy song song".

Atomicity (ACID):  [trừ tiền] [tạo order] ✗ crash  → rollback cả hai
Isolation (ACID):  A đang giữa chừng → B không thấy thay đổi của A trước khi A commit

Vậy mặc định READ COMMITTED có đủ an toàn cho API trừ tiền không?

Đủ, nếu bạn viết câu UPDATE đúng. READ COMMITTED không chặn lost update khi bạn "đọc vào Java rồi ghi lại". Nhưng PostgreSQL có một hành vi quan trọng: khi UPDATE gặp row đang bị transaction khác sửa, nó chờ, rồi đánh giá lại WHERE trên phiên bản mới nhất. Nên câu này an toàn ở READ COMMITTED:

UPDATE wallets SET balance = balance - 300
WHERE user_id = 7 AND balance >= 300;     -- 0 row = không đủ tiền

Còn ở REPEATABLE READ, cùng tình huống đó PostgreSQL ném 40001 could not serialize access due to concurrent update, bạn phải retry. Vì vậy cách phổ biến: giữ READ COMMITTED, dùng atomic UPDATE / FOR UPDATE / @Version cho từng chỗ cần thiết, chỉ nâng level ở chỗ thật sự cần.

Phỏng vấn hỏi ACID và isolation level thì trả lời sao?

Q1. ACID là gì?

🇻🇳 Tiếng Việt:

ACID là 4 đảm bảo của transaction. Atomicity: tất cả câu lệnh thành công hoặc rollback hết. Consistency: dữ liệu đi từ trạng thái hợp lệ sang trạng thái hợp lệ, nhờ constraint và logic ứng dụng. Isolation: các transaction song song không thấy trạng thái dở dang của nhau, mức độ tùy isolation level. Durability: đã commit thì không mất dù crash; PostgreSQL làm bằng WAL được fsync trước khi trả về commit.

🇬🇧 English:

ACID is the set of guarantees of a transaction. Atomicity: all statements succeed or everything is rolled back. Consistency: data moves from one valid state to another, enforced by constraints and application logic. Isolation: concurrent transactions do not see each other's intermediate state, to a degree set by the isolation level. Durability: once committed, data survives a crash; PostgreSQL does this by flushing the WAL before acknowledging the commit.

Q2. Các isolation level và anomaly? PostgreSQL mặc định là gì?

🇻🇳 Tiếng Việt:

Anomaly gồm dirty read, non-repeatable read, phantom read, lost update và write skew. PostgreSQL mặc định READ COMMITTED: mỗi câu lệnh thấy snapshot mới, không có dirty read nhưng có non-repeatable read và lost update nếu đọc-sửa-ghi. REPEATABLE READ dùng một snapshot cho cả transaction, chặn cả phantom nhưng còn write skew. SERIALIZABLE dùng SSI, chặn tất cả và hủy transaction bằng lỗi 40001, nên app phải retry.

🇬🇧 English:

The anomalies are dirty read, non-repeatable read, phantom read, lost update and write skew. PostgreSQL defaults to READ COMMITTED: each statement sees a fresh snapshot, so there is no dirty read, but non-repeatable reads and lost updates are possible with read-modify-write. REPEATABLE READ uses one snapshot for the whole transaction and also prevents phantoms, but write skew remains. SERIALIZABLE uses SSI, prevents all of them and aborts a transaction with error 40001, so the application must retry.

Q3. Write skew là gì, xử lý thế nào?

🇻🇳 Tiếng Việt:

Hai transaction cùng đọc một tập dữ liệu, mỗi bên ghi vào row khác nhau, riêng từng bên thì hợp lệ nhưng gộp lại vi phạm luật, ví dụ một user có hai order PENDING. Em xử lý bằng constraint như partial unique index nếu được, hoặc khóa một row chung bằng FOR UPDATE, hoặc dùng SERIALIZABLE kèm retry.

🇬🇧 English:

Two transactions read the same data, each writes a different row, and each is valid alone but together they break a rule, for example a user ending up with two PENDING orders. I fix it with a constraint such as a partial unique index when possible, or by locking a shared row with FOR UPDATE, or by using SERIALIZABLE with retry.

Cách nhớ ngắn gọn


Tự kiểm tra

PostgreSQL có dirty read ở READ UNCOMMITTED không?

Không. PostgreSQL xử lý READ UNCOMMITTED như READ COMMITTED.

Khác nhau giữa READ COMMITTED và REPEATABLE READ về snapshot?

READ COMMITTED lấy snapshot mới cho mỗi câu lệnh; REPEATABLE READ lấy một snapshot ở câu lệnh đầu tiên và dùng cho cả transaction.

Write skew khác lost update chỗ nào?

Lost update là hai bên ghi cùng một row; write skew là mỗi bên ghi row khác nhau dựa trên cùng dữ liệu đã đọc.

Gặp SQLState 40001 thì app làm gì?

Rollback và chạy lại cả transaction từ đầu (giới hạn số lần), retry đặt ngoài @Transactional.

Atomic trong Java và Atomicity trong ACID khác nhau thế nào?

Atomic trong Java nói về thread khác không thấy trạng thái dở dang (gần với Isolation); Atomicity trong ACID nói về lỗi giữa chừng thì rollback toàn bộ.