你保存了订单,但订单项从未进入数据库。或者,库存计数器减少了,支付网关超时了,结果客户被扣了款,却没有任何订单记录。这些场景听起来像是极端情况,但实际上并非如此。在繁忙的应用中,它们会变成每天都要面对的头疼问题。

根本原因几乎总是相同的:一系列数据库写入操作被当作了独立的、隔离的事件来处理。当其中一个失败时,其他的操作却留在了原地。解决办法是使用数据库事务,而在 Laravel 中,工具就是 DB::transaction()

事务究竟承诺了什么

数据库事务将多个操作绑定为一个单一的工作单元。数据库引擎保证其中的所有内容要么永久提交,要么完全回滚。不存在中间状态。

以银行转账为例。系统必须从一个账户扣款,并向另一个账户入账。如果扣款后入账失败,资金不会凭空消失。银行会撤销扣款操作。这种撤销就是“回滚”(rollback)。如果两个步骤都成功,转账就会被“提交”(commit),这意味着新的余额已被持久化保存。

这种“要么全部成功,要么全部失败”的行为是保持数据一致性的关键。如果没有它,部分失败会导致半截记录散落在你的表中,而这些记录会在数据库中“腐烂”,因为没有任何自动化流程知道如何安全地清理它们。

Laravel 是如何实现的

在原生 SQL 中,你需要自己编写 BEGINCOMMITROLLBACK,并记得捕获每一个可能的错误,以免让事务一直处于挂起状态。Laravel 将这些样板代码封装进了一个单一的方法中。

你向 DB::transaction() 传递一个闭包(closure)。Laravel 会启动事务,运行你的代码;如果闭包在没有抛出异常的情况下执行完毕,它会自动提交。如果发生任何错误,Laravel 会捕获异常,回滚所有操作,并重新抛出错误,以便你的日志记录和错误处理仍能按预期工作。

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',
    ]);
});

如果因为缺少外键或数据库连接中断导致支付插入失败,那么订单、订单项以及库存变更都会被撤销。你不会面临库存莫名其妙消失的情况,也不会面临一个从未支付却要求发货的订单。

哪些场景能让你受益

某些工作流绝对依赖于事务安全性。结账示例是最显而易见的,但这种模式随处可见。

用户注册。 创建用户行,然后是个人资料行,最后是默认设置。如果因为验证的极端情况导致个人资料插入失败,那么没有个人资料的用户就会变成“幽灵账户”。任何假设每个用户都有个人资料的页面都会崩溃或显示损坏的 UI。

批量导入。 上传一个包含 50 条记录的 CSV 文件时,不应该因为第 26 行日期格式错误而导致只剩下了 25 条记录。将批处理包装在事务中,可以让整个导入作为一个干净的单元整体失败。管理员只需修复文件并重试,而不是花几个小时去寻找哪些行部分泄露了。

库存与会计。 每当一个表追踪物理资源,而另一个表追踪资金或信用时,两者必须同步变动。将它们拆分会引发无法对账的审计和错误的报表。

何时需要手动控制

闭包方法涵盖了大多数情况,但有时你需要更多的控制权。在 Service 类中复杂的条件逻辑,或者需要在运行时决定是否提交的需求,可能会让单一的闭包显得很笨拙。在这些时刻,你可以手动管理事务:

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;
}

注意 catch 块中的顺序:先回滚,再抛出。如果你在回滚之前抛出异常,事务会在连接上保持开启状态。这可能会锁定行、导致其他查询死锁或耗尽你的连接池。手动事务功能强大,但它将清理的负担交给了你。

避免在事务中包含副作用

这是在生产环境中让团队栽跟头的规则。事务只能撤销数据库操作。它无法撤回已发送的电子邮件、无法删除云存储中的文件,也无法通过支付 API 退款。

如果你在事务闭包中调用了 Mail::send(),而数据库在两行之后回滚了,那封邮件仍然会发送到客户的收件箱。收件人现在收到了一份在你的系统中并不存在的订单发票。这同样适用于 Slack 通知、上传到 S3 的文件或 Webhook 的分发。

正确的顺序是:

  1. 完成事务并获取所需的任何 ID 或结果。
  2. 只有在那之后,才触发外部副作用。

例如,在提交(commit)之后再将确认邮件放入队列,而不是在事务内部:

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

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

将外部调用保持在事务之外的第二个原因是:时间。事务会持有锁并占用数据库连接。在事务内部等待 Stripe API 响应三秒钟,意味着增加了三秒钟不必要的锁持有时间。保持事务精简且快速。

为什么这个 Bug 会被隐藏

在一个只有单一用户且使用本地数据库的开发机上,相关的插入操作几乎总是会成功。网络很稳定,磁盘永远不会满,也没有竞争负载。代码看起来是正确的,因为它通常能正常运行。

生产环境则完全不同。两个客户在完全相同的毫秒内提交了订单。队列工作进程在部署过程中于任务执行中途重启。支付提供商超时了三十秒。如果没有事务,这些事件会产生孤儿记录和不匹配的总额,且极难追踪。最糟糕的是,标准的特性测试很少能发现它们,因为这种失败模式取决于时机并与基础设施相关,而非简单的逻辑错误。

修复方法并不复杂。它是机械性的。当你看到两个或多个相关的写入操作时,就将它们包裹起来。随着时间的推移,这应该变得像验证请求一样自然。

使其成为一种本能

DB::transaction() 不会增加复杂性。它消除了事后尝试清理部分数据的隐性复杂性。如果一组数据库操作是相关的,从一开始就将它们视为一个整体。这样,你的应用程序在负载下将保持数据一致性,错误日志会保持整洁,你的数据库也不会变成半成品记录的坟场。