数据库笔记04:锁

Posted by LiYixian on Friday, April 10, 2026 | 阅读 | ,阅读约 5 分钟

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 主动死锁检测,发现死锁就主动回滚其中一个事务