"Bóng ma" trong System: Khi Transaction không an toàn như bạn nghĩ

Bạn là một Backend Developer. Bạn thuộc nằm lòng câu thần chú:

"Việc gì không được phép sai nửa chừng → cho vào Transaction."

Bạn tin rằng BEGIN TRANSACTIONCOMMIT là tấm khiên bất hoại bảo vệ hệ thống khỏi mọi lỗi logic. Nhưng thực tế lại phũ phàng hơn nhiều. Có những lỗi "ma quái" chỉ xuất hiện khi hệ thống chịu tải cao, những lỗi mà việc bật Transaction đơn thuần không thể ngăn chặn.

Hôm nay, chúng ta sẽ mổ xẻ:

👻 Write Skew

👻 Phantom Reads

Hai "bóng ma" ám ảnh những hệ thống có vẻ ngoài hoàn hảo.

1. Câu chuyện kinh dị tại bệnh viện (Alice & Bob)

Hãy bắt đầu với một kịch bản kinh điển về Database, nhưng là ác mộng ngoài đời thực.

Bối cảnh:

  • Bệnh viện yêu cầu luôn phải có ít nhất 1 bác sĩ trực ca.
  • Hiện tại, ca trực có 2 người: AliceBob. Cả hai đều đang kiệt sức và muốn xin nghỉ sớm.

Diễn biến (Concurrency):

Cả Alice và Bob cùng lúc đăng nhập vào hệ thống để bấm nút "Xin nghỉ".

1. Alice (Transaction A): Kiểm tra số lượng bác sĩ đang trực.

  • Query: SELECT count(*) FROM doctors WHERE on_call = true;
  • Kết quả: 2 (Alice & Bob).
  • Logic: "Ồ, còn 2 người. Mình nghỉ thì vẫn còn Bob. OK!"

2.  Bob (Transaction B): Cũng kiểm tra y hệt cùng thời điểm.

  • Query: SELECT count(*) FROM doctors WHERE on_call = true;
  • Kết quả: 2 (Alice & Bob).
  • Logic: "Tuyệt, còn Alice trực. Mình té đây!"

3. Alice: Update trạng thái bản thân thành false -> COMMIT.

4. Bob: Update trạng thái bản thân thành false -> COMMIT.

Kết quả:

  • Cả hai Transaction đều thành công.
  • Database ghi nhận: 0 bác sĩ trực ca.
  • Business Rule bị vi phạm nghiêm trọng.
  • Hệ thống vẫn dùng Transaction đầy đủ.

Tại sao chuyện này lại xảy ra? Chẳng phải ACID sinh ra để chặn điều này sao?

2. Cú lừa của Snapshot Isolation

Thủ phạm chính là cơ chế Snapshot Isolation (thường thấy ở mức cô lập Repeatable Read hoặc Read Committed mặc định của nhiều DB như PostgreSQL hay MySQL/InnoDB).

  • Nguyên lý: Để đảm bảo hiệu năng cao (tốc độ nhanh), họ thường sử dụng kỹ thuật MVCC (Multi-Version Concurrency Control). Thay vì khóa (lock) dữ liệu lại không cho ai đụng vào khi bạn đang đọc, Database sẽ tạo ra nhiều "phiên bản" (version) của dữ liệu đó.
  • Snapshot (Tấm ảnh): Khi Alice bắt đầu Transaction (lệnh BEGIN), Database xác định một "thời điểm T0". Từ lúc đó, mọi câu lệnh SELECT của Alice chỉ nhìn thấy dữ liệu tính đến thời điểm T0. Nếu Bob thay đổi dữ liệu sau thời điểm T0, Alice hoàn toàn không nhìn thấy. Đối với Alice, thế giới đã "đóng băng" tại thời điểm cô bắt đầu.

Hãy xem lại kịch bản Alice và Bob:

1) Khi Alice bắt đầu Transaction, Database đưa cho cô một snapshot của dữ liệu tại thời điểm đó. Trong snapshot đó:

Bob vẫn đang trực.

2) Alice ra quyết định dựa trên tiền đề:

"Vẫn còn 2 người trực."

3) Nhưng ngay khi Alice chuẩn bị Commit:

  • Bob đã commit lệnh nghỉ trước đó.
  • Tiền đề ban đầu không còn đúng nữa.
  • Database không báo lỗi.

Vấn đề là: Ngay khi Alice chuẩn bị Commit, tiền đề đó đã không còn đúng nữa (do Bob đã commit lệnh nghỉ), nhưng Database không hề báo lỗi.

Đây gọi là lỗi Outdated Premise (Tiền đề lỗi thời). Bạn ra quyết định ghi (Write) dựa trên một dữ liệu đọc (Read) trong quá khứ, và dữ liệu đó đã bị thay đổi bởi người khác mà bạn không hề hay biết.

3. Vạch mặt "Bóng ma": Write Skew & Phantom Reads

Đây không phải lỗi cú pháp, đây là Race Condition ở tầng logic.

Write Skew (Lệch ghi)

Trường hợp của Alice và Bob chính là Write Skew.

Định nghĩa: Hai Transaction đọc cùng một tập dữ liệu, nhưng lại cập nhật hai tập dữ liệu rời rạc (disjoint).

  • Alice update dòng của Alice.
  • Bob update dòng của Bob.
  • Vì chúng update các dòng khác nhau, Database (ở mức Repeatable Read) không thấy sự xung đột khóa (Lock conflict). Nó cho phép cả hai cùng chạy. Nhưng kết hợp lại, chúng phá vỡ quy tắc chung.

Phantom Reads (Bóng ma)

Đây là một biến thể tinh vi hơn.

  • Kịch bản: Bạn SELECT một danh sách theo điều kiện (ví dụ: lấy danh sách phòng họp trống). Bạn thấy phòng 101 trống, bạn book nó.
  • Vấn đề: Trong khi bạn đang suy nghĩ, một Transaction khác chèn (INSERT) một bản ghi mới đặt phòng 101.
  • Bản ghi mới này xuất hiện như một "bóng ma" – nó không tồn tại khi bạn SELECT lần đầu, nhưng nó làm thay đổi kết quả nếu bạn SELECT lại. Transaction của bạn vẫn book đè lên hoặc gây lỗi dữ liệu không nhất quán.

4. Giải pháp thực dụng: Đừng chỉ cầu nguyện, hãy khóa lại!

Dưới đây là 2 cách để xử lý Write Skew:

Cách 1: Khóa Bi Quan (Pessimistic Locking) với SELECT ... FOR UPDATE

Nếu bạn định thay đổi dữ liệu dựa trên kết quả đọc, hãy nói rõ cho Database biết: "Này, tao đang đọc cái này để sửa đấy, thằng nào đụng vào là tao chém!".

Quay lại ví dụ Alice & Bob, chúng ta sửa query như sau:

BEGIN;
-- Bước 1: Khóa các dòng đang "on_call"
-- Lưu ý: Phải đảm bảo cột 'on_call' có Index để tránh khóa toàn bộ bảng (Table Lock)
SELECT * FROM doctors WHERE on_call = true FOR UPDATE;

-- Bước 2: Ứng dụng kiểm tra logic: Nếu count >= 2 thì mới chạy tiếp
-- Bước 3: Update
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;

Lưu ý: Bạn không thể khóa thứ chưa tồn tại.

  • FOR UPDATE chỉ khóa các dòng record nó tìm thấy. Nếu "bóng ma" (Phantom Read) sinh ra từ một lệnh INSERT mới tinh của Transaction khác, FOR UPDATE đôi khi không bắt được "bóng ma".

Cách 2 : Materializing Conflicts (Cụ thể hóa xung đột)

Bản chất của Write Skew là hai Transaction chạm vào hai vùng dữ liệu khác nhau (Alice sửa dòng Alice, Bob sửa dòng Bob), khiến Database không phát hiện ra sự xung đột.

Giải pháp: Hãy tạo ra một "điểm nóng" (Choke Point) để buộc chúng phải va chạm nhau. Chúng ta tạo thêm bảng shifts quản lý thông tin ca trực. Trước khi sửa bất kỳ ai, Transaction bắt buộc phải giành được quyền khóa ca trực đó.

Query:

  • Tạo bảng quản lý ca trực (Nơi xảy ra tranh chấp khóa)

CREATE TABLE shifts (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    active_doc_count INT -- Số bác sĩ đang trực
);

-- Dữ liệu khởi tạo: Ca sáng (ID=1) đang có 2 bác sĩ
INSERT INTO shifts (id, name, active_doc_count) VALUES (1, 'Morning Shift', 2);

  • Quy trình xử lý (Transaction): Khi Alice muốn nghỉ, cô ấy bắt buộc phải giành được quyền kiểm soát dòng shifts này trước.
BEGIN;

-- 1. Giành quyền khóa ("Cầm thẻ bài")
-- BLOCK tất cả các transaction khác đang cố đọc/sửa dòng ID=1
SELECT active_doc_count FROM shifts WHERE id = 1 FOR UPDATE;

-- 2. Kiểm tra Logic (Application Layer)
-- Nếu active_doc_count < 2 => ROLLBACK (Không cho nghỉ nữa)

-- 3. Cập nhật (Nếu thỏa mãn)
UPDATE doctors SET on_call = false WHERE name = 'Alice';
UPDATE shifts SET active_doc_count = active_doc_count - 1 WHERE id = 1;

COMMIT;

Cơ chế:Thay vì "mạnh ai nấy chạy", Alice và Bob giờ đây phải tranh nhau khóa dòng id=1 này.

  • Người đến trước: Giữ khóa và thực hiện thay đổi.
  • Người đến sau: Bị Database bắt đứng chờ (Block) cho đến khi người trước Commit.

Kỹ thuật này biến bài toán Write Skew mơ hồ thành bài toán Update dòng đơn lẻ (Row Update) – thứ mà mọi Database đều xử lý hoàn hảo, bất kể Isolation Level nào.

Cách 3: Nâng cấp lên Serializable (Khóa toàn cục)

Mức cô lập Serializable đảm bảo Database thực thi các Transaction như thể chúng chạy lần lượt (tuần tự), triệt tiêu hoàn toàn Write Skew và Phantom Reads.

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT * FROM bookings WHERE room = 101 AND slot = '9AM';
INSERT INTO bookings ...;
COMMIT;
  • Ưu điểm: An toàn tuyệt đối về logic. Code sạch, không cần FOR UPDATE.
  • Nhược điểm: Hiệu năng giảm. Khả năng gặp lỗi Serialization Failure (Deadlock hoặc Retry) tăng vọt. Bạn phải viết code để Retry Transaction khi bị fail.

Lời khuyên: Chỉ dùng Serializable cho các giao dịch cực kỳ quan trọng và tần suất thấp (ví dụ: chuyển tiền, chốt sổ cuối ngày).

5. Kết luận

Transaction không phải là phép màu. Việc bật @Transactional trong Spring hay TypeORM chỉ là bước đầu tiên.

Là một Engineer, bạn cần hiểu rõ:

  1. Isolation Level mà Database của bạn đang chạy là gì? (Mặc định thường KHÔNG PHẢI là Serializable).
  2. Logic của bạn có phụ thuộc vào một "Tiền đề" có thể bị thay đổi bởi người khác không?

Đừng để code chạy ngon lành trên máy Local (nơi chỉ có 1 request tại 1 thời điểm) đánh lừa bạn. "Bóng ma" chỉ xuất hiện khi lên Production – nơi hàng nghìn request đang chực chờ xâu xé dữ liệu của bạn.

Tài liệu tham khảo