Dạo này mình đang phải vật lộn với một con bug kinh điển trong hệ thống thanh toán - thằng khách hàng than phiền tài khoản của họ bị trừ tiền 2 lần cho cùng một giao dịch. Nghĩ cũng lạ, code mình viết rõ ràng là chỉ trừ một lần, vậy mà sao lại có chuyện này nhỉ?
Ngồi debug cả tuần mới vỡ lẽ ra vấn đề - hoá ra là anh em đang dính phải cái bẫy kinh điển của database: read-modify-write cycles.
Vấn đề 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 update balance = 100. Kết quả là tài khoản chỉ bị trừ 100 thay vì 200!
Ngược lại, nếu ban đầu balance = 150 và hai giao dịch cùng trừ 100, cả hai sẽ đều nghĩ là đủ tiền và update balance = 50, khiến tài khoản bị âm!
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
Mình đã tìm ra 4 cách để giải quyết vấn đề này:
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 thì ngắn gọn, hiệu quả. Còn cách 4 thì linh hoạt, dễ mở rộng và không bị deadlock.
Kết quả là không còn ai than phiền bị trừ tiền 2 lần nữa, và hệ thống vẫn xử lý tốt tải cao trong giờ cao điểm.
Các bạn đã từng gặp vấn đề tương tự chưa? Bạn thích cách giải quyết nào nhất? Chia sẻ kinh nghiệm của bạn trong phần bình luận nhé!