Khi làm việc với database, một trong những lỗi concurrency kinh điển nhất chính là read-modify-write cycle — vòng lặp đọc-sửa-ghi tưởng chừng vô hại nhưng có thể gây ra lost update (mất cập nhật) nếu không có biện pháp bảo vệ phù hợp. Bài viết này phân tích các chiến lược xử lý vòng lặp đọc-sửa-ghi trong PostgreSQL: từ SELECT FOR UPDATE, optimistic locking, đến SERIALIZABLE isolation.
Read-modify-write là gì?
Tưởng tượng bạn muốn truy vấn số dư tài khoản của người dùng, trừ đi 100 nếu đủ tiền, rồi lưu lại. Cách “ngây thơ” sẽ như thế này:
SELECT balance FROM accounts WHERE user_id = 1;
-- trong ứng dụng, trừ 100 từ balance nếu nó lớn hơn 100
-- rồi update lại với balance mới:
UPDATE accounts SET balance = ? WHERE user_id = 1;
Tưởng đơn giản nhưng nguy hiểm vô cùng! Nếu hai giao dịch chạy đồng thời cùng đọc balance = 200, cả hai đều nghĩ “Ồ, đủ tiền, trừ thôi” và cùng ghi balance = 100. Kết quả là lost update: phía nghiệp vụ đã “trừ tiền” hai lần nhưng số dư chỉ phản ánh một lần (200 → 100 thay vì 0).
Một biến thể khác: balance = 150 mà hai lần trừ 100 chạy song song đều đọc 150 rồi ghi 50, số dư cuối 50 trong khi đúng ra sau hai lần trừ 100 phải là -50 (hoặc một trong hai giao dịch phải bị từ chối). Đây vẫn là mất cập nhật / sai số dư, không nhất thiết hiển thị âm trong DB nhưng vi phạm quy tắc nghiệp vụ (double spend, cho phép rút quá số dư nếu không kiểm tra nguyên tử).
sequenceDiagram
participant T1 as Transaction 1
participant DB as Database
participant T2 as Transaction 2
Note over T1, T2: Tài khoản hiện có 200
T1->>DB: SELECT balance FROM accounts WHERE user_id = 1
DB-->>T1: balance = 200
T2->>DB: SELECT balance FROM accounts WHERE user_id = 1
DB-->>T2: balance = 200
Note over T1: Tính toán: 200 - 100 = 100
Note over T2: Tính toán: 200 - 100 = 100
T1->>DB: UPDATE accounts SET balance = 100 WHERE user_id = 1
DB-->>T1: OK (1 row affected)
T2->>DB: UPDATE accounts SET balance = 100 WHERE user_id = 1
DB-->>T2: OK (1 row affected)
Note over T1, T2: Tài khoản còn 100 thay vì 0!
Note over T1, T2: Mất 100 trong quá trình xử lý
4 chiến thuật để giải quyết
Có bốn hướng xử lý mình thấy thực tế nhất:
1. Tính toán trực tiếp trong SQL
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1 AND balance >= 100;
Phương pháp này đơn giản và hiệu quả nhất. Cập nhật xảy ra nguyên tử (atomic) và SQL tự kiểm tra điều kiện luôn. Chỉ cần kiểm tra số dòng bị ảnh hưởng để biết giao dịch có thành công không.
2. Khoá dòng với SELECT FOR UPDATE
BEGIN;
SELECT balance FROM accounts WHERE user_id = 1 FOR UPDATE;
-- kiểm tra và update trong ứng dụng
UPDATE accounts SET balance = ? WHERE user_id = 1;
COMMIT;
Khi dùng FOR UPDATE, PostgreSQL sẽ khoá dòng đó lại, các transaction khác muốn truy cập phải đợi transaction hiện tại hoàn thành.
Mình từng nghĩ phương pháp này tệ về hiệu năng, nhưng sau khi test thì thấy nó khá ổn đấy chứ. Mấu chốt là đừng giữ transaction mở quá lâu.
3. SERIALIZABLE isolation level
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE user_id = 1;
-- kiểm tra và update trong ứng dụng
UPDATE accounts SET balance = ? WHERE user_id = 1;
COMMIT;
Cách này cao cấp nhất nhưng cũng phức tạp. PostgreSQL sẽ phát hiện xung đột giữa các transaction và buộc một transaction phải rollback. App phải xử lý lỗi và thử lại.
4. Optimistic locking
SELECT balance, version FROM accounts WHERE user_id = 1;
UPDATE accounts
SET balance = ?, version = version + 1
WHERE user_id = 1 AND version = ?;
Cách này cực kỳ linh hoạt. Thêm trường version vào bảng, mỗi khi update thì tăng version lên và kiểm tra xem version có còn giống lúc đọc không. Nếu có ai đó đã update trước đó, câu UPDATE sẽ không tác động đến dòng nào.
Mình đã chọn gì?
Sau một hồi cân nhắc, mình chọn cách 1 cho các tác vụ đơn giản và cách 4 cho các luồng phức tạp. Cách 1 ngắn gọn và hiệu quả. Còn cách 4 linh hoạt hơn, dễ mở rộng hơn, và giảm nguy cơ deadlock so với giữ FOR UPDATE quá lâu (dù vẫn có thể deadlock nếu nhiều bảng hoặc nhiều khoá bị lấy sai thứ tự).
Kết quả là không còn vụ trừ tiền 2 lần nữa, và hệ thống vẫn chịu tải ổn trong giờ cao điểm. Nhìn lại thì bài học lớn nhất không phải là “chọn đúng isolation level”, mà là đừng tách riêng bước đọc, tính toán và ghi khi bản thân DB đã có cách làm nguyên tử hơn nhiều.