Cơn ác mộng Locking trong cơ sở dữ liệu
Hãy tưởng tượng một đợt flash sale với 10.000 người dùng đồng thời nhấn nút thanh toán. Hàng nghìn người mua đang kiểm tra kho hàng trong khi dịch vụ kiểm kê của bạn đang điên cuồng cập nhật số lượng. Trong các kiến trúc cơ sở dữ liệu cũ, cách duy nhất để giữ cho dữ liệu nhất quán là thông qua cơ chế khóa (locking) hạng nặng. Nếu Giao dịch A đang đọc một hàng, Giao dịch B chỉ đơn giản là phải đợi. Chúng ta gọi đây là Pessimistic Locking (Khóa bi quan). Nó hoạt động được, nhưng khả năng mở rộng cực kỳ kém.
Tôi từng tiếp nhận một hệ thống cũ nơi row-level lock (khóa cấp hàng) là tiêu chuẩn. Khi lưu lượng truy cập tăng vọt, độ trễ nhảy từ 50ms lên 5 giây. Cơ sở dữ liệu không hề cạn kiệt CPU hay RAM; nó chỉ đang nhàn rỗi, bị mắc kẹt khi chờ giải phóng các lock. Multi-Version Concurrency Control (MVCC – Kiểm soát truy cập đồng thời đa phiên bản) giải quyết vấn đề này. Sau khi triển khai các chiến lược dựa trên MVCC trên PostgreSQL và MySQL cho các dự án quy mô lớn, tôi nhận thấy rằng việc hiểu rõ sự khác biệt nội tại giữa chúng là bí mật để xây dựng các hệ thống luôn nhanh nhạy dưới áp lực.
Xung đột cốt lõi: Readers và Writers
Tính đồng thời của cơ sở dữ liệu thất bại khi người đọc (readers) và người viết (writers) cản trở lẫn nhau. Để đạt được hiệu suất cao, chúng ta cần một hệ thống mà tại đó:
- Nhiều người dùng có thể đọc cùng một dữ liệu đồng thời.
- Người đọc không bao giờ chặn người viết.
- Người viết không bao giờ chặn người đọc.
Cơ chế locking truyền thống coi dữ liệu là một bản chụp (snapshot) tĩnh duy nhất. Nếu bạn thay đổi một giá trị, bạn phải ẩn nó với tất cả những người khác cho đến khi thực hiện xong. MVCC đi theo một con đường khác. Nó cho phép nhiều phiên bản của cùng một hàng tồn tại cùng một lúc. Thay vì ghi đè dữ liệu, cơ sở dữ liệu tạo ra một phiên bản mới. Mỗi giao dịch sẽ nhìn thấy một “snapshot” riêng tư của dữ liệu như nó vốn có tại thời điểm truy vấn bắt đầu.
PostgreSQL và MySQL: Hai con đường dẫn đến cùng một mục tiêu
Mặc dù cả hai engine đều sử dụng MVCC, nhưng cơ chế nội tại của chúng khác biệt hoàn toàn. Việc chọn đúng loại — hoặc tinh chỉnh loại bạn đang có — đòi hỏi bạn phải biết điều gì đang xảy ra bên dưới lớp vỏ.
PostgreSQL: Cách tiếp cận Phiên bản-trong-Bảng
PostgreSQL lưu trữ mọi phiên bản của một hàng trực tiếp trong các tệp dữ liệu chính. Mỗi hàng mang metadata ẩn, cụ thể là xmin (giao dịch đã tạo ra nó) và xmax (giao dịch đã xóa hoặc thay thế nó).
-- Kiểm tra metadata MVCC ẩn trong Postgres
SELECT ctid, xmin, xmax, * FROM users WHERE id = 1;
Khi bạn cập nhật một hàng, Postgres không chạm vào dữ liệu cũ. Nó đánh dấu phiên bản cũ là “hết hạn” bằng cách sử dụng xmax và chèn một hàng hoàn toàn mới. Điều này giúp việc ghi cực kỳ nhanh chóng. Tuy nhiên, nó tạo ra hiện tượng “bloat” (phình to dữ liệu). Tôi đã thấy những bảng 1GB phình lên tới 5GB vì tiến trình VACUUM không thể theo kịp các dead tuples (tuple đã chết). Nếu không được dọn dẹp thường xuyên, hiệu năng của bạn cuối cùng sẽ sụp đổ.
MySQL (InnoDB): Cách tiếp cận Undo Log
MySQL (InnoDB) chọn hướng ngược lại. Nó chỉ giữ phiên bản mới nhất của một hàng trong bảng chính. Để cung cấp MVCC, nó chuyển dữ liệu cũ sang một cấu trúc riêng biệt gọi là Undo Log. Mỗi hàng chứa một con trỏ tới trạng thái trước đó của nó được lưu trữ trong log đó.
Nếu một giao dịch cần một phiên bản cũ hơn của dữ liệu, InnoDB sẽ tái cấu trúc nó ngay lập tức bằng cách sử dụng các bản ghi undo đó. Điều này ngăn chặn tình trạng phình to bảng như trong Postgres. Sự đánh đổi là gì? Các giao dịch chạy lâu có thể khiến Undo Log bùng nổ về kích thước, làm chậm toàn bộ hệ thống khi nó phải duyệt qua các chuỗi phiên bản dài dằng dặc.
Isolation Levels: Nơi dữ liệu gặp lỗi
MVCC không phải là viên đạn bạc. Transaction Isolation Level (Cấp độ cô lập giao dịch) của bạn sẽ quyết định những hiện tượng bất thường nào bạn sẽ gặp phải. Hai vấn đề cụ thể — Phantom Reads và Write Skew — thường khiến các nhà phát triển mất cảnh giác.
1. Phantom Read (Đọc bóng ma)
Phantom Read xảy ra khi một giao dịch chạy cùng một truy vấn hai lần nhưng lại tìm thấy các hàng mới ở lần thứ hai do một người dùng khác đã chèn dữ liệu. Trong MySQL, chế độ mặc định REPEATABLE READ sử dụng Gap Locking để khóa các “khoảng trống” giữa các hàng, ngăn chặn các bóng ma này. REPEATABLE READ của PostgreSQL thậm chí còn nghiêm ngặt hơn; nó chỉ đơn giản là báo lỗi nếu phát hiện snapshot dữ liệu đã thay đổi, buộc ứng dụng phải xử lý xung đột.
2. Write Skew: Kẻ giết người thầm lặng
Write Skew (Lệch ghi) là một lỗi tinh vi có thể lọt qua ngay cả ở cấp độ REPEATABLE READ. Nó xảy ra khi hai giao dịch đọc cùng một dữ liệu, đưa ra quyết định dựa trên logic, và sau đó cập nhật các hàng khác nhau làm mất hiệu lực giả định của nhau.
Kịch bản Bác sĩ trực ca:
Một bệnh viện yêu cầu ít nhất một bác sĩ phải trực. Alice và Bob đều đang trong ca trực. Cả hai đều cố gắng xin nghỉ cùng một lúc.
-- Giao dịch 1 (Alice)
SELECT count(*) FROM doctors WHERE on_call = true; -- Trả về 2
UPDATE doctors SET on_call = false WHERE name = 'Alice'; -- Được phép
-- Giao dịch 2 (Bob)
SELECT count(*) FROM doctors WHERE on_call = true; -- Trả về 2 (do snapshot MVCC)
UPDATE doctors SET on_call = false WHERE name = 'Bob'; -- Được phép
-- Kết quả: 0 bác sĩ đang trực. Hệ thống đã thất bại.
Vì họ cập nhật các hàng khác nhau, không có lock nào được kích hoạt. MVCC đã giữ cho cơ sở dữ liệu hoạt động thành công, nhưng nó lại cho phép vi phạm logic nghiệp vụ.
Chiến lược để thành công trong môi trường Production
Đừng chỉ đặt mọi thứ thành SERIALIZABLE. Đó là cấp độ an toàn nhất, nhưng nó có thể giết chết thông lượng (throughput) bằng cách buộc các giao dịch phải chạy gần như tuần tự. Dưới đây là cách tốt hơn để xử lý logic có tính đồng thời cao.
Sử dụng Explicit Locking cho các luồng quan trọng
Khi có rủi ro Write Skew, hãy sử dụng SELECT ... FOR UPDATE. Điều này buộc cơ sở dữ liệu phải khóa các hàng bạn đang đọc, khiến các giao dịch khác phải chờ cho đến khi bạn hoàn thành.
-- Ngăn chặn Write Skew thủ công
BEGIN;
SELECT count(*) FROM doctors WHERE on_call = true FOR UPDATE;
-- Alice hiện đang giữ lock. Truy vấn của Bob sẽ phải chờ tại đây.
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;
Lợi thế SSI của PostgreSQL
PostgreSQL cung cấp một tính năng gọi là Serializable Snapshot Isolation (SSI). Không giống như các cấp độ serializable truyền thống khóa toàn bộ bảng, SSI theo dõi các phụ thuộc. Nếu nó phát hiện một Write Skew tiềm ẩn, nó sẽ hủy một giao dịch và trả về lỗi serialization. Mã nguồn của bạn phải sẵn sàng để bắt lỗi này và thử lại thao tác (retry) ngay lập tức.
Chiến lược MySQL: Theo dõi phiên bản
Đối với MySQL, tôi thường ưu tiên Optimistic Concurrency Control (OCC – Kiểm soát đồng thời lạc quan). Thay vì dựa vào các lock nặng nề của cơ sở dữ liệu, hãy thêm một cột version vào bảng của bạn. Cách này nhẹ nhàng và hoạt động hoàn hảo cho các ứng dụng web.
-- Optimistic locking ở tầng ứng dụng
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 101 AND version = 12; -- 12 là phiên bản chúng ta đã lấy trước đó
Nếu lệnh update tác động đến 0 hàng, nghĩa là ai đó đã nhanh chân hơn bạn. Ứng dụng của bạn sau đó nên lấy dữ liệu mới và thử lại.
Danh sách kiểm tra cuối cùng
- Ưu tiên READ COMMITTED: Đây là lựa chọn mặc định tốt nhất cho 90% các trường hợp sử dụng. Nó mang lại hiệu suất cao và ngăn chặn dirty reads.
- Theo dõi hiện tượng Bloat (Postgres): Giám sát
pg_stat_all_tables. Nếu số lượng dead tuple tăng lên, cài đặtautovacuumcủa bạn cần được tinh chỉnh. - Hủy các giao dịch chạy lâu (MySQL): Giám sát History List Length của bạn. Các giao dịch mở trong nhiều giờ sẽ làm phình Undo Log và làm giảm hiệu năng.
- Xây dựng logic thử lại (Retry): Nếu bạn sử dụng các cấp độ cô lập cao, việc thử lại không phải là tùy chọn. Đó là một phần cốt lõi trong độ tin cậy của ứng dụng.
MVCC là động cơ cho phép các cơ sở dữ liệu hiện đại mở rộng quy mô lên hàng triệu hàng. Bằng cách hiểu cách các phiên bản được lưu trữ và nơi logic có thể thất bại, bạn có thể xây dựng các lớp dữ liệu vừa nhanh như chớp vừa nhất quán hoàn hảo.

