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

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:

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:

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:

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:

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:


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

Back
0%