+1

Đừng để "toang" Production rồi mới nghiêm túc với Database Transaction

Chào anh em Viblo!

Hôm nay cuối tuần rảnh rỗi, ngồi dọn dẹp lại mớ code cũ, mình chợt nhớ lại mộ "vết nhơ" trong sự nghiệp code backend của mình cách đây vài năm. Chuyện là hồi đó mình làm tính năng thanh toán cho một app e-commerce. Logic nhìn qua siêu đơn giản:

  1. Trừ tiền trong ví user.
  2. Tạo đơn hàng (Insert vào bảng orders).
  3. Trừ số lượng tồn kho (Update bảng products).

DEv xong, test ở local trơn tru, ném lên staging AQ test cũng pass cái rụp. Mình tự tin merge code và đi uống cafe. Và bùm... rạng sáng hôm sai, Custommer Service gõ đầu mình báo: "Khách bị trừ tiền nhưng không thấy đơn hàng đâu em ơi!".

Check log thì ôi thôi, server bị timeout đúng lúc insert đơn hàng, thế là bước 1(trừ tiền) đã chạy, nhưng bước 2 và 3 gãy. Tiền khách bị bay màu, đồ thì không thấy. Mình tốn cả buổi sáng để fix data bằng tay, vừa làm vừa toát mồ hôi hột xin lỗi sếp.

Đố là lúc mình thực sự nhận ra tầm quan trọng của Database Transaction, và đau đơn hơn là nhận ra viết Transaction sao cho đúng còn quan trọng hơn.

Dưới đây là vài kinh nghiệm "Xương máu" mà mình đúc kết được sau vô số lần ăn hành. Chia sẻ lại để anh em nào mới đi làm không giẫm phải vết xe đổ của mình nhé.

1.Hiểu bản chất: Không phải "Cứu tinh", mà là "Lời thề sống chết"

Nhiều anh em mới tiếp cận thường học lý thuyết về ACID (Atomicity, Consistency, Isolation, Durability). Nghe thì hàn lâm, nhưng hiểu nôm na Transaction là một "lời thề": Một là thành công tất cả, hai là coi như chưa có gì xảy ra (All or Nothing).. nếu ở ví dụ trên mình gom cả 3 bước vào 1 transaction. Khi bước 2 tạch, transaction sẽ rollback lại. Trạng thái database quay về hệt như lúc chưa trừ tiền. Gọn gàng, sạch sẽ, không ai phải đền tiền oan.

Nhưng biết dùng là một chuyện, dùng sai thì hệ thống còn "chết dơ" hơn.

2. Những cái bẫy chết người khi dùng Transaction

Bẫy #1: Ôm tất cả thế giới vào Transaction Đây là lỗi kinh điển nhất. Mình từng thấy (và từng viết) những đoạn code kiểu:

BEGIN TRANSACTION;
  1. Update data user
  2. Call API gửi Email/SMS thông báo
  3. Call API cổng thanh toán bên thứ 3
  4. Insert log
COMMIT;

Đừng bao giờ làm thế này! khi mở transaction, database sẽ giữ một DB Connection, và đôi khi là lock luôn các row liên quan. Nếu cái API gửi Email nó lag mất 5 giây, DB connection của bạn cũng bị ngâm 5 giây. Đột nhiên có 100 người mua hàng cùng lúc? Boom! Hết sạch Connection Pool, toàn bộ hệ thống sập vị nghẽn cổ chai.

Bài học: Giữ transaction càng ngắn càng tốt. Chỉ bọc những thao tác thuần Database. Call API, gửi Email, tính toán logic nặng... hãy lôi nó ra ngoài transaction hoặc dùng Message Queue để xử lý bất đồng bộ.

bẫy #2: Nuốt Exception (Swallowing exceptions)

Cái này thường gặp ở mấy anh em dùng framework có hỗi trợ sẵn annotation như @Transactional (Spring Boot) hay bọc trong function closure (như Laravel DB::transaction() ). Anh em cẩn thận try-catch để handle lỗi, nhưng lại quên ném lỗi (re-throw) ra ngoài:

DB::beginTransaction();
try {
    // Trừ tiền
    // Tạo đơn hàng (lỗi ở đây)
    DB::commit();
} catch (Exception $e) {
    Log::error("Có lỗi rồi: " . $e->getMessage());
    // ... Nuốt luôn exception, không rollback, không throw
}

Khúc này thì thôi rồi, framework/DB đâu biết là có lỗi để mà Rollback? Lỗi thì vẫn văng, tiền thì vẫn trừ.

Bài học: Bắt exception thì nhớ phải Rollback một cách rõ ràng, hoặc để framework tự handle thì đừng "nuốt" mất exception của nó.

Bẫy #3: Lầm tưởng Transaction giải quyết được mọi Race Condition

Trở lại bài toán tồn khi: Còn 1 cái iPhone. Hai user A và B cùng click mua lúc 12:00:00.001.

Cả 2 transaction cùng được mở:

  • Transaction A đọc thấy tồn kho = 1.
  • Transaction B cũng đọc thấy tồn kho = 1.
  • A update tồn kho = 0.
  • B cũng update tồn kho = 0.

Thế là bán được 2 cái iPhone trong khi kho chỉ có 1. Mặc dù đã dùng Transaction!

bài học: Transaction bình thường không cứu được bạn trong trường hợp độ trễ/cạnh tranh cao thế này đâu. Bạn phải hiểu Isolation Levels (các mức độ cô lập). Với case này, bét nhất cũng phải dùng Pessimistic Locking (SELECT ... FOR UPDATE - Ông nào vào trước thì khóa luôn row đó lại, ông kia phải đứng đợi) hoặc Optimistic Locking (dùng version column).

Lời kết

Database Transaction giống như phanh xe vậy. Không có nó, bạn lao thẳng xuống vực khi có biến. Nhưng nếu đạp phanh liên tục và sai lúc (long-running transaction, deadlocks), xe của bạn sẽ cháy bố phanh và lật giữa đường.

Hy vọng vài dòng tâm sự mỏng này giúp anh em có cái nhìn thực tế hơn khi gõ chữ BEGIN TRANSACTION. Đã có anh em nào từng làm rơi rớt data hay sập server vì vụ này chưa? Comment chia sẻ bên dưới cho vui nhé, để mình biết mình không cô đơn 😂.

Happy coding anh em!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí