面试考点分析
- 死锁的本质:能否说清死锁是事务之间对锁资源的循环等待,并列出互斥、持有并等待、不可抢占、循环等待四个必要条件。
- innodb 锁机制:是否了解记录锁、间隙锁、next-key lock 的加锁范围,以及它们如何影响死锁的产生。
- 死锁检测与处理:是否掌握 innodb 基于等待图的死锁检测机制,以及死锁发生后自动回滚代价较小事务的策略。
- 定位与排查能力:是否会使用 show engine innodb status 分析 latest detected deadlock,从中提取事务、sql 与锁信息。
- 工程化解决思路:是否能给出固定加锁顺序、合理建索引、应用层重试等可落地的规避与兜底方案。
一、标准回答
总结:mysql innodb 中的死锁,指的是两个或多个事务互相持有对方需要的锁资源,形成循环等待,导致所有相关事务都无法继续执行。innodb 会自动检测死锁,并选择回滚其中代价较小的事务来打破死锁。
作用:死锁检测机制保证了在高并发事务场景下,系统不会因为锁的循环等待而永久阻塞,从而保障数据库的可用性。
特点:死锁检测默认开启,由参数 innodb_deadlock_detect 控制;当检测到死锁时,innodb 会立即回滚其中一个事务,并以错误码 1213 反馈给客户端。
解决死锁通常从四个层面入手:
- 缩短事务:减少事务持有锁的时间,降低冲突概率。
- 按固定顺序访问数据:让所有事务以一致的顺序加锁,从根源上避免循环等待。
- 合理设计索引:缩小加锁范围,避免因全表扫描或间隙锁放大锁冲突。
- 应用层兜底重试:捕获死锁异常后按退避策略重试,保证业务最终成功。
二、核心原理
2.1 死锁的四个必要条件
死锁的产生必须同时满足以下四个条件,缺一不可:
- 互斥条件:同一资源同一时刻只能被一个事务持有,例如排他锁。
- 持有并等待:事务已经持有资源,又在等待其他资源。
- 不可抢占:事务持有的锁不能被其他事务强行剥夺,只能由持有者自己释放。
- 循环等待:事务之间形成首尾相接的资源等待环。
数据库解决死锁的思路,本质上就是破坏其中至少一个条件。例如回滚事务相当于破坏“持有并等待”,而固定加锁顺序则相当于破坏“循环等待”。
2.2 innodb 的锁类型
理解死锁先要理解 innodb 的锁。按锁的粒度可以划分为表级锁和行级锁:
| 锁粒度 | 锁类型 | 说明 |
|---|---|---|
| 表级锁 | 共享锁 s / 排他锁 x | 针对整张表加锁,如 lock tables。 |
| 意向锁 is / ix | 标记事务是否准备对表中的行加锁,用于提高表锁和行锁的兼容性判断效率。 | |
| 行级锁 | 记录锁 record lock | 锁定单个索引记录。 |
| 间隙锁 gap lock | 锁定索引记录之间的空隙,防止幻读,仅在 rr 隔离级别下使用。 | |
| next-key lock | 记录锁和间隙锁的组合,锁定索引记录及其前面的空隙。 | |
| 插入意向锁 insert intention lock | 插入数据前设置的特殊间隙锁,相互之间通常不冲突,但可能与间隙锁冲突。 |
其中间隙锁和 next-key lock 是 rr 隔离级别下产生死锁的常见来源,因为它会把加锁范围从一个点扩大到一段区间。
2.3 死锁检测机制
innodb 通过维护事务之间的等待关系图(wait-for graph)来实时检测死锁:
- 当某个事务等待锁时,innodb 在等待图中加入一条“等待方指向持有方”的边。
- 如果图中出现了环,说明发生了循环等待,即判定为死锁。
- 检测到死锁后,innodb 选择回滚回滚代价较小的事务,通常以 undo 日志量较少为标准。
- 被回滚的事务收到 1213 错误信息,提示客户端重启事务。
该检测机制由参数 innodb_deadlock_detect 控制,默认开启。关闭后不再实时检测死锁,事务会一直等待,直到超过 innodb_lock_wait_timeout(默认 50 秒)才报 1205 锁超时错误。在官方文档innodb deadlock detection中特别提到:死锁检测在并发线程特别多时可能成为性能瓶颈,因为每次加锁等待都要在等待图中做遍历检查。
2.4 如何查看死锁信息
发生死锁后,最直接的排查方式是查看 innodb 状态:
show engine innodb status\g
在输出中定位 latest detected deadlock 部分,可以看到死锁发生的时刻、参与死锁的两个事务、各自执行的 sql,以及各自持有和等待的锁类型,这是定位死锁的第一手证据。
三、应用场景
3.1 日常开发场景
- 账户转账:两个账户之间互相转账,多个事务以不同顺序更新同一组账户时容易死锁。
- 库存扣减:同时扣减多个商品的库存,且不同请求的商品顺序不一致。
- 批量数据更新:定时任务和大促活动并发更新同一批记录。
- 多表关联更新:一个事务中同时更新订单表、库存表、账户表,多表加锁顺序混乱。
3.2 企业真实场景
在电商订单系统中,典型的死锁链路是:
- 下单服务锁定商品 a 的库存行,然后准备锁定商品 b 的库存行。
- 退款服务锁定商品 b 的库存行,然后准备锁定商品 a 的库存行。
- 两个事务互相等待,最终触发 innodb 死锁检测,其中一个被回滚。
这种场景在高并发秒杀、组合商品促销、账务对账系统里非常常见。单纯靠提高数据库连接数无法解决,必须从加锁顺序和事务设计上入手。
四、使用方式
下面以“账户转账”为例,演示死锁的产生以及 java 侧如何规避和兜底。先创建测试表:
create table account (
id bigint primary key,
balance decimal(10, 2) not null
) engine = innodb;
insert into account (id, balance) values (1, 1000.00), (2, 1000.00);4.1 死锁是怎么产生的
在会话 a 中执行:
begin; update account set balance = balance - 100 where id = 1; -- 先锁 id=1 -- 不提交,稍后执行下面的语句 update account set balance = balance + 100 where id = 2; -- 等待 id=2 上的锁
在会话 b 中执行:
begin; update account set balance = balance - 100 where id = 2; -- 先锁 id=2 update account set balance = balance + 100 where id = 1; -- 等待 id=1 上的锁
会话 a 持有 id=1 等待 id=2,会话 b 持有 id=2 等待 id=1,形成循环等待,innodb 随即检测到死锁并回滚其中一个事务。
4.2 java 规避方案:固定加锁顺序
核心思想是让所有事务都按 id 从小到大依次加锁,彻底消除循环等待。示例使用 spring 和 mybatis:
@service
public class accounttransferservice {
private final accountmapper accountmapper;
public accounttransferservice(accountmapper accountmapper) {
this.accountmapper = accountmapper;
}
@transactional(rollbackfor = exception.class)
public void transfer(long fromid, long toid, bigdecimal amount) {
// 关键:固定加锁顺序,按 id 从小到大依次加锁
long firstid = math.min(fromid, toid);
long secondid = math.max(fromid, toid);
account first = lockandget(firstid);
if (first.getbalance().compareto(amount) < 0) {
throw new bizexception("余额不足");
}
debit(first, amount);
account second = lockandget(secondid);
credit(second, amount);
}
private account lockandget(long accountid) {
return accountmapper.selectbyidforupdate(accountid);
}
private void debit(account account, bigdecimal amount) {
account.setbalance(account.getbalance().subtract(amount));
accountmapper.updatebalance(account);
}
private void credit(account account, bigdecimal amount) {
account.setbalance(account.getbalance().add(amount));
accountmapper.updatebalance(account);
}
}对应的 mybatis mapper:
public interface accountmapper {
@select("select * from account where id = #{id} for update")
account selectbyidforupdate(@param("id") long id);
@update("update account set balance = #{balance} where id = #{id}")
int updatebalance(account account);
}4.3 java 兜底方案:死锁重试
即使固定加锁顺序能规避大部分死锁,仍然建议在应用层加一层重试兜底。spring 会把 mysql 死锁错误封装为 deadlockloserdataaccessexception:
@service
public class transferfacade {
private static final int max_retry = 3;
private final accounttransferservice transferservice;
public transferfacade(accounttransferservice transferservice) {
this.transferservice = transferservice;
}
public void transferwithretry(long fromid, long toid, bigdecimal amount) {
for (int attempt = 1; attempt <= max_retry; attempt++) {
try {
transferservice.transfer(fromid, toid, amount);
return;
} catch (deadlockloserdataaccessexception e) {
log.warn("发生死锁,第 {} 次重试", attempt);
if (attempt == max_retry) {
throw new bizexception("系统繁忙,请稍后重试", e);
}
sleepbackoff(attempt);
}
}
}
private void sleepbackoff(int attempt) {
try {
thread.sleep(attempt * 100l);
} catch (interruptedexception e) {
thread.currentthread().interrupt();
throw new bizexception("线程中断", e);
}
}
}4.4 执行流程与注意事项
执行流程:
- 开启事务后,先对 id 较小的账户执行
select ... for update加行锁。 - 校验余额并完成扣款、更新。
- 再对 id 较大的账户加锁并完成入账、更新,最后提交事务。
注意事项:
select ... for update必须在事务内执行,否则语句结束锁即释放,无法起到保护作用。- 固定顺序要求所有涉及同一组数据的事务都遵守,只要有一条路径加锁顺序不一致,循环等待仍可能发生。
- 重试必须有最大次数和退避时间,避免死锁高发期产生雪崩。
- 务必保证 update 语句走索引,否则可能出现全表扫描或间隙锁,导致锁范围被放大。
五、扩展延伸
5.1 死锁与锁等待的区别
| 维度 | 死锁 | 锁等待 |
|---|---|---|
| 定义 | 事务互相持有对方需要的锁,形成循环等待 | 一个事务等待另一个事务持有的锁 |
| 处理方式 | innodb 主动检测并回滚一方 | 等待直到超时后报错 |
| 错误码 | 1213 | 1205 |
| 本质 | 循环依赖,无法自行解开 | 单向等待,可自行解除 |
5.2 死锁检测的优缺点
优点:能够快速发现并打破死锁,避免事务无限期阻塞,保障系统可用性。
缺点:检测本身存在性能开销。官方文档指出,在高并发且大量事务互相等待的场景下,等待图的遍历检测可能造成明显的 cpu 争用。此时可以考虑在极端场景下临时关闭 innodb_deadlock_detect,改用锁等待超时兜底,但要接受更长的事务阻塞时间。
5.3 实际开发注意事项
- 尽量减少事务执行时间,把非数据库操作挪到事务外。
- 同一业务内尽量使用一致的表访问顺序,例如统一按主键、按部门编号排序。
- rr 隔离级别下间隙锁更容易引发死锁;业务允许时可评估降为 rc(read committed)隔离级别。
- 为关键更新语句设计合适索引,避免锁范围扩大。
- 对可容忍短暂失败的业务增加重试机制,并设置合理上限。
六、面试追问
追问 1:死锁和锁等待有什么区别?
回答思路:先说本质差异,再补充处理机制、错误码和业务影响。
参考回答:死锁是事务之间形成循环等待,谁都无法主动释放锁,只能靠 innodb 检测并回滚一方,错误码是 1213;锁等待是单向等待,只要持有锁的事务提交或回滚,等待方就能继续,若超过 innodb_lock_wait_timeout 仍未获得锁才报 1205。死锁是循环依赖,锁等待是普通的排队阻塞。
追问 2:innodb 是怎么检测到死锁的?为什么有时候要关闭死锁检测?
回答思路:围绕等待图检测讲清原理,再说明关闭检测的性能考量。
参考回答:innodb 维护事务之间的等待关系图,当事务等待锁时加入一条等待边,一旦图中形成环就判定为死锁,然后回滚 undo 量较小的事务。之所以有时关闭 innodb_deadlock_detect,是因为高并发大量等待时,等待图遍历检测会带来明显 cpu 开销,这时可以用锁等待超时替代检测,代价是单次等待时间变长。
追问 3:把隔离级别从 rr 降到 rc 能避免死锁吗?
回答思路:先给出“不能完全避免,但能降低概率”的结论,再解释原因。
参考回答:不能完全避免。rc 隔离级别不使用间隙锁,死锁的一种常见来源消失了,因此死锁概率通常会下降;但记录锁之间的循环等待仍然可能存在。降低隔离级别是优化手段之一,真正要从根本上规避,还是需要固定加锁顺序、缩短事务和合理使用索引。
追问 4:应用层捕获到死锁异常后直接重试就够了吗?
回答思路:强调重试的必要条件,说明直接无限重试的风险。
参考回答:不够。重试必须限定最大次数,并采用退避策略,否则高发期会造成雪崩;同时要保证被回滚事务重试时重新从头执行,不会基于已失效的中间状态继续。最稳妥的做法是让重试逻辑落在事务外层,事务边界清晰,失败后整体重跑。
追问 5:如何通过 show engine innodb status 快速定位死锁双方?
回答思路:说明关注点,不必逐字复述输出。
参考回答:重点查看 latest detected deadlock 中列出的两个事务:分别确认每个事务执行到哪条 sql、持有和等待哪些锁,再看 “we roll back transaction” 判断哪个事务被回滚。结合事务对应的业务代码,就能判断加锁顺序不一致的路径并针对性修复。
总结
到此这篇关于mysql死锁怎么解决及底层原理与java实战指南的文章就介绍到这了,更多相关mysql死锁解决及底层原理内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论