1. Transaction là gì?
Một transaction (giao dịch) là một chuỗi các thao tác được DB xử lý như một đơn vị nguyên tử duy nhất. Hoặc tất cả thành công và được commit, hoặc tất cả thất bại và rollback.
Ví dụ kinh điển: chuyển tiền từ A sang B.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- A
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- B
COMMIT;
Nếu giữa 2 UPDATE server crash, hoặc UPDATE thứ 2 lỗi (account #2 không tồn tại), cả 2 phải hoàn tác. Không thể có trường hợp tiền A bị trừ mà B không nhận.
1.1. Vòng đời transaction
1.2. Cú pháp
-- Bắt đầu:
BEGIN; -- hoặc START TRANSACTION;
-- Trong transaction:
UPDATE ...;
INSERT ...;
-- Kết thúc:
COMMIT; -- xác nhận, dữ liệu lưu
-- HOẶC
ROLLBACK; -- hủy, mọi thay đổi hoàn tác
-- Đánh mốc:
SAVEPOINT s1;
ROLLBACK TO SAVEPOINT s1;
RELEASE SAVEPOINT s1;
2. ACID — bốn chữ định nghĩa "đáng tin"
| Chữ | Tên | Đảm bảo |
|---|---|---|
| A | Atomicity | Tất-cả-hoặc-không-gì-cả. Không có "thành công nửa chừng". |
| C | Consistency | Sau transaction, mọi constraint (FK/CHECK/UNIQUE) thỏa mãn. |
| I | Isolation | Transactions chạy đồng thời như chạy tuần tự (ở mức cao nhất). |
| D | Durability | Sau COMMIT, dữ liệu sống sót qua mọi crash. |
4 tính chất này không độc lập — chúng phụ thuộc nhau. Hệ phân tán phải đánh đổi (CAP). Single-node Postgres/MySQL/Oracle đều hỗ trợ ACID đầy đủ.
Ngược lại, nhiều NoSQL chỉ BASE: Basically Available, Soft state, Eventual consistency — nhanh hơn, scale tốt hơn, nhưng dữ liệu có thể "hơi sai" trong khoảng ngắn (xem Ch9).
3. Atomicity — implementation
Postgres triển khai atomicity qua WAL (Write-Ahead Log) + MVCC:
- Mỗi transaction có 1 transaction ID (XID).
- UPDATE thực ra tạo phiên bản mới của row (dead tuple cũ giữ lại tạm).
- Mọi thay đổi ghi WAL trước khi áp vào data file.
- COMMIT ghi 1 record "commit record" vào WAL → fsync để đảm bảo durability.
- ROLLBACK chỉ cần không ghi commit record → các phiên bản mới sẽ bị vacuum dọn.
Ý nghĩa: ROLLBACK không "undo" theo nghĩa sửa lại data. Nó chỉ đánh dấu xid là aborted — các tuple version có xid đó tự động bị bỏ qua.
3.1. Implicit transaction
Mỗi câu SQL đơn lẻ ngoài BEGIN tự động chạy trong 1 transaction ngầm + auto-commit. Nghĩa là:
UPDATE users SET name = 'X' WHERE id = 1;
-- Tương đương:
-- BEGIN; UPDATE ...; COMMIT;
Để bao 1 nhóm thao tác, phải explicit BEGIN.
4. Consistency — vai trò của constraint
Consistency nói: "Sau khi transaction commit, mọi ràng buộc dữ liệu (FK, UNIQUE, CHECK, NOT NULL) đều thỏa."
Đây là tính chất nửa app, nửa DB:
- DB enforce mọi constraint declaratively. Nếu vi phạm → tự động ROLLBACK transaction.
- App có nhiệm vụ chỉ commit khi data hợp lý nghiệp vụ.
Constraint enforcement có thể là IMMEDIATE (default — check sau mỗi statement) hoặc DEFERRED (check ở COMMIT) cho FK:
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED
);
BEGIN;
INSERT INTO orders (id, user_id) VALUES (1, 100); -- user 100 chưa tồn tại, OK
INSERT INTO users (id, ...) VALUES (100, ...); -- giờ tạo user
COMMIT; -- ở đây mới check FK
Hữu ích cho seed data / circular FK.
5. Durability — Write-Ahead Log (WAL)
Khi DB nói "COMMIT thành công", dữ liệu phải tồn tại sau khi mất điện ngay sau đó.
5.1. WAL là gì?
WAL (Write-Ahead Log) = log file ghi trước mọi thay đổi vào data file. Trình tự:
Sau crash, restart:
- Đọc lại WAL từ điểm checkpoint cuối.
- Replay mọi commit record vào data file.
- Ignore mọi xid chưa commit.
- DB lên trạng thái nhất quán.
5.2. Vì sao WAL nhanh?
- WAL ghi tuần tự (sequential write) — disk fast nhất.
- Data files có thể flush asynchronous sau, không cản đường commit.
- 1 fsync WAL có thể "đóng gói" commit của nhiều transaction (group commit).
5.3. Replication và backup dùng WAL
WAL stream sang server khác → replication realtime. Lưu WAL archive → Point-in-Time Recovery. Sẽ học ở Ch10 và Ch12.
5.4. synchronous_commit tradeoff
-- Default ON: chờ fsync trước khi ack COMMIT (an toàn)
SET synchronous_commit = on;
-- OFF: ack COMMIT trước, fsync sau (nhanh, có nguy cơ mất vài ms commit gần nhất nếu crash)
SET synchronous_commit = off;
Trade-off: nếu tolerate mất vài commit (log, analytics), set off để tăng throughput. Tài chính/đặt hàng — luôn để on.
6. Read Anomalies — vì sao cần Isolation
Khi nhiều transaction chạy đồng thời, nếu không có isolation, các anomaly sau có thể xảy ra:
6.1. Dirty Read
T1 đọc dữ liệu T2 đã ghi nhưng chưa commit. Sau đó T2 rollback → T1 đã đọc dữ liệu "ma".
6.2. Non-Repeatable Read (Fuzzy Read)
T1 đọc cùng row 2 lần trong 1 transaction, kết quả khác nhau vì T2 đã commit update giữa chừng.
6.3. Phantom Read
T1 chạy cùng SELECT 2 lần với điều kiện range, lần 2 thấy dòng MỚI xuất hiện do T2 INSERT + commit.
6.4. Lost Update
2 transaction cùng read-modify-write 1 row. Update sau đè lên update trước — cập nhật đầu tiên bị mất.
6.5. Write Skew
Phức tạp hơn: 2 transaction read overlap rồi write disjoint, mỗi cái đúng riêng nhưng tổ hợp vi phạm constraint.
Ví dụ: bệnh viện yêu cầu "luôn có ≥ 1 bác sĩ trực". 2 bác sĩ cùng nghỉ:
7. Bốn Isolation Level (SQL Standard)
| Level | Dirty Read | Non-Repeatable | Phantom | Lost Update |
|---|---|---|---|---|
| READ UNCOMMITTED | ✗ có thể | ✗ có thể | ✗ có thể | ✗ có thể |
| READ COMMITTED | ✓ ngăn | ✗ có thể | ✗ có thể | ✗ có thể |
| REPEATABLE READ | ✓ ngăn | ✓ ngăn | ✗ có thể* | ✓ ngăn (Postgres) |
| SERIALIZABLE | ✓ ngăn | ✓ ngăn | ✓ ngăn | ✓ ngăn |
* Postgres REPEATABLE READ cũng ngăn được Phantom (snapshot isolation chặt). Standard nói "có thể có".
7.1. READ UNCOMMITTED
Cho phép đọc dữ liệu chưa commit. Không có DB phổ biến nào thực sự dùng level này:
- Postgres KHÔNG hỗ trợ — set thành READ COMMITTED.
- MySQL hỗ trợ nhưng ít ai dùng.
7.2. READ COMMITTED (default đa số DB)
Mỗi statement chỉ thấy dữ liệu đã commit ở thời điểm statement đó bắt đầu.
Dirty read: ngăn. Non-repeatable read: VẪN CÓ (2 SELECT trong 1 transaction có thể khác nhau).
Đây là default của Postgres và Oracle. Đủ cho 90% workload OLTP.
7.3. REPEATABLE READ
Snapshot xác lập ở thời điểm transaction bắt đầu. Mọi SELECT trong transaction thấy cùng snapshot, dù người khác commit gì.
Postgres dùng Snapshot Isolation: lấy snapshot xid ở BEGIN, chỉ thấy version row có xid < snapshot và đã commit.
7.4. SERIALIZABLE
"As if" các transaction chạy tuần tự. Ngăn mọi anomaly bao gồm write skew.
Postgres triển khai bằng SSI (Serializable Snapshot Isolation) — phát hiện chu trình rw-conflict, abort 1 transaction.
Đảm bảo cao nhất, nhưng có thể có nhiều ROLLBACK do conflict → app phải retry.
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- hoặc:
BEGIN ISOLATION LEVEL SERIALIZABLE;
8. Postgres reality — MVCC & SSI
8.1. MVCC — Multi-Version Concurrency Control
Postgres KHÔNG dùng read locks. Thay vào đó:
- Mỗi UPDATE tạo version mới của tuple (old version còn).
- Mỗi transaction có 1 snapshot xid → chỉ thấy version visible với snapshot.
- Read và write không block nhau.
- Vacuum định kỳ dọn dead tuple.
MVCC là lý do Postgres có concurrency cực mạnh nhưng cần vacuum để tránh table bloat. Sẽ học sâu ở Ch7.
8.2. Postgres isolation thực tế
| Level Postgres | Tương đương | Phantom? | Write skew? |
|---|---|---|---|
| READ COMMITTED (default) | SQL READ COMMITTED | Có thể | Có thể |
| REPEATABLE READ | Snapshot Isolation (chặt hơn standard) | Ngăn | Có thể |
| SERIALIZABLE | SSI | Ngăn | Ngăn |
8.3. Set isolation
-- Mức transaction:
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- ...
COMMIT;
-- Mức session:
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- Read-only optimize:
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
9. Hands-on demo — mở 2 client psql
Setup:
CREATE TABLE accounts (id INT PRIMARY KEY, balance INT);
INSERT INTO accounts VALUES (1, 100), (2, 100);
9.1. Demo non-repeatable read (READ COMMITTED)
Session A
BEGIN;
SELECT balance FROM accounts WHERE id=1;
-- 100
-- (sau B commit)
SELECT balance FROM accounts WHERE id=1;
-- 200 ← Khác kết quả lần đầu!
COMMIT;
Session B
BEGIN;
UPDATE accounts SET balance = 200
WHERE id = 1;
COMMIT;
9.2. Demo Snapshot Isolation (REPEATABLE READ)
Thay đổi: Session A dùng BEGIN ISOLATION LEVEL REPEATABLE READ;
Session A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id=1;
-- 100
-- (sau B commit)
SELECT balance FROM accounts WHERE id=1;
-- 100 ← Vẫn snapshot ban đầu ✓
COMMIT;
Session B
BEGIN;
UPDATE accounts SET balance = 200
WHERE id = 1;
COMMIT;
9.3. Demo lost update (READ COMMITTED) → fix bằng SELECT FOR UPDATE
Session A — chưa fix
BEGIN;
SELECT balance FROM accounts WHERE id=1;
-- 100
-- App tính: new = 100 + 10 = 110
UPDATE accounts SET balance = 110
WHERE id = 1;
COMMIT;
-- balance bây giờ là 110 ❌
-- nhưng B vừa cộng 20 đã mất
Session B — chưa fix
BEGIN;
SELECT balance FROM accounts WHERE id=1;
-- 100
-- App tính: 100 + 20 = 120
UPDATE accounts SET balance = 120
WHERE id = 1;
COMMIT; -- giả sử commit TRƯỚC A
-- balance = 120, sau đó A đè lên 110
Fix bằng SELECT FOR UPDATE (xem Ch7) hoặc UPDATE atomic:
-- Tốt nhất nếu logic đơn giản:
UPDATE accounts SET balance = balance + 10 WHERE id = 1;
UPDATE accounts SET balance = balance + 20 WHERE id = 1;
-- Mỗi UPDATE đều atomic, kết quả: 130 đúng
9.4. Demo Write Skew (cần SERIALIZABLE)
Setup constraint: "ít nhất 1 bác sĩ trực".
Dr A — REPEATABLE READ
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM doctors
WHERE on_duty = TRUE;
-- 2
UPDATE doctors SET on_duty = FALSE
WHERE id = 1;
COMMIT;
Dr B — REPEATABLE READ
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM doctors
WHERE on_duty = TRUE;
-- 2
UPDATE doctors SET on_duty = FALSE
WHERE id = 2;
COMMIT;
-- Cả 2 commit thành công, 0 bác sĩ trực ❌
Với SERIALIZABLE, Postgres sẽ phát hiện rw-conflict và abort 1 transaction. App retry.
10. Anti-patterns & Best practices
10.1. Long-running transactions
Giữ transaction mở 30 phút → giữ snapshot, ngăn vacuum, gây bloat. Giữ transaction ngắn nhất có thể. Đừng:
- Mở transaction trước khi gọi API ngoài.
- Mở transaction để đọc/sleep/render UI.
- Bỏ quên transaction trong code (rồi exception).
10.2. Wrap mỗi câu trong BEGIN/COMMIT thừa thãi
Nếu chỉ chạy 1 SELECT, không cần BEGIN/COMMIT — auto-commit đã đủ.
10.3. Không retry trên SERIALIZABLE
SERIALIZABLE có thể abort do conflict → app phải retry với backoff. Code phải có wrap retry.
async function withRetry<T>(fn: () => Promise<T>, maxRetry = 3): Promise<T> {
for (let i = 0; i < maxRetry; i++) {
try { return await fn(); }
catch (e: any) {
if (e.code === '40001') { // serialization_failure
await sleep(2 ** i * 50);
continue;
}
throw e;
}
}
throw new Error('max retry');
}
10.4. Đếm sai trong transaction
-- ❌ Race condition (lost update style):
BEGIN;
SELECT balance FROM accounts WHERE id=1; -- 100
-- app tính 100 + 50 = 150
UPDATE accounts SET balance = 150 WHERE id=1;
COMMIT;
-- ✓ Atomic update:
UPDATE accounts SET balance = balance + 50 WHERE id=1;
10.5. Transaction lồng (nested)
SQL không có transaction lồng thật. BEGIN ... BEGIN ... bỏ qua BEGIN thứ 2. Dùng SAVEPOINT thay thế.
11. Bài tập
Cài Postgres local, mở 2 cửa sổ psql:
- Reproduce non-repeatable read ở READ COMMITTED. Sau đó chuyển sang REPEATABLE READ và verify ngăn được.
- Tạo bảng
seats(id, available BOOLEAN). 2 user cùng book ghế cuối cùng. Demo lost update. Sửa bằngSELECT FOR UPDATE. - Demo write skew với 2 doctors như mục 9.4. Chuyển sang SERIALIZABLE, observe abort.
- Viết hàm Node.js (hoặc TS) chạy chuyển tiền giữa 2 account, retry tự động khi gặp serialization failure.
- Vì sao long-running transaction nguy hiểm với MVCC? Hệ quả về vacuum, table bloat?
12. Quiz
Quiz cuối Chương 6
Atomicity nghĩa là:
Default isolation level của Postgres và Oracle là:
Phantom Read xảy ra khi:
Postgres dùng MVCC nghĩa là:
Cách an toàn để cộng thêm 10 vào balance:
balance = balance + N tránh lost update vì DB tự lock row trong UPDATE. Read-then-write trong app gặp race nếu không có FOR UPDATE.Write skew chỉ ngăn được hoàn toàn ở:
WAL (Write-Ahead Log) đảm bảo tính:
Transaction kéo dài 30 phút trong Postgres gây vấn đề gì?
idle_in_transaction_session_timeout.Hoàn thành Chương 6. Tiếp theo: Chương 7 — Concurrency Control: Locking & MVCC →