1. 事务到底在解决什么问题
先说一个我当年线上真实踩过的坑。那是一个订单支付回调服务,逻辑很简单:用户确认支付后,先更新账户余额,再更新订单状态为已支付。当时代码没加事务,两条 update 各自执行,运气不好时恰好第一条执行完、第二条执行前服务重启了,结果用户余额已经扣了,订单却依然停在“待支付”。用户来投诉,客服去数据库一查,账平不了,还得多赔一堆优惠券才能安抚用户。
这类问题的根源,就是两个数据操作没有绑定成一个 transaction 整体。在 MySQL 这类关系型数据库里,事务能保证一批操作要么全部提交(commit),要么全部回滚(rollback),不会出现“改了一半”的状态。这个“全部成功才算成功,任何一个失败就整体作废”的特性,就是我们常说的事务原子性。
我特别想强调一句:这里讨论的原子性和前端圈讨论的 Atomic CSS 完全是两个世界。Atomic CSS 说的是把样式拆成原子化的工具类,而数据库事务里的原子性,指的是不可拆分的数据操作单元。你要是去面试或者写方案的时候把这两个搞混,会被笑话的。
这篇文章适合三种人看:刚接触数据库事务、写 CRUD 时不太确定怎么切事务边界的初级后端;已经在用 @Transactional 但偶尔遇到回滚不生效、对隔离级别和锁不熟的开发;以及希望系统性地把“本地事务的原子性设计”讲清楚、能直接套用到业务代码里的团队成员。后面所有内容都以 MySQL + Spring 这套最常用的技术栈为例,其他数据库的差异我会顺带说明。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先弄明白一件事:原子性到底保护了什么,不保护什么
事务的原子性,最容易理解,也最容易被误解。你得先知道它精确管到哪一层,后面写代码才不会抱着错误的预期去设计。
2.1 原子性的本质是“一荣俱荣,一损俱损”
我习惯用一个生活化的类比来解释原子性:你带着女朋友去餐厅吃饭,点完菜之后发现忘带钱包,这时候你应该怎么办?正常人的做法是找服务员把还没上的菜退掉,已经上的菜也要想办法结账,不能吃霸王餐跑路。事务里的原子性就是这个道理:不论中途卡在哪个环节,数据库都会主动清理现场,让所有数据恢复到你开事务之前的样子。
数据库层面发生的事比餐厅复杂得多。一条 UPDATE 可能要修改索引,要更新 redo log,要写 undo log,步骤非常多。如果这些步骤做了一半系统突然崩溃,数据库恢复时靠什么知道该把数据还原到哪里?靠的就是 undo log。事务回滚时,InnoDB 会用 undo log 里记录的旧值把数据恢复回去。这是原子性能够成立的底层前提,也是 InnoDB 和 MyISAM 之间一个巨大的差异点:MyISAM 根本不支持事务,也没有 undo log,所以它一条 SQL 写一半崩了,数据就真的坏在那里。
理解了这一层,你就明白一个关键结论:原子性不等于“这个操作过程绝对不会中途失败”,而是“中途失败后系统会把已做的修改全部撤销”。编程时不要把“事务”当做不报错的免死金牌,它只是帮你收拾残局的机制。
2.2 ACID 另外三兄弟与原子性的合作关系
大学课本会说事务有 ACID 四大特性,但我建议你别把它们当成四个孤立的考点,它们其实是在说同一件事的不同侧面。
- 原子性(Atomicity):一组操作不可分割,要么全都成功,要么全都像没发生过。
- 一致性(Consistency):事务开始前和结束后,数据都要满足业务上的约束,不能出现“转账后总金额变少”这种离谱状态。
- 隔离性(Isolation):两个事务同时操作同一批数据时,不能互相干扰到产生错误结果。
- 持久性(Durability):事务一旦提交,修改就要永久保留下来,即使数据库立刻宕机,重启后数据也在。
一致性最容易和原子性混在一起。我的理解是:一致性是目标,原子性、隔离性、持久性是达成目标的手段。比如转账场景,要求“A扣100块、B加100块”这件事前后总账不变。如果不用事务,A 扣了 B 没加,账就不平了,这是一致性被破坏。事务把扣款和加分绑定成一个原子操作,避免了一半成功的情况;同时配合行锁保证并发时不会有人看到中间状态。所以你会发现,想保住一致性,往往不仅要靠原子性,还要靠隔离级别和锁,甚至还要靠数据库的约束、触发器之类的东西。
这里必须泼一盆冷水:原子性并不是什么灵丹妙药,它没有“并发安全”“数据不超卖”这种额外的能力。两个事务同时扣同一个账户的余额,如果没有锁或版本号控制,即使它们各自是原子操作,最后也可能把余额扣成负数。并发控制是隔离性那一侧的工作,后面我会专门说明。
3. 保证原子性的基础配置:引擎、连接、事务边界
既然事务能力取决于底层引擎和连接状态,那么第一步不是急着写代码,而是确认你的表真的具备支持事务的前提。这一步我见过太多次坑了。
3.1 存储引擎没选对,写了事务也白搭
MySQL 有多个存储引擎,默认的 InnoDB 支持完整的事务能力,老旧的 MyISAM 则完全不支持。在生产环境里,只要用了 MyISAM,那么你代码写的 @Transactional、SQL 里写的 BEGIN/COMMIT,统统都会失效。MySQL 对 MyISAM 的处理方式是“假装没有事务”,每条 SQL 都是自动提交的,不会因为你要回滚就撤销已经执行的操作。
所以排查“事务怎么不回滚”问题,第一件事就是检查表引擎:
sql复制SHOW TABLE STATUS LIKE 'orders';
看 Engine 那一列是不是 InnoDB。如果是 MyISAM,你后面所做的所有事务优化都没意义,趁早做迁移:
sql复制ALTER TABLE orders ENGINE = InnoDB;
顺带再提一个点:如果你用的连接池有多个连接,事务操作必须在同一个数据库连接上完成。Spring 的 @Transactional 默认帮你绑定了当前线程的连接,所以通常不用操心。但如果你在事务里手动创建了新的数据库连接,或者通过多线程去执行 SQL,那些操作就会脱离当前事务控制,要么不自动回滚,要么因为锁等待直接抛异常。这属于初学者最容易忽略的“连接边界”。
3.2 事务边界怎么划:多长才算合适
事务不是越长越好。很多人写代码时习惯把整个方法体丢进一个事务里,不分青红皂白。比如在事务里调第三方支付接口、发短信、写文件、调另一个微服务接口,等外部响应等了几秒钟甚至几十秒。这样做轻则锁占用时间过长引发锁等待,重则整个数据库连接池被拖死。
我自己总结的划分原则很简单:事务里只放数据库操作,而且是对一致性要求高的那部分数据库操作。冗余的查询、外部远程调用、可以延迟处理的异步任务,全部移出去。拿电商下单举例,最典型的本地事务场景是“创建订单 + 扣减库存 + 生成支付流水”,这三个动作必须是一个原子操作,它们才应该放在同一个事务里。至于下单成功后通知用户、发送优惠券、刷新推荐缓存,这些根本不涉及关键数据强一致,完全可以在主事务提交后再做,用 MQ 也好、定时任务也好,都不应该进事务。
有的团队还特别喜欢在事务里做大批量操作,比如一次性更新十几万行数据,结果锁范围巨大,还容易拖垮主从同步。遇到这种情况,要评估能否分批提交,比如每处理 1000 条提交一次。虽然牺牲了“所有数据要么全成功要么全失败”的整体原子性,但业务上通常可以接收分批加状态标记加补偿任务的方式。
4. 代码里的最小原子单元实战:@Transactional 这样用才对
理论说清楚了,接下来看实际代码。Spring 的 @Transactional 应该是 Java 后端最常用的事务注解,但“最常用”不等于“最会用”,这个注解背后其实藏着不少坑。
4.1 一个干净的转账示例
先看一段简化版的转账逻辑:
java复制@Service
public class AccountService {
private final AccountMapper accountMapper;
private final TradeFlowMapper tradeFlowMapper;
@Transactional(rollbackFor = Exception.class)
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 扣减转出账户余额
accountMapper.deductBalance(fromId, amount);
// 增加转入账户余额
accountMapper.increaseBalance(toId, amount);
// 记录流水
tradeFlowMapper.insert(fromId, toId, amount);
}
}
为什么这段代码能保证原子性?关键在于 @Transactional 会让 transfer 方法包在同一个事务里执行,Spring AOP 会在方法进入前开启事务,方法正常返回后提交,方法抛异常后回滚。三条数据库操作只要有一条失败,前面已经成功的操作也会一并被回滚。
rollbackFor = Exception.class 这个参数值得单独讲。Spring 默认只对 RuntimeException 和 Error 回滚,如果业务方法抛出了一个自定义的 checked exception(继承 Exception 但不继承 RuntimeException),默认情况下事务不会回滚。很多新手会在这个地方踩坑:明明 catch 了异常想回滚,却发现自己手动 catch 之后事务悄悄提交了。
正确的处理方式有两种。第一种是把异常继续往上抛,由 Spring 感知:
java复制@Transactional(rollbackFor = Exception.class)
public void transfer(...) {
...
if (notEnough) {
throw new BizException("余额不足");
}
}
第二种是显式调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),不过这不是很优雅,我一般只在事务边界比较复杂、确实需要手动标记回滚时才用。
4.2 事务注解失效的典型场景
下面这几个 @Transactional 失效场景是我在实际 review 代码时经常能抓出来的,几乎每个项目里都有。
同类内部调用。Spring 的事务是基于 AOP 代理实现的,当你通过 this.xxx() 这种方式在同一个类里调用另一个带 @Transactional 的方法,实际上绕过了代理对象,事务注解不会生效。正确的做法是拆到另一个 Spring Bean 里,或者在类内部注入自己,让调用走代理。
方法不是 public。Spring 的注解代理对 private、protected 方法无能为力。这个我印象太深了,之前有个同事把事务方法设成了 private,结果抛异常后数据库数据照样写进去了。数据恢复花了三个小时。
catch 住了异常但没有抛出。有人为了“友好提示”,在事务方法里 try-catch 全部吞掉,事务框架根本看不到异常,自然就不会回滚。如果你需要在 catch 之后做提醒,也一定要把异常重新抛出去。
数据库引擎不支持。这个上文说过了,MyISAM 表上做多少努力都白搭。
事务传播行为设置错了。Spring 默认的传播行为是 REQUIRED,意为“有事务就加入,没有就新建”,这适合大多数场景。但如果你改成了 NOT_SUPPORTED,那么当前方法会在没有事务的状态下执行,之前的坑就会重新出现。我遇到过有同事为了让某个方法不占用数据库连接,把传播行为改成 NOT_SUPPORTED,结果这个方法要写核心交易数据,导致写了一半就“成功”了。涉及核心数据,不要轻易使用非事务传播行为。
4.3 业务上还要配合唯一约束与幂等控制
原子性主要防止“逻辑上只做一半”,但有一种情况它会无能为力:你的业务方法被执行了两次。比如订单支付回调被消息队列重复推送,接口被客户端重试,如果代码没有做幂等控制,同样一笔支付可能扣两次余额,即使每次扣款事务都是原子的,最终数据也错了。
对付这种场景,纯靠事务兜底不够,要在数据表设计上堵住重复入口。最有效的方法是加唯一约束。比如支付流水表里,每个支付请求都带一个全局唯一的 requestNo,表结构定义 UNIQUE KEY uk_request_no(request_no),即使同一条请求并发进来两次,第二次插入时数据库会直接报重复键异常,配合事务回滚,等于给原子性上了一道保险。
所以事务 + 唯一索引 + 幂等控制,三者配合才算是完整可靠的本地数据写入方案。少一个,都可能在某些极端情况下出问题。
5. 并发场景下,“原子”还不够,还要管住隔离
事务原子性保证的是单事务内部的完整性,但两个事务同时操作同一行数据时,光靠原子性是不够的。你得了解隔离级别和锁,才能避免“扣成负数”“重复写入”这类并发问题。
5.1 常见的隔离级别怎么选
SQL 标准定义了四种隔离级别,MySQL InnoDB 默认是可重复读(REPEATABLE READ)。很多人一看到这个术语就头大,其实从低到高理解就清楚了:
- READ UNCOMMITTED:可以读到别人还没提交的数据,基本没人用,脏读风险极高。
- READ COMMITTED:只能读到已提交的数据,解决了脏读,但同一事务内两次相同的查询结果可能不同(不可重复读)。
- REPEATABLE READ:同一事务内多次读取同一行,结果一致,解决了不可重复读。MySQL 默认,配合间隙锁还能在一定程度上解决幻读。
- SERIALIZABLE:事务串行执行,并发能力最差,生产环境很少用。
事务隔离级别最底层的原理,实际上是读写锁和 MVCC(多版本并发控制)的组合。MVCC 让普通读不阻塞写,写不阻塞读,提高了并发能力;同时事务内读的是自己启动时的快照,所以才有了可重复读的效果。
在本地业务系统里,把隔离级别保持默认就好,大部分互联网场景都在这个级别下工作。只有当你为了压榨并发性能、把隔离级别改成 READ COMMITTED 时,才需要配合额外的锁机制来保证一致性。对比一下:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 典型场景 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 几乎不使用 |
| READ COMMITTED | 不会 | 可能 | 可能 | Oracle 默认,部分高并发读多写少系统 |
| REPEATABLE READ | 不会 | 不会 | 可能(InnoDB 已较好解决) | MySQL 默认 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 对并发极不敏感的场景 |
5.2 扣库存这类写操作,如何锁住关键行
可重复读虽然保证了“读自己快照”的一致性,但两个并发事务同时对一个库存字段做“读值再写回”的操作时,如果没有锁,就可能出现丢失更新。比如库存只剩 1 件,两个请求同时读到库存是 1,各自扣减后又都写回 0,最终买出两件商品,这就是超卖。
解决思路有两个方向:悲观锁和乐观锁。
悲观锁就是直接锁住那行数据,让其他事务没法同时操作。在事务里执行:
sql复制SELECT stock FROM goods WHERE id = 123 FOR UPDATE;
执行完这句,其他事务对同一行的更新就会阻塞,直到当前事务提交或回滚。注意,FOR UPDATE 只有在事务中以及索引命中的情况下才能发挥有效锁行能力。如果查询没走索引,锁的可能是整个表,并发能力就会很差。
乐观锁的思路则不锁数据库行,而是在更新时校验版本号:
sql复制UPDATE goods
SET stock = stock - 1, version = version + 1
WHERE id = 123 AND stock >= 1 AND version = 1;
受影响行数为 0,说明版本已经变了或者库存不够,程序可以提示用户重试。这条 SQL 本身是原子的,update 在 InnoDB 中会自动加上行锁。
我用一个小表来整理两者的适用差异:
| 对比维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 使用方式 | SELECT ... FOR UPDATE | UPDATE ... WHERE version = ? |
| 适用场景 | 并发冲突高、重试代价大 | 并发冲突低、冲突后可重试 |
| 锁持有时间 | 整个事务期间 | 只在单条 UPDATE 执行瞬间 |
| 性能风险 | 高并发下容易形成队列等待 | 高冲突下大量失败重试 |
真正写代码时,扣减库存这种操作我一般会把 SQL 写成原子条件更新:UPDATE stock SET stock = stock - #{num} WHERE id = #{id} AND stock >= #{num},这种方式简洁且不容易出错,不需要单独查一遍库存,也不用额外维护版本字段。它依赖事务的原子性和行锁来保证安全,这也是我最推荐的扣库存写法。
6. 事务真实演进:本地事务、分布式事务怎么分界
写业务系统时,总会有绕不开的时刻:你要面对的不只是单库里的几张表的事务,还可能跨服务、跨库。这时候本地事务就不能解决问题了,你得想清楚边界在哪里。
6.1 什么时候必须升级到分布式事务方案
本地事务最典型的约束是:只能作用于同一个数据库连接、同一个数据库实例内部。一旦你的订单表在订单库,库存表在库存库,哪怕这两个库部署在同一台物理机上,@Transactional 也无法跨库回滚。更典型的是订单服务和库存服务是两个进程,各自用各自的库,本地事务的 undo log 只覆盖自己的库,根本无法通知另一个服务回滚已经扣减的库存。
所以判断是否需要分布式事务,最简单的问题是:这个业务操作要修改的数据,是不是都在同一个数据库里且能被同一个应用进程访问到?如果不是,就要考虑分布式事务方案。
业界常见的分布式事务方案有这几种,我在项目里都做过不同程度的使用:
- 基于消息队列的最终一致性。比如下单成功后将“扣库存”事件发到 MQ,库存服务消费后扣减。顺序是先写本地消息表和业务数据,再可靠地投递消息,MQ 或定时任务做补偿。
- TCC(Try-Confirm-Cancel)。将业务拆成预留资源、确认、取消三个阶段。实现成本最高,但对一致性控制最精确。
- Saga 长事务。把一个全局事务拆成一组本地事务序列,中间某个事务失败,则逐级反向执行补偿操作。
- Seata 这类分布式事务框架。AT 模式通过拦截 SQL 生成反向回滚日志,实现类似本地事务的补偿效果。
要特别记住:分布式事务不是银弹,很多团队引入之后反而带来巨大的运维负担。能用本地事务解决的并发问题,绝不升级到分布式事务。
6.2 本地事务里不要掺入远程调用,这条铁律要记住
我在代码评审时几乎每次都要强调这条规则:事务方法里不要包含远程调用。远程调用的延迟不可控、成功性不确定,如果把它包在本地事务中,会导致事务长时间执行,锁定大量资源,连接池耗尽后,其他请求全部排队,然后整套系统出现雪崩。
正确姿势是两段式:第一阶段开启本地事务,只操作本地数据和写“待发送”状态的消息表,提交事务;第二阶段把消息发出去或者扫描未发送的消息。业务同学常说的“订单和库存的分布式事务”,本质上就是靠这种本地消息表加最终一致性的思路落地。用本地事务保护了一个足够小的原子单元,再通过可靠消息把状态传递出去,这是工程上可落地的折中方案。
7. 事务运行时的典型事故:锁等待、死锁和长事务排查
事务代码写得多了,线上总会遇到两类很经典的问题:lock wait timeout exceeded 和 deadlock。每次都靠重启数据库来救火肯定不行,要把定位和预防做成一套固定动作。
7.1 lock wait timeout exceeded 为什么会出现
先解释一下那句让无数后端头疼的报错:Lock wait timeout exceeded; try restarting transaction。
InnoDB 在对某一行进行更新时,如果这行被其他事务的锁占着,当前事务会进入等待状态。等待时间超过 innodb_lock_wait_timeout 的设置(默认 50 秒),就会抛出这个异常。
常见触发场景是:事务 A 更新了订单行后迟迟不提交,同时在事务里调用了一个需要 30 秒才能返回的外围系统;事务 B 这时也想更新同一个订单行,于是卡住等待。等到第 50 秒时事务 B 超时,而事务 A 可能还在等外围服务返回,后续请求就陆续开始报错。
排查时,我通常会按这几个步骤走:
- 看当前正在运行的事务列表:
sql复制SELECT * FROM information_schema.innodb_trx\G
重点看 trx_state、trx_started、trx_mysql_thread_id,找到长时间未结束的事务。
- 找到持有锁的会话正在执行什么 SQL:
sql复制SELECT * FROM sys.innodb_lock_waits\G
- 如果确认某个会话是问题源头,评估是否可以临时批量 kill:
sql复制-- 谨慎使用
SELECT concat('KILL ', blocking_trx_id) FROM sys.innodb_lock_waits;
前面列的定位原理很简单:查 MySQL 自带的元数据表,找出锁的阻塞链,然后处理源头的长事务。很多慢 SQL、误加锁、大事务,都能从这里找到蛛丝马迹。
7.2 死锁和锁超时不是一回事
死锁和锁超时经常被混为一谈,两者成因和表现差别很大。
死锁是事务 A 持有行 1 的锁,等待行 2;事务 B 持有行 2 的锁,等待行 1。两个事务互相等对方释放锁,谁都等不到,InnoDB 的死锁检测机制会立即选择一个成本较低的事务回滚,让另一个事务继续执行。注意,死锁是立即发生的,而不是等待几十秒才报错。
我遇到过好几次死锁,最终确认都是应用层更新数据的顺序不一致导致的。比如在同一个事务里,先更新订单状态再更新账户余额,而另一个事务里先更新账户余额再更新订单状态。两个事务并发时,就很容易形成一个循环等待。
解决死锁的标准做法是约定所有事务都按同一个顺序访问资源。比如统一“先账户后订单”,那么两个并发事务都会先尝试锁账户表那一行,其中一个等待另一个释放,排队进行,不会出现环。
把两者的对比写成一张表会更直观:
| 问题类型 | 底层原因 | 表现 | 常规解决方式 |
|---|---|---|---|
| 锁等待超时 | 事务长时间持有锁不释放 | 等待一段时间后报错 | 缩短事务、减小锁范围、kill 长事务 |
| 死锁 | 多个事务互相持锁等待 | 立即检测到,回滚其中一个事务 | 统一资源访问顺序、快速重试、控制事务并发度 |
对死锁还有一种务实处理:既然 InnoDB 会自动回滚受害者事务,那么业务层捕获死锁异常后自动重试一次,很多场景下就能直接解决。但要注意,如果事务里还包含外部系统调用,不能盲目重试,否则可能重复执行业务逻辑。
7.3 通过监控长事务来主动避免事故
与其等线上出问题再去排查,我建议你把“长事务监控”前置到日常开发里。比如定时查一下 information_schema.innodb_trx,事务运行时间超过 5 秒、10 秒的,直接把 SQL 和账号捞出来,钉钉告警发到群里。刚开始是发现很多慢日志,优化几次之后,整体数据库的锁等待问题会显著下降。
运行时间超过 30 秒的事务,强烈建议立即处理。这种长事务在可重复读隔离级别下,还可能导致 undo log 无法被清理,undo 膨胀后磁盘空间也会不断增加,属于隐患积累型问题。
另外一个日常建议是开慢查询日志,将所有超过 1 秒的 SQL 记录下来。结合事务监控,你会发现自己系统里真正拖后腿的到底是谁:是没走索引的更新语句,还是某个事务里藏着大循环。
8. 把事务用“对”的一些个人体会
我在业务系统里和事务打了很多年交道,从最初只会给方法加 @Transactional,到后来可以比较熟练地预判不同方案带锁带来的影响,这个成长过程中踩过的坑,可能比书本上教的知识更有用。
我体会最深的一点是:事务本身是数据库提供的一个工具,你想让数据安全,就必须先弄清楚要保护的是哪些数据之间的关系,然后才谈得上用什么隔离级别、什么样的锁,怎么设置事务边界。一上来就无脑加注解,或者把事务方法越包越大,都会让系统越来越脆弱。
如果你现在遇到一个诡异的数据不一致问题,我的建议是按这个顺序排查:先看表引擎是不是 InnoDB,再看事务注解有没有失效,然后看异常是不是被吞了,接着看操作的隔离级别和涉及的锁,最后通过元数据表找出有没有长事务或锁等待。九成问题都能在这个过程中被定位。
本地事务的原子性,是整个数据一致性体系的地基。把这块地基打扎实了,后面再接触分布式事务、消息队列、对账补偿,你会有一种“原来如此”的通透感。希望这篇文章能帮你少走一些弯路。
