LOST
DIRTY
PHTM
Isolation Level
rc • rr • for update

Race condition trong MySQL: isolation level nào giải quyết được?

MySQL có 4 isolation level nhưng mặc định REPEATABLE READ không chặn được mọi race condition. Dirty read, lost update, phantom read — từng loại xảy ra khi nào, isolation level nào ngăn được, và khi nào cần SELECT FOR UPDATE.

12 phút đọc18/06/2026

Triệu chứng

Balance bị trừ sai. Số lượng hàng tồn âm. Hai user cùng mua được vé cuối. Email confirmation gửi hai lần. Không có lỗi trong log, tất cả query đều chạy thành công — chỉ là kết quả không đúng.

Đây là dấu hiệu của race condition trong database.


4 isolation level trong InnoDB

InnoDB hỗ trợ 4 isolation level, mặc định là REPEATABLE READ:

-- Xem isolation level hiện tại
SELECT @@transaction_isolation;

-- Đổi cho session
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- Đổi trong transaction
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
Isolation Level Dirty Read Non-repeatable Read Phantom Read
READ UNCOMMITTED Có thể Có thể Có thể
READ COMMITTED Ngăn được Có thể Có thể
REPEATABLE READ (default) Ngăn được Ngăn được Có thể*
SERIALIZABLE Ngăn được Ngăn được Ngăn được

*InnoDB dùng MVCC và gap lock giảm phantom read ở REPEATABLE READ, nhưng không hoàn toàn loại bỏ trong mọi trường hợp.


Các loại race condition thường gặp

1. Lost Update — mất cập nhật

Hai transaction cùng đọc một giá trị, tính toán rồi cùng ghi — một bản ghi bị mất.

-- Balance ban đầu: 1000
TX A: SELECT balance FROM accounts WHERE id = 1; -- đọc: 1000
TX B: SELECT balance FROM accounts WHERE id = 1; -- đọc: 1000

TX A: UPDATE accounts SET balance = 1000 - 200 WHERE id = 1; -- ghi: 800
TX B: UPDATE accounts SET balance = 1000 - 300 WHERE id = 1; -- ghi: 700
-- Kết quả: 700, nhưng đúng phải là 1000 - 200 - 300 = 500

Isolation level không giải quyết được lost update. Cả REPEATABLE READ và SERIALIZABLE vẫn có thể bị nếu code dùng pattern read-then-write với hai statement riêng.

Cách fix:

-- Cách 1: Atomic update — không cần đọc trước
UPDATE accounts SET balance = balance - 200 WHERE id = 1;

-- Cách 2: SELECT FOR UPDATE — lock row khi đọc
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- TX khác sẽ phải chờ ở đây
UPDATE accounts SET balance = balance - 200 WHERE id = 1;
COMMIT;

-- Cách 3: Optimistic locking — check version khi ghi
UPDATE accounts
SET balance = balance - 200, version = version + 1
WHERE id = 1 AND version = 5; -- 5 là version đọc được trước đó
-- Nếu version đã thay đổi → affected rows = 0 → retry

2. Dirty Read — đọc data chưa commit

Một transaction đọc được data mà transaction khác đang thay đổi nhưng chưa commit — nếu transaction kia rollback thì data đã đọc là sai.

TX A: BEGIN;
TX A: UPDATE products SET stock = 0 WHERE id = 10;

TX B: SELECT stock FROM products WHERE id = 10;
-- Với READ UNCOMMITTED → đọc được stock = 0 (chưa commit!)
-- Với READ COMMITTED trở lên → vẫn đọc giá trị cũ

TX A: ROLLBACK; -- không commit, stock vẫn là giá trị cũ
-- TX B đã dùng stock = 0 để quyết định → sai

Giải pháp: dùng READ COMMITTED hoặc cao hơn. READ UNCOMMITTED hầu như không dùng trong production.

InnoDB mặc định REPEATABLE READ nên dirty read không xảy ra trong phần lớn codebase.

3. Non-repeatable Read — đọc lại thấy khác

Trong cùng một transaction, đọc cùng một row hai lần nhưng thấy giá trị khác — vì transaction khác đã commit thay đổi ở giữa.

TX A: BEGIN;
TX A: SELECT price FROM products WHERE id = 1; -- đọc: 100

TX B: UPDATE products SET price = 150 WHERE id = 1; COMMIT;

TX A: SELECT price FROM products WHERE id = 1; -- đọc: 100 hay 150?
  • READ COMMITTED: TX A đọc lần 2 thấy 150 (đọc snapshot mới nhất)
  • REPEATABLE READ: TX A đọc lần 2 vẫn thấy 100 (snapshot từ đầu transaction)

Khi nào thành vấn đề: nếu TX A dùng giá 100 để tính toán từ đầu đến cuối transaction — với READ COMMITTED, lần tính sau có thể dùng giá 150, inconsistent.

Giải pháp: dùng REPEATABLE READ (đã là default) để đảm bảo consistency trong transaction.

4. Phantom Read — hàng mới xuất hiện

Trong cùng một transaction, query đếm số hàng hai lần nhưng lần sau có hàng mới mà lần trước không thấy — vì transaction khác đã INSERT.

TX A: BEGIN;
TX A: SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- 10

TX B: INSERT INTO orders (status, ...) VALUES ('pending', ...); COMMIT;

TX A: SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- 11?
  • REPEATABLE READ: InnoDB dùng MVCC nên TX A thường vẫn thấy 10 (snapshot cũ)
  • Nhưng nếu TX A dùng SELECT ... FOR UPDATE hoặc SELECT ... LOCK IN SHARE MODE thì InnoDB dùng gap lock, và INSERT của TX B sẽ bị block
  • SERIALIZABLE: hoàn toàn ngăn phantom read bằng cách tự động thêm lock vào mọi SELECT

5. Write Skew — ghi dựa trên điều kiện lỗi thời

Ít gặp hơn nhưng nguy hiểm. Hai transaction đọc cùng một tập dữ liệu, mỗi cái quyết định dựa trên snapshot đó rồi cùng ghi — và tổng kết quả vi phạm ràng buộc.

-- Ràng buộc: ít nhất một bác sĩ phải trực
-- Hiện tại: Dr A và Dr B đều đang trực (on_call = true)

TX A (Dr A xin nghỉ):
  SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2, OK
  UPDATE doctors SET on_call = false WHERE id = 'A';

TX B (Dr B xin nghỉ, cùng lúc):
  SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2, OK
  UPDATE doctors SET on_call = false WHERE id = 'B';

-- Kết quả: không còn bác sĩ nào trực — vi phạm ràng buộc

REPEATABLE READ không ngăn được write skew vì hai transaction không ghi cùng row.

Giải pháp: SELECT ... FOR UPDATE trên toàn bộ tập dữ liệu liên quan, hoặc SERIALIZABLE.


SELECT FOR UPDATE và LOCK IN SHARE MODE

SELECT FOR UPDATE — exclusive lock

BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- Lúc này row id=1 bị lock exclusive
-- Mọi SELECT FOR UPDATE hoặc UPDATE từ transaction khác sẽ phải chờ
UPDATE accounts SET balance = balance - 200 WHERE id = 1;
COMMIT;

Dùng khi:

  • Cần đọc rồi ghi và đảm bảo không bị race
  • Pattern check-then-act (kiểm tra điều kiện rồi thực hiện hành động)

SELECT LOCK IN SHARE MODE — shared lock

BEGIN;
SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE;
-- Row bị lock shared — nhiều transaction có thể cùng đọc
-- Nhưng không ai UPDATE được cho đến khi tất cả commit
COMMIT;

Dùng khi:

  • Cần đảm bảo row không thay đổi trong lúc đọc để tính toán
  • Không cần ghi, chỉ cần đảm bảo consistency khi đọc

Chú ý: FOR UPDATE chỉ có nghĩa khi trong transaction

-- Sai: FOR UPDATE không có ý nghĩa nếu không trong transaction
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;

-- Đúng: phải trong BEGIN...COMMIT
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- xử lý...
COMMIT;

Optimistic locking — tránh lock khi tranh chấp ít

Khi race condition xảy ra ít, lock pessimistic (FOR UPDATE) làm giảm throughput không cần thiết. Optimistic locking không lock khi đọc — chỉ check khi ghi:

-- Schema cần thêm cột version
ALTER TABLE products ADD COLUMN version INT NOT NULL DEFAULT 0;

-- Đọc
SELECT id, stock, version FROM products WHERE id = 10;
-- Giả sử: stock=5, version=3

-- Ghi (chỉ cập nhật nếu version vẫn còn là 3)
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 10 AND version = 3;

-- Nếu affected rows = 0 → có transaction khác đã thay đổi → retry

Trong TypeORM:

@Entity()
export class Product {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  stock: number;

  @VersionColumn()
  version: number; // TypeORM tự quản lý, tự throw OptimisticLockVersionMismatchError
}

// Khi save, TypeORM tự check version và throw nếu conflict
await productRepository.save(product);

Chọn isolation level nào

Tình huống Isolation Level Ghi chú
Đọc thống kê, report, không cần chính xác tuyệt đối READ COMMITTED Ít lock hơn, throughput cao hơn
App OLTP thông thường REPEATABLE READ (default) Đủ cho hầu hết case
Cần đảm bảo tổng không thay đổi trong transaction REPEATABLE READ + FOR UPDATE Linh hoạt hơn SERIALIZABLE
Tài chính, cần tuyệt đối không có race SERIALIZABLE hoặc FOR UPDATE Performance thấp hơn

Không nên dùng SERIALIZABLE cho toàn bộ app — nó lock mọi SELECT, giảm throughput đáng kể. Thay vào đó dùng FOR UPDATE có chọn lọc ở những chỗ thực sự cần.


Checklist khi gặp race condition nghi ngờ

  1. Xác định loại: lost update, dirty read, phantom read hay write skew?
  2. Kiểm tra isolation level: SELECT @@transaction_isolation;
  3. Xem pattern code: có dùng read-then-write tách rời không?
  4. Thêm FOR UPDATE ở SELECT trước khi UPDATE nếu dùng read-then-write
  5. Cân nhắc atomic update: UPDATE t SET col = col - n thay vì đọc rồi ghi
  6. Thêm optimistic locking nếu tranh chấp ít và không muốn giảm throughput