Đang tải nội dung…
Đang tải nội dung…
Tom và Ben đọc wait-for graph, tách khóa ngoại PostgreSQL khỏi next-key lock InnoDB
Transaction, Isolation & Locking · Phần 4Bối cảnh hư cấu, bằng chứng lab thật. Fortex trong tuyến truyện này là bối cảnh hư cấu; Tom và Ben là nhân vật. Các cuộc trao đổi minh họa quá trình suy nghĩ. Số đo và assertion thuộc lab Docker được dẫn trong bài, không phải số liệu production của Fortex.
Hành trình ở Fortex · Chương 13
Đồng hồ phòng trực incident điểm 21:17. Ánh sáng xanh le lói từ màn hình Grafana hắt lên gương mặt căng thẳng của Ben.
Chỉ mới chiều nay, Ben vừa hào hứng khoe với cả nhóm bản pull request tối ưu hóa luồng thanh toán giỏ hàng. Thay vì thực hiện nhiều câu lệnh query cồng kềnh qua nhiều bảng như trước, cậu gom logic lại thành một câu lệnh UPDATE duy nhất nhắm thẳng vào bảng cart_items. Biểu đồ P50 latency trong đợt thử nghiệm giảm đẹp như mơ. Thế nhưng, đúng 21:00 khi chiến dịch Flash Sale buổi tối vừa bùng nổ, log của MySQL cluster bắt đầu liên tục xuất hiện những dòng cảnh báo đỏ rực:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
Ben ôm laptop xoay sang phía Tom, giọng đầy vẻ hoang mang:
“Em không hiểu nổi anh Tom ơi! Hồi đại học thầy dạy deadlock chỉ xảy ra khi Transaction 1 khóa bảng A chờ bảng B, còn Transaction 2 khóa bảng B chờ bảng A. Đằng này trong code của em, cả hai request chỉ cập nhật đúng một bảng duy nhất là cart_items! Đã thế, hai khách hàng này còn thao tác trên hai dòng giỏ hàng hoàn toàn khác nhau. Tại sao hai dòng khác nhau trong cùng một bảng lại có thể tự khóa chéo nhau đến mức deadlock được? Hay là em cứ bọc try-catch rồi retry 3 lần là xong ạ?”
Tom không vội bác bỏ ý kiến của Ben. Anh kéo một chiếc ghế lại gần, nhẹ nhàng hỏi:
“Theo em, lúc hai khách hàng cùng cập nhật giỏ hàng có nhiều món, hệ thống của em cần cập nhật nguyên tử cả giỏ hàng hay từng món tách rời độc lập?”
Ben ngập ngừng: “Dạ, phải cập nhật nguyên tử trong cùng một database transaction chứ anh. Nếu khách mua 2 món mà 1 món cập nhật thành công còn 1 món bị lỗi thì giỏ hàng sẽ bị lệch trạng thái ngay.”
“Tốt. Vậy em thử lấy giấy bút ra, vẽ thứ tự các câu lệnh mà hai request đó gửi xuống database xem nào.” Tom mỉm cười.
Ben cặm cụi vẽ hai luồng request song song:
Cây bút dạ trên tay Ben bỗng khựng lại giữa không trung. Cậu nhìn chằm chằm vào hai mũi tên đan chéo nhau trên tờ giấy. Chưa đợi Tom lên tiếng, Ben đã tự thốt lên:
“Thôi chết em rồi... Cùng một bảng không có nghĩa là cùng một thứ tự khóa!”
“Chính xác,” Tom gật đầu. “Database engine chưa bao giờ quan tâm em đang chạm vào một bảng, hai bảng hay mười bảng. Thứ duy nhất mà trình phát hiện deadlock (Deadlock Detector) tìm kiếm là: Wait-for Graph (Đồ thị chờ đợi) có bị khép kín thành một chu trình (cycle) hay không!”
Mô hình nhỏ nhất của một deadlock luôn tuân theo công thức kinh điển: Transaction 1 giữ tài nguyên A và chờ tài nguyên B; trong khi Transaction 2 đang giữ tài nguyên B và chờ tài nguyên A.
Và tài nguyên ở đây có thể là bất kỳ thứ gì mà database engine quản lý:
Tài liệu chính thức của PostgreSQL mô tả rất rõ ràng: Deadlock có thể xảy ra ngay ở mức khóa dòng (Row-level lock) dù ứng dụng của bạn không hề sử dụng bất kỳ câu lệnh LOCK TABLE rõ ràng nào. Khi phát hiện chu trình chờ đợi, engine sẽ tự động can thiệp bằng cách hủy bỏ (abort) một trong các transaction để giải thoát cho transaction còn lại (PostgreSQL explicit locking).
Ben nhìn lại đoạn code giỏ hàng của mình: Cả hai mảng SKU từ client gửi lên đều hoàn toàn hợp lệ, nhưng thứ tự các phần tử lại phụ thuộc vào việc người dùng bấm chọn món nào trước.
“Vậy giải pháp đầu tiên là phải chuẩn hóa thứ tự!” Ben hào hứng. “Trước khi mở transaction và chạy vòng lặp cập nhật, em chỉ cần sắp xếp (sort) danh sách ID theo thứ tự tăng dần: items.sort((a, b) => a.id - b.id). Khi cả Request A và Request B đều bắt buộc phải khóa SKU 1 trước rồi mới khóa SKU 2, chu trình khóa chéo sẽ hoàn toàn bị triệt tiêu!”
Tom gật đầu tán thưởng, nhưng vẫn giữ màn hình log mở:
“Sắp xếp thứ tự khóa nhất quán (Deterministic Lock Ordering) là nguyên tắc vàng. Nhưng đừng vội nghĩ rằng cứ sort ID là em đã giải quyết được mọi loại deadlock trên đời. PostgreSQL và MySQL InnoDB có cơ chế khóa ngầm bên dưới hoàn toàn khác nhau. Nếu không hiểu bản chất, em sẽ gặp những deadlock mà em thậm chí không nhìn thấy cái khóa thứ hai nằm ở đâu.”
“Anh lấy ví dụ về PostgreSQL,” Tom nói tiếp. “Ở Fortex, chúng ta có bảng đơn hàng cha (orders) và bảng chi tiết đơn hàng con (order_items). Em nghĩ rằng một câu lệnh INSERT vào bảng con thì chỉ tạo dòng mới ở bảng con thôi đúng không?”
“Vâng, insert thì chỉ ghi dòng mới chứ liên quan gì đến khóa dòng của bảng cha đâu anh?” Ben thắc mắc.
“Thực tế hoàn toàn ngược lại. Khi em insert một dòng con có tham chiếu khóa ngoại (parent_id = 1), PostgreSQL bắt buộc phải bảo đảm rằng bản ghi cha có ID = 1 không bị ai xóa mất hoặc thay đổi khóa chính trong suốt thời gian transaction con đang chạy!”
Để làm được điều đó, PostgreSQL tự động đặt một Row-level Lock mang tên FOR KEY SHARE lên bản ghi cha (PostgreSQL row-level lock modes). Loại khóa này cho phép các transaction khác đọc hoặc cập nhật các cột phụ của dòng cha, nhưng chặn đứng mọi thao tác DELETE hoặc UPDATE làm thay đổi các cột thuộc khóa chính của dòng cha.
Ngược lại, nếu một transaction khác muốn cập nhật khóa chính của dòng cha, nó bắt buộc phải xin một khóa độc quyền mạnh hơn rất nhiều.
Trong bài lab thực nghiệm của WhyBit, hai transaction đồng thời được kích hoạt trong PostgreSQL:
parent 1 (Postgres ngầm cấp khóa FOR KEY SHARE trên parent 1). Ngay sau đó, Transaction 151 cố gắng cập nhật khóa chính của parent 2: UPDATE parent SET id = 99 WHERE id = 2.parent 2 (Postgres ngầm cấp khóa FOR KEY SHARE trên parent 2). Ngay sau đó, Transaction 152 cố gắng cập nhật khóa chính của parent 1: UPDATE parent SET id = 88 WHERE id = 1.Trước khi Deadlock Detector của PostgreSQL can thiệp, câu lệnh truy vấn trạng thái khóa pg_blocking_pids() đã chụp lại được đồ thị chờ đợi thực tế:
151 waits_for 152
152 waits_for 151
Hai transaction tự trói chặt lẫn nhau! PostgreSQL lập tức chọn một transaction làm nạn nhân và ném ra lỗi deadlock detected, trong khi transaction còn lại được đi tiếp an toàn.
“Em thấy chưa?” Tom chỉ vào kết quả. “Nếu lập trình viên chỉ nhìn vào hai câu lệnh UPDATE parent, họ sẽ thề thốt rằng hệ thống không thể nào deadlock. Họ hoàn toàn bỏ quên rằng câu lệnh INSERT bảng con diễn ra trước đó trong transaction đã âm thầm giữ một cái khóa trên dòng cha rồi!”
Chuyển sang nhánh MySQL 8.4, Tom chỉ cho Ben thấy cơ chế khóa đặc thù của công nghệ InnoDB:
“Trong MySQL InnoDB, khóa không nằm lơ lửng trên dữ liệu thô, mà khóa luôn luôn gắn liền với các bản ghi index (Index Records). Khi câu lệnh SQL quét qua các index entry, InnoDB sẽ đặt khóa trên chính các entry đó.”
Khi cập nhật dữ liệu thông qua một Secondary Index, InnoDB trước tiên sẽ khóa bản ghi trên Secondary Index đó, sau đó lần theo con trỏ để tìm và khóa tiếp bản ghi tương ứng trên Clustered Index (Primary Key). Một số tài liệu thường đơn giản hóa thành câu thần chú 'secondary luôn khóa trước primary', nhưng thực tế execution plan của MySQL mới là yếu tố quyết định đường đi và thứ tự khóa thực tế (MySQL 8.4 locks set by statements).
Nhưng điểm nguy hiểm và bất ngờ nhất đối với các kỹ sư backend nằm ở mức độ cô lập mặc định của MySQL: REPEATABLE READ.
Để ngăn chặn hiện tượng đọc bóng ma (Phantom Read) theo chuẩn ANSI SQL, InnoDB sử dụng cơ chế Next-Key Locking — sự kết hợp giữa Record Lock (khóa bản ghi) và Gap Lock (khóa khoảng trống ngay phía trước bản ghi).
“Điều này có nghĩa là gì?” Ben tròn mắt.
“Có nghĩa là InnoDB không chỉ khóa những bản ghi đang tồn tại, mà nó còn khóa sạch toàn bộ khoảng trống giữa các bản ghi!” Tom nhấn mạnh.
Trong bài lab kiểm chứng:
SELECT * FROM items WHERE id BETWEEN 10 AND 20 FOR UPDATE.id = 15.Mặc dù bản ghi id = 15 hoàn toàn chưa hề tồn tại trong database, câu lệnh INSERT của Transaction 2 vẫn bị chặn đứng hoàn toàn! Transaction 2 đã phải ngậm ngùi chờ suốt 1.584 ms trong lab cho đến khi Transaction 1 hoàn tất lệnh COMMIT.
“Đó là Lock Wait,” Tom phân tích. “Bản thân việc chờ đợi này chưa phải là deadlock. Nhưng nếu Transaction 2 lúc này đang giữ một bản ghi khác mà Transaction 1 cần chạm tới ở bước tiếp theo, thì một deadlock kinh điển sẽ lập tức nổ ra ngay trên một khoảng trống dữ liệu ảo!” Cơ chế này được mô tả chi tiết tại tài liệu InnoDB locking.
Ben ghi chép kín cả hai trang sổ tay. Cậu tổng kết lại chiến lược xử lý mà mình định áp dụng cho Fortex thành ba lớp phòng thủ:
pg_locks, pg_stat_activity, và hàm pg_blocking_pids() để vẽ ra cạnh chờ thực tế.SHOW ENGINE INNODB STATUS để đọc phần LATEST DETECTED DEADLOCK, soi rõ câu lệnh SQL và loại khóa cụ thể gây ra xung đột.Tom mỉm cười gật đầu, nhưng không quên nhắc thêm một lưu ý sống còn:
“Em phải nhớ một nguyên tắc bất di bất dịch: Retry không bao giờ được áp dụng mù quáng nếu transaction có chứa Side-effect bên ngoài cơ sở dữ liệu! Nếu transaction của em đã lỡ gọi sang cổng thanh toán trừ tiền khách hàng trước khi bị database chọn làm nạn nhân deadlock, thì việc retry transaction sẽ trừ tiền của khách lần thứ hai! Đó là lý do vì sao kiến trúc phân tán bắt buộc phải kết hợp chặt chẽ giữa Transactional Outbox và Idempotency Key.”
Toàn bộ các giả thuyết và cơ chế khóa nêu trên được kiểm chứng độc lập trên môi trường Docker cô lập, sử dụng phiên bản PostgreSQL 17.6 và MySQL 8.4.7 chính thức. Cả bốn nhánh thử nghiệm đều có assertion tự động:
Hình báo cáo được dựng từ kết quả của run d810f6c6-1a64-4ea6-8534-bc327cd22e17: chu trình PID 151 ↔ 152 và thời gian chờ gap insert 1.584 ms. Đây không phải ảnh production của Fortex.
| Nhánh thực nghiệm | Hiện tượng quan sát | Kết quả đo lường |
|---|---|---|
| PostgreSQL FK | Chu trình Wait-for Graph hai chiều do kiểm tra khóa ngoại | 1 transaction bị chọn làm nạn nhân (deadlock detected) |
| InnoDB Thứ tự ngược | Hai transaction update ID 1→2 đối đầu với 2→1 | 1 transaction bị rollback với mã lỗi 1213 |
| InnoDB Thứ tự thống nhất | Cả hai transaction cùng sort ID tăng dần (1→2) | 0 deadlock, cả hai dòng cập nhật thành công với qty=2,2 |
| InnoDB Next-Key Lock | Khóa dải giá trị [10, 20], cố gắng insert ID 15 | Lệnh insert bị chặn đứng, chờ 1.584 ms tới khi commit |
Report JSON · Raw log · Mã nguồn lab · Run d810f6c6-1a64-4ea6-8534-bc327cd22e17
Lưu ý về phạm vi thực nghiệm: Lab chạy trên một node container cục bộ để cô lập bản chất logic khóa của database engine, không đại diện cho độ trễ mạng hay mức độ tranh chấp tải thực tế trên production. Thử nghiệm Gap Lock chứng minh việc khóa một dải giá trị chưa tồn tại, không có nghĩa là mọi câu lệnh update hai ID khác nhau đều sẽ dẫn đến deadlock.
Sáng hôm sau tại văn phòng Fortex, Ben gửi bản pull request mới:
db_error_retry thành deadlock_victim_retry — bởi vì lần đầu tiên, đội ngũ kỹ thuật hiểu chính xác hệ thống đang làm gì dưới tầng lưu trữ.Tom đọc lướt qua code review, mỉm cười hỏi một câu cuối:
“Nếu ngày mai đồ thị chờ đợi không phải xuất phát từ hai cart item thì sao?”
Ben ngẩng lên, tự tin đáp:
“Dạ, em sẽ mở pg_blocking_pids() hoặc SHOW ENGINE INNODB STATUS để đọc từng cạnh chờ thực tế trong Wait-for Graph. Em không bao giờ ngồi đoán mò từ cái tên bảng nữa!”
Fortex và các nhân vật hội thoại là bối cảnh hư cấu minh họa. Toàn bộ số đo, đồ thị khóa và log kỹ thuật trong bài viết đều được trích xuất nguyên bản từ phòng lab Docker có thể tái lập.
Đọc tiếp: Chương 14 — Xóa cache trước hay cập nhật DB trước?
PostgreSQL 17.6 + MySQL 8.4.7 · digest pinned · isolated Docker
Tải source Docker Lab (.zip)COMMIT a035d919d67e3863bdaab6961089bec3d917905e
Đọc kỹ README trước khi chạy. Các lệnh thực nghiệm chuẩn trong Docker Lab:
make setupmake seedmake reproducemake verifymake cleanupMôi trường Docker độc lập, giới hạn tài nguyên và tự động dọn dẹp sau khi chạy. Số liệu trong bài viết là kết quả lần chạy chính thức; mỗi lần chạy mới đều lưu lịch sử riêng.