本文目录导读:

在PHP项目中处理数据库死锁,核心策略是 预防为主,捕获处理为辅,死锁无法完全避免,因此必须有一套完整的策略来应对。
以下是处理数据库死锁的完整方案,从预防、检测到恢复,包含PHP代码示例。
第一步:预防死锁(最佳实践)
从架构和SQL设计层面减少死锁发生的概率。
- 保持事务简短:事务中只包含必要的SQL语句,尽快提交或回滚。
- 一致的访问顺序:
- 错误示例:会话1更新A -> 更新B;会话2更新B -> 更新A,这是经典死锁。
- 正确做法:所有会话都按相同顺序访问资源(如先A后B)。
- 合理使用索引:
- 没有索引时,更新操作可能升级为表锁,导致更多冲突。
- 有索引时,通常使用行锁,冲突范围小。
- 避免在事务中交互:不要在事务中读取用户输入或调用外部API,这会无限延长锁持有时间。
- 使用较低的隔离级别:如果不是必须,避免使用
SERIALIZABLE,使用READ COMMITTED。
第二步:捕获死锁(必须实现)
死锁发生时,数据库会立即抛出特定错误代码,PHP应捕获并重试。
- MySQL:错误代码
1213(ER_LOCK_DEADLOCK)。 - PostgreSQL:错误代码
40P01。 - SQL Server:错误代码
1205。
PHP示例(使用PDO):
<?php
class DatabaseService {
private int $maxRetries = 3;
private int $delayMicroseconds = 200000; // 0.2 秒
public function executeWithDeadlockRetry(callable $callback, ...$args): mixed {
$attempts = 0;
$lastException = null;
while ($attempts < $this->maxRetries) {
$attempts++;
try {
// 执行事务回调
return $callback(...$args);
} catch (\PDOException $e) {
// 检查是否是死锁错误(MySQL: 1213)
if ($e->getCode() == 1213 || strpos($e->getMessage(), 'Deadlock') !== false) {
$lastException = $e;
// 随机延迟,避免多个客户端同时重试再次死锁
$delay = $this->delayMicroseconds + random_int(0, 100000);
usleep($delay);
// 可选:记录日志
error_log("Deadlock detected, retrying ({$attempts}/{$this->maxRetries})...");
continue;
}
// 不是死锁错误,直接抛出
throw $e;
}
}
// 重试次数耗尽,抛出最后一次异常
throw $lastException ?? new \RuntimeException('Deadlock retry exhausted');
}
}
// 使用示例
$dbService = new DatabaseService();
try {
$result = $dbService->executeWithDeadlockRetry(function() use ($pdo) {
$pdo->beginTransaction();
// 你的事务逻辑(注意按顺序访问资源)
$stmt = $pdo->prepare("UPDATE accounts SET balance = balance - ? WHERE id = ?");
$stmt->execute([100, 1]); // 先更新账户1
$stmt2 = $pdo->prepare("UPDATE accounts SET balance = balance + ? WHERE id = ?");
$stmt2->execute([100, 2]); // 再更新账户2
$pdo->commit();
return true;
});
} catch (\Exception $e) {
// 最终失败处理
error_log("Transaction failed after retry: " . $e->getMessage());
// 通知用户或记录告警
}
第三步:死锁后的恢复处理
成功捕获死锁并重试后,需要考虑业务层面的恢复:
-
幂等性设计:确保重试操作不会导致重复扣款、重复发单等问题。
- 方案一:使用唯一约束(如订单号
UNIQUE),重复插入会报错,此时可以返回已经存在的订单ID。 - 方案二:使用乐观锁(版本号
version)。
- 方案一:使用唯一约束(如订单号
-
记录重试次数:如果连续重试
3次仍然失败,需要记录详细日志并触发告警(通过邮件、钉钉、企业微信等)。
第四步:监控与告警
代码只能处理死锁,但无法消除死锁,需要监控工具来发现死锁根因:
-
MySQL:
SHOW ENGINE INNODB STATUS; -- 查看最近死锁的详细信息
可以在凌晨低峰期执行此命令,将结果写入日志文件分析。
-
全局监控:使用Prometheus + Grafana或Datadog采集数据库死锁次数指标,当死锁频率异常升高时自动告警。
底层原理(为什么死锁会发生?如何避免?)
| 场景 | 会话1 | 会话2 | 结果与解决方案 |
|---|---|---|---|
| 行锁顺序不一致 | UPDATE A ... |
UPDATE B ... |
❌ 死锁 |
UPDATE B ... |
UPDATE A ... |
✅ 解决方案:统一顺序(都先A后B) | |
| 间隙锁互锁 | SELECT ... WHERE id > 10 FOR UPDATE |
INSERT INTO ... id = 15 |
❌ 死锁 (间隙锁与插入意向锁冲突) |
| (锁住间隙10-20) | (等待插入意向锁) | ✅ 解决方案:降低隔离级别至READ COMMITTED,或使用唯一索引避免间隙锁 |
|
| 索引缺失 | UPDATE table SET ... WHERE name='...' |
UPDATE table SET ... WHERE name='...' |
❌ 表锁(无索引导致行锁升级) |
| ✅ 解决方案:为查询条件建立合适的索引 |
最佳实践清单
- 短事务:事务内只做数据库操作,不调用外部API或长时间计算。
- 有序访问:所有事务按照相同的顺序更新表或行。
- 索引到位:
WHERE和UPDATE的条件列必须有索引。 - 重试机制:使用PDO捕获错误码
1213并重试最多3次,重试间隔随机化。 - 幂等逻辑:业务逻辑支持重复执行而不产生副作用。
- 监控告警:定期检查
SHOW ENGINE INNODB STATUS,并设置死锁频率告警阈值。