编程

Laravel 死锁:原因及解决方法

6 2026-02-24 06:17:00

死锁通常出现在应用程序有足够流量、查询请求重叠时。它们很少在开发阶段出现,因此初次遇到时往往令人困惑难解。此外,死锁在本地难以复现,事后诊断更是困难重重。

Laravel 在此处融入了其独特设计。该框架通过队列、Horizon、计划任务和事件分发机制简化并行工作,但这也意味着多个进程会同时操作相同数据行。本地环境中仅使用一个队列工作进程时不会暴露这些问题,但生产环境若部署 10 个 Horizon 工作进程并行执行任务,则会显现这些情况。

本指南将解释死锁是什么、为何会在 Laravel 工作负载中出现,以及如何在不彻底重构代码库的情况下进行调试和减少死锁。

什么是死锁?

当两个事务分别持有对方所需的锁时,就会发生死锁。双方均停止并等待,无法继续推进,因此 MySQL 会取消其中一个事务以打破死锁,确保服务器继续运行。被取消的查询会收到死锁错误,而另一个事务则继续正常执行。

一个基本示例:

  • 事务 A 更新行 1 后,等待行 2。
  • 事务 B 更新了第 2 行,随后等待第 1 行。

它们已被锁定。解决方案并非手动解锁。MySQL 会自动终止成本较低的事务并记录循环。死锁是正常现象,而非数据库崩溃的征兆。它们是并发机制及 InnoDB 隔离级别强制执行方式的副作用。

MySQL 中的常见死锁模式

MySQL 通常会暴露出几种常见的死锁模式或类型。这些模式源于工作线程、队列和 HTTP 请求在负载下对相同表的交互方式。

以下是你最常遇到的死锁类型:

  • 更新-更新:两个事务以相反的顺序更新相关行。
  • 查询更新冲突:一个工作线程锁定行进行处理,而另一个工作线程试图修改这些行。
  • 自增插入:并发插入会与后续更新或关联表写入发生冲突。
  • 间隙锁冲突:范围查询或非选择性索引锁定的行数超出预期。
  • 插入意图冲突:多个工作线程同时向同一索引间隙插入数据。
  • 相同主键插入:两个写入器同时尝试插入相同的主键值。
  • 外键交互:父/子更新与删除操作按不同顺序执行。
  • 长事务:广泛的锁占用与快速写入发生冲突。

开发人员在 Laravel 中看到了什么?

当死锁发生时,Laravel 不会显示锁循环或冲突查询。它只报告 MySQL返回的错误:

SQLSTATE[40001]: Deadlock found when trying to get lock; try restarting transaction

这是在 Laravel 应用日志、Horizon 失败作业、队列工作者输出、Sentry、Bugsnag 或其他 APM 工具中看到的消息。它确认了一笔交易被回滚,但它没有告诉你:

  • 它与哪个事务冲突
  • 涉及哪个表或索引
  • MySQL 锁定了哪些行
  • 为什么锁顺序不同

大多数 Laravel 死锁来自两个重复出现的模式:

1.查询锁定的行数超出预期

当查询扫描的表比预期的多时,通常会发生这种情况。可能是因为索引缺失或选择性不足,也可能是因为 Eloquent 在更新之前发出隐式 SELECT,或者是因为 REPEATABLE READ 扩展了锁定范围。

2.以不同的顺序查询触碰表格

如果两个 worker 更新了同一个模型,但以不同的顺序到达底层表,MySQL 最终会出现不匹配的锁顺序,并取消其中一个。

下一节将展示这些模式如何出现在真实的 Laravel 代码中,以及你可以进行哪些调整来减少它们。

Laravel 应用发生死锁的 6 个原因

Laravel 代码本身不会导致死锁。该框架生成简单的 SQL。当多个 worker 同时触摸同一个表或行时,问题就开始了。时间上的微小差异会产生新的锁序列。

1.作业或请求更新相同的行

症状

如果两个 worker 以不同的顺序接触同一行,则可能会形成死锁。

示例

// Worker A
Order::where('id', $id)->update(['status' => 'processing']);
 
// Worker B (same row, different path)
Order::find($id)->update(['last_checked_at' => now()]);

原因
订单、发票、购物车和库存等表经常收到来自 web 请求、队列工作者和计划任务的重叠更新。Laravel 使这些更新易于用 Eloquent 表达,但每次更新都可能包含隐藏的读取或连接,从而扩大锁的作用域。

当两条执行路径更新同一条记录但以不同的顺序到达时,MySQL 最终会出现不匹配的锁顺序。如果你还加载了关联模型(→with()),则初始 SELECT 可能会锁定比预期更多的行。

修复

  • 对高流量表使用一致的更新路径。
  • 添加查找使用的确切索引(id、id、状态等),以保持行锁的窄。

如果你确实需要独占访问权限,考虑 SELECT... FOR UPDATE 

2.具有多个查询的长事务

症状

死锁发生在 DB::transaction() 内部,即使每个查询本身似乎都是无害的。

示例

DB::transaction(function () use ($order, $inventory) {
    $order->update(['status' => 'paid']);
    $inventory->decrement('count');  // touches a second table
});

另一个 worker 可能会执行相同的两个语句,但顺序相反。

原因

DB::transaction() 块很方便,通常也是正确的,但它也将多个操作分组在同一个锁足迹下。如果一个 worker 更新订单然后更新库存,而另一个 worker 更新库存然后更新订单,则锁定顺序会发散,并可能出现死锁。

交易中的步骤越多,发生这种情况的地方就越多。

  • 修复
    保持交易的简短和狭窄。
  • 在整个应用中,始终以相同的顺序操作表格。
    将慢速操作(API 调用、大量查询)移动到事务外部。

3.Eloquent 隐藏式锁定

症状

即使你通过主键进行更新,简单的模型更新也会死锁。

示例

$user = User::where('email', $email)->first();
$user->update(['last_login_at' => now()]);

如果列未被索引,则第一个查询可能会扫描比预期更多的行。

原因

Eloquent 经常在执行 UPDATE 之前执行 SELECT。如果 SELECT 接触的行比你预期的多(因为索引缺失或选择性不够),则事务将在更多的行上保持更长的锁。范围锁为死锁的形成提供了空间。

修复

添加缺失的索引(本例中,是 email 索引)。

避免在非索引列上使用 first() + update() 模式。

尽可能使用 whereKey() 或直接 update() 调用。

4.繁忙队列或 Horizon 设置上的高并发性

症状

只有当 Horizon 或队列工作人员规模扩大时,才会出现死锁。

示例

$product->increment('views');
LogView::create([...]);

当同一作业并行运行时,每个 worker 可能会根据负载、缓存或条件代码分支以不同的顺序访问产品和 log_views
原因

同时运行相同作业类型的队列工作人员采用不同的执行路径。代码是相同的,但不能保证查询的顺序。高并发性暴露了不会在本地出现的锁顺序问题。

修复

  • 为繁重的写入操作使用专用队列,以便它们串行运行。
  • 审核作业中更改查询顺序的条件分支。
  • 如果写入操作频繁,请考虑将其转移到批量更新或单个“写入器”服务中。

5.增加锁定范围的模式形状

症状

死锁指向锁图中意外的行或范围,即使代码只涉及一个模型。

示例

Post::where('category_id', $id)->update(['updated_at' => now()]);
  • 在非选择性列上搜索会强制进行范围扫描。在这种情况下,如果 category_id 没有索引或基数较低,MySQL 可能会锁定更广泛的范围。
    原因
    有时死锁与代码结构无关。索引缺失或不完整可能会迫使 MySQL 选择效率较低的路径。在“可重复读取”下,这可能意味着间隙锁、下一个密钥锁或与其他更新(甚至是无关的更新)冲突的宽范围锁。
    修复
    为最常见的查找模式添加正确的复合索引。
  • 避免大范围更新。如果可能的话,把它们分成更小的批次。
  • 查看慢速查询,以确认优化器的路径很窄。

6.添加隐藏查询的模型事件、变量和 touches()

症状

即使正在更新的行被索引并且查询看起来很简单,update() 调用也会死锁。

示例

class Comment extends Model
{
    protected $touches = ['post'];
}
 
$comment->update(['body' => $newBody]);

此单个更新会自动触发相关帖子行的另一个更新。

原因

Eloquent 模型事件(savingsavedupdating 等)、属性变量和 touches() 关联都在幕后引入了额外的查询。如果两个 worker 更新了附加到同一帖子的不同注释,那么您现在有多个针对 posts.id 的隐藏写入。这些额外的写入扩大了锁足迹,并创建了新的锁顺序路径,这些路径在代码中并不明显。

修复

  • 禁用高频写入表的 touches()
  • 审核额外查询的模型事件,并将繁重的逻辑转移到作业中。
  • 在需要真正最小写入量的地方使用内联 DB::table()->update()

为什么死锁在生产中感觉是随机的

开发者经常报告死锁“突然出现”。这种行为感觉是随机的,因为时间受到您在本地测试中看不到的因素的影响。锁序列随着每个请求而略有变化,这使得即使事件来自同一模式,它们看起来也不相关。

造成这种“随机”行为的情况包括:

  • 请求以稍微不同的顺序到达的流量
  • 队列 worker 以不同的速度完成任务

MySQL 调整成本估算时查询计划发生变化

后台作业覆盖正常写入
偶尔扫描的行数超出预期的大型读取

这些更改对 MySQL 内部的锁进行了重新排序。你可能永远不会看到同样的模式两次,但根本原因通常是稳定的。它之所以看起来混乱,是因为锁碰撞取决于负载下变化的微定时。

如何在生产环境中调试死锁

Laravel 的异常消息可以帮助你检测死锁,但真正的真相来源是 MySQL 死锁报告。MySQL 会记录整个锁周期,这样你就可以看到发生了什么。你阅读这些痕迹越多,就越快发现问题。

1.从MySQL中提取死锁详细信息

使用以下方法之一:

  • 显示引擎 INNODB 状态(SHOW ENGINE INNODB STATUS)-返回最近的死锁
  • Performance Schema:
    • events_transactions_history_long
    • data_locks
    • data_lock_waits
    • MySQL 错误日志(如果启用死锁日志记录)

2.读取锁信息

  • 仔细看三个部分:
    受害者查询
  • 冲突的查询
  • 锁的索引和行范围
  • 每个事务获取锁的顺序

真正的死锁报告看起来像这样

------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 123456, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s)
MySQL thread id 98, OS thread handle 140294
query id 5542 Update order_items set quantity = 3 where id = 42
 
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 27 page no 123 n bits 72
index `PRIMARY` of table `order_items` trx id 123456 lock mode X locks rec but not gap
Record lock, heap no 8 PHYSICAL RECORD: n_fields 5; (...)
 
*** (2) TRANSACTION:
TRANSACTION 123457, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
5 lock struct(s)
MySQL thread id 99, OS thread handle 140310
query id 5543 Update orders set status = 'paid' where id = 10
 
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 27 page no 123 n bits 72
index `PRIMARY` of table `order_items` trx id 123457 lock mode X
 
*** WE ROLL BACK TRANSACTION (1)

事务 1 已回滚,因此这是受害者查询。交易 2 继续进行,因此这是一个冲突(且获胜)的查询。查询 id 告诉你哪些语句参与了锁定。

死锁通常是由于数据库需要锁定比预期更多的行造成的。如果你在表上看到一个应该由窄索引处理的大范围,这是一个提示。

3.检查操作顺序

比较事务之间的查询顺序。如果它们以不同的顺序接触行,这通常是直接原因。

例如,如果代码的一部分在相关子模型之前更新父模型,另一部分在父模型之前更新子模型,则锁顺序会发生变化。

4.尽量在本地复刻

带有并行 worker 的小脚本通常会显示不一致的锁定顺序。即使你遇到死锁,也会看到不匹配的查询顺序。

如何减少和防止Laravel中的死锁

你无法完全消除死锁,但可以减少死锁发生的频率和调试所需的时间。大多数修复来自收紧事务范围和提高锁的可预测性。

1.保持事务简短

只包装真正需要一起发生的查询。每一个额外的查询都会扩大锁的占用空间。

DB::transaction(function () use ($id) {
    Order::where('id', $id)->update(['confirmed' => true]);
});

尽可能避免在事务中加载关联或运行昂贵的 SELECT

2.以一致的顺序更新行

如果应用有两个地方修改了相关行,请标准化顺序。例如,始终在子项之前更新父项,或者始终按主键升序更新。当更新总是以相同的顺序锁定行时,死锁会急剧下降。

3.必要时使用行级锁

Laravel 的查询构建器支持使用 lockForUpdate() 进行行级锁定:

$item = Inventory::where('sku', $sku)->lockForUpdate()->first();
$item->decrement('quantity');

4.添加正确的索引

死锁通常来自锁定多行的查询。索引缺失或弱索引会扩大死锁范围。寻找:

  • 查询扫描整个表以查找单个记录
  • 使用非选择性索引的更新
  • 锁定比预期更宽范围的 JOIN

更好的索引意味着接触的行更少,这意味着碰撞更少。

5.避免不必要的读写操作

如果写入操作需要读取,请尝试将读取移动到事务之外,或依赖已知的主键而不是扫描。

6.为容易死锁的操作实现重试逻辑

死锁是暂时的错误——如果立即重试,回滚的事务通常可以成功。Laravel支持事务中的自动重试:

// Laravel 8+
DB::transaction(function () use ($order) {
    $order->update(['status' => 'paid']);
    $order->inventory()->decrement('count');
}, attempts: 3);
 
For more control, use manual retry logic:
 
use Illuminate\Database\QueryException;
 
retry(3, function () {
    DB::transaction(function () {
        // your logic here
    });
}, sleepMilliseconds: 100, when: function ($exception) {
    return $exception instanceof QueryException
        && str_contains($exception->getMessage(), 'Deadlock found');
});

这种方法对于处理高争用行(如库存计数或用户余额)的队列作业特别有用。