Phần trước cho thấy đọc thường không khoá gì nhờ MVCC. Nhưng ghi thì phải khoá, và ngay cả đọc đôi khi cũng cần khoá. Hai khái niệm chi phối chuyện này là mức cô lập — một giao dịch được phép thấy gì của các giao dịch khác — và khoá — ai phải chờ ai khi đụng cùng một hàng. Bài này đo thật ba thứ: sự khác nhau giữa hai mức cô lập hay dùng nhất, cách gap lock chặn "bóng ma" (phantom) xuất hiện giữa chừng, và một deadlock thật bị InnoDB phát hiện rồi phá trong tích tắc.

REPEATABLE READ và READ COMMITTED: đọc lại ra gì

Mức cô lập mặc định của InnoDB là REPEATABLE READ (RR); lựa chọn phổ biến thứ hai là READ COMMITTED (RC). Khác biệt cốt lõi nằm ở khi nào ảnh chụp đọc được dựng. Chạy cùng một kịch bản ở hai mức: A đọc một hàng, B sửa và commit, A đọc lại.

                          REPEATABLE READ        READ COMMITTED
A: SELECT bal  (lần 1)         1000                  1000
B: UPDATE bal=3000; COMMIT;
A: SELECT bal  (lần 2)         1000                  3000

Ở RR, read view được dựng một lần cho cả giao dịch, nên A đọc lại vẫn ra 1000 — hai lần đọc trong một giao dịch luôn nhất quán. Ở RC, mỗi câu lệnh dựng một read view mới, nên lần đọc thứ hai của A thấy luôn giá trị 3000 mà B vừa commit. RC cho thấy dữ liệu mới sớm hơn (đánh đổi là "đọc không lặp lại" — cùng câu đọc hai lần ra hai kết quả), còn RR giữ một bức tranh đông cứng suốt giao dịch. Chọn mức nào là chọn giữa tính nhất quán trong giao dịch và việc thấy thay đổi sớm.

Ảnh chụp sơ đồ nền tối mức cô lập và khoá của InnoDB, mức cô lập quyết định giao dịch thấy gì khoá quyết định ai chờ ai, REPEATABLE READ dựng read view một lần cho cả giao dịch nên đọc lại luôn nhất quán ra cùng giá trị, READ COMMITTED dựng read view mới mỗi câu lệnh nên đọc lại thấy giá trị vừa commit gọi là đọc không lặp lại, khoá hàng X lock khi ghi hoặc SELECT FOR UPDATE giữ đúng hàng, gap lock chỉ có ở REPEATABLE READ khoá khe trống giữa các bản ghi index để chặn bóng ma phantom tức một hàng mới chèn vào khoảng đang đọc, deadlock hai giao dịch khoá hai hàng theo thứ tự ngược nhau A giữ hàng 10 chờ hàng 20 B giữ hàng 20 chờ hàng 10 tạo vòng chờ InnoDB phát hiện vòng và cuộn lại một nạn nhân với lỗi 1213 Deadlock found, cách đo dùng hai phiên song song qua FIFO và SELECT SLEEP đo thời gian B bị chặn và đọc SHOW ENGINE INNODB STATUS mục LATEST DETECTED DEADLOCK

Hình 1: mức cô lập quyết định thấy gì (RR một ảnh chụp cả giao dịch, RC ảnh chụp mỗi câu), khoá quyết định ai chờ ai; gap lock chặn phantom, deadlock bị InnoDB phá.

Gap lock: chặn bóng ma chèn vào giữa chừng

Một giao dịch đọc một khoảng rồi đọc lại, nếu giữa hai lần có người chèn thêm hàng vào khoảng đó, lần đọc sau thấy một hàng "bóng ma" không có ở lần đầu — gọi là phantom. REPEATABLE READ của InnoDB chặn phantom bằng gap lock: khi khoá một khoảng index, nó khoá luôn cả các khe trống giữa các bản ghi, nên không ai chèn được vào đó cho tới khi giao dịch kết thúc. Đo bằng cách cho A khoá khoảng id BETWEEN 10 AND 30 (bảng có sẵn 10, 20, 30) rồi xem B chèn id = 15 mất bao lâu:

A: SELECT * FROM gaptest WHERE id BETWEEN 10 AND 30 FOR UPDATE;   (giữ 3 giây)

REPEATABLE READ:  B INSERT id=15  →  bị chặn  2035 ms  (chờ A nhả gap lock)
READ COMMITTED:   B INSERT id=15  →  chèn ngay   57 ms  (RC không dùng gap lock)

Ở RR, B phải chờ 2035 ms — đúng bằng thời gian A giữ giao dịch — vì gap lock của A phủ khe giữa 10 và 20, chặn mọi id mới rơi vào đó. Ở RC, không có gap lock, B chèn ngay sau 57 ms. Đây chính là đánh đổi: RR chặn phantom nhưng khoá rộng hơn nên giảm đồng thời; RC cho đồng thời cao hơn nhưng để lọt phantom. Gap lock cũng là nguyên nhân thường gặp của những pha chờ khoá khó hiểu — một INSERT bị chặn bởi một giao dịch khác thậm chí không đụng tới đúng hàng đó, chỉ vì nó giữ gap bao quanh.

Một deadlock thật, bị phá trong tích tắc

Khi hai giao dịch khoá cùng những hàng nhưng theo thứ tự ngược nhau, chúng có thể kẹt vòng: mỗi cái giữ hàng mà cái kia đang chờ. Dựng đúng tình huống đó bằng hai phiên song song:

A: UPDATE gaptest SET note='A1' WHERE id=10;   -- A khoá hàng 10
B: UPDATE gaptest SET note='B1' WHERE id=20;   -- B khoá hàng 20
A: UPDATE gaptest SET note='A2' WHERE id=20;   -- A chờ B (hàng 20)
B: UPDATE gaptest SET note='B2' WHERE id=10;   -- B chờ A (hàng 10) → VÒNG!

Ngay khoảnh khắc B xin khoá hàng 10, một vòng chờ khép kín. InnoDB không ngồi chờ hết thời gian như với khoá thường — nó có bộ phát hiện deadlock, nhận ra vòng tức thì và cuộn lại một nạn nhân. Phiên B nhận lỗi ngay:

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

Giao dịch B bị huỷ, nhả mọi khoá, và A chạy tiếp bình thường. Toàn bộ chuyện xảy ra trong tích tắc, không phải chờ timeout. Nạn nhân được chọn là giao dịch đã làm ít việc hơn (ít bản ghi undo hơn) để cuộn lại cho rẻ. Bài học ứng dụng: deadlock là chuyện bình thường, không phải lỗi nghiêm trọng — mã gọi phải sẵn sàng bắt lỗi 1213 và thử lại giao dịch, chứ không coi nó là sự cố.

Ảnh chụp output thật nền tối đo khoá và mức cô lập trên bảng trong mysql 8, một đọc lại REPEATABLE READ lần 1 ra 1000 lần 2 vẫn 1000 còn READ COMMITTED lần 1 ra 1000 lần 2 ra 3000 vì mỗi câu dựng read view mới, hai gap lock A khoá khoảng id BETWEEN 10 AND 30 FOR UPDATE rồi B chèn id 15 ở REPEATABLE READ bị chặn 2035 ms chờ nhả gap lock ở READ COMMITTED chèn ngay 57 ms vì RC không dùng gap lock, ba deadlock A khoá hàng 10 rồi B khoá hàng 20 rồi A chờ hàng 20 rồi B xin hàng 10 tạo vòng InnoDB phát hiện tức thì cuộn lại nạn nhân B với lỗi 1213 Deadlock found when trying to get lock try restarting transaction A chạy tiếp, trích SHOW ENGINE INNODB STATUS mục LATEST DETECTED DEADLOCK cho thấy transaction 1 giữ khoá hàng 10 chờ hàng 20 transaction 2 giữ hàng 20 chờ hàng 10 lock_mode X, kết luận deadlock là bình thường mã phải bắt lỗi 1213 và thử lại

Hình 2: RR và RC đọc lại khác nhau; gap lock chặn chèn 2035 ms; deadlock bị InnoDB phá với lỗi 1213 và ghi lại trong SHOW ENGINE INNODB STATUS.

Đọc hiện trường trong SHOW ENGINE INNODB STATUS

Sau một deadlock, SHOW ENGINE INNODB STATUS giữ lại hiện trường ở mục LATEST DETECTED DEADLOCK. Nó liệt kê hai giao dịch, câu lệnh mỗi cái đang chạy, khoá mỗi cái đang giữ và khoá mỗi cái đang chờ. Trong lần đo này, nó cho thấy một giao dịch chạy UPDATE ... WHERE id=20 đang giữ khoá X trên hàng id=10 và chờ hàng id=20, còn giao dịch kia chạy UPDATE ... WHERE id=10 giữ hàng id=20 và chờ hàng id=10 — đúng vòng chờ chéo đã dựng. Đây là công cụ chẩn đoán đầu tiên khi deadlock xảy ra trên môi trường thật: nó cho biết chính xác hai câu lệnh nào và hai hàng nào tạo ra vòng, từ đó tìm được thứ tự khoá cần thống nhất lại trong mã.

Ba ý mang về

  1. Mức cô lập quyết định một giao dịch đọc lại thấy gì. REPEATABLE READ dựng ảnh chụp một lần cho cả giao dịch (đọc lại vẫn ra 1000); READ COMMITTED dựng ảnh chụp mới mỗi câu (đọc lại ra 3000 mà B vừa commit). RR nhất quán trong giao dịch, RC thấy thay đổi sớm hơn.
  2. Gap lock của REPEATABLE READ chặn phantom bằng cách khoá cả khe trống. A khoá khoảng id BETWEEN 10 AND 30 làm B chèn id=15 bị chặn 2035 ms; dưới READ COMMITTED không có gap lock nên B chèn ngay 57 ms. Đổi lại độ đồng thời lấy việc chặn bóng ma.
  3. Deadlock bị InnoDB phát hiện tức thì và cuộn lại một nạn nhân với lỗi 1213. Hai giao dịch khoá hai hàng theo thứ tự ngược nhau tạo vòng chờ; InnoDB phá vòng ngay, không chờ timeout, và SHOW ENGINE INNODB STATUS ghi lại ai giữ ai chờ. Mã ứng dụng phải bắt lỗi 1213 và thử lại.

Nguồn

Phần sau đi vào redo log và độ bền: vì sao một giao dịch commit xong là dữ liệu không mất dù máy chủ tắt đột ngột, cơ chế group commit gộp nhiều commit thành một lần ghi đĩa, và đo thật throughput ghi thay đổi thế nào theo từng mức của innodb_flush_log_at_trx_commit.