Transaction Isolation Levels: 4 nấc thang cô lập trong MySQL
“What we know is a drop, what we don't know is an ocean.” – Isaac Newton
Nội dung bài viết
- 3 hiện tượng cần tránh
- Dirty read — đọc dữ liệu chưa commit
- Non-repeatable read — đọc lại một dòng, ra giá trị khác
- Phantom read — chạy lại một truy vấn, "mọc" thêm dòng
- 4 nấc thang cô lập
- 1. READ UNCOMMITTED
- 2. READ COMMITTED
- 3. REPEATABLE READ — mặc định của MySQL
- 4. SERIALIZABLE
- InnoDB làm điều đó bằng gì? — MVCC
- Tự kiểm chứng bằng 2 terminal
- Chọn mức nào cho ứng dụng của bạn?
- Kết luận
- Tài liệu tham khảo
Hi các bạn 👋. Mình là Hùng Anh.
Thử tưởng tượng: transaction A đang đọc số dư tài khoản để quyết định cho rút tiền, đúng lúc đó transaction B trừ tiền cùng tài khoản và commit. Câu SELECT thứ hai của A sẽ thấy số cũ hay số mới? Câu trả lời nghe hơi khó chịu: tuỳ — tuỳ vào isolation level mà bạn (hoặc mặc định của database) đã chọn.
Isolation chính là chữ I trong ACID, và nó không phải công tắc bật/tắt mà là một thang 4 nấc — mỗi nấc đánh đổi giữa độ "sạch" của dữ liệu đọc được và mức độ cho phép chạy song song. Hiểu rõ thang này, bạn sẽ lý giải được hàng loạt bug "lúc có lúc không" ở tầng ứng dụng.
Hôm nay mình sẽ đi qua: 3 hiện tượng đọc bất thường, 4 mức cô lập chuẩn SQL, và cách InnoDB hiện thực chúng bên dưới bằng MVCC. Bắt đầu thôi.
3 hiện tượng cần tránh
Mọi mức cô lập đều xoay quanh việc chặn (hoặc chấp nhận) 3 hiện tượng sau:
Dirty read — đọc dữ liệu chưa commit
A đọc được giá trị mà B mới ghi nhưng chưa commit. Nếu B rollback, A đã trót ra quyết định dựa trên dữ liệu… chưa từng tồn tại. Đây là hiện tượng nghiêm trọng nhất.
Non-repeatable read — đọc lại một dòng, ra giá trị khác
A đọc một dòng, B sửa dòng đó và commit, A đọc lại cùng dòng trong cùng transaction — và nhận giá trị khác lần trước. Logic kiểu "đọc để kiểm tra rồi hành động" sẽ gãy âm thầm.
Phantom read — chạy lại một truy vấn, "mọc" thêm dòng
Khác với non-repeatable read (dòng cũ đổi giá trị), phantom là chuyện tập kết quả thay đổi: A đếm được 10 đơn hàng, B chèn thêm một đơn và commit, A đếm lại ra 11 — dòng thứ 11 là "bóng ma" xuất hiện giữa chừng.
4 nấc thang cô lập
Chuẩn SQL định nghĩa 4 mức, xếp theo độ nghiêm ngặt tăng dần — mức càng cao chặn được càng nhiều hiện tượng, nhưng càng phải khoá nhiều hơn:
1. READ UNCOMMITTED
Không chặn gì cả — kể cả dirty read. Transaction này thấy cả những thay đổi chưa commit của transaction khác. Trên thực tế gần như không có lý do gì để dùng mức này trong InnoDB: bạn nhận mọi rủi ro mà chẳng đổi lại được bao nhiêu hiệu năng.
2. READ COMMITTED
Chỉ đọc được dữ liệu đã commit — hết dirty read. Nhưng mỗi câu SELECT nhìn database ở thời điểm nó chạy, nên hai lần đọc trong cùng transaction vẫn có thể ra hai kết quả (non-repeatable read và phantom vẫn xảy ra).
Đây là mặc định của PostgreSQL, Oracle, SQL Server — và là lựa chọn phổ biến cho hệ thống nhiều ghi, vì nó giữ khoá ít hơn REPEATABLE READ.
3. REPEATABLE READ — mặc định của MySQL
Mọi câu đọc trong transaction đều nhìn vào một "bức ảnh" (snapshot) chụp tại lần đọc đầu tiên. Đọc lại bao nhiêu lần cũng ra đúng kết quả cũ — hết non-repeatable read. Với InnoDB, snapshot này còn chặn được phần lớn phantom (mình giải thích ở phần MVCC bên dưới).
4. SERIALIZABLE
Mức chặt nhất: kết quả tương đương với việc các transaction chạy tuần tự từng cái một. InnoDB hiện thực bằng cách biến mọi SELECT thường thành SELECT ... FOR SHARE — đọc cũng giữ khoá, nên độ song song giảm mạnh và dễ deadlock hơn. Chỉ nên dùng khi tính đúng đắn quan trọng tuyệt đối và tải cho phép.
InnoDB làm điều đó bằng gì? — MVCC
Điều thú vị nhất: ở READ COMMITTED và REPEATABLE READ, InnoDB cho các câu đọc thường chạy không cần khoá nhờ MVCC (Multi-Version Concurrency Control):
- Mỗi lần sửa một dòng, InnoDB không ghi đè tại chỗ mà giữ lại phiên bản cũ trong undo log.
- Mỗi transaction đọc qua một read view — danh sách các transaction đang mở tại thời điểm chụp. Gặp dòng có phiên bản "quá mới", InnoDB lần ngược undo log để trả về đúng phiên bản mà read view được phép thấy.
- Khác biệt giữa hai mức chỉ nằm ở thời điểm chụp read view:
READ COMMITTEDchụp lại mỗi câu lệnh, cònREPEATABLE READchụp một lần cho cả transaction.
Cùng một cơ chế, đổi mỗi thời điểm chụp — mà hành vi khác hẳn nhau. Mình thấy đây là một trong những thiết kế thanh lịch nhất của InnoDB.
Còn với các câu đọc có khoá (SELECT ... FOR UPDATE, UPDATE, DELETE) ở REPEATABLE READ, InnoDB dùng thêm next-key lock — khoá cả dòng lẫn khoảng trống trước nó — để transaction khác không chèn được "bóng ma" vào vùng bạn đã quét. Đây là lý do InnoDB chặn được phantom ngay ở REPEATABLE READ, điều mà chuẩn SQL không đòi hỏi.
Tự kiểm chứng bằng 2 terminal
Cách hiểu nhanh nhất là tự tay tạo ra hiện tượng. Mở 2 kết nối MySQL:
-- Terminal 1
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- → 100
-- Terminal 2
BEGIN;
UPDATE accounts SET balance = 90 WHERE id = 1;
COMMIT;
-- Terminal 1 (vẫn trong transaction cũ)
SELECT balance FROM accounts WHERE id = 1; -- → 90 (non-repeatable read!)
Giờ đổi Terminal 1 sang REPEATABLE READ và lặp lại kịch bản: lần đọc thứ hai vẫn trả về 100 — snapshot giữ nguyên thế giới tại thời điểm đầu transaction, dù Terminal 2 đã commit 90 từ lâu.
Xem và đổi mức cô lập:
SELECT @@transaction_isolation; -- xem mức hiện tại
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- cho session
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED; -- cho kết nối mới
Chọn mức nào cho ứng dụng của bạn?
Vài kinh nghiệm mình đúc kết:
- Cứ ở lại REPEATABLE READ (mặc định) nếu bạn không có lý do cụ thể để đổi — hành vi ổn định, đọc không khoá, chặn được nhiều hiện tượng nhất mà chi phí vẫn thấp.
- Cân nhắc READ COMMITTED cho hệ thống ghi nhiều, transaction dài, hay gặp lock wait / deadlock do gap lock — đổi lấy việc ứng dụng phải chấp nhận hai lần đọc có thể khác nhau.
- Đừng dựa vào isolation level để thay thế khoá tường minh: logic "đọc rồi quyết định ghi" (kiểm tra tồn kho, trừ số dư…) nên dùng
SELECT ... FOR UPDATEhoặc ràng buộc/UPDATEcó điều kiện — snapshot đẹp đến mấy cũng không chặn được hai transaction cùng đưa ra quyết định dựa trên ảnh chụp cũ. - SERIALIZABLE là phương án cuối cùng, khi bạn chứng minh được các mức dưới không đủ đúng đắn.
Kết luận
Isolation level là một cái núm vặn đánh đổi: vặn lên thì dữ liệu đọc được "sạch" hơn nhưng hệ thống khoá nhiều hơn; vặn xuống thì chạy song song tốt hơn nhưng ứng dụng phải tự chịu vài hiện tượng đọc. MySQL mặc định đứng ở REPEATABLE READ với MVCC bên dưới — một điểm cân bằng rất tốt, và trong đa số trường hợp bạn không cần đổi. Điều quan trọng là biết mình đang đứng ở nấc nào, và nấc đó cho phép hiện tượng gì xảy ra với code của bạn.
Hẹn gặp lại các bạn ở những bài viết sau. Nếu thấy bài viết hữu ích, đừng quên chia sẻ và để lại bình luận nhé 👇.
Tài liệu tham khảo
- MySQL 8.0 Reference Manual — Transaction Isolation Levels
- MySQL 8.0 Reference Manual — InnoDB Multi-Versioning
- MySQL 8.0 Reference Manual — InnoDB Locking (next-key locks)
- Đọc thêm: chuyên mục MySQL của xiaolincoding.com và loạt bài database trên blog.algomaster.io
Bài viết liên quan
Sự khác biệt giữa count(*) và count(1) và cái nào hiệu quả hơn?
Khi chúng ta đếm các bản ghi trong bảng dữ liệu, chúng ta đã quen với việc sử dụng hàm count để đếm, nhưng có nhiều loại tham số có thể được truyền trong hàm count, chẳng hạn như count(1), count(*), count(field), ... Vậy hàm count nào sẽ đem lại hiệu suất tốt nhất?

Cache với Redis: Cache-Aside và 3 sự cố kinh điển
Thêm một lớp cache Redis trước database nghe thì đơn giản: có thì trả, không có thì xuống DB lấy rồi cất lại. Nhưng đằng sau pattern quen thuộc đó là ba cái bẫy đã đánh sập không ít hệ thống lớn: penetration, breakdown và avalanche. Bài này mình giải thích pattern Cache-Aside cho đúng, vì sao dữ liệu cache và DB có thể lệch nhau, và cách phòng cả ba sự cố.
Kafka Consumer Groups: Chia việc, Offset và Rebalancing
Một topic Kafka có 4 partition và bạn bật 5 consumer trong cùng một group — chuyện gì xảy ra? Một con sẽ ngồi chơi. Consumer group là cơ chế chia việc trung tâm của Kafka, và hiểu nó (cùng với offset và rebalancing) là chìa khoá để scale consumer đúng cách, không mất message và không xử lý trùng. Bài này mình giải thích từ gốc kèm các cấu hình quan trọng nhất.