数据库与并发控制:MyBatis-Plus 乐观锁 / 逻辑删除 / 并发扣减 / MySQL 原理
一句话速览:并发写安全的终极答案只有一句话——把”校验 + 修改”放进同一条原子 SQL 的 WHERE 条件里,让数据库行锁替你守住底线;应用层的乐观锁、Java 锁都只是辅助。数据库排查的第一工具是
EXPLAIN和慢查询日志,不是猜。涉及原始记录:
07-面试问题/开发问题/02-框架与中间件集成问题.mdP1-03;07-面试问题/验证问题/01-业务逻辑问题.mdP1-02最近修订:2026-08-07
目录
- 技术点 1:乐观锁(
@Version)与逻辑删除(@TableLogic)叠加使用的坑 - 技术点 2:字段级参数校验的必要性
- 技术点 3:MySQL 索引与慢查询排查 —— 必补的地基
- 技术点 4:事务隔离级别、MVCC 与锁 —— 必补的地基
- 技术点 5:并发控制手段全景对比
- 术语表
技术点 1:乐观锁(@Version)与逻辑删除(@TableLogic)叠加使用的坑
难度:⭐⭐⭐ | 掌握要求:能说清两个拦截器叠加的风险、手写 SQL 为什么更可控
核心概念
这两个 MyBatis-Plus 特性分别解决不同的问题:
- 乐观锁:并发更新同一行数据时,靠一个
version字段做 CAS(Compare And Swap)判断——读的时候记住当前 version,更新时带上这个 version 作为 WHERE 条件,如果这行数据在读和写之间被别人改过(version 已经变了),这次更新就会失败(影响行数为 0),需要业务代码感知并重试或报错。 - 逻辑删除:不真的执行
DELETE,而是给数据打一个is_deleted标记,MyBatis-Plus 会自动在所有查询/更新语句上追加is_deleted=0的条件,让”已删除”的数据在业务上看起来不存在。
这两个特性都是通过 MyBatis-Plus 的拦截器自动往 SQL 里拼条件实现的,问题就出在”自动拼接”这个环节:两个拦截器叠加时,生成的最终 SQL 会同时带上 version=? 和 is_deleted=0 两个条件,理论上没问题,但在配合 Seata AT 模式做分布式事务时,行锁的处理会更复杂。
我们踩过的坑
P1-03:并发更新偶发报”version 不匹配”,但实际没有真正的并发冲突
livin-house 的 TimeSlot(预约时间槽)表同时用了 @Version 乐观锁和 @TableLogic 逻辑删除。排查发现问题是 MyBatis-Plus 自动生成的 UPDATE 语句、叠加 Seata AT 模式对整行加的锁,两层机制叠加后在部分场景下会导致乐观锁版本号判断出现误判——本来没有真正的并发写冲突,却被判定为冲突。
解决思路不是去深挖框架内部机制打补丁,而是收窄乐观锁的使用范围:把 version 字段的比较从”依赖 MyBatis-Plus 自动生成 SQL”改成完全手写 SQL,把所有需要的条件显式写清楚,不依赖框架的自动拼接行为:
1 | UPDATE t_time_slot |
这条 SQL 本身也顺带解决了另一个问题——用 booked_count < max_count 作为 WHERE 条件,把”人数是否超额”的判断下推到数据库层做原子判断,而不是先在 Java 代码里查出当前人数、判断没超再发起更新(那样在并发场景下存在”查的时候没超,更新的时候已经超了”的竞态窗口)。
必须记住的结论
- 框架自动生成 SQL 的便利性和”多个自动拼接机制叠加时行为可预测性”之间存在权衡。当乐观锁 + 逻辑删除 + 分布式事务锁三者叠加,出现难以定位的诡异报错时,与其花大量时间去理解框架内部具体是怎么冲突的,不如直接对最关键的并发场景手写 SQL,把所有条件显式化,牺牲一点框架便利性换取行为可预测。
- 涉及库存/名额类的并发扣减场景(预约名额、优惠券库存、下单库存),标准做法是把”数量是否足够”的校验直接写进 UPDATE 语句的 WHERE 条件里(
WHERE booked_count < max_count),让数据库用行级锁保证这个判断和更新是原子的,而不是在应用层先查询后判断再更新——后者无论加不加乐观锁字段,都存在”两次数据库交互之间”的竞态窗口。 - 乐观锁的本质是”检测冲突后交给业务处理”,不是”防止冲突发生”;而下推到 SQL 层的条件更新是”从数据库层面直接杜绝了不合法的更新执行”,两者不是互斥的,但在同一个字段上不要既指望乐观锁自动重试、又依赖 WHERE 条件兜底,容易在排查时把两层逻辑搞混。
技术点 2:字段级参数校验的必要性
难度:⭐ | 掌握要求:NOT NULL 是最后防线不是校验手段、400 vs 500 的语义
核心概念
Java 实体类的字段类型(比如 Integer floor)本身不会阻止空值传到持久层,只有当持久层执行插入/更新,遇到数据库列的 NOT NULL 约束时才会报错。如果不在接口入参层做校验,这个”必填但漏传”的错误会一路穿透到 DB 层才暴露,报错信息也是数据库异常而不是业务语义的提示。
我们踩过的坑
验证问题 P1-02:漏传字段导致 500 而不是友好的 400
t_house 表的 floor(楼层)、total_floor(总楼层)字段是 NOT NULL 且无默认值,但 House 实体上没有加任何校验注解。前端一旦漏传,请求能顺利通过 Controller,一路走到 Mapper 执行 INSERT 时才被数据库拒绝:
1 | java.sql.SQLException: Field 'floor' doesn't have a default value |
这类问题返回给前端的是 500(服务器内部错误),而不是 400(请求参数错误)——语义上是错的:这明明是客户端传参有问题,不是服务端出了故障。
修复是加上 Bean Validation 注解,配合 Controller 方法参数上的 @Valid,让 Spring 在进入业务逻辑之前就完成校验、统一返回友好的 400:
1 |
|
必须记住的结论
- 数据库的
NOT NULL约束是最后一道防线,不是校验手段。所有对用户可见的接口,必填字段应该在实体类或 DTO 上用@NotNull/@NotBlank等注解显式声明,配合 Controller 的@Valid,让校验失败在进入业务逻辑之前就以 400 的形式返回,不要让数据库异常直接冒泡成 500 抛给客户端。 - 看到接口返回 500 并且异常栈显示是
SQLException/约束冲突之类的问题,通常说明上层缺少了本该有的参数校验,修复思路不是”处理这个 SQL 异常”,而是”把校验往前移”。
技术点 3:MySQL 索引与慢查询排查 —— 必补的地基
难度:⭐⭐⭐ | 掌握要求:B+树结构、最左前缀、覆盖索引、EXPLAIN 关键列
本篇前半部分讲的是”应用层怎么用数据库”,这部分补”数据库本身是怎么工作的”——不理解索引结构,就无法解释为什么有些查询慢。
B+ 树索引:为什么是它
InnoDB 的索引是一棵 B+ 树:非叶子节点只存索引键值和指针(所以一层能存上千个节点,3~4 层就能支撑亿级行——树高决定磁盘 IO 次数,这是选 B+ 树的根本原因),叶子节点存数据且用双向链表串起来(所以范围查询 BETWEEN/> 只需定位起点后顺序扫叶子,天然高效)。
- 聚簇索引(主键索引):叶子节点直接存整行数据,表数据本身就是按主键组织的一棵 B+ 树。
- 二级索引(普通索引):叶子节点存的是”索引键值 + 主键值”。用二级索引查非索引列时,要拿主键再回聚簇索引查一次——这就是回表。
- 覆盖索引:查询要的列全部包含在二级索引里,不用回表,是成本最低的优化手段之一(
EXPLAIN的 Extra 列显示Using index)。
最左前缀原则
联合索引 (a, b, c) 的 B+ 树是先按 a 排序、a 相同按 b 排、再按 c 排。因此只有查询条件从索引最左列开始连续匹配(a、a+b、a+b+c)才能用上索引;跳过 a 直接查 b 则整棵树无序可循,只能全扫。范围查询(>/LIKE '%xx')之后的列也无法继续用索引——因为范围列之后的部分在树上不再有序。
慢查询排查标准动作
- 开启慢查询日志(
slow_query_log+long_query_time),先拿到”到底哪条 SQL 慢”的事实。 - 对可疑 SQL 执行
EXPLAIN,重点看四列:type(至少要range/ref,看到ALL全表扫描就要警惕)、key(实际用了哪个索引,NULL 就是没用)、rows(估算扫描行数)、Extra(Using filesort/Using temporary是性能杀手信号,Using index是好消息)。 - 对症下药:缺索引加索引、索引失效改写法(对列做函数运算、隐式类型转换、前导通配符 LIKE 都会让索引失效)、大分页改游标式翻页。
技术点 4:事务隔离级别、MVCC 与锁 —— 必补的地基
难度:⭐⭐⭐ | 掌握要求:四个隔离级别解决什么问题、MVCC 快照读原理、间隙锁
四个隔离级别与三种读现象
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| 读未提交 RU | ❌ | ❌ | ❌ | 几乎不用 |
| 读已提交 RC | ✅ | ❌ | ❌ | Oracle/多数 PG 默认 |
| 可重复读 RR | ✅ | ✅ | 基本解决(快照读+间隙锁) | InnoDB 默认 |
| 串行化 | ✅ | ✅ | ✅ | 读写都加锁,性能差,极少用 |
三个读现象的定义要能脱口而出:脏读——读到别人未提交的数据;不可重复读——同一事务内两次读同一行结果不同(被别人改了并提交);幻读——同一事务内两次范围查询,多出了新插入的行。
MVCC:RR 隔离级别是怎么实现的
InnoDB 的 MVCC(多版本并发控制)三要素:
- 隐藏列:每行数据带
trx_id(最后修改它的事务ID)和roll_pointer(指向 undo log 里的旧版本)。 - undo log 版本链:每次修改把旧值存入 undo log,形成”当前值 → 旧值 → 更旧值”的链。
- ReadView:事务执行快照读时生成一个”可见性视图”,规则是”只看得见在我开始前已提交的事务的修改”。沿版本链找到第一个可见版本返回。
关键区别:RC 是每条 SELECT 都生成新 ReadView,RR 是事务第一次快照读时生成、之后复用——所以 RR 下同一事务内反复读结果一致。注意 MVCC 只管”快照读”(普通 SELECT);”当前读”(SELECT ... FOR UPDATE、UPDATE、DELETE)永远读最新已提交数据并加锁,这两个概念不分清,面试必挂。
行锁与间隙锁
- 记录锁(Record Lock):锁住索引记录本身。
- 间隙锁(Gap Lock):锁住索引记录之间的”间隙”,防止其他事务在间隙里插入新行——这是 RR 级别解决幻读的关键武器,只在 RR 下存在(RC 没有间隙锁)。
- 临键锁(Next-Key Lock):记录锁 + 前面间隙的组合,左开右闭区间,RR 下 InnoDB 加锁的默认单位。
- 死锁:两个事务互相持有对方要的锁。排查工具:
SHOW ENGINE INNODB STATUS里的LATEST DETECTED DEADLOCK段会给出完整持锁/等锁链路。预防手段:固定访问顺序、减小事务粒度、给 WHERE 条件加索引(无索引的 UPDATE 会退化成锁更多行甚至锁表,这是”加索引也是并发控制手段”的原因)。
技术点 5:并发控制手段全景对比
难度:⭐⭐ | 掌握要求:能按场景选出正确手段并说出理由
把本篇和 03 篇、10 篇提到的并发手段放在一起对比,形成选型直觉:
| 手段 | 机制 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 原子 UPDATE(条件下推) | UPDATE ... WHERE 条件,行锁保证原子 |
最简单、一次 IO、最强保障 | 只能表达简单条件 | 库存扣减、名额抢占,首选 |
乐观锁 @Version |
CAS 思想,冲突时更新失败 | 无锁等待、高并发读友好 | 冲突方要自己重试/报错;高冲突下重试风暴 | 冲突概率低的更新(个人资料等) |
悲观锁 SELECT ... FOR UPDATE |
读时直接加行锁 | 语义直白,串行化安全 | 锁持有期间阻塞并发、可能死锁 | 冲突高、临界区必须串行的场景 |
| Java 锁(synchronized/ReentrantLock) | JVM 内互斥 | 无 DB 开销 | 只挡得住单 JVM,分布式下形同虚设 | 单机内保护共享内存结构 |
| 分布式锁(Redisson) | 跨进程互斥(Redis setnx+看门狗) | 跨节点有效 | 引入网络依赖、要处理锁超时/续期 | 跨节点必须互斥且 DB 条件表达不了的复杂临界区 |
记忆主线:能用数据库原子 SQL 解决的,不要用锁;能用乐观锁的,不用悲观锁;单机锁解决不了分布式问题。详见 10-JVM与并发编程.md 对分布式锁的展开。
术语表
| 术语 | 含义 |
|---|---|
乐观锁 / @Version |
用版本号 CAS 检测并发冲突的机制,冲突时更新失败交业务处理 |
逻辑删除 / @TableLogic |
用标记位代替物理删除,MyBatis-Plus 自动追加 is_deleted=0 条件 |
| 条件下推 | 把校验条件写进 UPDATE 的 WHERE,用行锁保证”判断+修改”原子 |
| 聚簇索引 / 二级索引 | 叶子节点存整行数据的主键索引 / 叶子存主键值、需回表的普通索引 |
| 回表 / 覆盖索引 | 二级索引查不到的列回聚簇索引再查一次 / 索引本身覆盖全部查询列无需回表 |
| 最左前缀 | 联合索引必须从最左列开始连续匹配才能被利用的原则 |
| MVCC | 多版本并发控制:隐藏列 + undo 版本链 + ReadView 实现无锁快照读 |
| 快照读 / 当前读 | 普通 SELECT 走 MVCC 读历史版本 / 加锁读永远读最新已提交数据 |
| 间隙锁 / 临键锁 | RR 级别锁索引间隙防幻读 / 记录+间隙的左开右闭锁单位 |
| EXPLAIN type=ALL | 全表扫描信号,慢查询排查中最需要警惕的访问类型 |




