Skip to content

Hiểu đúng 4 SQL Isolation Level: từ Read Uncommitted đến Serializable

12 min read

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ớ:

  1. 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).
  2. Default khác nhau theo DB: PostgreSQL, SQL Server, Oracle dùng Read Committed; MySQL (InnoDB) dùng Repeatable Read.
  3. 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_count từ 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:

  1. 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 SELECT thường được ngầm chuyển thành SELECT ... LOCK IN SHARE MODE (khi autocommit tắt).

  2. 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.

  3. 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à 40P01 cho 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