PAYATHON 2026

Laravel DB Transaction 间歇性 Lock Wait Timeout

支付阿杰

明确结论

问题的直接原因是 orders.uuid 没有设为主键。InnoDB 表既没有主键,也没有合适的 UNIQUE NOT NULL 索引时,会在内部创建一个 6-byte 的隐藏行标识。连续执行 INSERT 和 UPDATE 时,行定位和索引访问不符合预期,最终引发异常的锁等待。

为 orders.uuid 添加主键后,问题解决。

嵌套的 DB::transaction() 不会提前释放外层事务持有的锁。在外层事务提交前,Order::create() 产生的锁仍属于同一个事务。Foreign key 和索引缺失也可能扩大锁等待范围,但并非这个案例的最终根因。

为什么第二个 transaction 没有提高安全性

Laravel 的嵌套事务并不是两个相互独立的事务。具体实现取决于数据库驱动和 Laravel 版本,通常使用事务层级计数和 savepoint:

DB::transaction(function () {
    $order = Order::create([
        'status' => 'pending',
    ]);

    DB::transaction(function () use ($order) {
        $order->update([
            'status' => 'paid',
        ]);
    });
});

内层 transaction 执行完成时,外层事务并未提交。这意味着:

  • INSERT 获得的锁不会在内层 transaction 结束时释放。
  • 内层 transaction 的提交无法脱离外层事务单独生效。
  • 相关锁要等最外层 transaction 提交或回滚后才会释放。
  • 增加内层事务的重试次数,也无法解决外部事务持锁的问题。

在同一个数据库连接和事务中,后续 UPDATE 通常不会被此前的 INSERT 阻塞。如果日志显示 INSERT 等待了 3~23 秒,它更可能在等待另一个事务、索引记录、foreign key 相关记录或元数据锁,而不是自己创建的行锁。

正确设置主键

如果 uuid 是订单表的业务主键,应在数据库中明确声明:

ALTER TABLE orders
    ADD PRIMARY KEY (uuid);

执行前先确认有没有重复值:

SELECT uuid, COUNT(*) AS duplicate_count
FROM orders
GROUP BY uuid
HAVING COUNT(*) > 1;

还要检查是否存在 NULL:

SELECT COUNT(*) AS null_count
FROM orders
WHERE uuid IS NULL;

主键必须唯一,并且不能为 NULL。如果表的数据量较大,添加主键可能触发表重建或长时间持锁。应先在测试环境验证,再选择合适的变更窗口。

对应的 Eloquent 模型也要明确配置:

class Order extends Model
{
    protected $primaryKey = 'uuid';

    public $incrementing = false;

    protected $keyType = 'string';
}

如果项目以整数 id 作为数据库主键,而 uuid 只是对外标识,可以保留 id 主键,并为 uuid 添加唯一索引:

ALTER TABLE orders
    ADD UNIQUE KEY orders_uuid_unique (uuid);

这样既能让 InnoDB 使用显式主键定位记录,也能保证 UUID 唯一。

简化 transaction 结构

内层 transaction 没有必要,可以直接在外层 transaction 中完成更新:

DB::transaction(function () use ($data) {
    $order = Order::create([
        'uuid' => $data['uuid'],
        'status' => 'pending',
        'blah' => $data['blah'],
    ]);

    $this->handlePayment();

    $order->update([
        'status' => 'paid',
    ]);
}, 5);

不过,这段代码还有一个更值得注意的问题:handlePayment() 很可能会发起外部支付请求。等待支付服务响应时,数据库事务会一直保持打开,已经获得的锁也不会释放。支付接口偶尔延迟几秒,就会明显增加其他请求出现锁等待的概率。

可以通过缩短数据库事务来降低风险:

$order = DB::transaction(function () use ($data) {
    return Order::create([
        'uuid' => $data['uuid'],
        'status' => 'pending',
        'blah' => $data['blah'],
    ]);
}, 5);

try {
    $paymentResult = $this->handlePayment();

    DB::transaction(function () use ($order, $paymentResult) {
        $order->update([
            'status' => 'paid',
            'payment_reference' => $paymentResult->reference,
        ]);
    }, 5);
} catch (Throwable $exception) {
    DB::transaction(function () use ($order) {
        $order->update([
            'status' => 'payment_failed',
        ]);
    }, 5);

    throw $exception;
}

采用这种结构后,会出现“订单已经创建,但付款状态稍后才更新”的中间状态,因此还需要配合幂等支付、状态机、失败补偿或队列重试。数据库与支付平台之间的一致性,不能只靠一个跨越外部支付请求的数据库事务来保证。

Foreign key 和索引需要检查什么

Foreign key 本身没有问题,但 InnoDB 插入子表记录时,需要确认父表记录是否存在。如果父表中的对应记录正被其他事务修改或删除,INSERT 就可能等待相关锁。

先查看完整的建表结构:

SHOW CREATE TABLE orders;

检查以下内容:

  • orders 是否有明确的主键。
  • 每个 foreign key 引用的父表列是否为主键,或是否有合适的唯一索引。
  • orders 中参与查询、更新和关联的列是否有匹配的索引。
  • 是否存在重复、冗余或字段顺序不合理的复合索引。
  • Eloquent 使用的主键名称是否与数据库一致。

不要因为索引“可能减少锁”就盲目添加。额外的索引会增加每次 INSERT 和 UPDATE 的维护成本,是否添加应由实际 SQL、查询条件和执行计划决定。

如何定位正在阻塞的事务

问题发生时,先查看 InnoDB 状态:

SHOW ENGINE INNODB STATUS;

也可以检查当前事务和锁等待。不同 MySQL 版本提供的视图有所不同,例如:

SELECT *
FROM information_schema.innodb_trx;

如果当前版本支持 performance_schema.data_lock_waits,可以查询具体的等待关系:

SELECT *
FROM performance_schema.data_lock_waits;

排查时需要结合这些信息:

  • 等待事务正在执行的 SQL。
  • 持锁事务正在执行或最后执行的 SQL。
  • 事务已经运行了多长时间。
  • 是否有未提交的后台任务或长连接。
  • 等待发生在主键、唯一索引、普通索引还是 foreign key 检查上。
  • 是否有 DDL 操作造成 metadata lock。

只记录报错 SQL 无法定位完整原因。报错的 INSERT 往往只是等待锁的一方,真正的问题可能来自另一个长期未提交的事务。

关于 timeout 和重试

提高 innodb_lock_wait_timeout 只会延长请求的等待时间,通常不适合作为首要修复方式。它可以用于确实需要长事务的特定场景,却无法消除错误的事务边界、缺失的主键或长期持锁。

重试能缓解偶发的并发冲突,但必须满足两个条件:

  • 整个操作可以安全重试。
  • 外部支付等有副作用的操作具备幂等性。

如果 transaction 重试时再次调用支付接口,就可能造成重复扣款。支付请求应使用稳定的 idempotency key,并将支付结果与订单 UUID 可靠关联。

最终需要同时完成这些调整:为 orders 设置正确的显式主键,让 Eloquent 使用该主键,移除无意义的嵌套 transaction,缩短事务持续时间,并在异常发生时记录等待事务和持锁事务。这样处理才能解决根因,而不是单纯延长等待时间。

备注:内容仅供参考。