Skip to content

Concurrency Control: Isolation Levels & Read Phenomena

Khi có nhiều transaction chạy đồng thời, chuyện gì có thể sai? Bài này sẽ đi sâu vào các "hiện tượng đọc" nguy hiểm và cách Isolation Levels giúp kiểm soát chúng.


1️⃣ Read Phenomena: Ba hiện tượng đọc đáng sợ

Khi hai transaction chạy đồng thời và truy cập cùng dữ liệu, có thể xảy ra các hiện tượng kỳ lạ.

Dirty Read (Đọc bẩn) - Nghiêm trọng nhất

Transaction A đọc được dữ liệu mà Transaction B chưa COMMIT.

Timeline:
T1: BEGIN; UPDATE products SET stock = 0 WHERE id = 1; [chưa COMMIT]
T2:         BEGIN; SELECT stock FROM products WHERE id = 1; -> Thấy stock = 0!
T1:                                                           ROLLBACK;
T2:                                                           [Đã quyết định based on stock = 0,
  NHƯNG thực tế stock CHƯA TỪNG = 0!]

Hậu quả: T2 có thể đã hiển thị "Hết hàng" cho khách, hoặc từ chối đơn hàng. Nhưng T1 rollback -> stock thực tế vẫn còn. Khách mất đơn oan.

sql
-- Mô phỏng Dirty Read (chỉ xảy ra ở READ UNCOMMITTED)
-- Session 1:
BEGIN;
UPDATE products SET stock = 0 WHERE id = 1;
-- Không commit, chờ...

-- Session 2:
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT stock FROM products WHERE id = 1;  -- Thấy stock = 0 (dirty!)

Non-Repeatable Read (Đọc không lặp lại)

Transaction A đọc cùng một row 2 lần, nhưng kết quả khác nhau vì Transaction B đã UPDATE và COMMIT ở giữa.

Timeline:
T1: BEGIN; SELECT price FROM products WHERE id = 1; -> 100.000
T2:         BEGIN; UPDATE products SET price = 150000 WHERE id = 1; COMMIT;
T1:                SELECT price FROM products WHERE id = 1; -> 150.000 (Khác rồi!)
T1:                [Tính toán dựa trên giá nào? 100k hay 150k?]

Hậu quả: Báo cáo tài chính có thể sai nếu cùng một query trong cùng transaction trả về kết quả khác nhau.

sql
-- Mô phỏng Non-Repeatable Read (xảy ra ở READ COMMITTED)
-- Session 1:
BEGIN;
SELECT price FROM products WHERE id = 1;  -- 100000
-- Chờ session 2...
SELECT price FROM products WHERE id = 1;  -- 150000 (đã thay đổi!)

-- Session 2:
BEGIN;
UPDATE products SET price = 150000 WHERE id = 1;
COMMIT;

Phantom Read (Đọc ma)

Transaction A đọc một tập hợp rows 2 lần, nhưng số lượng rows khác nhau vì Transaction B đã INSERT/DELETE ở giữa.

Timeline:
T1: BEGIN; SELECT COUNT(*) FROM orders WHERE status = 'pending'; -> 10
T2:         BEGIN; INSERT INTO orders (status) VALUES ('pending'); COMMIT;
T1:                SELECT COUNT(*) FROM orders WHERE status = 'pending'; -> 11 (Thêm 1 "ma"!)

Khác với Non-Repeatable Read:

  • Non-Repeatable: Row cũ bị UPDATE.
  • Phantom: Row mới xuất hiện (hoặc biến mất).
sql
-- Mô phỏng Phantom Read (xảy ra ở REPEATABLE READ trong MySQL)
-- Session 1:
BEGIN;
SELECT * FROM orders WHERE total > 1000000;  -- 3 rows
-- Chờ session 2...
SELECT * FROM orders WHERE total > 1000000;  -- 4 rows (phantom!)

-- Session 2:
BEGIN;
INSERT INTO orders (total) VALUES (5000000);  -- Thỏa điều kiện total > 1000000
COMMIT;

2️⃣ Isolation Levels: 4 Tầng phòng thủ

SQL standard định nghĩa 4 mức độ cách ly, từ thấp nhất (ít cách ly, nhiều performance) đến cao nhất (cách ly hoàn toàn, ít performance hơn).

Bảng so sánh Isolation Levels

Isolation LevelDirty ReadNon-Repeatable ReadPhantom ReadPerformance
Read Uncommitted✅ Có thể xảy ra✅ Có thể xảy ra✅ Có thể xảy ra⚡⚡⚡⚡ Cực nhanh
Read Committed❌ Ngăn chặn✅ Có thể xảy ra✅ Có thể xảy ra⚡⚡⚡ Nhanh
Repeatable Read❌ Ngăn chặn❌ Ngăn chặn✅ Có thể xảy ra*⚡⚡ Trung bình
Serializable❌ Ngăn chặn❌ Ngăn chặn❌ Ngăn chặn⚡ Chậm nhất

*PostgreSQL implement REPEATABLE READ mạnh hơn SQL standard, ngăn cả Phantom Read.

Level 1: Read Uncommitted (Không ai dùng cả)

sql
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
  • Cho phép đọc dữ liệu chưa commit của transaction khác.
  • Use case: Gần như không có. Chỉ dùng cho thống kê "gần đúng" không cần chính xác.
  • Cảnh báo: MySQL InnoDB KHÔNG hỗ trợ level này, nó tự động nâng lên Read Committed.

Level 2: Read Committed (Default PostgreSQL)

sql
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Hoặc không cần set, đây là default của PostgreSQL
  • Chỉ đọc dữ liệu đã commit.
  • Mỗi câu SELECT thấy snapshot TẠI THỜI ĐIỂM CÂU SELECT CHẠY.
  • Use case: Hầu hết ứng dụng web, CRUD thông thường.
  • Vấn đề: Non-Repeatable Read vẫn xảy ra.
sql
-- Ví dụ: Hai lần SELECT trong cùng transaction
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 1000000 (tại 10:00:00)
-- Chờ 5 giây, transaction khác đã update và commit
SELECT balance FROM accounts WHERE id = 1;  -- 1500000 (tại 10:00:05)
-- Hai kết quả khác nhau trong cùng transaction!
COMMIT;

Level 3: Repeatable Read (Default MySQL InnoDB)

sql
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
  • Transaction thấy snapshot cố định tại thời điểm BẮT ĐẦU TRANSACTION (hoặc câu SELECT đầu tiên).
  • Mọi câu SELECT sau đó đều thấy cùng dữ liệu.
  • Use case: Báo cáo tài chính, thống kê cần consistency cao.
sql
-- PostgreSQL REPEATABLE READ
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;  -- 1000000

-- Transaction khác commit thay đổi balance thành 1500000 ở đây

SELECT balance FROM accounts WHERE id = 1;  -- VẪN 1000000!
COMMIT;

⚠️ Phantom Read và Gap Locking (MySQL)

MySQL InnoDB dùng Gap Locking để ngăn Phantom Read ở REPEATABLE READ. Điều này có thể gây deadlock nếu nhiều transaction INSERT vào cùng "gap" cùng lúc.

Level 4: Serializable (Nghiêm ngặt nhất)

sql
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  • Các transaction chạy như thể chúng thực hiện tuần tự, từng cái một.
  • Ngăn chặn TẤT CẢ các read phenomena.
  • Trade-off: Performance thấp, có thể gây nhiều lỗi serialization_failure phải retry.
sql
-- PostgreSQL Serializable với SSI (Serializable Snapshot Isolation)
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(balance) FROM accounts;  -- Tính tổng

-- Nếu transaction khác cũng đọc và modify accounts -> 
-- Một trong hai sẽ bị abort với "could not serialize access"
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;  -- Có thể thất bại!

3️⃣ Trade-off: Performance vs Consistency

Khi nào dùng cái gì?

Isolation LevelUse CaseVí dụ
Read CommittedCRUD thông thường, không cần consistency tuyệt đốiWeb app API, form submit
Repeatable ReadBáo cáo, thống kê trong transaction dàiGenerate monthly report
SerializableTài chính nghiêm ngặt, đấu giá, đặt véBank transfer, auction bidding

Chi phí ẩn của Serializable

python
# Pseudo-code retry pattern cho Serializable
import random
import time

MAX_RETRIES = 5

def transfer_money(from_id, to_id, amount):
    for attempt in range(MAX_RETRIES):
        try:
            with db.transaction(isolation='serializable'):
                # Check balance
                balance = db.query("SELECT balance FROM accounts WHERE id = %s", from_id)
                if balance < amount:
                    raise ValueError("Insufficient funds")
                
                # Transfer
                db.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", amount, from_id)
                db.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", amount, to_id)
                return True  # Success
                
        except SerializationFailure:
            # Retry với exponential backoff
            wait_time = (2 ** attempt) + random.uniform(0, 1)
            time.sleep(wait_time)
    
    raise Exception("Transaction failed after max retries")

💡 HPN Pro Tip: Đừng dùng Serializable mù quáng

Serializable không phải "silver bullet". Với workload cao (100+ concurrent transactions), retry rate có thể lên tới 30-50%, giết chết throughput.

Chiến lược thực tế:

  1. Dùng Read Committed cho 90% queries.
  2. Dùng Application-level locking (Optimistic/Pessimistic) cho critical paths.
  3. Serializable chỉ cho các transaction cực kỳ quan trọng và ngắn.

4️⃣ Pessimistic vs Optimistic Locking (Khi Isolation Levels không đủ)

Đôi khi bạn cần kiểm soát cụ thể hơn Isolation Levels.

Pessimistic Locking: SELECT ... FOR UPDATE

Lock row TRƯỚC khi đọc, ngăn transaction khác modify.

sql
BEGIN;
-- Lock row để đảm bảo không ai đổi trong lúc mình xử lý
SELECT * FROM products WHERE id = 1 FOR UPDATE;

-- Tính toán business logic...

UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;

-- Transaction khác cố SELECT FOR UPDATE cùng row sẽ phải CHỜ

Optimistic Locking: Version Column

Không lock, nhưng check version khi UPDATE.

sql
-- Lấy dữ liệu kèm version
SELECT id, stock, version FROM products WHERE id = 1;
-- Trả về: id=1, stock=10, version=5

-- Sau khi tính toán, update với điều kiện version
UPDATE products 
SET stock = stock - 1, version = version + 1 
WHERE id = 1 AND version = 5;

-- Nếu affected_rows = 0 -> Ai đó đã update trước -> RETRY!

5️⃣ PostgreSQL vs MySQL: Khác biệt quan trọng

Đặc điểmPostgreSQLMySQL InnoDB
Default LevelRead CommittedRepeatable Read
Repeatable Read ngăn Phantom?✅ Có (MVCC mạnh)⚠️ Có (Gap Lock, có thể deadlock)
Serializable mechanismSSI (Serializable Snapshot Isolation)2PL (Two-Phase Locking)

PostgreSQL MVCC (Multi-Version Concurrency Control)

PostgreSQL giữ nhiều versions của mỗi row. Mỗi transaction thấy version phù hợp với thời điểm nó bắt đầu.

Row versions:
[version 1: created by T1] -> [version 2: created by T2] -> [version 3: created by T3]

T4 với REPEATABLE READ bắt đầu tại thời điểm version 2 đã commit:
- Thấy version 2
- KHÔNG thấy version 3 (chưa commit tại thời điểm T4 bắt đầu)

MySQL Gap Locking

MySQL lock không chỉ row, mà cả "gap" giữa các rows để ngăn INSERT mới.

Index: 1, 3, 5, 7
Gap locks: [before 1], [1-3], [3-5], [5-7], [after 7]

Transaction lock row 3 -> Lock cả gap [1-3] và [3-5]
-> Transaction khác KHÔNG THỂ INSERT row với key 2 hoặc 4

Tổng kết

Thuật ngữMô tả
Dirty ReadĐọc dữ liệu chưa commit
Non-Repeatable ReadĐọc cùng row 2 lần, kết quả khác
Phantom ReadĐọc cùng điều kiện 2 lần, số row khác
Read CommittedChỉ đọc dữ liệu đã commit
Repeatable ReadSnapshot cố định trong transaction
SerializableTransaction chạy tuần tự

💡 HPN Golden Rule

1. Hiểu workload của bạn trước.
2. Bắt đầu với Read Committed (đủ cho 90% cases).
3. Nếu cần consistency cao hơn, thử Optimistic Locking trước.
4. Serializable là giải pháp cuối cùng, kèm retry strategy.