Laravel 死锁:原因及解决方法
死锁通常出现在应用程序有足够流量、查询请求重叠时。它们很少在开发阶段出现,因此初次遇到时往往令人困惑难解。此外,死锁在本地难以复现,事后诊断更是困难重重。
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 模型事件(saving、saved、updating 等)、属性变量和 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');
});这种方法对于处理高争用行(如库存计数或用户余额)的队列作业特别有用。