Hiểu đúng 4 SQL Isolation Level: từ Read Uncommitted đến Serializable
On this page
Khi nhiều transaction cùng chạy trên một database, chúng “nhìn thấy” dữ liệu của nhau đến mức nào? Câu trả lời nằm ở isolation level — chữ “I” trong ACID.
TL;DR — Tóm gọn trong 1 phút#
SQL standard (ANSI/ISO SQL-92) định nghĩa 4 isolation level, xếp từ lỏng đến chặt:
| Isolation Level | Dirty Read | Non-repeatable Read | Phantom Read | Performance |
|---|---|---|---|---|
| Read Uncommitted | ⚠️ Có thể | ⚠️ Có thể | ⚠️ Có thể | Nhanh nhất |
| Read Committed | ✅ Không | ⚠️ Có thể | ⚠️ Có thể | Nhanh |
| Repeatable Read | ✅ Không | ✅ Không | ⚠️ Có thể* | Trung bình |
| Serializable | ✅ Không | ✅ Không | ✅ Không | Chậm nhất |
* Trên thực tế, PostgreSQL và MySQL InnoDB đều ngăn phantom read ở Repeatable Read trong phần lớn trường hợp — xem chi tiết bên dưới.
Ba điều cần nhớ:
- Isolation level càng cao → càng an toàn → càng ít concurrency (nhiều lock hơn, hoặc nhiều transaction bị abort hơn).
- Default khác nhau theo DB: PostgreSQL, SQL Server, Oracle dùng Read Committed; MySQL (InnoDB) dùng Repeatable Read.
- Standard chỉ quy định “anomaly nào bị cấm”, không quy định cách implement. Vì vậy cùng một tên level nhưng mỗi DB hành xử hơi khác nhau.
flowchart LR
RU[Read Uncommitted] --> RC[Read Committed] --> RR[Repeatable Read] --> S[Serializable]
RU -.-|"Concurrency cao, rủi ro cao"| RU
S -.-|"An toàn tuyệt đối, chi phí cao"| S
1. Bối cảnh: tại sao cần isolation level?#
Nếu mọi transaction chạy tuần tự (hết cái này mới tới cái kia), dữ liệu luôn đúng — nhưng throughput sẽ tệ. Database cho phép transaction chạy đồng thời, và cái giá phải trả là các read phenomena (hay anomaly): những tình huống một transaction đọc được dữ liệu “không nhất quán” do transaction khác gây ra.
Isolation level thực chất là một bản hợp đồng: bạn chọn chấp nhận anomaly nào để đổi lấy performance.
2. Ba read phenomena cốt lõi#
Trước khi đi vào từng level, cần hiểu rõ 3 anomaly mà SQL standard dùng để định nghĩa chúng.
2.1. Dirty Read#
Transaction đọc được dữ liệu mà transaction khác chưa commit. Nếu transaction kia rollback, bạn đã đọc một giá trị “không bao giờ tồn tại”.
sequenceDiagram
participant T1 as Transaction 1
participant DB as Database
participant T2 as Transaction 2
Note over DB: balance = 100
T1->>DB: UPDATE accounts SET balance = 50
Note over DB: balance = 50 (chưa commit)
T2->>DB: SELECT balance
DB-->>T2: 50 (dirty data)
T1->>DB: ROLLBACK
Note over DB: balance = 100
Note over T2: T2 đang dùng giá trị 50 không có thật
2.2. Non-repeatable Read#
Trong cùng một transaction, đọc cùng một row hai lần nhưng ra hai giá trị khác nhau, vì giữa hai lần đọc có transaction khác đã UPDATE (hoặc DELETE) và commit.
sequenceDiagram
participant T1 as Transaction 1
participant DB as Database
participant T2 as Transaction 2
T1->>DB: SELECT balance WHERE id = 1
DB-->>T1: 100
T2->>DB: UPDATE accounts SET balance = 200 WHERE id = 1
T2->>DB: COMMIT
T1->>DB: SELECT balance WHERE id = 1
DB-->>T1: 200
Note over T1: Cùng query, cùng row, kết quả khác nhau
2.3. Phantom Read#
Trong cùng một transaction, chạy cùng một query có điều kiện (range query) hai lần nhưng tập kết quả khác nhau — xuất hiện row mới (hoặc mất row), vì transaction khác đã INSERT/DELETE và commit.
Khác biệt với non-repeatable read: non-repeatable read là row cũ bị thay đổi giá trị; phantom read là tập row bị thay đổi.
sequenceDiagram
participant T1 as Transaction 1
participant DB as Database
participant T2 as Transaction 2
T1->>DB: SELECT COUNT(*) FROM orders WHERE amount > 1000
DB-->>T1: 5
T2->>DB: INSERT INTO orders (amount) VALUES (5000)
T2->>DB: COMMIT
T1->>DB: SELECT COUNT(*) FROM orders WHERE amount > 1000
DB-->>T1: 6
Note over T1: Một "phantom row" vừa xuất hiện
2.4. Các anomaly ngoài standard (nên biết)#
SQL-92 chỉ nói về 3 anomaly trên, nhưng thực tế còn vài loại quan trọng không kém:
- Lost Update: hai transaction cùng đọc một giá trị, cùng tính toán rồi cùng ghi đè — update của một bên bị mất. Ví dụ kinh điển: hai request cùng tăng
view_counttừ 10 lên 11, kết quả là 11 thay vì 12. - Write Skew: hai transaction đọc cùng một tập dữ liệu, mỗi bên update một row khác nhau dựa trên điều kiện đã đọc, và kết quả chung vi phạm business rule. Ví dụ: rule “luôn phải có ít nhất 1 bác sĩ trực”, hai bác sĩ cùng xin nghỉ cùng lúc, mỗi transaction đều thấy “vẫn còn người kia” nên cả hai đều được duyệt.
Write skew là lý do chính khiến Repeatable Read (snapshot isolation) vẫn chưa phải Serializable.
3. Chi tiết từng Isolation Level#
3.1. Read Uncommitted#
Định nghĩa: level thấp nhất, cho phép đọc dữ liệu chưa commit của transaction khác.
Cho phép: dirty read, non-repeatable read, phantom read.
Cách hoạt động (với DB dùng locking): khi đọc, transaction không lấy shared lock và không tôn trọng exclusive lock của người khác — nó cứ thế đọc phiên bản mới nhất trong buffer, kể cả khi chưa commit.
Thực tế từng DB:
- SQL Server: hỗ trợ thật sự, tương đương hint
WITH (NOLOCK). - MySQL InnoDB: hỗ trợ thật sự.
- PostgreSQL: chấp nhận cú pháp nhưng hành xử y như Read Committed — PostgreSQL không bao giờ cho dirty read.
- Oracle: không hỗ trợ.
Khi nào dùng? Gần như không nên dùng cho logic nghiệp vụ. Chỉ chấp nhận được khi cần con số ước lượng trên bảng lớn, ví dụ dashboard monitoring “khoảng bao nhiêu row”, nơi sai lệch nhỏ không quan trọng và bạn không muốn bị block bởi các transaction ghi.
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT COUNT(*) FROM huge_log_table;
3.2. Read Committed#
Định nghĩa: mỗi câu lệnh chỉ nhìn thấy dữ liệu đã được commit tại thời điểm câu lệnh đó bắt đầu.
Ngăn chặn: dirty read. Vẫn cho phép: non-repeatable read, phantom read, lost update.
Điểm mấu chốt: snapshot được lấy theo từng statement, không phải theo cả transaction. Hai câu SELECT trong cùng transaction có thể thấy hai “thế giới” khác nhau.
Cách hoạt động:
- MVCC (PostgreSQL, Oracle, MySQL InnoDB, SQL Server với
READ_COMMITTED_SNAPSHOT ON): mỗi statement tạo một snapshot mới, đọc phiên bản đã commit gần nhất. Reader không block writer, writer không block reader. - Locking (SQL Server mặc định): lấy shared lock khi đọc và nhả ngay sau khi đọc xong row (không giữ tới cuối transaction). Vì vậy reader có thể bị block bởi writer đang giữ exclusive lock.
flowchart TB
subgraph TX["Transaction (Read Committed)"]
S1["Statement 1: SELECT"] --> SN1["Snapshot tại thời điểm t1"]
S2["Statement 2: SELECT"] --> SN2["Snapshot tại thời điểm t2"]
end
C["Transaction khác COMMIT giữa t1 và t2"] -.-> SN2
Cái bẫy phổ biến — Lost Update ở Read Committed:
-- Cả hai transaction cùng chạy đoạn này đồng thời
BEGIN;
SELECT stock FROM products WHERE id = 1; -- cả hai đều đọc được 10
-- application tính: new_stock = 10 - 1 = 9
UPDATE products SET stock = 9 WHERE id = 1;
COMMIT;
-- Kết quả: stock = 9, nhưng đúng ra phải là 8
Cách khắc phục mà không cần tăng isolation level:
-- Cách 1: atomic update, để DB tự tính
UPDATE products SET stock = stock - 1 WHERE id = 1;
-- Cách 2: pessimistic locking
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- Cách 3: optimistic locking với cột version
UPDATE products SET stock = 9, version = version + 1
WHERE id = 1 AND version = 5; -- kiểm tra affected rows = 1
Khi nào dùng? Đây là lựa chọn mặc định hợp lý cho phần lớn ứng dụng OLTP. Kết hợp với SELECT ... FOR UPDATE, atomic update hoặc optimistic locking ở những chỗ cần thiết là đủ cho đa số use case.
3.3. Repeatable Read#
Định nghĩa: nếu transaction đã đọc một row, các lần đọc sau của chính row đó trong cùng transaction sẽ luôn trả về cùng giá trị.
Ngăn chặn: dirty read, non-repeatable read. Theo standard vẫn cho phép: phantom read.
Cách hoạt động:
- Locking: giữ shared lock trên các row đã đọc cho tới khi transaction kết thúc. Transaction khác không thể update các row này, nhưng vẫn có thể insert row mới khớp điều kiện → phantom.
- MVCC: snapshot được lấy một lần duy nhất khi transaction bắt đầu (chính xác hơn là tại statement đầu tiên) và dùng cho toàn bộ transaction. Mô hình này thường được gọi là Snapshot Isolation.
flowchart TB
subgraph TX["Transaction (Repeatable Read / MVCC)"]
B["BEGIN + statement đầu tiên"] --> SN["Snapshot duy nhất tại t0"]
S1["Statement 1"] --> SN
S2["Statement 2"] --> SN
S3["Statement N"] --> SN
end
C["Các COMMIT khác sau t0"] -. "không nhìn thấy" .-x SN
Khác biệt quan trọng giữa các DB:
| PostgreSQL | MySQL InnoDB | |
|---|---|---|
| Là default? | Không | Có |
| Cơ chế | Snapshot Isolation thuần | MVCC cho consistent read + gap lock / next-key lock cho locking read |
Phantom read với SELECT thường |
Không xảy ra (snapshot) | Không xảy ra (snapshot) |
| Khi hai transaction cùng update một row | Transaction sau bị lỗi could not serialize access due to concurrent update → cần retry |
Transaction sau chờ lock, rồi update trên giá trị mới nhất (không theo snapshot) |
| Write skew | Có thể xảy ra | Có thể xảy ra |
Lưu ý với MySQL: SELECT thường đọc từ snapshot, nhưng UPDATE, DELETE, SELECT ... FOR UPDATE là current read — đọc bản mới nhất. Việc trộn hai kiểu đọc này trong một transaction có thể cho kết quả bất ngờ: SELECT không thấy row, nhưng UPDATE với cùng điều kiện lại tác động lên row đó.
Ví dụ write skew ở Repeatable Read:
sequenceDiagram
participant A as TX Bác sĩ A
participant DB as Database
participant B as TX Bác sĩ B
Note over DB: A và B đang trực (on_call = 2)
A->>DB: SELECT COUNT(*) WHERE on_call = true
DB-->>A: 2
B->>DB: SELECT COUNT(*) WHERE on_call = true
DB-->>B: 2
A->>DB: UPDATE doctors SET on_call = false WHERE name = A
B->>DB: UPDATE doctors SET on_call = false WHERE name = B
A->>DB: COMMIT
B->>DB: COMMIT
Note over DB: on_call = 0, vi phạm business rule
Hai transaction update hai row khác nhau nên không xung đột ghi, snapshot isolation không phát hiện được vấn đề.
Khi nào dùng? Báo cáo hoặc batch job cần một “bức ảnh” nhất quán của dữ liệu qua nhiều query (ví dụ: tổng hợp số liệu cuối ngày, export dữ liệu), hoặc khi bạn dùng MySQL và chấp nhận default.
3.4. Serializable#
Định nghĩa: level cao nhất. Kết quả của việc chạy đồng thời các transaction phải tương đương với một thứ tự chạy tuần tự nào đó của chúng.
Ngăn chặn: tất cả — dirty read, non-repeatable read, phantom read, lost update, write skew.
flowchart LR
subgraph Concurrent["Thực tế: chạy đồng thời"]
T1a[T1] --- T2a[T2] --- T3a[T3]
end
subgraph Serial["Tương đương: một thứ tự tuần tự nào đó"]
T2b[T2] --> T1b[T1] --> T3b[T3]
end
Concurrent == "kết quả giống hệt" ==> Serial
Ba cách implement phổ biến:
-
Two-Phase Locking (2PL) + range lock — SQL Server, MySQL InnoDB. Giữ shared lock trên mọi thứ đã đọc tới cuối transaction, kèm range lock / next-key lock để chặn insert vào khoảng đã đọc. An toàn nhưng dễ deadlock và block nhiều. Trong MySQL, ở level này mọi
SELECTthường được ngầm chuyển thànhSELECT ... LOCK IN SHARE MODE(khi autocommit tắt). -
Serializable Snapshot Isolation (SSI) — PostgreSQL (từ 9.1). Chạy như snapshot isolation (không block), nhưng theo dõi các read/write dependency giữa các transaction. Khi phát hiện một cấu trúc nguy hiểm có thể dẫn tới kết quả không serializable, DB sẽ abort một transaction với lỗi
could not serialize access due to read/write dependencies among transactions. Đây là cách optimistic: ít block, nhưng app bắt buộc phải có retry logic. -
Chạy tuần tự thật — một số DB in-memory (VoltDB, Redis với Lua script). Mỗi partition chỉ chạy một transaction một lúc.
Lưu ý với Oracle: level có tên SERIALIZABLE của Oracle thực chất là snapshot isolation, vẫn có thể bị write skew.
Pattern retry bắt buộc:
MAX_RETRIES = 5
for attempt in range(MAX_RETRIES):
try:
with conn.transaction(isolation_level="SERIALIZABLE"):
do_business_logic(conn)
break
except SerializationFailure: # SQLSTATE 40001
if attempt == MAX_RETRIES - 1:
raise
sleep(backoff(attempt))
Khi nào dùng? Nghiệp vụ tài chính, đặt chỗ/booking, quản lý tồn kho — nơi có invariant phức tạp liên quan nhiều row mà khó bảo vệ bằng constraint hoặc lock thủ công. Nên dùng cho transaction ngắn, và chỉ bật cho những luồng thực sự cần thay vì bật cho toàn hệ thống.
4. So sánh tổng hợp#
| Read Uncommitted | Read Committed | Repeatable Read | Serializable | |
|---|---|---|---|---|
| Snapshot lấy khi nào (MVCC) | Không dùng snapshot | Mỗi statement | Đầu transaction | Đầu transaction + kiểm tra conflict |
| Lock đọc (locking-based) | Không | Giữ ngắn, nhả ngay | Giữ tới cuối transaction | Giữ tới cuối + range lock |
| Dirty Read | Có thể | Không | Không | Không |
| Non-repeatable Read | Có thể | Có thể | Không | Không |
| Phantom Read | Có thể | Có thể | Standard: có thể / PG, InnoDB: hầu như không | Không |
| Lost Update | Có thể | Có thể | PG: bị phát hiện / InnoDB: có thể | Không |
| Write Skew | Có thể | Có thể | Có thể | Không |
| Cần retry logic | Không | Hiếm | PG: có | Có |
Default isolation level theo DB:
| Database | Default |
|---|---|
| PostgreSQL | Read Committed |
| MySQL (InnoDB) | Repeatable Read |
| SQL Server | Read Committed (locking; Azure SQL Database bật sẵn RCSI) |
| Oracle | Read Committed |
| SQLite | Serializable |
5. Cú pháp thiết lập#
-- PostgreSQL: cho một transaction
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ...
COMMIT;
-- PostgreSQL: default cho session
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- MySQL: cho transaction kế tiếp
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- ...
COMMIT;
-- MySQL: kiểm tra level hiện tại
SELECT @@transaction_isolation;
-- SQL Server
SET TRANSACTION ISOLATION LEVEL SNAPSHOT; -- level riêng của SQL Server
BEGIN TRAN;
-- ...
COMMIT;
Trong ứng dụng, thường set qua framework — ví dụ Spring @Transactional(isolation = Isolation.SERIALIZABLE), hay Django OPTIONS: {"isolation_level": ...}.
6. Chọn isolation level như thế nào?#
flowchart TD
Start([Bắt đầu]) --> Q1{"Chấp nhận đọc dữ liệu chưa commit? (số ước lượng, monitoring)"}
Q1 -- Có --> RU[Read Uncommitted]
Q1 -- Không --> Q2{"Cần nhiều query thấy cùng một snapshot nhất quán?"}
Q2 -- Không --> Q3{"Có invariant liên quan nhiều row mà lock thủ công khó bảo vệ?"}
Q2 -- Có --> Q4{"Có rủi ro write skew?"}
Q3 -- Không --> RC["Read Committed + atomic update / FOR UPDATE / optimistic lock"]
Q3 -- Có --> S
Q4 -- Không --> RR[Repeatable Read]
Q4 -- Có --> S["Serializable + retry logic"]
Một vài nguyên tắc thực chiến:
- Bắt đầu với default của DB và hiểu rõ nó hành xử thế nào. Đừng giả định Repeatable Read của MySQL giống của PostgreSQL.
- Ưu tiên giải pháp cục bộ (atomic
UPDATE,SELECT ... FOR UPDATE, unique constraint, optimistic locking) trước khi nâng isolation level cho cả hệ thống. - Nếu dùng Serializable hoặc Repeatable Read trên PostgreSQL, retry logic là bắt buộc, không phải tùy chọn. Bắt lỗi SQLSTATE
40001(và40P01cho deadlock). - Giữ transaction ngắn. Transaction càng dài, xác suất conflict, deadlock và abort càng cao ở mọi level.
- Test concurrency thật sự: mở hai session song song và chạy từng bước như các sơ đồ ở trên. Đó là cách nhanh nhất để thấy DB của bạn hành xử ra sao.
Kết luận#
Isolation level là một trade-off giữa correctness và concurrency. Read Committed đủ tốt cho phần lớn ứng dụng khi kết hợp đúng kỹ thuật locking. Repeatable Read cho bạn một snapshot nhất quán nhưng vẫn để lọt write skew. Serializable loại bỏ mọi anomaly, đổi lại bạn phải xử lý abort và retry.
Hiểu rõ anomaly nào có thể xảy ra ở level bạn đang dùng — và DB cụ thể của bạn implement level đó ra sao — quan trọng hơn nhiều so với việc chỉ nhớ bảng 4×3 trong standard.
Tài liệu tham khảo#
- PostgreSQL Documentation — Transaction Isolation
- MySQL Reference Manual — Transaction Isolation Levels (InnoDB)
- Microsoft Docs — SET TRANSACTION ISOLATION LEVEL (Transact-SQL)
- Berenson et al. (1995) — A Critique of ANSI SQL Isolation Levels
- Martin Kleppmann — Designing Data-Intensive Applications, Chapter 7: Transactions