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 UPDATEhoặcSELECT ... LOCK IN SHARE MODEthì 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ờ
- Xác định loại: lost update, dirty read, phantom read hay write skew?
- Kiểm tra isolation level:
SELECT @@transaction_isolation; - Xem pattern code: có dùng read-then-write tách rời không?
- Thêm FOR UPDATE ở SELECT trước khi UPDATE nếu dùng read-then-write
- Cân nhắc atomic update:
UPDATE t SET col = col - nthay vì đọc rồi ghi - Thêm optimistic locking nếu tranh chấp ít và không muốn giảm throughput