Summary:
- 乐观锁与悲观锁(常见实现方式)
- 行锁、表锁、间隙锁(Gap Lock)、临键锁(Next-Key Lock)
- 死锁是怎么产生的,怎么解决?
基础概念
锁的本质是用来保证并发环境下数据的一致性和完整性,因为多个事务可能同时访问同一条数据,如果不加控制,就可能产生脏读、不可重复读或幻读等问题。
锁有几个核心概念:
- 共享锁(读锁)、排他锁(写锁)
- 共享锁(S 锁):允许多个事务同时读取同一条数据,但不允许修改。也就是说,多个事务可以同时加读锁,但如果有事务想写,就必须等读锁释放。
- 排他锁(X 锁):只允许一个事务对数据进行修改,其他事务既不能读也不能写。写锁保证了修改的原子性和独占性。
- 可以看出,只有 S 锁和 S 锁兼容,其他全都不兼容。
- 意向锁
- 在行级锁数据库(如 InnoDB)中,用来告诉上层锁管理器:“我打算在表的某些行上加锁”,这样如果想加表锁就可以快速判断冲突,而不必检查每一行锁。
- 包括意向共享锁(IS)和意向排他锁(IX)
- 悲观锁、乐观锁
- 悲观锁:认为并发冲突很可能发生,所以在操作前就加锁,典型方式是数据库行锁:
select * from t where id = 1 for update - 乐观锁:认为冲突很少,所以操作时不加锁,而在提交时检查数据是否被修改(常用版本号或时间戳):
update t set value = 200, version = version + 1 where id = 1 and version = 10
- 悲观锁:认为并发冲突很可能发生,所以在操作前就加锁,典型方式是数据库行锁:
数据库层面的锁
在 MySQL 里,根据加锁的范围,可以分为全局锁、表级锁和行锁三类。
全局锁
使用全局锁时,执行 flush tables with read lock,整个数据库就变成只读状态,此时其他线程无论是要增删改数据,还是表结构,都会被阻塞,直到 unlock table 为止。
全局锁主要用于做全库的逻辑备份,这样在备份数据库期间,不会出现数据不一致的问题。问题是,如果备份时间较长,业务就会停滞;如果数据库引擎支持可重复读,那么在备份数据库之前开启事务,创建一个 Read View,那么在备份期间都使用这个 Read View,这期间业务仍然可以更新数据。
表级锁
- 表锁
- 锁住整个表,任何事务都不能同时修改表中数据;
- 在高并发场景下不推荐,InnoDB 优先行锁。
- 意向锁
- 它不是用来直接阻止事务修改某条数据的,而是用来协调表级锁和行级锁之间的关系,作用是快速判断锁冲突。
- 比如说,对于数据库的表 A,事务 T1 想对某几行加行锁,而事务 T2 想对整个表加表锁;如果没有意向锁,数据库在判断 T2 的表锁能否加时,需要扫描整张表每一行的锁状态,开销很大;只要 T1 在表 A 上加了 IX(意向排他锁),T2 想加表锁时,只看表上的意向锁即可判断是否冲突,不必遍历每行。
- 事务对某个表加行锁时会自动对整个表加上相应的意向锁。
- 它不是用来直接阻止事务修改某条数据的,而是用来协调表级锁和行级锁之间的关系,作用是快速判断锁冲突。
行锁
行级锁只锁定单条记录,不锁整个表,所以并发度高,典型操作如 select ... for update(加排他锁)和 select ... lock in share mode(加共享锁)。在 InnoDB 中,是通过索引加锁的,如果对没有索引的列加锁,会全表扫描把所有的索引都锁上,相当于退化成了表锁。
行锁主要可以分为三种:Record Lock 记录锁,Gap Lock 间隙锁,Next-Key Lock。事务 commit 之后,事务过程中生成的锁都会被释放。
- Record Lock
- 锁住具体的索引记录(行)
- 最小锁粒度,只影响特定的行,不会阻塞其他行的操作
- 应用场景:UPDATE、DELETE 或 SELECT … FOR UPDATE 时加的行锁
- 只有 S 和 S 锁不互斥
- Gap Lock
- 锁住两个索引值之间的间隙,而不是具体记录本身
- 锁的是间隙,防止其他事务在这个间隙插入新行
- 应用场景:只存在于可重复读隔离级别,防止幻读。例如,事务 A 查询 id BETWEEN 10 AND 20 时,间隙锁会锁住 10-20 的间隙,阻止事务 B 在 10-20 插入新行
- 间隙锁不会阻塞已经存在的记录,只阻塞插入操作;间隙锁之间是兼容的,两个事务可以同时持有 overlap 的间隙锁,不存在互斥关系
- Next-Key Lock
- 是记录锁 + 间隙锁的组合,锁住索引记录本身 + 前面的间隙(前开后闭)
- 应用场景:InnoDB 在可重复读隔离级别下默认使用 next-key lock,防止幻读
- 插入意向锁
- 事务 A 在插入一条记录的时候,判断插入位置是否已经有了 gap lock;如果有就阻塞,并生成插入意向锁,表明自己想在这个区间插入,现在正在等待 gap lock 被释放
- 插入意向锁和间隙锁如果在相同区间,是不兼容的;插入意向锁自己之间是兼容的
- 防止幻读
- 可以提高并发插入的效率,因为不同事务可以持有同一间隙的插入意向锁
加锁规则
- 读操作
- select 快照读不加锁
- select for update 加 X 锁(record / gap / next-key)
- select for share 加 S 锁
- 写操作
- insert 加插入意图锁、唯一索引检查(可能加 record / gap)
- update 如果命中了唯一索引加 record,范围条件加 next-key
- 注意,如果查的是二级索引,就先在二级索引加 gap / next-key,再给主键加 record
- delete 本质等价于 update
具体加法非常复杂,详见:MySQL 是怎么加行级锁的?
死锁
数据库中死锁的本质就是多个事务循环等待:每个事务都拿着一部分锁,同时又在等别人手里的锁,结果谁也走不了。
比较经典的例子:事务 A 和事务 B 都持有同一区间的间隙锁,然后都想在这个区间插入数据;这时双方的插入意向锁都要等待对面的间隙锁释放,就死锁了。
// 前提:order_no 有唯一索引,1007 和 1008 两条记录目前不存在
select id from t_order where order_no = 1007 for update; // A 加间隙锁
select id from t_order where order_no = 1008 for update; // B 加间隙锁
insert into t_order (order_no) values(1007); // A 等待 B
insert into t_order (order_no) values(1008); // B 等待 A
(此处 select ... for update 是为了防止并发插入时重复,就提前把这个位置锁住)
为什么会发生死锁?主要的原因有:
- 加锁顺序不一致
- 锁的范围不确定(尤其是范围查询)
- 事务太长,持锁时间太久
- 索引不合理,特别是 update 语句的条件没有带上索引,会全表扫描
那么,如何避免和处理死锁?
- 统一加锁顺序,比如所有地方都按照 id 从小到大更新
- 尽量用索引,缩小锁范围;少用 between / < / > 更新
- 拆小事务,越快提交越好
- 设置事务等待锁的 timeout 时间,超时就回滚
- 开启 InnoDB 主动死锁检测,发现死锁就主动回滚其中一个事务