死锁定义
死锁是指在并发事务中,多个事务彼此持有某些资源,并同时又在等待对方持有的资源,从而导致循环等待,最终所有相关事务都无法继续执行的状态。
- 简单理解:两个事务互相卡住,你等我释放,我等你释放,结果谁也走不下去。
必要条件
死锁的发生通常需要以下四个条件同时满足(任意一个不成立就不会死锁):
- 互斥条件:资源同一时间只能被一个事务占有(如表锁、行锁)。
- 不可抢占条件:已经分配给事务的资源,不能被强制剥夺,只能事务自己释放。
- 部分分配条件(请求并保持条件):事务已经持有了一部分资源,但还在申请新的资源。
- 循环等待条件:存在一个事务等待队列,形成了环路:T1 等 T2 的资源,T2 等 T3 的资源,T3 又等 T1 的资源。
表锁死锁
产生的原因: 当多个数据库用户并发访问资源时,如果请求顺序不当就可能产生死锁。下面是一个典型的死锁场景示例:
-
用户A的事务流程:
- 首先执行
SELECT * FROM tableA FOR UPDATE(获取表A的排他锁) - 然后尝试执行
SELECT * FROM tableB FOR UPDATE(等待获取表B的锁)
- 首先执行
-
同时用户B的事务流程:
- 先执行
SELECT * FROM tableB FOR UPDATE(获取表B的排他锁) - 然后尝试执行
SELECT * FROM tableA FOR UPDATE(等待获取表A的锁)
- 先执行
此时系统进入死锁状态:
- 用户A持有tableA的锁,等待获取tableB的锁
- 用户B持有tableB的锁,等待获取tableA的锁
- 两个事务互相等待对方释放资源,形成循环等待
常见应用场景:
- 电商系统中,用户A在修改订单表时需要同时更新库存表
- 用户B在修改库存表时需要同时更新订单表
- 如果两个操作同时发生且顺序不一致就可能死锁
解决方案:
-
资源排序法:
- 为所有资源(如表)定义固定的访问顺序
- 例如给所有表编号,操作时严格按照编号从小到大顺序访问
- 示例:若有表A(id=1)、B(id=2)、C(id=3),所有事务必须先访问A,再B,最后C
-
锁超时机制:
- 设置合理的锁等待超时时间(如5秒)
- 超时后自动回滚当前事务并重试
- SQL Server示例:
SET LOCK_TIMEOUT 5000
-
事务优化建议:
- 尽量缩小事务范围
- 避免在事务中包含用户交互
- 将大事务拆分为多个小事务
- 使用乐观锁替代悲观锁
行级死锁
产生的原因1: 如果事务中执行了一条没有索引条件的查询,引发全表扫描,把行级锁上升为全表记录锁定(等价于表级锁),多个这样的事务执行之后,很容易出产生死锁和阻塞,最终应用系统越来越慢,从而进一步加重阻塞和死锁。
解决方案1: SQL语句中不要使用太复杂的关联多表的查询,使用 EXPLAIN 执行计划对SQL语句进行分析,对于有全表扫描和全表锁定的SQL,建立相应的索引进行优化。
产生的原因2: 两个事务都想拿到对方持有的锁,互相等待,于是产生了死锁。
解决方案2: 在同一个事务中,尽可能做到一次锁定所需要的所有资源。按照id对资源进行排序,然后按照顺序进行处理。
共享锁转排他锁
产生原因: 事务A查询一条记录,然后更新该记录,此时事务B也更新该条记录,这时事务B的排他锁由于事务A有共享锁,必须等A释放共享锁后才可以获取,只能排队等待。
事务A:
-- 共享锁1
SELECT * FROM dept WHERE deptno = 1 LOCK IN SHARE MODE;
-- 排他锁3
UPDATE dept SET dname='java' WHERE deptno=1;
事务B:
-- 由于1有共享锁 没法获取其他锁 需要等待
UPDATE dept SET dname='java' WHERE deptno=1
解决方案:
- 对于按钮控件,点击立刻失效,不让用户重复点击,避免同一个时间发生多条记录操作。
- 使用乐观锁进行控制,乐观锁避免了长事务中的数据库加锁开销,大大提升了并发量下的系统性能。
死锁排查
查看死锁
通过以下指令查看近期死锁的日志:
SHOW ENGINE INNODB STATUS\G;
查看锁状态
要深入分析MySQL InnoDB引擎的行锁争夺情况,可以通过以下命令查看关键状态变量:
SHOW STATUS LIKE 'innodb_row_lock%';
这些状态变量提供了以下重要信息:
-
innodb_row_lock_current_waits(当前等待锁的数量)
- 实时显示系统中正在等待行锁的会话数量
-
innodb_row_lock_time(总锁定时间)
- 累计统计自数据库启动以来所有行锁等待的总时间(毫秒)
-
innodb_row_lock_time_avg(平均等待时间)
- 每次行锁等待的平均耗时(毫秒)
- 健康指标:通常应保持在100ms以下,超过500ms需重点关注
-
innodb_row_lock_time_max(最长等待时间)
- 记录系统启动以来单个事务等待行锁的最长时间
-
innodb_row_lock_waits(总等待次数)
- 累计发生的行锁等待总次数
避免死锁的常见方法
1. 保持一致的加锁顺序
- 主键排序法:当需要更新多行数据时,按照主键值的升序或降序统一进行加锁。
- 表级排序法:涉及多表操作时,约定固定的表访问顺序(如按表名字典序)。
- 资源分级法:将数据库资源分级(如表、页、行),按照从高到低或从低到高的固定顺序加锁。
2. 尽量缩短事务时间
- 减少交互:避免在事务中包含用户交互、网络请求等耗时操作
- 预处理数据:在事务外完成数据准备和校验工作
- 批量操作:使用批量更新替代循环单条更新
- 及时提交:完成核心操作后立即提交,不要保留不必要的事务
3. 合理选择事务隔离级别
- READ UNCOMMITTED:无锁读取,但可能读到脏数据
- READ COMMITTED:大多数数据库默认级别,只锁定正在读取的行
- REPEATABLE READ:保证事务内多次读取结果一致(MySQL默认)
- SERIALIZABLE:最严格的隔离级别,锁开销最大
4. 使用更合适的索引
- 避免全表扫描:确保WHERE条件都能命中索引,减少锁升级为表锁的风险
- 精准索引:使用覆盖索引减少回表操作
- 索引选择性:高选择性的索引能减少锁定的数据范围
- 注意索引失效:避免在索引列上使用函数、类型转换等导致索引失效的操作
5. 分解大事务
- 功能拆分:将一个大事务按功能拆分为多个独立小事务
- 数据分片:按数据维度拆分
- 定时提交:大数据量操作时每处理N条记录提交一次
- 补偿机制:对于必须保持原子性的操作,实现补偿事务处理部分失败的情况
6. 显式控制加锁顺序
- 预先锁定:使用SELECT … FOR UPDATE在事务开始时锁定所有必要资源
- 锁升级策略:先获取共享锁,需要时再升级为排他锁
- 超时机制:设置锁获取超时时间
- 死锁检测:实现应用层的死锁检测和重试机制
- 锁粒度控制:根据场景选择行锁、页锁或表锁
SELECT * FROM table LOCK IN SHARE MODE;
排他锁(行级锁-写锁)
SELECT * FROM employees WHERE id = 1 FOR UPDATE;
特性
- 排他性
- 独占访问
- 阻塞其他操作
乐观锁
乐观锁是一种并发控制机制,需要开发者自行设计实现。
实现方式
版本号机制
-- 读取
SELECT id, name, version FROM products WHERE id = 1;
-- 更新
UPDATE products
SET name = '新商品名称', version = version + 1
WHERE id = 1 AND version = 1;
适用场景
- 读多写少的应用
- 并发冲突概率较低
变体实现
- 时间戳方式
- 条件更新
注意事项
- 需要处理更新失败的情况
- 不适合高并发写场景