10个人9个答错,另外1个只对一半:数据库的锁,到底锁的是什么?

本文详细介绍了MySQL InnoDB的锁机制,包括RecordLock、GapLock和Next-KeyLock。RecordLock锁定索引记录,GapLock锁定索引记录间的间隙,Next-KeyLock则是两者的组合。在RepeatableReads隔离级别下,Next-KeyLock用于防止幻读。加锁规则涉及next-keylock的应用、等值查询的优化以及索引扫描。理解这些锁机制对于优化并发性能和避免死锁至关重要。

在MySQL数据库中,为了解决并发问题,引入了很多的锁机制,很多时候,数据库的锁是在有数据库操作的过程中自动添加的。

所以,这就导致很多程序员经常会忽略数据库的锁机制的真正的原理。比如,我经常在面试中会问候选人,你知道MySQL Innodb的锁,到底锁的是什么吗?

关于这个问题的回答,我听到过很多种,但是很少有人可以把他回答的很完美。因为想要回答好这个问题,需要对数据库的隔离级别、索引等都有一定的了解才行。

MySQL Innodb的锁的相关介绍,在MySQL的官方文档(https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html#innodb-insert-intention-locks )中有一定的介绍,本文的介绍也是基于这篇官方文档的。

Record Lock

Record Lock,翻译成记录锁,是加在索引记录上的锁。例如,SELECT c1 FROM t WHERE c1 = 10 For UPDATE;会对c1=10这条记录加锁,为了防止任何其他事务插入、更新或删除c1值为10的行。

10个人9个答错,另外1个只对一半:数据库的锁,到底锁的是什么?

需要特别注意的是,记录锁锁定的是索引记录。即使表没有定义索引,InnoDB也会创建一个隐藏的聚集索引,并使用这个索引来锁定记录。

Gap Lock

Gap Lock,翻译成间隙锁,他指的是在索引记录之间的间隙上的锁,或者在第一个索引记录之前或最后一个索引记录之后的间隙上的锁。

那么,这里所谓的Gap(间隙)又怎么理解呢?

Gap指的是InnoDB的索引数据结构中可以插入新值的位置。

当你用语句SELECT…FOR UPDATE锁定一组行时。InnoDB可以创建锁,应用于索引中的实际值以及他们之间的间隙。例如,如果选择所有大于10的值进行更新,间隙锁将阻止另一个事务插入大于10的新值。

10个人9个答错,另外1个只对一半:数据库的锁,到底锁的是什么?

既然是锁,那么就可能会影响到数据库的并发性,所以,间隙锁只有在Repeatable Reads这种隔离级别中才会起作用。

在Repeatable Reads这种隔离下,对于锁定的读操作(select … for update 、 lock in share mode)、update操作、delete操作时,会进行如下的加锁:

  • 对于具有唯一搜索条件的唯一索引,InnoDB只锁定找到的索引记录,而不会锁定间隙。
  • 对于其他搜索条件,InnoDB锁定扫描的索引范围,使用gap lock或next-key lock来阻塞其他事务插入范围覆盖的间隙。

也就是说,对于SELECT FOR UPDATE、LOCK IN SHARE MODE、UPDATE和DELETE等语句处理时,除了对唯一索引的唯一搜索外都会获取gap锁或next-key锁,即锁住其扫描的范围。

Next-Key Lock

Next-Key锁是索引记录上的记录锁和索引记录之前间隙上的间隙锁的组合。

10个人9个答错,另外1个只对一半:数据库的锁,到底锁的是什么?

假设一个索引包含值10、11、13和20。此索引可能的next-key锁包括以下区间:

(-∞, 10]

(10, 11]

(11, 13]

(13, 20]

(20, ∞ ]

对于最后一个间隙,∞不是一个真正的索引记录,因此,实际上,这个next-key锁只锁定最大索引值之后的间隙。

所以,Next-Key 的锁的范围都是左开右闭的。

Next-Key Lock和Gap Lock一样,只有在InnoDB的RR隔离级别中才会生效。

Repeatable Reads能解决幻读

很多人看过网上的关于数据库事务级别的介绍,会认为MySQL中Repeatable Reads能解决不可重复读的问题,但是不能解决幻读,只有Serializable才能解决。但其实,这种想法是不对的。

因为MySQL跟标准RR不一样,标准的Repeatable Reads确实存在幻读问题,但InnoDB中的Repeatable Reads是通过next-key lock解决了RR的幻读问题的

因为我们知道,因为有了next-key lock,所以在需要加行锁的时候,会同时在索引的间隙中加锁,这就使得其他事务无法在这些间隙中插入记录,这就解决了幻读的问题。

关于这个问题,引起过广泛的讨论,可以参考:https://github.com/Yhzhtk/note/issues/42 ,这里有很多大神发表过自己的看法。

MySQL的加锁原则

前面介绍过了Record Lock、Gap Lock和Next-Key Lock,但是并没有说明加锁规则。关于加锁规则,我是看了丁奇大佬的《MySQL实战45讲》中的文章之后理解的,他总结的加锁规则里面,包含了两个“原则”、两个“优化”和一个“bug”:

原则 1:加锁的基本单位是 next-key lock。是一个前开后闭区间。原则 2:查找过程中访问到的对象才会加锁。优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

假如,数据库表中当前有以下记录:

10个人9个答错,另外1个只对一半:数据库的锁,到底锁的是什么?

当我们执行update t set d=d+1 where id = 7的时候,由于表 t 中没有 id=7 的记录,所以:

  • 根据原则 1,加锁单位是 next-key lock,session A 加锁范围就是 (5,10];
  • 根据优化 2,这是一个等值查询 (id=7),而 id=10 不满足查询条件,next-key lock 退化成间隙锁,因此最终加锁的范围是 (5,10)。

当我们执行select * from t where id>=10 and id<11 for update的时候:

  • 根据原则 1,加锁单位是 next-key lock,会给 (5,10]加上 next-key lock,范围查找就往后继续找,找到 id=15 这一行停下来
  • 根据优化 1,主键 id 上的等值条件,退化成行锁,只加了 id=10 这一行的行锁。
  • 根据原则 2,访问到的都要加锁,因此需要加 next-key lock(10,15]。因此最终加的是行锁 id=10 和 next-key lock(10,15]。

当我们执行select * from t where id>10 and id<=15 for update的时候:* 根据原则 1,加锁单位是 next-key lock,会给 (10,15]加上 next-key lock,并且因为 id 是唯一键,所以循环判断到 id=15 这一行就应该停止了。* 但是,InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20。而且由于这是个范围扫描,因此索引 id 上的 (15,20]这个 next-key lock 也会被锁上。

假如,数据库表中当前有以下记录:

10个人9个答错,另外1个只对一半:数据库的锁,到底锁的是什么?

当我们执行select id from t where c=5 lock in share mode的时候:

  • 根据原则 1,加锁单位是 next-key lock,因此会给 (0,5]加上 next-key lock。要注意 c 是普通索引,因此仅访问 c=5 这一条记录是不能马上停下来的,需要向右遍历,查到 c=10 才放弃。
  • 根据原则 2,访问到的都要加锁,因此要给 (5,10]加 next-key lock。
  • 根据优化 2:等值判断,向右遍历,最后一个值不满足 c=5 这个等值条件,因此退化成间隙锁 (5,10)。
  • 根据原则 2 ,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁。

当我们执行select * from t where c>=10 and c<11 for update的时候:

  • 根据原则 1,加锁单位是 next-key lock,会给 (5,10]加上 next-key lock,范围查找就往后继续找,找到 id=15 这一行停下来
  • 根据原则 2,访问到的都要加锁,因此需要加 next-key lock(10,15]。
  • 由于索引 c 是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终 sesion A 加的锁是,索引 c 上的 (5,10] 和 (10,15] 这两个 next-key lock。

总结

以上,我们介绍了InnoDB中的锁机制,一共有三种锁,分别是Record Lock、Gap Lock和Next-Key Lock。

Record Lock表示记录锁,锁的是索引记录。Gap Lock是间隙锁,说的是索引记录之间的间隙。Next-Key Lock是Record Lock和Gap Lock的组合,同时锁索引记录和间隙。他的范围是左开右闭的。

InnoDB的RR级别中,加锁的基本单位是 next-key lock,只要扫描到的数据都会加锁。唯一索引上的范围查询会访问到不满足条件的第一个值为止。

同时,为了提升性能和并发度,也有两个优化点:

  • 索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
  • 索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。

关于锁的介绍,就是这么多了,但是其实,RR的隔离级别引入的这些锁,虽然一定程度上可解决很多如幻读这样的问题,但是也会带来一些副作用,比如并发度降低、容易导致死锁等。

后面我们再来单独介绍一下为什么RR作为InnoDB的默认级别,却"不受待见",很多大厂都会把数据库默认级别修改为RC。

“**数据库 + 应用层**”被称为 **双重保护机制**,它是在高并发写场景下确保数据一致性的两种不同层次的并发控制手段。它们各司其职、互为补充,但在某些情况下也可以只依赖其中之一。 下面我将详细解释: --- ## ✅ 一、“双重保护”是什么意思? | 层级 | 类型 | 作用 | |------|--------|------| | **应用层** | 如 `ReentrantLock`、Redis 分布式 | 防止多个线程/实例同时进入同一段业务逻辑(读-改-写)流程 | | **数据库** | 如 `SELECT ... FOR UPDATE` | 在事务提交前数据库记录,防止其他事务修改 | > 🔗 二者结合:先通过应用层串行化请求 → 再由数据库兜底保证隔离性。 ### 🌰 举例说明 假设你要对某条用料单明细进行“扣减可冲销量”操作: ```java WOMOrderInDetail detail = dao.get(id); // 查询当前剩余量 BigDecimal after = detail.getChargeOffAfter().subtract(chargeOff); detail.setChargeOffAfter(after); dao.save(detail); ``` 如果没有保护,两个线程可能同时读到相同的 `ChargeOffAfter` 值,各自减一次后保存,导致 **超卖**。 ### 双重保护如何工作? | 步骤 | 操作 | 安全保障 | |------|------|----------| | 1 | 线程 A 获取 `ReentrantLock(detailId)` | ✅ 应用层互斥,B 被阻塞 | | 2 | A 执行查询:`SELECT * FROM table WHERE id = ?` | ❌ 此时还未加 DB | | 3 | A 加行:`SELECT ... FOR UPDATE` | ✅ 数据库定该行 | | 4 | A 修改并保存 | ✅ 安全完成 | | 5 | A 提交事务,释放 DB 行 | ⏳ B 开始执行下一步 | 👉 即使应用层失效(如分布式部署未使用分布式),只要数据库用了 `FOR UPDATE`,仍能防止并发修改。 --- ## ✅ 二、核心区别对比 | 特性 | 应用层 | 数据库 | |------|----------|------------| | **作用范围** | JVM 或分布式节点间 | 数据库事务级别 | | **实现方式** | `synchronized` / `ReentrantLock` / Redis | `SELECT ... FOR UPDATE`, `LOCK IN SHARE MODE` | | **生命周期** | 手动获取和释放 | 事务开始到提交/回滚自动释放 | | **性能开销** | 轻量(内存操作) | 较重(涉及数据库通信、管理器) | | **是否跨进程有效** | 否(除非是 Redis ) | 是(所有连接都受约束) | | **能否防止“ABA”问题** | 否 | 否(需配合版本号或 MVCC) | | **典型用途** | 控制代码执行顺序 | 保证事务隔离性 | --- ## ✅ 三、什么情况下可以只靠数据库? 在以下场景中,**可以省略应用层,仅依赖数据库即可**: ### ✅ 场景 1:并发量不高 or 热点不集中 - 如果 `detailId` 分布均匀,很少出现多个线程争抢同一个记录。 - 数据库本身的 MVCC 和行机制足以应对。 👉 做法: ```sql -- 在事务中加行 SELECT * FROM wom_order_in_detail WHERE id = ? FOR UPDATE; ``` Java 中开启事务即可: ```java @Transactional public void edit(...) { WOMOrderInDetail detail = dao.selectForUpdate(id); // 自定义方法执行 FOR UPDATE // 修改逻辑... dao.save(detail); } ``` ✅ 优点:简单、无需维护额外状态 ⚠️ 缺点:高并发争抢热点数据时容易引发死或连接池耗尽 --- ### ✅ 场景 2:已使用乐观(version 版本号) ```sql UPDATE wom_order_in_detail SET charge_off_after = ?, version = version + 1 WHERE id = ? AND version = ? ``` - 多个线程并发更新失败 → 重试机制处理冲突。 - 不需要任何悲观(无论是应用层还是数据库)。 👉 适用于:冲突概率低、允许失败重试的业务(如库存扣减、点赞等) --- ### ✅ 场景 3:使用了分布式协调服务(如 Zookeeper、etcd) - 使用外部协调器做全局调度,替代本地 `ReentrantLock`。 --- ## ✅ 四、什么时候必须加上应用层? ### ❌ 必须加的场景: #### 1. **热点数据高频更新(如秒杀商品、订单投料)** - 大量请求打到同一个 `detailId` - 数据库排队 → 连接被长时间占用 → 连接池耗尽 - CPU 消耗在竞争、死检测上 💡 解决方案:**用应用层将“读-改-写”过程串行化**,避免大量请求涌入数据库 #### 2. **复杂业务逻辑(非原子 SQL 能完成)** 比如: - 先查库存 → 再判断是否满足 → 发消息 → 更新状态 - 中间有远程调用或条件分支 👉 若不加应用层,即使加了 `FOR UPDATE`,也可能因事务过长导致性能下降。 #### 3. **分布式部署且无统一缓存支持** - 多台机器上的 JVM 各自执行,`synchronized` 完全无效 - 必须升级为 **Redis 分布式**(即广义的“应用层”) --- ## ✅ 五、推荐实践模式:分层防御策略 ```text 客户端请求 ↓ [ 接口限流 ] —— 防止洪峰冲击 ↓ [ 应用层(按 detailId)] —— 串行化热点更新 ↓ [ 开启事务 + SELECT FOR UPDATE ] —— 数据库兜底防并发 ↓ [ 执行业务逻辑 ] ↓ [ 提交事务 → 自动释放行 ] ↓ [ 释放应用层 ] ``` 📌 **原则**: > “**应用层控流量,数据库保底线**” --- ## ✅ 总结:何时可以只靠数据库? | 条件 | 是否可以只用数据库? | 说明 | |------|------------------------|------| | 并发请求分散在多个 detailId | ✅ 是 | 行压力小 | | 业务逻辑简单、SQL 原子性强 | ✅ 是 | 可用 `FOR UPDATE` 或乐观 | | 支持失败重试机制 | ✅ 是 | 适合乐观 | | 存在热点数据(少数 ID 被频繁修改) | ❌ 否 | 易压垮数据库连接池 | | 分布式部署且无共享机制 | ❌ 否 | 本地无效 | | 要求强一致性 & 低延迟响应 | ❌ 否 | 需要应用层预判和排队 | --- ###
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值