SQL & Database — Thực chiến PostgreSQL/ACID deep dive — Atomicity, Consistency, Isolation, Durability thực sự là gì
~22 phútTransactions, ACID & MVCC lượt xem

ACID deep dive — Atomicity, Consistency, Isolation, Durability thực sự là gì

Phá huỷ misconception về ACID: Consistency không phải 'data luôn đúng', Atomicity không phải magic, Durability không phải 'ghi disk'. Hiểu cơ chế thực — undo log, WAL, fsync, group commit.

AI cũng nói ACID. Mọi tutorial đều có slide "Atomicity = all-or-nothing, Consistency = data luôn đúng, Isolation = các tx không ảnh hưởng nhau, Durability = ghi vào disk". Nghe quen. Nhưng khi phỏng vấn hỏi "Consistency trong ACID nghĩa là gì chính xác?" — đa số trả lời sai. Nhiều kỹ sư nghĩ ACID đảm bảo toàn bộ business logic đúng, rồi bị bất ngờ khi bug tiền vẫn xuất hiện dù database dùng transaction.

Thực tế, mỗi chữ trong ACID có một định nghĩa hẹp và cụ thể hơn rất nhiều so với cách thường được giải thích. Bài này mở từng chữ ra, xem cơ chế implement thực sự là gì, và chỉ ra đúng nơi ACID bảo vệ — cũng như đúng nơi bạn vẫn phải tự lo.

1. Analogy — ATM rút tiền

Hãy dùng ATM như metaphor sống: bạn bước đến máy, nhét thẻ, nhập số tiền, máy trừ tài khoản và nhả tiền mặt. Bốn bước, bốn câu hỏi ACID:

Câu hỏiATMACID property
Nếu máy mất điện sau khi trừ tài khoản nhưng chưa nhả tiền?Giao dịch phải rollback hoàn toàn — không mất tiềnAtomicity
Số dư có thể xuống âm không?Không — constraint balance >= 0 phải giữConsistency
Hai thẻ cùng một tài khoản rút cùng lúc có overdraw không?Không nếu isolation level đúngIsolation
Máy in receipt rồi mà reboot ngay sau đó — tiền có bị trừ lại không?Không — giao dịch đã committed không mấtDurability
Ai đảm bảo "tổng tiền trong hệ thống ngân hàng không đổi"?Application — không phải DB constraintBusiness invariant (ngoài ACID)
Ai đảm bảo "không rút quá hạn mức ngày"?Application — DB chỉ biết constraint, không biết rule nàyApplication-level logic
Ai đảm bảo receipt in đúng số?Application — DB lưu số, app format inPresentation layer
💡 Cách nhớ

ACID là tường bảo vệ chung quanh từng transaction. Nó đảm bảo transaction không bị xé đôi, constraint không bị vi phạm, các tx không thấy nhau ở trạng thái trung gian, và kết quả không mất sau crash. Nhưng nó không biết business rule của bạn là gì — đó là việc của application code.

2. Atomicity — không phải magic, là undo log và commit record

"All-or-nothing" nghe như phép thuật. Thực tế implement qua hai cơ chế:

Write-Ahead Log (WAL): Trước khi bất kỳ thay đổi nào được ghi xuống data page, PostgreSQL ghi một bản ghi WAL mô tả thay đổi đó — "TX 12345: UPDATE accounts SET balance = 900 WHERE id = 1 (truoc: 1000)". WAL flush trước, data page flush sau. Thứ tự này là bất biến.

Crash recovery: Khi server khởi động lại sau crash, PG đọc WAL từ checkpoint gần nhất:

  • WAL record có "TX 12345 COMMITTED" → redo thay đổi đó vào data page.
  • WAL record không có "COMMITTED" → transaction chưa commit khi crash → bỏ qua (với MVCC, các tuple chưa commit bị bỏ qua tự nhiên vì xmin của chúng chưa được đánh dấu committed trong pg_xact).

Commit record: Dòng WAL "TX 12345 COMMITTED" là ranh giới. Flush dòng này xuống disk = transaction durable và committed. Chưa flush = chưa commit, dù đã sửa bao nhiêu data page trong bộ nhớ.

-- Anatomy cua mot atomic transfer
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  -- WAL ghi: "TX 100: accounts.id=1 balance 1000->900"
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
  -- WAL ghi: "TX 100: accounts.id=2 balance 500->600"
COMMIT;
  -- WAL ghi: "TX 100 COMMITTED" -> flush -> return client
  -- Chi sau dong nay moi la committed

Nếu crash xảy ra trước dòng COMMITTED: PG restart thấy TX 100 không có commit record → coi như chưa tồn tại. Hai UPDATE kia không bao giờ được apply vào data page (hoặc nếu đã apply vào buffer, bị discard vì tuple có xmin = 100 và TX 100 không được đánh dấu committed trong pg_xact).

3. Consistency — chỉ là constraint, không phải business logic

Đây là chữ bị hiểu sai nhiều nhất. Định nghĩa chính xác từ Jim Gray 1981: một transaction đưa database từ một consistent state sang một consistent state khác — nghĩa là không vi phạm bất kỳ integrity constraint nào database đang enforce.

Database enforce được:

-- NOT NULL: khong the insert task khong co title
INSERT INTO tasks(project_id, title, status)
VALUES (1, NULL, 'todo');
-- ERROR: null value in column "title" violates not-null constraint

-- CHECK constraint: balance phai >= 0
CREATE TABLE accounts (
  id      BIGSERIAL PRIMARY KEY,
  user_id BIGINT NOT NULL REFERENCES users(id),
  balance NUMERIC(15,2) NOT NULL CHECK (balance >= 0)
);
UPDATE accounts SET balance = -50 WHERE id = 1;
-- ERROR: new row violates check constraint "accounts_balance_check"

-- UNIQUE: email phai doc nhat
INSERT INTO users(email, name) VALUES ('[email protected]', 'Duplicate');
-- ERROR: duplicate key value violates unique constraint "users_email_key"

-- FOREIGN KEY: task phai thuoc project ton tai
INSERT INTO tasks(project_id, title, status) VALUES (99999, 'Orphan', 'todo');
-- ERROR: insert or update violates foreign key constraint

Database KHÔNG enforce được (dù bạn muốn):

  • "Tổng debit = tổng credit trong mọi giao dịch kế toán" — đây là business invariant, không phải DB constraint. Database không biết luật kế toán tồn tại.
  • "User không thể comment trên task thuộc project mà user không phải thành viên" — cần CHECK phức tạp hoặc trigger, không tự nhiên được enforce.
  • "Số task đang làm (doing) trong một sprint không vượt quá 10" — không thể viết thành constraint đơn giản.

Những invariant này là trách nhiệm của application layer — validation logic, service layer, hoặc tầng domain model.

4. Isolation — tease, sẽ đi sâu ở bài 3 của module này

Database phải hành xử như mỗi transaction chạy một mình — dù thực tế có hàng chục transaction chạy đồng thời. Làm thế nào? Đây là câu hỏi phức tạp nhất trong ACID.

SQL chuẩn định nghĩa 4 isolation level (Read Uncommitted, Read Committed, Repeatable Read, Serializable) và 4 anomaly cần ngăn (dirty read, non-repeatable read, phantom read, serialization anomaly). PostgreSQL implement qua MVCC — mỗi transaction nhìn vào một snapshot nhất quán của data tại thời điểm transaction bắt đầu, không block lẫn nhau khi chỉ đọc.

Phần này sẽ được mở rộng đầy đủ ở bài 3 của module này (M06.3 — Isolation Levels và MVCC anomalies). Ở đây chỉ cần nhớ: Isolation KHÔNG có nghĩa là "các tx hoàn toàn không ảnh hưởng nhau" — mức độ cô lập phụ thuộc isolation level, và mỗi level có trade-off riêng về correctness vs throughput.

5. Durability — fsync WAL, không phải ghi disk magic

"Ghi vào disk" nghe đơn giản, nhưng OS có page cache — ghi xuống OS chưa chắc đã xuống disk vật lý. Server mất điện ngay sau đó, OS cache mất, data mất.

Durability PostgreSQL đảm bảo: sau khi COMMIT return về client, data không mất dù server crash ngay lập tức. Cơ chế:

  1. COMMIT trigger flush WAL buffer → WAL file trên disk.
  2. Flush thực hiện qua fsync() — gọi OS yêu cầu disk controller xác nhận data đã xuống persistent storage.
  3. Chỉ sau khi fsync() return, COMMIT mới return về client.
Client                  PG process              WAL file (disk)
  |                         |                        |
  |--- COMMIT ------------->|                        |
  |                         |--- WAL flush --------->|
  |                         |<--- fsync() OK --------|
  |<--- COMMIT OK ----------|                        |
  |                         |                        |
  (neu crash o day: WAL da tren disk -> recovery OK)

Group commit: nếu 100 transaction commit gần nhau trong cùng một khoảng thời gian ngắn, PG có thể gộp tất cả WAL record của chúng vào một lần fsync() duy nhất. Thay vì 100 fsync() (tốn kém), chỉ cần 1 fsync() cho 100 tx. Throughput tăng đáng kể; latency mỗi tx tăng nhẹ (phải chờ window group commit).

synchronous_commit = off — async commit:

-- Thay doi cho session hien tai
SET synchronous_commit = off;

-- Hoac thay doi cho mot transaction cu the
BEGIN;
  INSERT INTO analytics_events(event_type, payload) VALUES ('page_view', '{}');
COMMIT; -- Return ngay, KHONG cho fsync WAL

Với synchronous_commit = off: COMMIT return về client ngay khi WAL ghi vào RAM buffer của PG — không chờ fsync(). Crash ngay sau COMMIT? WAL chưa xuống disk, transaction có thể mất. Đây là tradeoff durability vs throughput — chấp nhận được cho workload analytics (mất vài page view không sao) nhưng không được cho chuyển khoản ngân hàng.

6. Vì sao group commit và async commit là trade-off

ModeThroughputLatencyDurabilityDùng khi
Sync commit (default)TốtCao hơnFullFinancial tx, audit log, mọi data quan trọng
Group commit (tự động)Rất tốtThêm vài msFullMặc định khi nhiều tx đến cùng lúc
Async commitTốt nhấtThấp nhấtPartial (có thể mất tx vừa commit)Analytics event, session tracking, log không quan trọng

PostgreSQL default là sync commit với group commit tự động khi tải cao — tradeoff hợp lý nhất cho OLTP. Async commit là opt-in, phải hiểu rõ trước khi bật.

7. Pitfall — ACID không thay thế app validation

Pitfall — ACID = 'DB an toàn' là misconception nguy hiểm

Pitfall 1 — Race condition vẫn xảy ra ở Read Committed (default PG): Hai transaction cùng đọc balance = 1000, cùng tính 1000 - 600 = 400, cùng ghi balance = 400. Kết quả: 1200 bị trừ mất, chỉ còn 400 thay vì phải còn -200 (hoặc reject). ACID Consistency chỉ đảm bảo balance >= 0 constraint — không đảm bảo lost update. Giải pháp: SELECT FOR UPDATE, Serializable isolation, hoặc optimistic locking. Chi tiết ở bài 3 của module này.

Pitfall 2 — Network failure giữa COMMIT và client: Transaction đã commit trên DB, nhưng network cắt trước khi response về tới client. Client không biết tx đã commit hay chưa. Nếu retry mù quáng → duplicate transaction. Giải pháp: idempotency key — mỗi request có unique key, server check trước khi apply.

Pitfall 3 — synchronous_commit = off cho audit log chuyển khoản: Dev bật async commit để tăng throughput, quên rằng bảng transfer_audit là legal requirement không được mất. Crash sau COMMIT → mất vài giây log → compliance violation. Rule: chỉ bật async commit khi bạn chủ động chấp nhận mất data đó.

8. Applied — TaskFlow chuyển khoản 2 account

Scenario: TaskFlow mở rộng sang feature "thanh toán milestone" — khi task hoàn thành, tiền từ tài khoản client chuyển sang tài khoản contractor. Đây là nơi Atomicity trở nên quan trọng nhất.

-- Demo schema phu them ngoai TaskFlow goc
-- (5 bang goc: users/projects/project_members/tasks/comments khong thay doi)
CREATE TABLE accounts (
  id      BIGSERIAL PRIMARY KEY,
  user_id BIGINT NOT NULL REFERENCES users(id),
  balance NUMERIC(15,2) NOT NULL CHECK (balance >= 0)
);

-- Seed du lieu test
INSERT INTO accounts(user_id, balance) VALUES
  (1, 1000.00),  -- client
  (2, 500.00);   -- contractor

-- Atomic transfer: tru tien client, cong tien contractor
BEGIN;

UPDATE accounts
  SET balance = balance - 100.00
  WHERE id = 1;
-- WAL ghi mutation nay

UPDATE accounts
  SET balance = balance + 100.00
  WHERE id = 2;
-- WAL ghi mutation nay

COMMIT;
-- WAL flush -> fsync -> return OK
-- Ca hai UPDATE hoac khong cai nao

Thử scenario thất bại — CHECK constraint bắt balance âm:

-- Client chi con 50, thu transfer 200
BEGIN;

UPDATE accounts SET balance = balance - 200.00 WHERE id = 1;
-- Lenh nay thanh cong (balance = -150 trong buffer)

UPDATE accounts SET balance = balance + 200.00 WHERE id = 2;
-- Cung thanh cong trong buffer

COMMIT;
-- CHECK (balance >= 0) duoc enforce khi ghi -> ROLLBACK toan bo
-- ERROR: new row for relation "accounts" violates check constraint
-- "accounts_balance_check"

Kết quả: cả hai UPDATE bị rollback — không trừ, không cộng. Atomicity giữ trạng thái nhất quán.

Lưu ý điều CHECK constraint KHÔNG làm: nếu hai transaction cùng đọc balance = 1000 và cùng trừ 800 — cả hai có thể pass CHECK (1000 - 800 = 200 ≥ 0) và commit thành công, kết quả balance = 200 thay vì phải là -600. Race condition này cần SELECT FOR UPDATE — xem bài 3 của module này.

-- Pattern dung cho concurrent transfer
BEGIN;

SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- Lock row id=1, transaction khac phai cho

UPDATE accounts SET balance = balance - 100.00 WHERE id = 1;
UPDATE accounts SET balance = balance + 100.00 WHERE id = 2;

COMMIT;

9. Deep Dive — Nguồn gốc và cơ chế ACID

📚 Deep Dive — Nguồn gốc và cơ chế ACID

10. Tóm tắt

  • Atomicity = undo log + commit record (WAL): WAL ghi mọi mutation trước khi flush data page; crash recovery redo WAL committed, bỏ qua WAL uncommitted. Không phải magic.
  • Consistency = constraint không bị vi phạm: DB enforce NOT NULL, CHECK, UNIQUE, FK — không hơn không kém. Business invariant là trách nhiệm của application.
  • Isolation = snapshot, sẽ đi sâu ở bài 3 của module này: 4 isolation level, 4 anomaly. PG dùng MVCC — reader không block writer và ngược lại.
  • Durability = fsync WAL trước khi COMMIT return: Không phải "ghi disk" chung chung — là fsync() xác nhận WAL đã ở persistent storage.
  • Group commit tự động gộp nhiều tx vào 1 fsync: Throughput tốt hơn với latency tăng nhẹ — mặc định trong PG.
  • synchronous_commit = off là opt-in trade-off: COMMIT return trước khi WAL xuống disk — có thể mất tx vừa commit nếu crash. Chỉ dùng cho data không quan trọng.
  • ACID không thay thế app validation: Race condition (lost update), network failure + duplicate, và business invariant vẫn cần application xử lý.
  • Bài tiếp theo: BEGIN, COMMIT, SAVEPOINT — kiểm soát transaction lifecycle

11. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao Consistency trong ACID KHÔNG đảm bảo 'data luôn đúng theo business logic'? Cho ví dụ một business invariant mà DB constraint không thể tự enforce.

Consistency trong ACID có định nghĩa hẹp: transaction chỉ được phép đưa database từ một trạng thái không vi phạm integrity constraint sang một trạng thái khác cũng không vi phạm constraint. DB "consistent" theo nghĩa này chỉ đơn giản là: sau transaction, không có constraint nào bị broken (NOT NULL, CHECK, UNIQUE, FK).

DB không biết business rule của bạn là gì trừ khi bạn encode thành constraint. Ví dụ: "tổng số tiền trong toàn bộ hệ thống phải bảo toàn qua mỗi giao dịch chuyển khoản" — đây là bất biến kế toán. DB chỉ có thể enforce từng constraint trên từng row (như balance >= 0), nhưng không tự biết rằng tổng tất cả balance phải không đổi. Ứng dụng phải viết logic: trừ X ở tài khoản nguồn, cộng X ở tài khoản đích — trong cùng một transaction. Nếu app code quên cộng bên đích, DB vẫn commit vui vẻ dù tiền biến mất.

Q2
Phân biệt WAL commit record và data page flush. Khi nào một transaction được coi là 'durable'? Điều gì xảy ra nếu server crash sau khi WAL commit record đã xuống disk nhưng data page chưa được flush?

Transaction được coi là durable khi và chỉ khi dòng WAL "TX N COMMITTED" đã được fsync() xuống persistent storage. Không cần data page đã xuống disk.

Nếu server crash sau khi WAL commit record đã xuống disk nhưng data page chưa flush: khi server khởi động lại, PG đọc WAL từ checkpoint, thấy "TX N COMMITTED" → redo toàn bộ thay đổi của TX N vào data page từ WAL record. Data được phục hồi hoàn toàn. Đây chính là lý do WAL được gọi là "write-ahead" — log ghi trước data, nên log là nguồn truth để recovery.

Q3
Khi nào nên dùng `synchronous_commit = off`? Tradeoff cụ thể là gì — và workload nào KHÔNG bao giờ được dùng setting này?

Nên dùng khi: workload chấp nhận mất một lượng nhỏ data vừa committed nếu server crash. Ví dụ: analytics event tracking (mất vài page view không ảnh hưởng business), session activity log, A/B test impression counter, cache warm-up log.

Tradeoff: COMMIT return ngay khi WAL ghi vào RAM buffer, không chờ fsync. Nếu crash trong khoảng vài trăm millisecond sau COMMIT, WAL chưa xuống disk → transaction mất dù client đã nhận OK. Latency mỗi COMMIT giảm đáng kể (không chờ disk I/O), throughput tăng.

KHÔNG bao giờ dùng cho: chuyển khoản tài chính, audit log có yêu cầu pháp lý (compliance), order management, inventory update, bất kỳ data nào mà mất một record là bug nghiêm trọng. Nguyên tắc: nếu mất data đó phải tìm và fix, đừng dùng async commit.

Q4
Vì sao CHECK constraint `balance >= 0` không đủ để ngăn race condition overdraft khi hai transaction đồng thời rút tiền từ cùng một tài khoản? Cơ chế cụ thể dẫn đến bug?

Scenario: account có balance = 1000. TX A và TX B cùng muốn rút 800.

Timeline với Read Committed (default PG):

  1. TX A đọc balance = 1000. Tính 1000 - 800 = 200. Ghi balance = 200. CHECK: 200 >= 0 → OK.
  2. TX B đọc balance = 1000 (snapshot trước commit của TX A, hoặc TX A chưa commit). Tính 1000 - 800 = 200. Ghi balance = 200. CHECK: 200 >= 0 → OK.
  3. Cả hai commit. Balance = 200. Nhưng đáng lẽ phải reject TX B (1000 - 800 - 800 = -600 < 0).

CHECK constraint chỉ evaluate giá trị tại thời điểm ghi row, với giá trị bạn đang ghi (200) — không biết TX khác đang đồng thời ghi gì. Giải pháp: SELECT FOR UPDATE lock row trước khi đọc, buộc TX B chờ TX A commit xong mới đọc balance mới (200), tính 200 - 800 = -600 → CHECK fail → rollback đúng.

Q5
Group commit hoạt động thế nào? Trong trường hợp nào group commit tự động kích hoạt, và tradeoff với latency là gì?

Group commit: khi nhiều transaction commit gần nhau về thời gian, PG gộp WAL record của tất cả chúng vào một lần fsync() duy nhất. Thay vì mỗi transaction phải chờ một fsync() riêng (tốn khoảng 1-10ms mỗi lần trên SSD), 100 transaction chỉ cần 1 fsync() chung.

Tự động kích hoạt khi: nhiều transaction đang chờ commit cùng lúc — tức là khi tải cao. Ở tải thấp (ít tx đồng thời), group commit ít hiệu quả hơn vì không có đủ tx để gộp.

Tradeoff latency: mỗi transaction phải chờ một khoảng thời gian ngắn để PG tích lũy đủ tx vào nhóm trước khi fsync. Latency mỗi tx tăng nhẹ (thêm vài ms) nhưng throughput tổng thể tăng đáng kể. Đây là tradeoff "latency mỗi request" vs "throughput tổng hệ thống" — chấp nhận được với OLTP thông thường.

Q6
Network timeout xảy ra sau khi DB đã COMMIT nhưng trước khi response về tới client. Client không biết tx đã commit hay chưa, và retry có thể gây duplicate. Thiết kế giải pháp idempotency key cho transfer scenario.

Vấn đề: Atomicity đảm bảo DB không bị inconsistent. Nhưng client không biết tx đã commit hay chưa → retry mù quáng → duplicate transfer.

Giải pháp idempotency key:

  1. Client tạo transfer_id UUID (ví dụ UUID v4) trước khi gửi request.
  2. Server nhận request: SELECT id FROM transfers WHERE transfer_id = $1. Nếu đã tồn tại → return kết quả cũ (transfer đã xảy ra), không tạo mới.
  3. Nếu chưa tồn tại → BEGIN → INSERT transfer record với transfer_id → UPDATE accounts → COMMIT.
  4. Client retry với cùng transfer_id: server thấy đã xử lý → return idempotent response.

Schema bổ sung: thêm cột transfer_id UUID UNIQUE NOT NULL vào bảng transfers. UNIQUE constraint đảm bảo dù hai request đến đồng thời, chỉ một INSERT thành công — cái kia fail với unique violation và có thể retry an toàn.

Bài tiếp theo: BEGIN, COMMIT, SAVEPOINT — kiểm soát transaction lifecycle

Bài này có giúp bạn hiểu bản chất không?