没做过电商库存系统的人,可能觉得 MySQL 的锁和事务就是个面试八股文。直到你上线第一天,用户同时抢购同一个商品,库存从 50 变成 -5,订单表里出现了两条一模一样的数据,你才会意识到:事务是保护数据完整性的底线,锁则是让这条底线在并发下还能成立的根基。
这篇内容我打算用最直白的方式,把 MySQL 里的事务、锁、隔离级别、行锁表锁这些概念串起来讲一遍。面向新手,但不局限于写 demo 的那种新手,而是你已经开始写业务代码、被并发问题坑过、想在问题爆发前建立系统认知的开发者。我会结合自己实际排查过的案例,把原理讲透,也把实操能落地的排查 SQL、策略选择、避坑经验一并给出来。
1. 为什么说并发控制是数据库的核心问题
1.1 从一次扣库存事故说起
先回忆一个真实场景。当时我做的是一个秒杀类的活动页,后端逻辑很简单:用户点击抢购,服务端先查库存,如果库存大于 0,就执行更新扣减,然后生成订单。
sql复制-- 伪代码逻辑
SELECT stock FROM product WHERE id = 100; -- 假设返回 1
if (stock > 0) {
UPDATE product SET stock = stock - 1 WHERE id = 100;
INSERT INTO order(...) VALUES(...);
}
单用户测试屁事没有。一上压测,并发 200 个请求进来,库存直接变成负数。原因不复杂:两个请求同时读到 stock = 1,都判断“大于 0”,然后都执行了 UPDATE,各扣一次,库存变 -1。这里的核心问题不是 SQL 写错了,而是两个操作之间缺少一道屏障,让“读库存”和“扣库存”没有成为一个不可分割的原子操作。
这就是事务和锁要解决的第一个问题:原子性。在 MySQL 里,解决这类问题通常有两种思路,一种是把多条 SQL 放进一个事务里,再配合行锁保证同一时刻只有一个事务在修改同一条库存记录;另一种是直接用原子更新语句:
sql复制UPDATE product SET stock = stock - 1 WHERE id = 100 AND stock > 0;
如果你检查受影响行数为 0,说明库存被其他线程先扣了,这时候再返回“库存不足”。这种方式天然避免了“读-判断-写”之间的竞态窗口,也是我在真实项目里更推荐的做法。
1.2 事务和锁是两套体系
很多新手会把“事务”和“锁”当成同一个东西,其实它们是两套协作的体系。事务是一组 SQL 执行时的逻辑边界,它要求这一组操作要么全部成功,要么全部回滚,强调的是整体性;锁是并发控制的具体手段,它限制多个事务同时操作同一份数据时的相互干扰。
可以这么理解:事务是“规则”,规定了一批操作应该表现出什么样的效果;锁是“警察”,在规则执行过程中拦住那些不该同时发生的访问。譬如两个事务同时对同一行做更新,如果没有锁,就会出现 Lost Update;有了行锁,后到的事务就必须排队等待,直到前一个事务提交或回滚,锁才会释放。因此,讨论事务就离不开锁,讨论锁的最终目的也是服务事务的隔离性。
我见过不少新手会问:不对啊,我只执行一条 UPDATE,没写 BEGIN,也没有事务啊?其实 MySQL 默认开启 autocommit,每条单独 SQL 都是独立事务。你之所以感受不到事务的存在,是因为它被隐式地包裹了。一旦你写多条 SQL 的业务逻辑,就必须手动控制事务边界,否则一条成功、一条失败,数据就处于半成品状态。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 事务:并发安全的四梁八柱
2.1 ACID 不是四个抽象名词
ACID 是事务的四个特征:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。教科书上定义很严谨,但实战中的理解应该是这样的:
- 原子性:事务里的操作要么全部完成,要么全部不完成。对应到 MySQL 就是 undo log 的存在,允许执行一半出错时回滚到事务开始前的状态。
- 一致性:事务执行前后,数据的完整性约束不被破坏。这里的约束包括外键、唯一索引、业务规则等。一致性既是数据库层面的要求,也是业务层面的要求,比如转账前后双方总额不变。
- 隔离性:多个事务并发执行时,结果要跟串行执行时一样。隔离性由锁和 MVCC(多版本并发控制)共同实现。
- 持久性:事务提交后,即使数据库崩溃,数据也不会丢。对应到 MySQL 就是 redo log 的落盘机制。
这里我想单独强调一下一致性。很多程序员以为只要用了事务,数据就一致了,实际上数据库只能帮你保证约束层面的完整性,业务语义上的一致性仍要靠代码逻辑来保证。比如前面提到的扣库存场景,如果代码里少了 stock > 0 这个条件,事务再完美也挡不住库存被扣成负数,因为数据库不知道“库存不能为负”这个业务规则。所以数据库约束能加尽量加,像库存这种关键字段,甚至可以在表上增加 CHECK (stock >= 0) 约束作为最后防线。
2.2 四种隔离级别怎么选
SQL 标准定义了四种隔离级别,MySQL InnoDB 都支持,分别从低到高是:读未提交、读已提交、可重复读、串行化。这里的“隔离级别”回答的问题是:一个事务能“看到”其他事务尚未提交或者已提交但发生在自己执行过程中的数据变更吗?
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 并发性能 |
|---|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 | 最高 |
| 读已提交 | 不可能 | 可能 | 可能 | 高 |
| 可重复读(默认) | 不可能 | 不可能 | 可能(InnoDB 实际解决) | 中 |
| 串行化 | 不可能 | 不可能 | 不可能 | 最低 |
MySQL InnoDB 的默认隔离级别是可重复读,这一点跟 Oracle 的默认读已提交不一样。很多从 Oracle 转过来的同学会踩坑,以为默认不会出现不可重复读,其实 InnoDB 不仅可重复读,还通过间隙锁把幻读问题也解决了。这个后面我会单独展开。
读未提交这个级别基本没人用,因为它允许事务读到别人未提交的脏数据。万一对方回滚了,你这边的数据就成了无源之水,后续所有计算都建立在一个不存在的事实上。读已提交则保证每次读到的都是已提交的数据,但同一事务内两次查询可能得到不同结果,这就是不可重复读。可重复读通过建立一致性快照,保证事务内多次查询结果一致。
2.3 脏读、不可重复读、幻读的现场演示
三个并发问题的名字搞不清楚,就很容易在面试和实际排查时犯迷糊。我用一张表 t_account(id, balance) 来演示。
脏读:事务 A 给用户扣款 100,但还没提交。事务 B 此时去查这个用户的余额,如果用的是读未提交隔离级别,B 会查到扣款后的金额。如果 A 最后回滚了,B 读到了一个不存在的金额,这就是脏读。
不可重复读:事务 A 先查余额是 500,事务 B 此时提交了一笔入账 100,事务 A 再去查,发现余额变成 600。A 的两次查询结果不一致,这叫不可重复读。它强调同一行数据在同一个事务内两次读取不同。
幻读:事务 A 执行 SELECT * FROM t_order WHERE user_id = 1 查出 3 条订单。此时事务 B 插入了一条新的订单并提交。事务 A 再次执行同样查询,发现多了 1 条,像幻觉一样。幻读强调的是一批数据的总行数发生变化。
在 MySQL 默认的可重复读级别下,普通查询(非当前读)通过 MVCC 快照不会出现幻读;但如果你使用 SELECT ... FOR UPDATE 这种当前读,就必须依赖间隙锁来阻止其他事务插入新行,避免幻读。具体机制我会在讲锁的时候再细说。
3. 锁的类型:从锁粒度到锁模式
3.1 共享锁和排它锁:读写之间的博弈
InnoDB 的锁可以按模式分成两类:共享锁(S 锁)和排它锁(X 锁)。共享锁的意思是多个事务可以同时持有同一资源的 S 锁,大家都能读,但谁都不能改。排它锁则要求同一时刻只能有一个事务持有,写事务会阻塞其他事务的读和写。
你可以把 S 锁理解成图书馆里一本被人翻开的书,其他人可以一起看,但不能在上面写字;X 锁理解成有人正在修改这本书,他改完之前,别人既不能看也不能动。
SQL 层面,加 S 锁用 LOCK IN SHARE MODE 或 FOR SHARE,加 X 锁用 FOR UPDATE。这里有个新手容易踩的坑:普通的 SELECT 默认不加任何锁,它是快照读,走 MVCC,不会阻塞写;但 FOR SHARE 和 FOR UPDATE 是当前读,必须读取最新已提交版本,并会加锁。
| 锁模式 | 兼容性 | 典型 SQL | 应用场景 |
|---|---|---|---|
| S 锁 | 与 S 兼容,与 X 互斥 | SELECT ... FOR SHARE | 只读但希望其他事务别改 |
| X 锁 | 与 S、X 均互斥 | SELECT ... FOR UPDATE、UPDATE、DELETE | 修改、扣减、防止并发覆盖 |
实际项目中,SELECT ... FOR UPDATE 是最常用的悲观锁写法,但用的时候一定要记得加事务。如果你只执行一条带 FOR UPDATE 的 SQL,事务提交后锁就释放了,那这个锁就相当于只保护了一条 SQL 的原子性,保护不了后面的业务操作。
3.2 行锁、表锁、意向锁:粒度决定并发度
锁粒度越大,并发度越低,实现越简单;粒度越小,并发度越高,实现越复杂。MySQL 里主要涉及三种粒度:表锁、行锁、意向锁。
表锁锁住整张表,在 MyISAM 引擎中很常见,InnoDB 也保留了表锁,主要用于 DDL 操作,比如 ALTER TABLE。表锁的优点是开销小、不会死锁(至少死锁概率极低),缺点是并发差,任何写操作都会阻塞其他写操作。
InnoDB 的行锁是建立在索引上的。这句话非常重要,很多人优化 SQL 半天没效果,就是因为行锁失效变成了表锁。如果更新语句的 WHERE 条件没有走索引,InnoDB 就只能锁住全表所有记录,相当于表锁,并发直接报废。有一条血泪经验:给 UPDATE 和 DELETE 的 WHERE 条件都配上合适的索引,否则线上并发一上来就是大面积锁等待。
意向锁是一种表级锁,但它不真的锁表,它只是为了快速判断表里是否有行锁而存在的“标记”。事务想给某行加 X 锁时,先给自己在表级别加一个意向排它锁;另一个事务想对整个表加表锁时,只要发现表上有意向锁,就知道下面有行锁,必须等待。这个机制避免了逐行扫描检查锁状态的昂贵开销。意向锁之间是相互兼容的,因为它们只是标记,不是真正互斥的锁。
3.3 记录锁、间隙锁、临键锁:行锁里的细分
InnoDB 的行锁实际上有三种形态:
- 记录锁(Record Lock):锁住索引上的一条记录。
- 间隙锁(Gap Lock):锁住两条记录之间的范围,但不锁记录本身。它的作用是阻止其他事务在这个间隙里插入数据,从而避免幻读。
- 临键锁(Next-Key Lock):记录锁和间隙锁的组合,同时锁住记录及其前面的间隙,是 InnoDB 在可重复读级别下默认的行锁策略。
临键锁的存在可以解释一个很多人想不通的现象:事务 A 执行 UPDATE user SET status = 1 WHERE age BETWEEN 20 AND 30,即使满足条件的行很少,事务 B 想在这个范围内插入一条新记录,也会被阻塞。原因就是 A 的 UPDATE 在 age 索引对应的范围内加了临键锁,把范围内的“空隙”也锁住了。
这里我必须提醒一下:间隙锁只在可重复读和串行化隔离级别下生效。如果你把隔离级别调成读已提交,间隙锁会失效,幻读问题就需要其他手段来兜底,比如在应用层加锁或重新设计 SQL。所以不要轻易把线上数据库的隔离级别调低,除非你清楚地知道自己要承担什么后果。
4. InnoDB 加锁的两条暗线:当前读与快照读
4.1 快照读不是哪一刻的全量备份
想理解 MySQL 默认隔离级别下的隔离效果,必须分清两种读:快照读和当前读。
普通 SELECT 就是快照读。在可重复读级别下,事务第一次执行查询时,InnoDB 会生成一个一致性快照(ReadView),后续普通查询都基于这个快照,所以能保证同一个事务内多次查询结果一致。这个快照不是内存里的整表备份,而是一套版本链的核对机制。每条记录可能有多个版本,每个版本对应一个事务 ID,快照通过比较事务 ID 决定“该看到哪个版本”。因此快照读的并发性能非常好,因为它不加锁,不阻塞写操作。
当前读则相反,它必须读取记录的最新版本,并且会加锁。常见的当前读包括 SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE、INSERT。当前读之间会互相阻塞,比如一个事务更新了某行未提交,另一个事务来更新同一行,就会被阻塞在锁等待状态。
4.2 MVCC 和锁是如何配合的
MVCC 和锁并不是二选一的关系,而是分工协作。MVCC 负责让普通读操作不阻塞写操作,从而提升并发性能;锁负责保护写操作之间、以及当前读之间的互斥关系。
举例来说,两个事务同时执行 UPDATE 同一行,InnoDB 通过行锁让它们串行化执行,但这不意味着后一个事务必须从头等起。它会在前一个事务提交后,基于最新的版本继续执行自己的更新。如果你在事务里先 SELECT 了一次快照,再执行 UPDATE,两次看到的数据版本可能不同,这就是所谓的“当前读读到最新值,快照读读到旧值”,也是很多诡异 BUG 的来源。
这种机制带来的提醒是:如果你想实现“基于旧值做更新”的业务逻辑,不能靠事务里先 SELECT 再 UPDATE 这种快照读叠加当前读的组合,因为快照读到的是旧值,而 UPDATE 走的是当前读,两者可能不一致。更稳妥的方式是直接使用带条件的原子 UPDATE,或者用 SELECT ... FOR UPDATE 走当前读锁住记录,再基于最新的值做计算。
4.3 可重复读下到底还会不会幻读
教科书上会告诉你可重复读存在幻读问题,但 MySQL InnoDB 在可重复读下通过临键锁和快照读,基本消除了幻读。为什么说“基本”?因为只对当前读生效。如果你的事务里全程使用普通 SELECT,因为有快照,不会出现幻读;但如果先普通 SELECT 查询,再去执行 INSERT,可能会遇到唯一键冲突,因为 INSERT 是当前读,它走的是最新数据。
举个我遇到过的例子:两个请求同时查询订单号 A001 是否存在,都不存在,于是都执行 INSERT 插入订单号 A001。结果第二次 INSERT 报唯一键冲突。这就是典型的“快照读没有感知到并发插入”。解决方案是:在插入前的判断查询上直接加 SELECT ... FOR UPDATE 锁住范围,让两个事务串行判断,或者干脆提前在数据库层面把唯一索引建好,让数据库自动拦截重复数据。
5. 事务与锁的实战操作与排查
5.1 事务开启的常见误区
我见过最坑的案例是:开发者在代码里写了事务,但因为连接池复用和事务没提交,导致连接一直被占用,最终连接池爆掉。这里有几个关键点需要记住。
第一,MySQL 客户端默认 autocommit = 1,意味着每条 SQL 都会自动提交。如果你在命令行测试事务,一定要先执行 SET autocommit = 0; 或者显式使用 BEGIN / START TRANSACTION。
第二,DDL 语句(如 ALTER TABLE)会导致隐式提交,也就是说事务里执行了 DDL,前面的修改会强制提交。更坑的是有些数据库管理工具在执行某些操作时会自动发送 commit。所以线上执行 DDL 前,务必确认它不会打断正在进行的业务事务。
第三,Spring Boot 里使用 @Transactional 时,要注意事务默认只在 RuntimeException 下回滚,如果代码里 catch 了异常但是没有往外抛,事务是不会回滚的。这个切面代理的细节我后面会展开。
第四,不要在大事务里做远程调用、外部 API 请求或耗时的循环,因为大事务持锁时间长,会造成大面积的锁等待和连接堆积。我在项目里给代码评审定的一个规矩是:事务方法里绝对不允许调用 RPC 接口,宁可先完成所有远程调用,最后再开事务做数据修改。
5.2 查看当前锁和事务的实战 SQL
线上出了问题,最直接的问题是:是谁锁住了我的记录?用 MySQL 自带的几张表和命令可以快速定位。
sql复制-- 查看当前所有事务
SELECT * FROM information_schema.INNODB_TRX\G
-- 查看事务中持有的锁
SELECT * FROM information_schema.INNODB_LOCKS; -- 老版本 5.7 及以下
-- 查看锁等待关系(MySQL 8.0)
SELECT * FROM performance_schema.data_lock_waits\G
比较常用的一个查询,是把正在执行的事务、锁等待和对应的 SQL 都关联出来。MySQL 8.0 可以这样查:
sql复制SELECT
trx.trx_id,
trx.trx_state,
trx.trx_started,
trx.trx_mysql_thread_id,
trx.trx_query,
lock_wait.requesting_trx_id,
lock_wait.blocking_trx_id
FROM information_schema.INNODB_TRX trx
LEFT JOIN performance_schema.data_lock_waits lock_wait
ON trx.trx_id = lock_wait.requesting_trx_id;
定位到阻塞源头之后,如果确实需要干预,可以查看 sys.innodb_lock_waits 视图,它直接给出当前锁等待关系:
sql复制SELECT * FROM sys.innodb_lock_waits;
如果确定某个事务卡死了,可以使用 KILL {trx_mysql_thread_id} 来终止该连接,从而释放锁。但在生产环境执行 KILL 之前,一定要先看清楚这个连接在执行什么 SQL,确认它不是重要业务,否则会造成业务中断。
5.3 锁超时和死锁怎么处理
死锁是并发系统中的经典问题。InnoDB 检测到死锁后,会自动回滚其中一个事务,让另一个继续执行。大多数时候,你只需要在业务代码里捕获死锁异常并重试即可。但如果你不主动处理,死锁会导致其中一次操作失败,用户看到报错,影响体验。
死锁的常见成因是:两个事务以不同顺序访问同一批资源。比如事务 A 先更新表 1 再更新表 2,事务 B 先更新表 2 再更新表 1,两者互相等待对方已经持有的锁。解决办法有两种,一是统一加锁顺序,所有事务都先访问表 1 再访问表 2;二是减少事务持锁时间,把和锁无关的计算挪到事务外。
MySQL 有一个重要参数 innodb_lock_wait_timeout,默认 50 秒,表示等待锁超过这个时间会主动放弃。我建议根据业务特点调低,比如电商扣减库存场景可以设成 5~10 秒,避免一个请求挂太久拖垮整个连接池。同时也要关注 innodb_deadlock_detect 参数,默认开启,大部分场景保持开启即可;如果并发量极大导致死锁检测本身成为性能瓶颈,可以考虑关闭并配合锁超时兜底,但这种优化决策必须经过充分压测,不要盲目照搬。
6. 常见问题与排查技巧实录
6.1 明明是行锁,为什么变成锁表了
这是新手最容易困惑的问题。InnoDB 行锁挂在索引上,如果 UPDATE 的 WHERE 条件不是索引列,或者索引列上有隐式类型转换导致索引失效,InnoDB 就只能执行全表扫描,把扫到的每一条记录都加上锁。表面看你是要锁一行,实际上把整张表的行都锁了,相当于锁表。
比如 user_id 是 varchar 类型,SQL 里写成 WHERE user_id = 123,MySQL 会把 123 转成字符串再比较,如果没走对索引,就会引发全表锁。排查方法很简单:用 EXPLAIN 看你 UPDATE 语句的执行计划,type 是 ALL 或 index 就说明扫描范围过大,需要警惕。实际写业务的时候,我给自己定了一个规则:所有 UPDATE 和 DELETE 的 WHERE 条件里必须带唯一索引或明确的范围索引,否则上线前代码评审这一关就过不了。
6.2 Spring Boot 事务失效的几个常见场景
搜索热词里出现“Spring Boot 事务失效场景”,说明这个坑让太多人疼过。我梳理一下我实际遇到过的案例。
第一个是同一个类内部方法自调用。事务是通过 Spring AOP 代理实现的,如果你在一个方法里直接调用同类中的另一个 @Transactional 方法,调用的是 this 对象的方法,而不是代理对象的方法,事务注解不生效。解决方法是注入自身代理,或者把事务方法拆到另一个 Bean 里。
第二个是方法被 final 修饰或者类是 final 的。CGLIB 代理不能覆盖 final 方法,事务就失效了。
第三个是异常被捕获但没有抛出。事务回滚的触发条件是异常传播到代理方法之外,如果你在 catch 块里把异常吞掉了,事务就认为方法正常结束,于是提交。正确姿势是:catch 中处理完业务后,如果需要回滚,就用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() 或者重新抛出能触发回滚的异常。
第四个是数据库引擎问题。MyISAM 引擎不支持事务,所以建表时如果没指定 InnoDB,@Transactional 就是个摆设。虽然 MySQL 8.0 默认就是 InnoDB,但很多历史项目里遗留的 MyISAM 表仍然存在。
6.3 乐观锁和悲观锁到底选哪个
这个问题在面试里快被问烂了,实战里也确实需要决定。悲观锁就是数据库层面的锁,比如 SELECT ... FOR UPDATE,它假设数据一定会被并发修改,所以在操作前先锁住资源,适合写入冲突频繁的场景。乐观锁则假设冲突很少,所以不加数据库锁,而是通过版本号或时间戳来判断更新时数据是否被改变。
乐观锁最经典的做法是:表中加一个 version 字段,更新时带上之前查到的版本号:
sql复制UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 1;
如果受影响行数为 0,说明版本不匹配,需要重新查询并重试。这个做法避免了数据库锁等待,适合读多写少、冲突概率低的场景,比如用户修改个人信息;但库存扣减这种高频写场景,我更倾向于用行锁或原子更新,因为乐观锁重试会放大请求量。
6.4 别急着上分布式锁,先看看是不是在单库单表里
热词里有一堆“分布式锁”“Redis 分布式锁”“分布式事务”相关的内容。这里我想泼一盆冷水:很多团队的并发问题根本还没到需要分布式锁的级别。单机应用、单库单表、并发量几百 QPS 的情况下,数据库自身的行锁完全能解决数据一致性问题,引入 Redis 分布式锁反而会带来更多复杂度,比如锁过期、续期、可重入、集群故障等问题。
什么时候真的需要分布式锁?只有当你确定多个应用实例同时操作同一个共享资源,且数据库行锁已经不够用,或者你需要在应用层面协调跨库跨服务的资源时,才值得去设计。分布式锁只是“锁”的一种实现形态,底层逻辑仍然是互斥和防并发,把它基础原理搞清楚之前,盲目套用只会制造事故。
6.5 一个我最近踩过的锁等待坑
最后分享一个实际排查经历。当时线上监控报警,数据库连接数飙升,大量请求卡在同一个 UPDATE 语句上。我用 sys.innodb_lock_waits 查到阻塞源头是一个长时间未提交的事务,日志显示是这个事务调用了外部短信接口,耗时 30 秒,导致事务一直不提交,行锁一直不释放。
处理方式分两部分:先把阻塞源头这个连接 KILL 掉,让业务恢复;然后代码层面把 RPC 调用从事务里挪出去,先发短信,确认成功后最后再更新状态。那次之后,我在代码评审里增加了硬性要求:事务方法内部不允许出现远程调用和 Thread.sleep 等操作。这虽然是一句看起来理所当然的规则,但踩坑之前根本没人会提醒你。
我个人做项目时的习惯是:隔离级别尽量保持默认的可重复读,不为“提升性能”轻易下调;所有涉及更新的 SQL 都先用 EXPLAIN 看执行计划;所有 UPDATE、DELETE 都尽量带上主键或唯一索引条件;线上事故排查时第一件事就是查询 INNODB_TRX 和 sys.innodb_lock_waits,而不是直接重启服务。这些习惯不一定完全适用于你的场景,但至少能让你在并发问题来临时,少踩几个坑。
