这是系列文章的第7篇。今天要聊的是后端同学几乎天天挂在嘴边、但一上生产就很容易翻车的三个词:事务、并发和锁。先给个范围提醒,这里的锁不是锁屏壁纸、手机BL锁、mac监管锁或者智能锁说明书里那种锁,而是数据库和分布式系统里的并发控制锁。理解这三件事,建议直接从最经典的业务场景入手,比如电商下单扣库存、转账、订单与库存对账。你会发现它们不是三个独立知识点:事务决定了操作的边界,并发决定了多个边界怎么交错,锁则保证了交错不出乱子。这篇我会从单机数据库讲到微服务下的分布式事务,再聊到分布式锁和压测实操,最后整理一些问题排查和面试答题思路,新手照着学,老手也能拿来当查漏补缺的清单。
1. 事务到底在管什么:从ACID到隔离级别,再到事务注解
1.1 以转账模型理解ACID和事务生命周期
理解事务,我始终觉得用转账模型最直接。A给B转了1000块,账务系统里至少要有两条SQL:一条从A的账户扣1000,一条往B的账户加1000。如果扣款成功之后、入账之前数据库突然崩溃,只扣不加,这账就平不了。事务要做的事,就是把这组操作包成一个不可分割的单元:要么全部成功,要么全部不生效。
传统关系型数据库对这个能力的定义被总结成ACID,也就是原子性、一致性、隔离性和持久性。原子性对应的是“要么都做要么都不做”,InnoDB里通过undo log来实现,操作中途失败时用undo log回滚到事务开始前的状态。一致性更偏业务层面,就是说事务执行前后,数据的完整性约束不能被破坏,约束包括主外键、唯一键以及你自己在业务代码里定的规则,数据库只是提供机制,规则还得靠应用把关。隔离性解决的是多个事务同时跑的时候互不干扰的问题,这里背后是锁和MVCC多版本并发控制在配合。持久性则依赖redo log,事务一旦提交,即使机器掉电,重启后也能把数据恢复回来,不会出现“提交成功但数据丢了”的情况。
生命周期就三步:开启事务、执行读写、提交或回滚。用命令行或者客户端时,有显式的BEGIN和COMMIT;走MyBatis这类ORM框架时,事务边界通常由Spring的@Transactional托管。很多线上问题恰恰出在边界不清晰:有人在一个事务里做大量远程调用,锁资源被握在手里好几个秒甚至分钟;有人事务执行完了长时间不回滚,导致连接池、undo日志全部被拖累。我的习惯是事务里只放必须保证原子性的写操作,查数据、调接口、组装消息这类能移出去的统统移出去。
1.2 隔离级别与脏读、不可重复读、幻读的对应关系
事务并发时会遇到三类经典问题:
- 脏读:一个事务读到了另一个事务还没提交的数据。如果对方回滚,你读到的数据就是“不存在”的脏数据。
- 不可重复读:事务内两次读取同一行,结果不一样。原因是有别的事务在这期间提交了修改。
- 幻读:事务内两次执行同一个范围查询,返回的行数变多了。原因是有别的事务插入了新行。
SQL标准按照隔离强度从低到高定义了四种隔离级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 |
| 读已提交 | 不会 | 可能 | 可能 |
| 可重复读 | 不会 | 不会 | 可能 |
| 串行化 | 不会 | 不会 | 不会 |
实际生产里,几乎没有正经系统会用读未提交。读已提交是很多互联网公司的默认选择,因为并发能力更好。MySQL默认是REPEATABLE READ可重复读,理论上还存在幻读的可能,但InnoDB通过next-key lock间隙锁把“当前读”场景下的幻读问题也基本堵住了。这里有一个很容易面试被问倒的点:可重复读的“读”一般指快照读,就是通过undo版本链读历史快照;如果你执行SELECT ... FOR UPDATE或者UPDATE这类当前读,InnoDB会在索引区间上加next-key lock,从而防止其他事务往这个区间插入新记录,所以RR下也能挡住幻读。代价是间隙锁的范围比行锁大,锁冲突和死锁的概率也上去了,所以很多要求高并发写入的系统会故意把隔离级别调到读已提交。
1.3 事务传播行为与事务模式,还有那些悄悄失效的事务注解
隔离级别管的是“事务之间互相怎么隔离”,事务传播行为管的是“多个事务方法互相调用时怎么合并或拆分”。企业开发最常见的事务注解是Spring的@Transactional,传播行为有7种:
- REQUIRED:默认,如果外层有事务就加入外层,没有则新建。
- REQUIRES_NEW:无论如何都新开一个事务,外层事务会被挂起。
- SUPPORTS:有事务就加入,没有就直接在非事务状态执行。
- MANDATORY:必须在一个已有事务里运行,否则抛异常。
- NOT_SUPPORTED:以非事务方式运行,已有事务会被挂起。
- NEVER:以非事务方式运行,如果外层有事务就抛异常。
- NESTED:嵌套事务,相当于在外层事务里再开一个保存点,内层回滚不影响已经提交的外层部分。
这些传播行为里,最常用的是REQUIRED和REQUIRES_NEW。我举一个真实业务例子:主业务更新订单状态后,要往审计日志表写入一条记录。审计日志一定不能被主业务回滚掉。这时如果直接用REQUIRED,审计和主业务共用一个事务,主业务异常回滚,日志也跟着没了。改成REQUIRES_NEW之后,日志在独立事务里先落库,哪怕外层业务失败,日志也留得下来。
还要注意一批很容易踩到的事务注解失效场景。第一类是同类内部方法调用,比如Service里的方法A没加@Transactional,方法A内部调用了加了事务注解的同类的B,这时候事务不生效,因为代理对象没生效,你是直接this调用的。解决办法是把调用改成通过注入的自身代理或拆分到另一个Bean。第二类是方法不是public,Spring默认只对public方法做事务代理。第三类是异常被自己在方法里catch了,事务感知不到异常,自然就不回滚。第四类是抛出的受检异常触发了默认回滚规则,@Transactional默认只对RuntimeException和Error回滚,对Exceptio
