Một trong những điều khó tin nhất khi mới học CSDL: hai giao dịch có thể đọc và ghi cùng một hàng cùng lúc mà không chặn nhau, và mỗi cái vẫn thấy một bức tranh dữ liệu nhất quán. InnoDB làm được nhờ MVCC — multi-version concurrency control — giữ lại các phiên bản cũ của hàng trong undo log để phục vụ những giao dịch cần nhìn quá khứ. Bài này đo thật hai mặt của cơ chế đó: một giao dịch thấy dữ liệu đông cứng theo thời điểm nó bắt đầu dù người khác đã sửa và commit, và việc đọc thường không lấy khóa nên không bao giờ chặn người ghi.

Mỗi hàng mang theo lịch sử của chính nó

InnoDB gắn vào mỗi hàng hai cột ẩn: DB_TRX_ID (mã giao dịch sửa hàng lần cuối) và DB_ROLL_PTR (con trỏ tới bản ghi undo — tức phiên bản trước đó của hàng). Khi một giao dịch sửa hàng, InnoDB không xoá giá trị cũ ngay; nó ghi giá trị cũ vào undo log và cho DB_ROLL_PTR trỏ tới đó. Nhiều lần sửa tạo thành một chuỗi phiên bản nối ngược về quá khứ qua các con trỏ undo.

Khi một giao dịch đọc, InnoDB dựng một read view — ảnh chụp cho biết những giao dịch nào đã commit tại thời điểm đó. Đọc một hàng, nó so DB_TRX_ID của phiên bản hiện tại với read view: nếu phiên bản đó do một giao dịch commit sau ảnh chụp (hoặc chưa commit), nó quá mới, InnoDB men theo DB_ROLL_PTR vào undo log lấy phiên bản cũ hơn, lặp cho tới khi gặp phiên bản mà ảnh chụp được phép thấy. Nhờ vậy mỗi giao dịch đọc ra đúng trạng thái dữ liệu như lúc nó bắt đầu — gọi là đọc nhất quán (consistent read).

Ảnh chụp sơ đồ nền tối cơ chế MVCC của InnoDB mỗi hàng mang hai cột ẩn DB_TRX_ID mã giao dịch sửa lần cuối và DB_ROLL_PTR con trỏ tới bản ghi undo phiên bản trước đó, khi sửa hàng InnoDB ghi giá trị cũ vào undo log và cho con trỏ trỏ tới đó nhiều lần sửa tạo chuỗi phiên bản nối ngược quá khứ, khi đọc giao dịch dựng read view ảnh chụp các giao dịch đã commit so DB_TRX_ID của phiên bản hiện tại nếu quá mới do giao dịch commit sau ảnh chụp thì men theo con trỏ undo lấy phiên bản cũ hơn cho tới khi gặp phiên bản ảnh chụp được phép thấy gọi là đọc nhất quán consistent read, kịch bản đo ở mức REPEATABLE READ bảng acct id 1 bal 1000, giao dịch A START TRANSACTION đọc bal bằng 1000 rồi giao dịch B UPDATE bal bằng 2000 COMMIT rồi A đọc lại vẫn thấy 1000 vì read view của A chụp trước khi B commit nên men undo log về phiên bản cũ chỉ sau khi A COMMIT và đọc lại mới thấy 2000, mặt thứ hai đọc thường không lấy khóa nên B cập nhật không phải chờ còn SELECT FOR UPDATE lấy khóa hàng nên B phải chờ

Hình 1: mỗi hàng có DB_TRX_ID và DB_ROLL_PTR; đọc nhất quán men theo con trỏ undo để dựng lại phiên bản đúng với ảnh chụp của giao dịch.

A vẫn thấy 1000 sau khi B đã ghi 2000

Bảng acct có một hàng id = 1, bal = 1000. Mức cô lập mặc định của InnoDB là REPEATABLE READ. Chạy hai giao dịch xen kẽ nhau:

Giao dịch A                              Giao dịch B
START TRANSACTION;
SELECT bal WHERE id=1;   → 1000
                                         UPDATE acct SET bal=2000 WHERE id=1;
                                         COMMIT;
SELECT bal WHERE id=1;   → 1000   ← vẫn thấy giá trị cũ!
COMMIT;
SELECT bal WHERE id=1;   → 2000   ← giờ mới thấy giá trị mới

Lần đọc thứ hai của A diễn ra sau khi B đã sửa thành 2000 và commit, vậy mà A vẫn đọc ra 1000. Đó không phải dữ liệu cũ bị kẹt cache — đó là đọc nhất quán: read view của A được chụp từ lúc A bắt đầu, trước khi B commit, nên với A, phiên bản 2000 là "quá mới". InnoDB men theo con trỏ undo lấy lại phiên bản 1000 mà A được phép thấy. Chỉ khi A kết thúc giao dịch và mở read view mới, lần đọc thứ ba mới thấy 2000. Hai giao dịch chạy song song, không cái nào chặn cái nào, mà mỗi cái vẫn thấy một thế giới nhất quán.

Đọc thường không khóa: B không phải chờ

Mặt thứ hai của MVCC là lý do nó quan trọng cho hiệu năng: vì đọc thường phục vụ từ ảnh chụp (undo log), nó không cần lấy khóa trên hàng, nên không bao giờ chặn người ghi. Đo bằng cách cho A giữ một giao dịch mở ba giây rồi xem B mất bao lâu để cập nhật cùng hàng:

A giữ:  SELECT bal WHERE id=1;             (đọc thường, MVCC)
        → B UPDATE hoàn thành sau   55 ms     ← không phải chờ

A giữ:  SELECT bal WHERE id=1 FOR UPDATE;  (đọc có khóa)
        → B UPDATE hoàn thành sau 2035 ms     ← chờ tới khi A nhả khóa

Khi A đọc thường, B cập nhật xong trong 55 mili-giây — A không hề giữ khóa gì trên hàng, việc đọc của A và việc ghi của B hoàn toàn độc lập. Nhưng khi A đọc bằng SELECT ... FOR UPDATE, đó là đọc có khóa: A lấy khóa hàng, và B phải chờ 2035 mili-giây — đúng bằng thời gian A giữ giao dịch — rồi mới ghi được. Cùng một hàng, cùng một câu ghi của B, khác nhau chỉ ở chỗ A đọc thường hay đọc có khóa. Đây là lằn ranh thực dụng: dùng đọc thường khi chỉ cần xem, chỉ dùng FOR UPDATE khi thực sự cần giữ hàng để sửa, vì nó biến một thao tác không chặn thành một thao tác chặn.

Ảnh chụp output thật nền tối đo MVCC trên bảng acct trong mysql 8 mức REPEATABLE READ, phần một ảnh chụp nhất quán giao dịch A đọc lần 1 bal bằng 1000 rồi B UPDATE bal bằng 2000 COMMIT rồi A đọc lần 2 vẫn bằng 1000 vì read view của A chụp trước khi B commit men undo log về phiên bản cũ rồi A COMMIT đọc lần 3 bằng 2000, phần hai đọc thường không khóa A giữ SELECT bal thường thì B UPDATE hoàn thành sau 55 ms không phải chờ, A giữ SELECT bal FOR UPDATE đọc có khóa thì B UPDATE hoàn thành sau 2035 ms phải chờ A nhả khóa đúng bằng thời gian A giữ giao dịch, kết luận đọc thường phục vụ từ ảnh chụp undo log nên không lấy khóa không chặn người ghi chỉ dùng FOR UPDATE khi thực sự cần giữ hàng để sửa vì nó biến thao tác không chặn thành chặn

Hình 2: A giữ ảnh chụp cũ sau khi B commit; và đọc thường để B ghi trong 55 ms còn FOR UPDATE bắt B chờ 2035 ms.

Cái giá của MVCC: undo phải được giữ lại

MVCC không miễn phí. Các phiên bản cũ trong undo log chỉ được dọn (purge) khi không còn giao dịch nào có thể cần nhìn thấy chúng nữa. Một giao dịch mở lâu — kể cả một giao dịch chỉ đọc, ngồi im với một read view cũ — buộc InnoDB giữ lại mọi phiên bản cũ kể từ thời điểm nó bắt đầu, vì biết đâu nó sẽ đọc tới. Đây là lý do thực tế vì sao giao dịch mở lâu là tai hoạ: chúng khiến undo log phình ra, chuỗi phiên bản của những hàng bị sửa nhiều dài thêm, và mọi lần đọc nhất quán phải men qua chuỗi dài hơn để tìm phiên bản đúng. Lời khuyên "đừng để giao dịch mở lâu, nhất là giao dịch chỉ đọc mà ngồi chờ ứng dụng" bắt nguồn trực tiếp từ cơ chế undo log — đó là tài nguyên mà một giao dịch ngủ quên vẫn đang nắm giữ.

Ba ý mang về

  1. MVCC cho đọc nhất quán: giao dịch thấy dữ liệu đông cứng theo thời điểm nó bắt đầu. A đọc bal = 1000, B sửa thành 2000 và commit, A đọc lại vẫn ra 1000 vì read view của A men theo con trỏ undo về phiên bản cũ; chỉ sau khi A commit mới thấy 2000. Mỗi hàng mang DB_TRX_ID và DB_ROLL_PTR để dựng lại phiên bản đúng.
  2. Đọc thường không lấy khóa nên không chặn người ghi. A giữ một SELECT thường thì B cập nhật xong trong 55 ms; A giữ SELECT ... FOR UPDATE thì B phải chờ 2035 ms tới khi A nhả khóa. Chỉ dùng FOR UPDATE khi thật sự cần giữ hàng để sửa.
  3. Cái giá là undo phải được giữ cho tới khi không ai cần phiên bản cũ. Giao dịch mở lâu — kể cả chỉ đọc — buộc InnoDB giữ undo và kéo dài chuỗi phiên bản, làm mọi đọc nhất quán chậm đi. Đó là lý do kỹ thuật đằng sau lời khuyên "đừng để giao dịch mở lâu".

Nguồn

Phần sau đi vào khoá hàng và mức cô lập: sự khác nhau giữa READ COMMITTED và REPEATABLE READ, gap lock giữ khoảng trống để chặn phantom, và cách dựng một deadlock thật rồi đọc SHOW ENGINE INNODB STATUS để thấy InnoDB phát hiện và phá vòng khoá thế nào.