做后端这些年,我见过太多线上事故,真查起来十有八九都和“事务”有关。比如对不上账、库存多扣少扣、并发订单产生脏数据,根因往往就是事务边界划错了,或者隔离级别选得不对。今天这篇把“事务操作”这件事从头到尾捋一遍,从ACID四个基本特性,到实际写代码时的那些坑,再到我平时排查问题用的方法,一次性讲透。内容适合写业务代码的开发者,特别是刚接触数据库事务、搞不清隔离级别和传播行为怎么选的朋友。
1. 事务到底是什么:先从转账场景说起
1.1 一个经典案例:转账为什么会“丢钱”
先想象一个最简单的转账场景。账户A里有1000元,账户B里有1000元,现在A要给B转100元。正常情况下,操作完成后A应该是900元,B应该是1100元。
这个操作在数据库里至少要拆成两条SQL,一条是UPDATE account SET balance = balance - 100 WHERE id = A,另一条是UPDATE account SET balance = balance + 100 WHERE id = B。如果这条扣款语句先执行成功,入账语句执行失败,A的余额变成了900,B的余额还是1000,那这100元就凭空消失了。
更麻烦的是,这两条SQL之间如果插入了一条查询,你去查A的余额,可能正在执行到一半,数据的中间状态就被别人看到了。
这类问题归根结底是一件事:多个数据库操作被拆散执行,彼此没有关联,任何一个步骤失败都会留下脏数据。事务要解决的,就是这个。
1.2 事务的定义与核心价值
事务,简单说就是把一组数据库操作捆绑成一个工作单元,这个单元里的操作要么全部成功,要么全部回滚,不允许出现“做了一半”的状态。
数据库层面的实现逻辑大致是这样的:事务开始后,所有操作都先记录日志,操作过程中如果某一步出错,数据库根据日志把已执行的操作全部撤销,回到事务开始之前的状态;如果所有操作都成功,才把结果最终落盘。这个机制保证了数据从一个一致的起点,走到另一个一致的状态点,中间不会出现错乱。
可以用一个生活例子来理解:你在外卖平台点餐,先下单、再付款、然后商家接单。如果付款失败,整单取消,而不是“下单成功但没付款,商家也出餐了”。下单、付款、接单这三个环节被绑定成一个整体事务,任何一个环节失败,前面已经完成的步骤都要撤销。
1.3 哪些业务场景必须考虑事务
不是所有代码都需要开事务。单条UPDATE或INSERT本身就有隐式事务,执行成功自动提交,执行失败自动回滚,不需要额外处理。真正需要显式事务的,是多个写操作必须作为一个整体提交的场景。
最常见的几类:
- 订单创建:创建订单主表、扣减库存、记录订单快照、生成支付流水,这几个动作必须一起成功或一起失败,否则可能出现“订单生成了但库存没扣”的情况。
- 账户体系:转账、充值、提现、积分变动,任何涉及金额加减的操作都必须放在事务里。
- 状态流转:审批通过后更新审批单状态、写入操作日志、触发后续流程,状态更新和日志写入不能拆开。
- 批量导入:导入一批数据时,如果中途某条数据格式错误,通常要求整批回滚,避免出现一半导入成功一半失败。
我个人的判断标准很简单:有没有两条以上的写操作?它们之间有没有业务上的依赖关系?如果有,就开事务。如果一个接口里只有一次写操作,那就不需要画蛇添足。
2. ACID特性逐个拆解
2.1 原子性:全做或全不做的底层逻辑
原子性追求的是“执行过程不可分割”。事务中的操作要么全部执行,要么全部不执行,不存在只做了一部分的中间状态。
数据库在实现原子性时,核心依赖的是回滚日志。它在事务执行前,把要修改数据的原始值记录到日志中;事务执行过程中,如果某一步出错,数据库就会根据日志里的原始值,把已经修改的数据恢复原样。
这个机制在真实场景里有一个很重要的启示:不要在事务代码里随意捕获异常并吞掉。我曾经排查过一个案例,业务代码里写了try-catch,ClassCastException和业务异常都被catch住,然后继续往下走,事务管理器等不到异常信号,不知道需要回滚,结果部分数据就真的提交了。这种情况看起来像“原子性失效”,其实是代码没把失败信号传递给事务机制。
原子性的前提是操作失败必须能被识别,并且必须向上传播。如果你在事务里写了catch后不重新抛出,等于亲手把数据库的回滚机会掐断了。
2.2 一致性:业务规则守护者
一致性经常被误解。它不是说数据库自己知道你的业务规则,而是说事务执行前后,数据必须满足所有预设的约束条件。
举个例子,转账场景里“A和B的余额总和必须保持2000不变”这就是业务约束。数据库没办法自动判断“余额总和该不该变”,它只能通过一些硬约束来帮助维护一致性,比如字段非空、唯一索引、外键、CHECK约束。
但业务上的一致性,大部分要靠代码自己保证。这要求开发者在设计事务时想清楚:哪些数据关系是不变量?哪些状态跳转是合法的?比如库存不能为负、订单状态只能从“待支付”变成“已支付”而不能跳成“已完成”、一条订单流水只能对应一次扣款。
这里有个很容易忽略的点:原子性、隔离性其实都是为一致性服务的。数据库提供了原子性和隔离性这两个工具,至于数据最终是否符合业务规则,还得看事务里面写的业务逻辑对不对。如果事务里写了一个错误算法,把100元扣成200元,数据库照样会把这个错误的结果提交。所以谈到ACID,一致性是最需要开发者自己把关的一环。
2.3 隔离性:并发事务如何互不干扰
当多个事务同时操作同一批数据时,隔离性负责控制它们之间的可见性和相互影响。没有隔离的话,事务A还没提交的数据,事务B就能读到,然后基于这个脏数据继续操作,后果不堪设想。
数据库实现隔离性的手段主要有两类:锁和版本链机制。锁是物理隔离,一个事务锁住了某行数据,其他事务要修改就得排队等待;版本链机制更像一种快照方案,每个事务看到的是数据在某个时间点的快照,互相之间不直接阻塞。
隔离性还引出一个经典权衡:隔离得越彻底,并发能力越低;隔离得越宽松,性能越好但要处理各种异常问题。这就涉及隔离级别怎么选了,后面第3节细说。这里先记住一个结论:隔离性不是越强越好,而是要看你的业务到底能不能容忍某些并发读问题。
2.4 持久性:数据落盘的最后一道防线
持久性说的是,一个事务一旦提交,哪怕系统下一秒宕机重启,这次修改也必须永久保留下来。
数据库实现持久性靠的是重做日志。事务提交时,先把本次修改写入重做日志并刷到磁盘,然后才把数据页真正写盘。这样即使数据页还没来得及刷到磁盘就发生了断电,数据库重启后也能根据重做日志把修改重新应用一遍。
这里要注意一个细节:很多新手以为“执行了COMMIT数据就马上写到磁盘了”,其实大多数数据库的默认策略是定期批量刷盘,并不会每条提交都立刻同步数据文件到磁盘。保证持久性的是重做日志,不是数据文件本身。所以不要贸然去关闭日志刷盘策略来提升性能,一旦宕机,很可能出现已提交事务丢失数据的情况,这在金融和订单场景里是不可接受的。
3. 隔离级别选择与配置
3.1 四种隔离级别怎么选
SQL标准定义了四种隔离级别,从低到高分别是读未提交、读已提交、可重复读、串行化。
| 隔离级别 | 是否可能脏读 | 是否可能不可重复读 | 是否可能幻读 | 并发能力 |
|---|---|---|---|---|
| 读未提交 | 会 | 会 | 会 | 最高 |
| 读已提交 | 不会 | 会 | 会 | 较高 |
| 可重复读 | 不会 | 不会 | 可能(不同数据库处理不同) | 中等 |
| 串行化 | 不会 | 不会 | 不会 | 最低 |
读未提交基本没人会用,它还允许读取另一事务未提交的修改,容易读到回滚前的脏数据。读已提交保证只能读到已提交的数据,解决了脏读,但同一事务内两次读取同一行,结果可能不同,因为两次读取之间其他事务可能提交了修改。可重复读解决了这个问题,在一个事务开始后多次读取同一行数据结果稳定。串行化则是强制事务挨个执行,彻底避免所有并发问题,代价是性能大幅下降。
实际项目里最常见的两个选择是读已提交和可重复读。读已提交适合对一致性要求不是极端苛刻、但并发量很大的系统,可重复读则更适合需要在一个事务里多次查询同一批数据保持结果一致的场景。
3.2 并发异常问题:脏读、不可重复读、幻读
这三个问题我用自己的话说一遍,比教科书定义好记。
脏读:事务A修改了一行数据但还没提交,事务B读取到了这个未提交的修改。如果事务A最终回滚,事务B就等于读了一条根本不存在的记录。
不可重复读:事务A读取一行数据后,事务B提交了对这行数据的修改,事务A再次读取时发现同一行的值变了。问题不在于数据错,而在于同一事务内前后读到的不一致。
幻读:事务A按条件查询一批数据,查到5条;事务B插入了一条满足条件的新记录并提交;事务A再次执行同一个查询,发现变成6条。多出来的这条就像“幻觉”一样出现。
这三种问题对应不同的隔离级别解决方案,实践中不要想着一口气解决全部问题。很多业务场景里,幻读的影响远没有不可重复读大,为了防住幻读把隔离级别提到串行化,反而可能把一个正常接口拖到超时。
3.3 隔离级别的配置方式
配置隔离级别的SQL标准写法是这样的:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; -- 业务操作 UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT;在框架层面的配置更简单,很多情况下直接用注解在方法上指定,比如@Transactional(isolation = Isolation.READ_COMMITTED)。这里要注意一个顺序问题:隔离级别必须在事务开始前设置才会对当前事务生效。如果是用框架声明式事务,框架会负责在开启事务前把隔离级别设置好,但如果你在事务方法内部手动更改隔离级别,通常不会生效,因为它已经晚了。
实操中我更推荐把隔离级别的决策放在全局配置或方法入口统一管理,散落在事务中间改级别,既不方便排查,也容易出线。默认隔离级别通常能满足大多数场景,只有当明确出现了不可重复读或幻读问题,才需要针对特定方法提升隔离级别。
4. 事务实战技巧:那些容易踩的坑
4.1 事务失效的几种经典场景
事务失效是实战中最坑的一类问题,代码看着没问题,事务就是没生效。
第一种是方法自调用。在同一个类中,一个方法调用另一个带事务注解的方法,直接透传调用并不会经过框架生成的代理对象,事务管理逻辑拿不到入口,注解形同虚设。解决方式是拆到不同类,或者自己注入代理对象再调用。
第二种是非public方法。很多框架的事务代理基于动态代理实现,private或package-private方法不会被代理拦截。把事务注解写在private方法上,基本等于没写。
第三种是catch吞掉了异常。事务方法里捕获异常后没有抛出,框架不知道执行失败,也就不会触发回滚。这不是框架的错,是异常处理策略出了问题。
第四种是抛出的是检查型异常,而框架默认只对运行时异常回滚。如果方法声明了检查型异常,比如Exception,事务默认不会回滚,除非在注解上显式指定回滚规则。
第五种是数据库表不支持事务。某些存储引擎本身不支持事务,事务注解写再多也没用,需要确认表使用的存储引擎支持事务。
排查这类问题时,我的习惯是先看代理有没有建立,再看异常是不是被吞掉,然后看异常类型是不是被回滚规则覆盖。按照这个顺序基本都能找到原因。
4.2 事务传播行为怎么理解
传播行为处理的是“多个事务方法嵌套调用时怎么办”的问题。最常见的有三种:
REQUIRED是默认传播行为,如果当前已经存在事务,就加入这个事务;如果当前没有事务,就新建一个。大多数业务方法适合用这个。
REQUIRES_NEW表示无论当前有没有事务,都新建一个独立事务。如果已经存在事务,当前事务会暂停挂起,等新事务提交后再继续。这个适合用在“操作日志记录”上,主事务回滚时日志不应该跟着回滚,日志要有自己的独立事务。
NESTED是嵌套事务,当前存在事务时开启一个保存点,内层事务回滚时可以只回滚到保存点,外层事务仍然可以继续。它和REQUIRES_NEW的区别在于,内层回滚不会导致外层事务整体回滚。
我举一个实际例子。一个订单接口里做了主业务,还调了一个发短信的方法。短信服务不稳定,如果短信发送写在主事务里,短信服务端超时会导致整个订单事务回滚,这是典型的远程调用放进事务里造成的灾难。把发短信的方法传播行为改成REQUIRES_NEW,或者干脆移到事务外执行,主业务就不会被短信服务拖垮。
传播行为不是越多越好,用多了事务边界会变得非常难排查。我见到的坏味道是那种层层嵌套的方法,每个方法都配了不同的传播行为,最终一个请求下来横跨多个数据库连接和多个事务,出了问题连日志都不好对齐。能用一种REQUIRED,就不要引入REQUIRES_NEW。
4.3 大事务拆分与性能优化
大事务是线上事故的重要根源之一,它的问题不在“事务”本身,而在“大”。
一个事务执行时间越长,锁持有的时间就越长,其他事务排队等待的时间也就越长;同时这个事务占用的数据库连接也一直没有释放,连接池可用连接数会被一点点耗尽;更重要的是,长时间不提交,回滚日志会不断膨胀,后续回滚的成本极高,还可能拖慢主从同步。
导致大事务的常见写法有三种:事务里循环几千次更新、事务里调用远程接口、事务里做大量查询计算。
大事务拆分的原则很简单:把不需要事务保护的操作挪出去。查数据和计算可以放到事务外面,事务里只保留最核心的几步写操作;需要批量处理时,可以分批提交,比如每500条提交一次;远程调用、发消息、发邮件这类操作一定要挪到事务提交后执行。
我自己处理过一个真实场景,一个导入功能把上万条数据放在一个事务里,跑一次要好几十秒,期间相关表的更新全部堵塞。拆分后每次处理500条提交一次,整个导入时间明显缩短,其他业务也不再被长时间阻塞。
事务代码要短平快,这个原则从我入行开始就一直强调。一个设计良好的事务方法,内部通常不超过十行核心写操作,绝不包含等待外部资源的逻辑。
4.4 事务与锁的配合
事务和锁是伴生关系。事务隔离性依赖锁,锁又带来了阻塞和死锁。理解两者的配合,才能在设计时少踩坑。
两个事务同时修改同一行数据时,后到的事务会进入锁等待状态,直到先到的事务提交或回滚释放锁。这个等待本身是正常的,但如果等待时间过长,就会变成锁超时,接口直接报错。
优化锁竞争有几个通用思路:一是让事务执行时间更短,尽早提交释放锁;二是多个事务并发访问多行数据时,固定访问顺序,比如都按主键从小到大更新,避免互相持有对方需要的锁而形成死锁;三是精确命中索引,让锁尽量锁在尽量少的行上,而不是锁住整张表。
关于死锁,数据库会主动检测并回滚其中一个事务来解除死锁。但这只是兜底机制,业务上还是应该通过设计避开。比如一个统计更新多个账户余额的服务,所有线程都按同一顺序更新账户,就可以从根上避免绝大部分死锁。
5. 常见问题与排查心得
5.1 事务没生效:先查代理和异常
线上发现事务没回滚,第一件事不是怀疑数据库,而是先看代码。
按这个顺序排查:第一步,确认事务注解是在public方法上。第二步,确认这个方法不是从同类内部调用。第三步,确认事务方法里的异常没有被catch吞掉。第四步,确认抛出的异常类型被回滚规则覆盖。第五步,确认数据库表存储引擎支持事务。
其中自调用问题最难一眼看出来,因为代码上看起来就是普通的方法调用。如果发现同一类里一个方法调用另一个带事务的方法,基本可以断定事务失效。拆出独立Service类再调用一次,问题立刻解决。
还有一个细节:事务方法上如果加了final修饰符,也可能影响事务代理的生成,导致注解不生效。退出设计时尽量保持方法可继承。
5.2 死锁排查实录
线上出现死锁异常时,数据库错误日志一般会有类似“Deadlock found”这样的关键词,同时会打印出两个事务各自持有的锁和等待的锁。
我处理过一个案例,两个服务同时在一个接口里更新订单表和账户表,但更新顺序相反,一个先更新订单再更新账户,另一个先更新账户再更新订单,两边互不相让,就形成了循环等待。日志里能明显看到两个事务各持有一把锁,同时在等对方释放。
定位方法分两步。第一步,确认死锁涉及哪几条SQL,通常在日志里能看到具体SQL文本。第二步,看这些SQL对应的索引,确认加锁范围是否合理。有些死锁是因为没走索引,导致行锁升级成间隙锁甚至表锁,加锁范围变大后冲突概率陡然上升。
最终的修复通常不是调整死锁参数,而是调整代码执行顺序或加锁范围。让所有线程都按同一个顺序访问资源,死锁基本就消失了。
5.3 数据库连接池被打满:留意长事务
连接池被打满是个比较常见的线上症状,表现是应用大量请求超时,新建连接失败。很多人第一反应是调大连接池,但实际上很多时候是长事务占着连接不放。
常见场景是事务里调用了第三方接口,第三方响应很慢,事务迟迟不提交,数据库连接一直被占用。等第三方恢复后,积压的请求全部涌进来,每个都新开事务,连接池瞬间被打满。
排查时先找出当前正在执行且持续时间很长的事务,看看它们执行的是什么SQL、由哪个会话发起、已经跑了多久。如果发现一个事务跑了十几分钟还没提交,那基本就是问题源头。
这类问题从根源上解决只有一条路:事务里禁止调用远程接口、禁止等待外部资源、禁止睡。事务方法的重要原则就是“只做最快的数据库操作”。
5.4 我常用的排查语句
排查时我习惯用几条固定的语句,整理出来供大家直接参考。
查看当前有哪些事务正在运行,可以用系统视图查询,比如通过信息架构表查看当前所有未提交的事务,关注事务开始时间和已经执行的时间。查看锁等待状态,可以用数据库自身的状态报告,关注InnoDB一节的锁等待信息。如果出现死锁,数据库的状态报告里也能看到最近一次死锁的详细日志,包括持有锁和请求锁的事务信息。
排查具体事务的耗时,在数据库的慢查询日志里找执行时间长的SQL,再结合代码定位是哪个方法发起的。定位到方法后,重点看这个方法里有没有循环、远程调用或者复杂查询。
这几条语句配合起来,基本能把长事务、锁等待、死锁三类问题定位到具体SQL和具体代码行。排查事务问题,最重要的不是会多少个高级命令,而是按照“连接被谁占着、锁被谁拿着、SQL在哪里”这个思路一步步缩小范围。
最后分享一个我自己的习惯。每次新建一个涉及多步写操作的接口时,我都会在文档里单独写一段“事务边界说明”,明确哪里开始事务、哪里提交、哪里回滚、哪些操作放在事务外。这个习惯在排查问题时帮了大忙,几次线上问题都是靠这个说明快速圈定了怀疑范围。写代码时顺手理一理事务边界,比出问题时熬夜查日志要划算得多。