Bạn đã lưu đơn hàng, nhưng các mặt hàng trong đơn hàng lại không bao giờ được ghi vào cơ sở dữ liệu. Hoặc có thể bộ đếm kho đã giảm, cổng thanh toán bị timeout, và giờ đây khách hàng đã bị trừ tiền trong thẻ nhưng không có bản ghi đơn hàng nào. Những kịch bản này nghe có vẻ như là các trường hợp biên (edge cases) cho đến khi chúng thực sự xảy ra. Trên một ứng dụng bận rộn, chúng trở thành những cơn đau đầu hàng ngày.

Nguyên nhân gốc rễ hầu như luôn giống nhau: một chuỗi các thao tác ghi vào cơ sở dữ liệu được xử lý như các sự kiện riêng biệt, độc lập. Khi một thao tác thất bại, các thao tác khác vẫn được giữ lại. Giải pháp chính là một database transaction, và trong Laravel, công cụ đó là DB::transaction().

Bản chất của một Transaction

Một database transaction liên kết nhiều thao tác thành một đơn vị công việc duy nhất. Công cụ cơ sở dữ liệu đảm bảo rằng mọi thứ bên trong hoặc được commit vĩnh viễn hoặc được rollback hoàn toàn. Không có sự lấp lửng ở giữa.

Hãy nghĩ về một giao dịch chuyển khoản ngân hàng. Hệ thống phải trừ tiền từ một tài khoản và cộng tiền vào một tài khoản khác. Nếu việc cộng tiền thất bại sau khi đã trừ tiền, số tiền đó không đơn giản là biến mất vào hư không. Ngân hàng sẽ đảo ngược thao tác trừ tiền. Sự đảo ngược đó chính là rollback. Nếu cả hai bước đều thành công, giao dịch được commit, nghĩa là số dư mới đã được lưu trữ một cách bền vững.

Hành vi "tất cả hoặc không có gì" này là thứ giúp dữ liệu của bạn luôn nhất quán. Nếu không có nó, các lỗi xảy ra một phần sẽ để lại những bản ghi viết dở dang rải rác khắp các bảng, và những bản ghi đó sẽ "mục nát" trong cơ sở dữ liệu của bạn vì không có quy trình tự động nào biết cách dọn dẹp chúng một cách an toàn.

Cách Laravel vận hành

Trong SQL thuần, bạn sẽ phải tự viết BEGIN, COMMIT, và ROLLBACK, đồng thời phải nhớ bắt mọi lỗi có thể xảy ra để không bao giờ để một transaction bị treo. Laravel gói gọn tất cả những mã rườm rà đó vào một phương thức duy nhất.

Bạn truyền một closure vào DB::transaction(). Laravel bắt đầu transaction, chạy mã của bạn, và nếu closure kết thúc mà không ném ra ngoại lệ (exception), nó sẽ tự động commit. Nếu có bất kỳ lỗi gì xảy ra, Laravel sẽ bắt ngoại lệ đó, rollback mọi thứ, và ném lại lỗi để việc ghi log và xử lý lỗi của bạn vẫn hoạt động như mong đợi.

use Illuminate\Support\Facades\DB;

DB::transaction(function () {
    $order = Order::create([/* ... */]);
    
    foreach ($cart->items as $item) {
        OrderItem::create([
            'order_id' => $order->id,
            'product_id' => $item->product_id,
            'quantity' => $item->quantity,
        ]);
        
        Product::find($item->product_id)
               ->decrement('stock', $item->quantity);
    }
    
    Payment::create([
        'order_id' => $order->id,
        'amount' => $cart->total,
        'status' => 'completed',
    ]);
});

Nếu việc chèn dữ liệu thanh toán thất bại do thiếu khóa ngoại hoặc kết nối cơ sở dữ liệu bị ngắt, thì đơn hàng, các mặt hàng trong đơn hàng và các thay đổi về kho hàng đều sẽ được hoàn tác. Bạn sẽ không rơi vào tình trạng hàng tồn kho biến mất không lý do, và cũng không có một đơn hàng yêu cầu giao hàng mà chưa được thanh toán.

Khi nào điều này sẽ cứu nguy cho bạn

Một số quy trình làm việc phụ thuộc hoàn toàn vào tính an toàn của transaction. Ví dụ về thanh toán là rõ ràng nhất, nhưng mô hình này xuất hiện ở khắp mọi nơi.

Đăng ký người dùng. Tạo một dòng người dùng, sau đó là một dòng hồ sơ, rồi đến các cài đặt mặc định. Nếu việc chèn hồ sơ thất bại do một trường hợp biên về validation, một người dùng không có hồ sơ sẽ trở thành một tài khoản "ma". Bất kỳ trang nào giả định rằng mọi người dùng đều có hồ sơ sẽ bị crash hoặc hiển thị giao diện lỗi.

Nhập dữ liệu hàng loạt (Bulk imports). Một tệp CSV tải lên để chèn năm mươi bản ghi không nên để lại hai mươi lăm bản ghi chỉ vì dòng thứ hai mươi sáu có định dạng ngày tháng sai. Việc bao bọc lô dữ liệu trong một transaction cho phép toàn bộ quá trình nhập thất bại như một đơn vị sạch sẽ. Quản trị viên sẽ sửa tệp và thử lại, thay vì dành hàng giờ để săn tìm xem những dòng nào đã bị rò rỉ một phần.

Kho hàng và kế toán. Bất cứ khi nào một bảng theo dõi tài nguyên vật lý và một bảng khác theo dõi tiền bạc hoặc tín dụng, cả hai phải di chuyển cùng nhau. Việc tách rời chúng sẽ dẫn đến các cuộc kiểm toán không khớp và các báo cáo sai lệch.

Khi bạn cần tự điều khiển thủ công

Cách tiếp cận bằng closure bao quát hầu hết các trường hợp, nhưng đôi khi bạn cần nhiều sự kiểm soát hơn. Logic điều kiện phức tạp bên trong một service class, hoặc nhu cầu quyết định việc commit tại thời điểm thực thi (runtime), có thể khiến một closure duy nhất trở nên vụng về. Trong những khoảnh khắc đó, bạn có thể tự quản lý transaction:

DB::beginTransaction();

try {
    // Run your operations
    $order = Order::create([/* ... */]);
    // ... more work ...
    
    if ($someBusinessRulePasses) {
        DB::commit();
    } else {
        DB::rollBack();
    }
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

Hãy lưu ý thứ tự bên trong khối catch: rollback trước, sau đó mới throw. Nếu bạn throw trước khi rollback, transaction sẽ vẫn mở trên kết nối. Điều đó có thể khóa các dòng (lock rows), gây deadlock cho các truy vấn khác, hoặc làm cạn kiệt pool kết nối của bạn. Transaction thủ công rất mạnh mẽ, nhưng chúng đặt gánh nặng dọn dẹp lên vai bạn.

Tránh để các tác dụng phụ (side effects) nằm trong Transaction

Đây là quy tắc thường gây rắc rối cho các đội ngũ khi vận hành thực tế (production). Một transaction chỉ có thể hoàn tác các thao tác với cơ sở dữ liệu. Nó không thể thu hồi một email đã gửi, xóa một tệp từ lưu trữ đám mây, hoặc hoàn tiền qua một API thanh toán.

Nếu bạn đặt một lệnh gọi Mail::send() bên trong closure của transaction, và cơ sở dữ liệu thực hiện rollback hai dòng sau đó, email đó vẫn sẽ được gửi đến hộp thư của khách hàng. Người nhận hiện có một hóa đơn cho một đơn hàng không tồn tại trong hệ thống của bạn. Điều tương tự cũng áp dụng cho thông báo Slack, tải tệp lên S3, hoặc gửi webhook.

Trình tự đúng là:

  1. Hoàn tất giao dịch và lưu lại bất kỳ ID hoặc kết quả nào bạn cần.
  2. Chỉ sau đó mới kích hoạt các tác dụng phụ (side effects) bên ngoài.

Ví dụ, hãy đưa email xác nhận vào hàng đợi sau khi commit, chứ không phải bên trong nó:

$order = DB::transaction(function () {
    // database work only
    return Order::create([/* ... */]);
});

// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);

Có một lý do thứ hai để giữ các lời gọi bên ngoài nằm ngoài giao dịch: thời gian. Một giao dịch sẽ giữ các khóa (locks) và khiến một kết nối cơ sở dữ liệu bận rộn. Việc chờ đợi phản hồi từ Stripe API trong ba giây khi đang ở trong một giao dịch chính là ba giây giữ khóa không cần thiết. Hãy giữ cho giao dịch diễn ra gọn gàng và nhanh chóng.

Tại sao lỗi này lại bị che khuất

Trên một máy phát triển với một người dùng duy nhất và cơ sở dữ liệu cục bộ, các lệnh insert liên quan hầu như luôn thành công. Mạng ổn định, ổ đĩa không bao giờ đầy và không có tải trọng cạnh tranh. Mã nguồn trông có vẻ chính xác vì nó thường hoạt động tốt.

Môi trường Production thì khác. Hai khách hàng gửi đơn hàng vào cùng một mili giây chính xác. Một queue worker khởi động lại giữa chừng trong quá trình deployment. Một nhà cung cấp thanh toán bị timeout trong ba mươi giây. Nếu không có giao dịch, những sự kiện này sẽ tạo ra các bản ghi mồ côi (orphan records) và tổng số không khớp, gây khó khăn cho việc truy vết. Điều tồi tệ nhất là các bài kiểm tra tính năng (feature tests) tiêu chuẩn hiếm khi phát hiện ra chúng, vì chế độ lỗi phụ thuộc vào thời điểm và gắn liền với hạ tầng, chứ không phải là các lỗi logic đơn thuần.

Cách khắc phục không hề cao siêu. Nó mang tính cơ học. Khi bạn thấy hai hoặc nhiều thao tác ghi liên quan, hãy bao bọc chúng lại. Theo thời gian, việc này sẽ trở nên tự động như việc xác thực một request.

Hãy biến nó thành phản xạ

DB::transaction() không làm tăng thêm sự phức tạp. Nó loại bỏ sự phức tạp tiềm ẩn của việc cố gắng dọn dẹp dữ liệu không hoàn chỉnh sau khi sự cố xảy ra. Nếu một nhóm các thao tác cơ sở dữ liệu thuộc về nhau, hãy xử lý chúng như vậy ngay từ đầu. Ứng dụng của bạn sẽ luôn hoạt động nhất quán dưới tải trọng lớn, nhật ký lỗi (error logs) sẽ luôn sạch sẽ, và cơ sở dữ liệu của bạn sẽ không trở thành một nghĩa địa của những bản ghi dang dở.