很多后端开发看到“事务、并发和锁”这几个词,第一反应都是“又是八股文”。尤其当标题还带着编号“7.”,像极了某些培训课程、技术书或者面试复盘提纲里固定的一章。说实话,我挺喜欢这种编号的,它说明这三个概念在你知识体系里应该作为一个整体去理解,而不是拆成三个独立考点。如果你最近在看数据库基础、准备后端面试,或者自己维护的系统里出现过死锁、锁等待、事务回滚这类问题,这篇文章就是按照这条主线给你做一次完整的底盘梳理。我会尽量用线上真实能用的排查思路来讲,不只是背概念。
先说一句题外话。最近网上的热词里关于“锁”的搜索特别杂,从智能门锁、Win11锁屏壁纸、手机BL锁到开机图片和锁屏不一致,凑了满满一屏。那些都是设备层面的“锁”,和本文要聊的数据库锁不是一回事。这里讲的锁是并发控制里的互斥手段,发生在数据库、缓存这类有状态组件内部,目的是保护数据一致性和隔离性。
1. 为什么是“事务、并发和锁”三件套
1.1 事务先解决“一揽子操作”的可靠性
先想一个最简单的订单支付场景:用户点了支付,系统要扣掉用户的余额、把订单状态改成已支付、再写一条流水。这三个操作如果分开执行,中间任何一步失败都会让数据变得不可信。要么钱扣了订单没改,要么流水没写对不上账。
事务的意义就是用一条边界把这些操作打包:要么全部成功,要么全部回滚到未发生的状态。很多教材把这一特性叫ACID,其中原子性(Atomicity)和持久性(Durability)更多是靠日志体系去保障的,比如InnoDB里的undo log负责回滚、redo log负责崩溃恢复,核心机制是WAL(Write-Ahead Logging),也就是先写日志、再改数据页。而一致性(Consistency)是结果,需要应用层规则配合。真正让数据库在多个事务同时执行时还不乱套的,是隔离性(Isolation),隔离性的实现就离不开锁和MVCC。
所以我理解的课本章节顺序是这样的:先告诉你事务是什么,再告诉你事务并发执行时会出现哪些问题,最后告诉你用什么机制去隔离。锁不是事务的附属品,它是隔离性的执行者。
1.2 并发一上来,问题从隔离开始
如果数据库同一时刻只跑一个事务,所有事务串行执行,那是不会有脏读、幻读这类问题的。但真实系统的吞吐量不允许这么干。线上数据库经常同时有几百上千个连接在读写,这时候一个事务还未提交,另一个事务可能已经读到了它修改过的中间数据,或者对同一行发出更新请求。
并发带来的经典问题有三个:脏读、不可重复读、幻读。脏读是读到别人还没提交的数据,这个数据后面可能回滚,属于“读了不该读的东西”。不可重复读是同一个事务里执行同一条SELECT,结果却因为别的已提交事务修改了某行而前后不一致,属于“同一行内容变了”。幻读更隐蔽,是同一个查询执行两次,结果集的行数变了,就像出现了“幻觉行”,通常是因为别的并发事务插入或删除了记录。要解决这些,数据库必须在事务之间加隔离层,隔离层的核心部件就是锁。
理解这三个问题的另一个抓手是指出它们的典型发生位置,我放到后面的隔离级别部分再展开。这里你只需要有结论:并发是问题制造者,事务隔离是防御墙,锁是墙上开的口子,它允许一部分并发进入,同时挡住会冲突的部分。
1.3 原子性、隔离性的落地靠日志和锁
这一节把逻辑再往下推一层。很多人以为ACID是一套魔法,其实它完全是工程设计的产物。原子性靠undo log,它记录更新前的旧值,一旦事务失败就把数据恢复到原来状态。持久性靠redo log配合doublewrite、fsync策略,保证数据库崩溃后已经提交的事务不丢。隔离性才轮到锁出场:当一个事务正在修改某行,它需要在该行记录上加一把写锁,阻止其他事务同时修改或未提交期间读到不可靠的状态。
事务还有另一条重要规则叫两阶段锁协议(2PL,Two-Phase Locking),意思是事务里的加锁和解锁要分成两个阶段。实际表现是,InnoDB会把事务执行过程中拿到的锁一直持有到事务提交或回滚,而不是用完一行就立刻释放。否则就会出现在一个事务里,自己修改了某条数据还没提交,另一个事务就把它改了,破坏整个事务的隔离语境。这个设计对排查死锁很重要,因为锁持有时间越长,等待链越容易出现环。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 锁的颗粒度与模式:先读懂一把数据库锁
2.1 共享锁与排他锁:最基础的读写互斥
从锁的兼容性来看,几乎所有数据库都实现了两种基本模式:共享锁(Shared Lock,简称S锁)和排他锁(Exclusive Lock,简称X锁)。名字直白,就是“大家一起读”和“只准我一个人动”。S锁和S锁之间兼容,因为两个事务同时读同一行不会破坏数据。S锁和X锁不兼容,因为一个在读、一个在写,读到的内容可能处于中间状态。X锁和X锁更不兼容,同一时间只能有一个事务写某一行。
MySQL里平时执行UPDATE、DELETE、INSERT,都会对命中行加排他锁。SELECT默认不加锁,因为它依赖MVCC做快照读。如果你希望一条SELECT语句把数据“看得死死的”,可以手动加锁:SELECT ... FOR UPDATE 是加排他锁,SELECT ... LOCK IN SHARE MODE 是加共享锁。稍后会说FOR UPDATE在业务里到底该怎么用。
2.2 锁的粒度:表锁、页锁、行锁
锁可以加在表上,也可以加在行上。表锁好理解,一张表只能有一个写锁或能共享读锁,开销小、冲突大,适合MyISAM那种不支持行锁的情况。行锁是InnoDB的看家本领,只锁住命中的索引记录,并发度最高,代价是管理锁结构的内存和CPU开销更大。页锁介于两者之间,比如SQL Server里就有这类实现。
行锁里还要留意意向锁这个概念。InnoDB在给某一行加锁前,会先在表级别加一个意向锁,它自己不阻止任何行锁,存在的意义是让“有人在某些行上加了锁”这件事在表级别可查。不然的话,一个事务想给整张表加锁,就得扫每一行去确认有没有行锁冲突,效率太低。有了意向锁,直接看表级标志就能判断。这条原理在面试里经常被问到“为什么InnoDB即使有很多行锁,加表锁还是很快”,答案就在意向锁。
再往下细分,InnoDB的行锁其实有三种形态:Record Lock记录锁、Gap Lock间隙锁、Next-Key Lock临键锁。记录锁锁的是索引上的一条具体记录。间隙锁锁的是索引记录之间的区间,比如在id=5和id=10之间没有任何记录的情况下,一个事务要把一个范围锁住,就会对这段间隙加锁,防止其他事务在中间插入新行。临键锁等于记录锁加间隙锁的合体,锁住“某条记录以及它前面的间隙”,这也是RR级别下InnoDB用来防幻读的主要武器。
2.3 悲观锁、乐观锁、自旋锁:从思路到使用场景
“悲观锁”和“乐观锁”是两种完全不同风格的并发控制思想。悲观锁认为冲突一定会发生,所以在操作数据前先加锁,让其他事务等着。数据库里最典型的就是SELECT ... FOR UPDATE。适合并发冲突频繁、写多读少的场景。乐观锁认为冲突不常见,先放行操作,提交的时候再校验一下有没有人改过。实现方式可以是版本号:UPDATE t SET balance = 100, version = version + 1 WHERE id = 1 AND version = 2,如果影响行数为0,说明版本被人改了,业务层决定重试还是提示失败。适合读多写少、冲突概率低的场景。
自旋锁则更多出现在编程语言和操作系统层的并发工具中,Java里比如AtomicInteger底层就用到了CAS自旋。它的特点是拿不到锁不主动让出CPU,而是循环检查,适合锁持有时间非常短的场景。不是所有场景都合适,如果临界区代码比较复杂还去自旋,那就是白白烧CPU。放到数据库事务的语镜里,我们更多关注悲观锁和乐观锁的选择,因为事务里锁持有时间是毫秒级甚至更长,不是自旋适合发挥的领域。
下面有一个来自个人实践的选择建议:如果行数据还会被其他服务、其他接口共同操作,而且你无法保证大家都是走乐观重试逻辑的话,建议直接上悲观锁,把并发控制交给数据库;如果你能确认冲突率很低,比如几万次请求只有几次撞车,用乐观锁加少量重试更合算。不要盲目迷信乐观锁“不加锁所以快”,积累大量重试就算循环去查一轮数据库,成本可能比行锁还高。
3. 隔离级别与MVCC:RR级别到底干了多少事
3.1 脏读、不可重复读、幻读的一行版记忆
前面说定义,这里我想给一个更可靠的一行版记忆方式,面试和排查都用得上。
- 脏读本质上发生在“另一个事务还没提交”的时间窗口里,读到了可能回滚的临时数据。
- 不可重复读发生在“另一个事务已提交”之后,同一事务里同一行内容被更新了。
- 幻读同样发生在“另一个事务已提交”之后,区别是这次的记录数变了,不是某行内容变了。
你注意观察两个规律:脏读是最危险的,因为它可能读到最终不存在的数据;不可重复读和幻读都是在“别人提交之后”暴露的问题,一个是行内容更新,一个是集合增删。不同隔离级别其实就是通过不同手段决定:你需不需要等别人提交、需不需要防止别人在你执行期间提交新行。
3.2 四种隔离级别实战对照
SQL标准定义了四档隔离级别,严格程度从低到高分别是:读未提交(READ UNCOMMITTED)、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)、串行化(SERIALIZABLE)。下面这个表格是每次提到隔离级别都可以直接拿来用的对照。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 典型实现 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 读不加锁,写才加锁 |
| READ COMMITTED | 不会 | 可能 | 可能 | 语句级快照/读提交 |
| REPEATABLE READ | 不会 | 不会 | 可能(InnoDB在多数场景下规避) | 事务级快照+间隙锁 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 所有读都加锁,近似串行 |
MySQL默认隔离级别是REPEATABLE READ,Oracle和PostgreSQL默认是READ COMMITTED。注意看表格里我写了“可能(InnoDB在多数场景下规避)”,这是因为MySQL借助MVCC和Next-Key Lock在大部分情况下把幻读也挡掉了,但要严谨地说,如果业务里混用当前读和快照读,边界情况仍然会产生类似幻读的表现,所以不能简单断言RR级别彻底消灭幻读。原理我在3.3继续展开。
3.3 MVCC快照读与当前读的边界
MVCC的完整名字是多版本并发控制。它是InnoDB实现高并发读的关键设计。简单讲,每行记录在更新时不会原地覆盖旧值,而是通过undo log串出一条版本链,新数据写在当前版本里,旧版本保留在链上。每个事务在做普通SELECT时,会基于自己的事务ID生成一个ReadView,这个ReadView基本决定它能看见版本链上哪些数据。
READ COMMITTED级别下,每个普通SELECT都重新生成新的ReadView,所以它能看见之前其他已提交事务做的修改,这就会在一个事务内部造成两次读到不同值的现象。REPEATABLE READ级别下,只有事务第一次SELECT时生成ReadView,之后整个事务都复用这个视图,所以其他事务后来提交的数据对当前快照不可见,实现了可重复读。这种不加锁的读被叫做快照读。
和快照读相对的是当前读。执行UPDATE、DELETE、INSERT,或者SELECT ... FOR UPDATE这种方式,都必须读最新版本并加锁,才能保证修改操作基于实时数据。这里也是很多人踩坑的重灾区:一个事务里先做快照读拿到某行余额100,另一个事务并发改成50并提交,如果第一个事务又用SELECT ... FOR UPDATE去读,它会发现还是最新值50;但如果它全程都用普通SELECT,它会一直看到100。了解这两类读的差异后,你才能理解为什么有些业务必须用FOR UPDATE当前读来锁住“先查再改”的窗口期。
3.4 Next-Key Lock:为什么说RR下也有缝隙
InnoDB在REPEATABLE READ级别下,默认用临键锁来锁当前读扫描过的范围。假设用户表里有id为1、5、10三条记录,一个事务执行SELECT * FROM user WHERE id > 3 FOR UPDATE,它不光会锁住5和10这两条已存在的记录,还会对(3,5)、(5,10)、(10,正无穷)这些间隙加间隙锁,防止其他事务插入id=4、7之类的记录。这就是RR下防幻读的手段:不是不让别人更新,而是不让别人往锁定区间插入新行。唯一键或主键的等值查询如果命中了记录,间隙锁通常会退化成记录锁,因为不可能再插入相同主键的记录,没必要锁间隙。
这会带来一个常见体验:在RR级别去锁一个不存在但范围比较大的条件,很可能把一大段范围锁死,导致其他业务插入数据被阻塞。这时候不需要上纲上线说什么级别高好还是低好,按场景选:报表类分析和多数OLTP读多写少服务,用READ COMMITTED往往已经够;要严格保证同事务内查询结果一致并且防幻读,再用RR。很多团队从RR切到RC改善并发,就是减少间隙锁带来的无谓冲突。
4. 以MySQL为例:看现场、抓死锁、搞并发
4.1 检查当前事务和锁:三条SQL看懂现场
排查线上问题时,最重要的事情是先看清数据库现在有哪些正在执行的事务、它们持有哪些锁、卡在等待谁的锁上。MySQL给出的排查入口不是只有SHOW PROCESSLIST,更准确的是下面这几个。
sql复制-- 查看当前未提交的事务、会话ID、执行时间和执行中的SQL
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx\G
-- 查看锁等待关系(8.0版本可查 performance_schema)
SELECT * FROM performance_schema.data_lock_waits\G
-- 查看当前锁结构,包括锁的类型、锁模式和锁住的索引记录
SELECT * FROM performance_schema.data_locks\G
-- 死锁发生后,MySQL会记录最近一次死锁的详情
SHOW ENGINE INNODB STATUS\G
我个人的排查动作是一套组合拳:先看innodb_trx找出运行时间特别长的老事务,然后看data_locks和data_lock_waits里有没有锁等待链,最后如果不是死锁只是锁等待超时,就找到持有锁的源头事务,判断它能否被KILL。注意KILL前最好先确认事务对应的业务是不是在跑什么批量任务,别一刀切掉正在写关键数据的操作。这条线最重要的一点是:多数的锁等待不是锁本身错了,是有事务长时间不提交。
4.2 一个真实死锁的复盘
死锁和锁等待的差别在于:锁等待是一条队列,前面的锁释放后后面还能继续;死锁是A等B、B等A,谁也不让谁,数据库检测到环后会让其中代价较小的事务回滚,另一个事务才能继续。
我之前在一个订单业务里遇到过很典型的死锁。两个事务都在改同一批订单,但加锁顺序不一致。事务1先更新订单A再更新订单B,事务2先更新订单B再更新订单A。正常情况下两个事务完全错开就没有交集,可一旦时间窗重叠,事务1持有了A的锁去等B,事务2持有了B的锁去等A,环就形成了。InnoDB一般会在第一时间检测到并回滚其中一个,业务日志里会看到“Deadlock found when trying to get lock; try restarting transaction”。
那次复盘之后我们定了一条规矩:凡是批量更新多个订单,必须在代码里先按订单号排序,保证所有事务以相同顺序加锁。这不能完全消灭死锁,但能把最常见的交叉锁问题压到极低。另一个实际体验是,死锁发生后立刻重试的效果通常很好,因为事务回滚后相当于重新排队执行。大多数ORM或者Spring的异常体系里都能捕获到类似DeadlockLoserDataAccessException,在这里加一层业务重试完全可行。
4.3 并发数、行锁粒度与压测的思路
很多人问“我这套系统到底多少并发算高”?这里有个工程化的估算公式:并发数约等于QPS乘平均响应时间。如果目标是每秒处理5000个请求,单次请求平均耗时100毫秒,那肚子里同时存活的请求大约就是5000乘0.1,也就是500。这和数据库上的活跃事务数有一定对应关系,因为每个请求通常会开启一个数据库事务。压测的时候照着这个数去控制并发线程数,往往比盲目调大线程池更有意义。
并发上来后,另一个重点是尽量减少事务锁覆盖的范围。有的开发写代码喜欢在一个事务里循环几十次逐条UPDATE,这会让每行锁的粒度小但锁的累计时间很长;有的人会为了省事把一批数据用一个大SQL更新,锁的范围一下扩大到整批记录之外的间隙。更平衡的做法是把事务控制在最小必要范围:不在事务里做远程HTTP调用、不做耗时计算、不查无关大表,这些都应该挪到事务开始之前。你在事务里待的时间越短,锁被别人等的窗口越小。
另外,行锁虽然粒度细,但也不是没有代价。每把行锁在内存里都要维护锁结构,同时持有大量行锁的事务在提交时也可能出现释放压力。我们线上一些批量任务会人为分批,比如每批事务只处理1000条,这样做的好处是单事务锁数量可控,失败回滚的范围也可控。
5. 代码层的事务陷阱:注解、传播行为与失效场景
5.1 @Transactional用起来很简单,坑在哪
Java后端几乎天天和Spring的@Transactional打交道,但这个注解带来的坑比大多数框架功能都多。先说最基本的一个:Spring默认捕获到RuntimeException或Error才回滚,遇到受检异常(checked exception)不会自动回滚。如果一个Service方法抛出IOException但没有在rollbackFor里指定,事务会照常提交,数据看起来就是半成品。所以规范是显式写:
java复制@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) {
// 业务代码
}
第二个高频坑是自调用。同一个类里,一个方法直接调另一个带@Transactional的方法,事务会失效。原因是Spring的声明式事务基于AOP代理实现,代理对象被注入到外部调用者手中,而类内部方法调用走的是this,不会经过代理,事务自然起不来。解决方式是把需要事务的方法放到另一个Bean里注入进来调用,或者自己注入代理。
第三个坑是事务只在当前线程内生效。如果你在事务方法里提交给线程池异步执行一段数据库操作,子线程开的连接不在父事务上下文里,它失败了不会连累父事务回滚,成功也不会和父事务一起提交。多线程并发处理经常因为这种错觉出现半个事务的问题。
5.2 传播行为的七种姿势
Spring事务的传播行为可以理解为一个事务方法嵌套调用另一个事务方法时,新方法如何处理事务。最常用的是REQUIRED和REQUIRES_NEW,后者的意思是把当前事务挂起,新开一个独立事务;如果内部事务回滚,外部事务还能继续提交,主要用于那种“即使主流程失败,也要把关键日志写进去”的场景。NESTED则基于保存点实现:内部失败只回滚到保存点,外部可以决定继续或全部回滚。后面这几个虽然用得少,但面试官特别爱问,它们的字面意思和实际行为一定要分清,我简单做成下表。
| 传播行为 | 行为描述 | 典型场景 |
|---|---|---|
| REQUIRED | 有事务就加入,没有就新建 | 最常见,默认值 |
| REQUIRES_NEW | 挂起当前事务,新开一个事务 | 独立提交的审计日志 |
| SUPPORTS | 有事务就用事务,没有也无所谓 | 非核心查询 |
| NOT_SUPPORTED | 以非事务方式执行 | 事务内不想占用连接做耗时查询 |
| MANDATORY | 必须已有事务,否则抛异常 | 强制要求调用方开启事务 |
| NEVER | 当前不能有事务,否则抛异常 | 测试或明确禁止事务 |
| NESTED | 基于保存点的嵌套事务 | 分批回滚的复杂业务 |
5.3 和并发锁配套的编码纪律
事务注解之外,编码上和并发锁配合还有几条值得写进团队规范的经验。第一,在事务方法里调用SELECT ... FOR UPDATE要锁定明确的索引条件。如果where条件没走索引,行锁会退化成表锁,看似只锁一行实际把整张表堵了。第二,FOR UPDATE锁的是当前事务直到提交,所以拿到锁后不要在事务里做休眠、等待RPC这类操作,否则别人全等着你。第三,任何“先查后更新”的逻辑如果不能容忍期间被修改,就用当前读或显式锁把间隙堵上;如果只是展示页面跳变一下没关系,用快照读就足够。
还有一条经验容易被忽略:连接池大小和数据库活跃连接数是强关联的。事务持有数据库连接的时间长,连接池即使在代码里配了200,数据库能同时服务的也只有少量活跃事务,多出来的线程都阻塞在等连接上。排查时如果发现“应用没报错、接口变慢、数据库CPU不高”,顺手看一眼连接池活跃数和活跃事务数,往往能定位到是某个慢事务把连接池占满了。
6. 从单库到微服务:分布式锁和分布式事务的取舍
6.1 分布式锁的基本盘:Redis SET NX EX
单库时代,数据库自身的事务和锁就够用了。到了微服务架构,多个服务、多份数据源之间要协调只有一个共享资源,传统的行锁就管不到了。这时候需要一把能在多台机器之间生效的锁,也就是分布式锁。最常见的底子是Redis,理由很简单:Redis单线程处理命令、原子性好、性能高,还能通过过期时间自动兜底。
用Redis实现分布式锁的起步是下面这条命令:
bash复制SET goods:1:lock 7f3a2c9e-4f5b-4c1d-9f36-2a6c9f8a1b2e NX EX 30
其中NX表示只有key不存在时才设置成功,EX 30表示30秒后自动过期。业务在真正更新前先尝试SET,拿到锁就执行操作,操作完删除key释放锁。删除时不能简单DEL,必须判断value是不是自己的标识,防止因为业务执行时间超过过期时间导致锁被自动释放、别人拿到锁后,你回头把别人的锁删了。比较可靠的释放脚本是Lua:
lua复制if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
这段脚本保证“检查和删除”是原子操作,是释放分布式锁的标配。应用侧还需要处理一个矛盾:锁过期时间设短了,业务没跑完锁就没了;设长了,万一执行到一半进程崩溃,其他线程要白白等很久。针对这个问题,Redisson里默认有看门狗机制,会起一个后台任务在锁快过期时自动续期,直到业务执行完或进程退出。用它而不是自己造轮子,能少踩很多时间边界的坑。
6.2 Zookeeper与数据库表锁:分布式锁的另外两条路
Redis之外,分布式锁也有基于Zookeeper和基于数据库的实现。ZooKeeper的核心是临时顺序节点:每个客户端到指定目录下创建一个临时顺序节点,编号最小者获得锁;其他客户端监听自己前一个节点的删除事件,当前一个节点消失时再去尝试获取。如果客户端会话断开,临时节点自动消失,锁自动释放,不需要像Redis那样纠结过期时间。这个机制的缺点是每次抢锁都涉及ZooKeeper的节点操作,性能不如Redis,且异常网络分区下同样存在脑裂风险。
基于数据库做分布式锁的“最朴素”玩法是在一张专门表里插入一个唯一键作为锁记录,谁插入成功谁持有锁,释放时删除记录。这种方案依赖数据库的唯一约束,代码简单、可靠,但性能很低,而且要自己处理过期释放,不然持有锁的客户端宕机后锁就变成了死锁。一般只在低并发、对一致性和审计要求高的内部系统里用,线上高并发场景不建议把它当主力方案。
说白了,分布式锁不是一个有标准答案的技术决策,它是根据并发量、可用时间、一致性强度的权衡。Redis方案引入运维组件但性能高;ZooKeeper方案模型安全但延迟高;数据库方案零成本但吞吐有限。面试里问到这题时,建议先说出“我会根据场景选”这句,再去解释为什么。
6.3 分布式事务的五套方案选型
如果说分布式锁解决的是“多个服务抢同一个资源”,那分布式事务解决的则是“多个数据源之间保持一致性”。这个问题的复杂度比单库事务高一个量级,因为不存在一个全局的undo log或者全局锁来协调所有服务。
第一类是两阶段提交(2PC),通常通过XA协议落地。它有一个协调者,第一阶段叫Prepare,让所有参与者把事务准备好并锁住资源,第二阶段根据准备结果决定Commit或Rollback。优点是强一致,缺点是准备阶段要占用资源锁,任何参与者出问题都可能让整个事务长时间挂起,性能比较差,适合对一致性要求极高、事务范围小、跨库数量有限的场景。
第二类是TCC,把每个操作拆成Try、Confirm、Cancel三步。Try阶段先预留资源,比如冻结库存、锁定金额;Confirm阶段真正提交;Cancel阶段做补偿。这个方案的优点是业务控制力强,能实现比较灵活的分布式一致性;缺点是每个参与者都得实现三段逻辑,开发量很大,如果补偿逻辑没写好,最终一致性也谈不上。适合订单、支付这类对业务动作边界比较清楚的核心链路。
第三类是本地消息表方案。业务在自己的本地事务里同时写业务表和消息表,写完一起提交;由单独的消息发送任务定时扫描消息表发送到MQ;下游消费成功后回调删除消息。方案可靠、实现也简单,但消息有延迟、每个服务都要额外建表。
第四类是基于可靠消息的最终一致性,比如RocketMQ的事务消息。发送方先把半消息发送到MQ,然后执行本地事务;本地事务通过提交或回滚来告诉Broker这条消息能不能被消费;如果发送方崩溃,MQ通过回查机制找发送方确认状态。这套方案把事务边界的复杂度收窄到了消息层,是当前比较推荐的分布式事务基调。
第五类是Saga长事务,把一个分布式事务拆成一串本地事务,每个本地事务配有补偿动作。比如A失败后执行B的补偿、A的补偿等依次回滚。Saga的灵活性最好,适合流程长、可容忍短暂不一致的旅游预订、订单状态机等场景,但补偿编排本身容易搞得很复杂。
这些方案没有谁是银弹。我自己的选择思路是:能靠业务重试解决的就别上分布式事务;基础数据必须强一致、跨节点又不多的优先2PC;业务动作可以预留资源的选择TCC;事件驱动和异步链路优先用事务消息;长流程订单一类场景考虑Saga。如果你只是想知道“现在业界用什么”,答案会是“大多数业务用事务消息和本地消息表解决最终一致性,极少数用TCC,2PC只会在特殊基础服务里出现”。
6.4 订单与库存分布式事务:一个落地推演
拿热词里“订单与库存分布式事务”来说,这是典型场景。用户下单要扣库存和生成订单,理想状况是两个操作要么都成功要么都失败。如果订单服务和库存服务分别有自己的数据库,直接用数据库事务是不可能的。
实际工程里推荐的路数要分流量看。低并发、内部系统可以直接用TCC:订单服务Try阶段创建待支付订单并把金额预冻结,库存服务Try阶段预扣库存,Confirm阶段把状态改成正式,Cancel阶段恢复冻结库存。高并发电商场景则更常见的是把库存管理放在Redis里做预扣:下单请求先执行DECR stock,返回负数则说明超卖直接拒绝,成功后异步发送消息,由库存服务消费后真正扣减数据库库存。数据库里的最终扣减可以用“条件更新”兜底,例如UPDATE inventory SET stock = stock - 1 WHERE goods_id = ? AND stock > 0来防止并发把库存减成负数。
这个推演想说明的是,分布式事务的落地离不开业务取舍。追求实时一致性时,让整个链路串行加锁虽然可靠但吞吐很低;改造成最终一致后,用户体验和吞吐都提升了,代价是引入对账和人工补偿机制。一个成熟的团队通常会有专门的定时任务或甚至一个简单的“对账中心”,周期性扫描订单、库存、支付流水三张表做数据比对,发现不一致再触发修复流程。这里要说一句:这才是真正的系统设计,不是选个分布式事务方案就万事大吉。
7. 事务与锁的高频问答和避坑速查
7.1 高频面试题与判断依据
这些年我自己面试别人也被人面试,事务并发锁这一块的问题翻来覆去就是那些。这里整理几个我觉得真正有区分度的问题,而不是简单“什么脏读”。
“MySQL为什么选RR作为默认隔离级别?”这个问题在面试里很容易被当成考概念,实际上它也有历史因素:MySQL的Binlog在statement格式下只记录主库执行的SQL,如果事务在防止幻读上不够严格,主从复制的SQL回放顺序变化可能导致从库数据不一致。后来虽然有了row格式Binlog,默认级别依然保持RR。顺带可以通过Binlog格式与隔离级别的关系讲出深度。
“可重复读能不能完全避免幻读?”前面其实已经给了答案。快照读下,RR通过复用ReadView避免了幻读;当前读场景,InnoDB用Next-Key Lock阻止间隙插入。真正容易出问题的应用代码是把快照读和当前读混用。能把边界讲清楚的开发者,面试基本就过关了。
“分布式锁用了Redis,为什么不能说保证100%正确?”常见的原因是Redis主从切换时锁信息可能丢失,或者持有锁的客户端发生长时间GC暂停导致锁过期,另一个客户端又拿到锁,两个客户端同时认为自己有锁。要规避这类问题,讨论Redlock只能做到提升概率,无法绝对解决。一般我会建议按业务容忍度去分析,如果数据错的后果很严重,就不要只用Redis锁扛全部一致性责任。
7.2 我踩过的坑和一些可复用的经验
坑一,把死锁只当成数据库要解决的问题,不认为业务层需要处理。线上死锁回滚后,如果代码没有捕获相关异常,用户点击一次支付就可能落得订单没生成、库存没扣、前端却以为成功的状态。后来我们在所有写操作入口统一做了短暂重试,死锁造成的业务失败率肉眼可见降低。
坑二,在事务里调用远程服务。数据库连接被事务占住,远程服务又反过来查询同一库的同一行数据,很容易形成跨进程的循环等待。有一次线上突然出现大量行锁等待,排查下来就是A服务事务中调B服务,B服务又去更新A服务正在锁的那张表。后来规范里明确写了禁止在事务中间发RPC或HTTP调用。
坑三,事务方法内用try...catch把异常吞掉。@Transactional默认只有异常向外抛出才会被代理感知到,一旦catch住,事务判断“一切正常”就提交了。这个坑隐蔽且高频,我建议出现回滚不符合预期时先检查是不是异常被吞了。
还有一个经验跟版本升级有关。数据库版本从5.7升到8.0后,锁监控视图从information_schema迁到了performance_schema,名字也改了。以前我习惯查INNODB_LOCKS和INNODB_LOCK_WAITS,8.0里要用data_locks和data_lock_waits。这提醒我,任何排障技能都要跟着版本持续迭代,别拿着老SQL硬套。
7.3 锁的范围是越小越好吗
最后分享一个有点反直觉的经验,关于锁的范围。很多人把“锁住的行越少越好”当原则,但真实业务里不是所有场景都适合用行锁。比如库存扣减,如果库存字段在商品主记录上,每秒几千个下单请求同时更新同一行,行锁会让这一行变成明显热点,请求大量排队。一些高并发系统会把热点行的扣减拆到多个子库存桶,或者用Redis先做一层预扣,把数据库的行锁压力降下来。这就说明,锁粒度只是手段,系统的最终目标是在一致性和吞吐之间找到平衡点。
在我个人维护过的系统里,最稳定有效的做法其实很简单:理解每个事务到底加了哪些锁、能持有多久,然后把事务边界收得尽量短,让锁住的行尽量精确。做到这一步,死锁和锁等待基本不会成为常态问题。剩下的,就是靠日常监控、死锁日志、慢查询日志来动态调整了。
