搞本地数据读写的时候,事务的原子性听着好像是个简单概念,但真到线上出问题,你才会发现一堆细节其实没搞透。我最早接手一个订单系统,用户下单后偶尔出现订单表写了、库存扣减却失败的情况,后端日志里一行 transaction rolled back,但订单还是落库了。查了很久才发现是事务根本没有包住整个业务链路。那次之后我把事务机制完整研究了一遍,换过编程式事务、折腾过传播行为,也踩过锁等待超时,很多知识点都是靠生产故障逼出来的。
这篇内容我打算用一套真实可复现的本地业务场景,把事务从原理、配置、代码实验,一直讲到并发锁、超时排查和分布式事务边界。无论你是后端开发还是刚接触数据库的初学者,只要你需要确保一批写操作要么全部成功、要么全部不生效,这篇就能给你一套能直接参考的完整思路。
1. 先搞明白:事务的原子性到底在解决什么问题
很多人一说事务就背四大特性 ACID,原子性排第一位,代码里加个 @Transactional 就以为万事大吉。但问题恰恰出在“以为”这两个字上,理解不透彻,后面连排查方向都会走偏。
1.1 一个让数据出现“半成品”的经典场景
拿我项目里最典型的转账场景举例。用户 A 给用户 B 转 100 元,数据库里要做两步操作:
- 从 A 账户扣减 100 元
- 给 B 账户增加 100 元
只要第一步成功、第二步失败,A 的钱少了,B 的钱没多,账就平不了。放在电商里也是一样,下单动作可能同时涉及订单表、库存表、账户流水表,哪怕只是本地单库,多个写操作之间也没有天然保证“同生共死”。
更隐蔽的是文件类本地写入。比如一个应用启动时写配置,先删旧文件再写新文件,中途进程崩溃,文件就处于半删除半写入状态,下次启动可能直接读到一个损坏配置。这就是没有原子性的典型症状:数据出现“中间态”并暴露给外部。
1.2 原子性的本质:要么全部提交,要么全部回滚
事务机制给出的原子性定义其实很朴素:一个事务内的所有操作,在数据库视角里是一个不可分割的执行单位。事务提交(commit)后,所有修改才对外可见;事务回滚(rollback)时,所有修改都被撤销,数据恢复到事务开始前的状态。
我常跟团队同事说,原子性不是“操作不能中断”,而是“中断了也不会留下半截结果”。数据库通过 undo log 或事务日志记录修改前的状态,一旦需要回滚,就把这些数据倒回去。从这个意义上讲,原子性是数据库用记录和日志换来的强保证。
在本地读写中,这个保证是比较便宜的。单库、单连接、确定的几张表,数据库自己能管理事务边界。而一旦数据分布到不同库、不同服务,就需要靠分布式事务补位,那也是后话了。
1.3 没有事务时,本地文件/数据库读写会发生什么
构建印象最好的方式是自己动手模拟。先看一个不用事务的伪代码:
java复制public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountDao.decreaseBalance(fromId, amount); // 第一步
accountDao.increaseBalance(toId, amount); // 第二步
}
第二步一旦抛异常,第一步的扣款已经被写进数据库,没有机制撤销。程序日志里会记录异常,但数据已经是脏的。此时必须靠人工补数据或者引入对账任务来纠偏。
如果换成事务包裹,情况完全不同:
java复制@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountDao.decreaseBalance(fromId, amount);
accountDao.increaseBalance(toId, amount);
}
第二步抛异常,事务拦截器捕获到异常后主动发出回滚指令,第一步的扣款被撤销,调用方看到的效果就是什么都没有发生过。这就是原子性最直接的价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 本地事务机制的核心组成与关键配置
原子性不是凭空实现的,需要一套底层的机制配合。理解数据库连接、提交、回滚、事务注解、事务传播行为和隔离级别之间的关系,才能真正用好事务。
2.1 事务、连接与 Commit/Rollback 的关系
一个基础概念要先讲透:事务是绑定在数据库连接上的。
数据库连接有一个默认的自动提交开关,很多数据库驱动初始情况下默认 autoCommit = true,意味着每一条 SQL 执行完立即提交。要让多条 SQL 共享同一个事务,你需要先关闭连接的自动提交,接着执行所有业务 SQL,最后统一提交或回滚。
看一段最朴素的 JDBC 代码:
java复制Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false); // 1. 关闭自动提交
preparedStatement1.executeUpdate(); // 2. 业务操作
preparedStatement2.executeUpdate(); // 3. 业务操作
conn.commit(); // 4. 全部成功则提交
} catch (Exception e) {
conn.rollback(); // 5. 任何异常则回滚
throw e;
} finally {
conn.setAutoCommit(true); // 6. 恢复默认
conn.close();
}
这套流程就是所有事务框架的底层模板。你日常用的 @Transactional 也只是把上述动作封装成了声明式语法,核心逻辑殊途同归。
2.2 事务注解与传播行为:REQUIRED、REQUIRES_NEW、NESTED 怎么选
Spring 等框架中,我们常用 @Transactional 来声明事务边界,于是事务传播行为就成了必须懂的概念。它的作用是回答一个问题:当 A 方法调用 B 方法时,如果 A 方法已经有事务,B 方法应该加入现有事务,还是另起一个事务?
主流传播行为有几个:
REQUIRED:默认值。如果有事务就加入,没有则新建。大多数业务方法都该用这个。REQUIRES_NEW:无论如何都挂起当前事务,新建一个独立事务,适合记录日志等不希望被外部回滚影响的操作。NESTED:利用数据库保存点(savepoint)实现嵌套事务。内部回滚不会影响外部事务,但只有外部最终提交时内部才一起提交。
选型经验只有一个:默认用 REQUIRED,不要上来就随意设成 REQUIRES_NEW。比如在一个下单大事务里,调用的库存方法如果声明了 REQUIRES_NEW,库存扣减会提前提交,一旦后面订单生成失败,库存已经扣了,整体原子性就被破坏了。
我举过一个生活化例子:REQUIRED 是大家一起坐同一辆车,要么一起到,要么一起回;REQUIRES_NEW 是后一个人单独打车先走,前车掉头回起点也影响不到他。业务场景不同,选的车就不一样。
2.3 事务级别(隔离级别)别和原子性混为一谈
事务隔离级别(isolation level)和原子性经常被放在一起讨论,但它们解决的是不同问题。
- 原子性解决“多步写操作要么全成要么全败”
- 隔离性是并发事务之间互相能看到多少中间数据的问题
隔离级别从低到高依次是 READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。日常开发最常用的是 READ COMMITTED(Oracle、SQL Server 默认)和 REPEATABLE READ(MySQL InnoDB 默认)。
设置隔离级别是一行配置:
java复制@Transactional(isolation = Isolation.REPEATABLE_READ)
public void updateStock(...) {
// ...
}
隔离级别和原子性不是一回事,但隔离级别会影响事务在并发环境下的表现。如果一个事务读取到了另一个未提交事务的数据,而这个事务最终回滚,读到的就是“幻影数据”,原子性依然成立,业务数据却已经混乱。所以设计时既要把事务边界画清楚,也要评估并发读写的隔离需求。
2.4 事务模式:JDBC、编程式、声明式怎么落地
本地事务按实现方式,可以分成三类:
- JDBC 原生事务:手动管理连接、commit、rollback,控制最细但代码繁琐。
- 编程式事务:通过
TransactionTemplate直接写事务逻辑,适合事务边界动态变化的场景。 - 声明式事务:在方法上用注解声明,框架自动管理,最常见但有一些隐藏限制。
Spring 中最小示例:
java复制@Service
public class OrderService {
@Autowired
private TransactionTemplate transactionTemplate;
public void createOrder(...) {
transactionTemplate.execute(status -> {
try {
orderDao.insert(order);
stockDao.deduct(stock);
return Boolean.TRUE;
} catch (Exception e) {
status.setRollbackOnly();
throw e;
}
});
}
}
声明式事务的最大优势是侵入少,一行注解就定义了边界。但它依赖 Spring AOP 代理,也就是说事务只会对通过代理对象调用的方法生效。这点后面单独讲踩坑时会细说。
3. 实操示例:用事务控制一条完整的订单库存写入链路
理论讲太多容易飘,还是落到代码上。我设计了一个最小但完整的本地业务链路:用户下单,需要同时写入订单主表、扣减库存。目标就是保证两个动作的原子性。
3.1 前置准备:一个最小可复现的环境
先准备两张表,用 MySQL 为例:
sql复制CREATE TABLE `t_order` (
`id` bigint PRIMARY KEY AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`product_id` bigint NOT NULL,
`amount` decimal(10,2) NOT NULL,
`status` varchar(20) NOT NULL DEFAULT 'NEW'
);
CREATE TABLE `t_stock` (
`id` bigint PRIMARY KEY AUTO_INCREMENT,
`product_id` bigint NOT NULL,
`quantity` int NOT NULL,
UNIQUE KEY `uk_product` (`product_id`)
);
在 Spring Boot 项目里引入依赖时,核心就两件:spring-boot-starter-jdbc 或 spring-boot-starter-data-jpa,以及对应的 MySQL 驱动连接配置。环境不是重点,能跑通 SQL 就行。
3.2 核心代码:声明式事务实现订单与库存的原子写入
写一个服务,把两步操作放进同一个事务方法:
java复制@Service
public class OrderService {
@Autowired
private OrderDao orderDao;
@Autowired
private StockDao stockDao;
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderCreateCommand command) {
orderDao.insert(command.toOrder());
int affected = stockDao.deduct(command.getProductId(), command.getQuantity());
if (affected == 0) {
throw new InsufficientStockException("库存不足");
}
}
}
这里有三处关键设计:
@Transactional声明在 public 方法上,这是 Spring 代理能够拦截的前提。rollbackFor = Exception.class明确指定所有异常都触发回滚,否则默认只在运行时异常时回滚,检查异常不一定会回滚,这是很多人会踩的坑。- 库存扣减返回影响行数为 0 时手动抛异常,主动让事务失败。
再看 stockDao.deduct 的实现:
java复制@Update("UPDATE t_stock SET quantity = quantity - #{quantity} WHERE product_id = #{productId} AND quantity >= #{quantity}")
int deduct(@Param("productId") Long productId, @Param("quantity") Integer quantity);
SQL 里故意带上 quantity >= #{quantity} 条件,既能防止库存扣成负数,又能用返回值判断是否扣减成功。这是数据库层与事务配合的很实用的写法。
3.3 手工验证:主动抛异常,看数据是否回滚
代码写完不能直接上线,要先实验。最简单的方式是写一个测试,在订单插入后主动抛出运行时异常,看数据库最终状态:
java复制@Test
void shouldRollbackWhenExceptionAfterInsert() {
OrderCreateCommand command = new OrderCreateCommand(1L, 100L, 200L, new BigDecimal("99.00"), 2);
try {
orderService.createOrderWithException(command);
} catch (RuntimeException e) {
// 预期异常
}
int orderCount = orderDao.countByProductId(200L);
assertThat(orderCount).isZero(); // 订单没有写入
int stock = stockDao.getQuantity(200L);
assertThat(stock).isEqualTo(originalStock); // 库存未变化
}
注意:测试里要把第二个操作故意改成抛异常,例如 throw new RuntimeException("mock exception")。跑完之后查询数据库,你会发现订单表里没有新增记录,库存扣减也被撤销。
这个实验看着简单,但能帮你建立手感:@Transactional 不是给方法加保险,而是给“事务边界内所有数据库操作”加保险。
3.4 常见陷阱:同一个类内部调用导致事务失效
上一个实验能成功,前提是 orderService.createOrder(...) 是从外部调用,Spring 能通过代理进入事务逻辑。但如果我在同一个类内部写一个方法,再调用另一个带事务的方法,事务往往会失效。
java复制@Service
public class OrderService {
public void createOrderWrapper(OrderCreateCommand command) {
this.createOrder(command); // 内部调用,事务不生效!
}
@Transactional
public void createOrder(OrderCreateCommand command) {
// ...
}
}
原因很好理解:Spring 声明式事务依靠 AOP 生成代理对象,外部调用 orderService.createOrderWrapper 时进入的是代理,但 this.createOrder 调用的是当前对象自己的原始方法,没有经过代理,事务拦截器自然就没机会介入。
解决方式我常用三种:
- 把内部调用拆到另外一个独立的
Spring Bean中,通过注入的 bean 对象调用。 - 在当前类注入自己的代理,像
selfProxy.createOrder(command)这样调用。 - 用编程式事务
TransactionTemplate显式包裹内部事务逻辑,不依赖方法调用链。
团队里新人最容易出的问题就是第一个:加了一个事务方法,又在同类的普通方法里直接调用,最后数据和日志一致显示没回滚,查半天。
4. 锁、超时与并发下的原子性边界
数据库并发访问一个项目做深了必然碰到。单说原子性,在无并发情况下很容易满足,一旦两个事务同时操作同一行,锁和超时的问题就会浮出水面。
4.1 "lock wait timeout exceeded; try restarting transaction" 是怎么来的
这个错误提示几乎每个后端开发都见过,字面意思是“等待锁超时,请尝试重启事务”。它本质上是并发事务之间争抢行锁超时导致的。
复现场景很典型:
- 事务 A 更新了某行库存数据,并且还没有提交。
- 事务 B 也尝试更新同一行,此时 B 需要等待 A 释放行锁。
- 如果 A 由于某种原因一直没有提交或回滚,B 等待超过数据库配置的锁等待超时时间,就会报这个错误。
MySQL 里 innodb_lock_wait_timeout 默认是 50 秒,Oracle 等数据库也有类似机制。报错的直接原因是锁等待,间接原因往往是事务没有及时结束。
有一次我排查线上问题,发现一个事务里调用了外部 HTTP 接口,接口响应很慢,事务迟迟不提交,后面所有操作同一行数据的请求就全部堆积。解决方式不是去调大锁超时时间,而是根本不该在事务里做外部网络调用。
4.2 从锁等待到死锁:本地事务并发写同一行
锁等待如果变成循环等待,就会升级成死锁。两事务互相持有对方需要的锁,最后数据库检测到死锁,会自动回滚其中一个事务,并把错误返回给应用。
死锁的典型触发场景是多个事务以不同顺序更新同一批资源。比如订单流程同时要更新库存表和订单表:
- 事务 1:先锁库存表,再锁订单表
- 事务 2:先锁订单表,再锁库存表
如果两个事务交叉执行,事务 1 持有库存锁等订单锁,事务 2 持有订单锁等库存锁,谁也等不到谁。数据库死锁检测发现后,会选择牺牲其中一个事务。
规避死锁的基本原则是:尽量固定多个资源加锁顺序。例如所有业务代码都先更新库存表再更新订单表,就能大幅降低死锁概率。
4.3 悲观锁/乐观锁/唯一索引对原子性的补充
原子性保证的是“事务内多步操作要么全成要么全败”,但并发下容易出现另一个问题:多次执行同一操作导致数据错乱,如重复扣库存、重复插入订单。这需要业务约束或锁机制来兜底。
三种常用手段:
- 悲观锁:
SELECT ... FOR UPDATE,事务内先锁定目标行,其他事务必须等待,适合并发冲突较高的写场景。 - 乐观锁:给表增加 version 字段,更新时携带旧版本号,影响行数为 0 就说明被并发改过,需要重试或失败处理。
- 唯一索引:在订单号或业务幂等键上建立唯一索引,重复插入直接被数据库拒绝,从源头防止重复。
我要提醒的是,锁机制会放大原子性的实现难度。锁的范围太大会影响吞吐量,锁范围太小又可能留下并发漏洞,所以上线前至少要做一个并发冒烟测试,用多线程同时跑同一个下单接口,观察是否存在数据不一致和异常错误。
4.4 原子性不等于一致性,注意事务边界
有一句我反复和队员强调的话:原子性只是手段,一致性才是业务真正想要的。事务把一组操作打包成一个原子单元,但业务规则依然需要由写代码的人来保证。
比如转账,事务可以保证扣款和加款要么同时成功要么同时失败,但转账金额不能是负数这个规则,事务不会自动帮你检查。你必须先在代码里校验余额;库存够不够,也要靠 SQL 条件或应用逻辑判断。
事务边界画得清楚,一致性才可能达到。尤其是微服务架构下,调用链横跨多个服务时,每个服务内部都有本地事务,服务间却不一定有统一的事务边界。这时候脑子里要有一条清晰的线:本地事务能管的边界到哪里,管不到的就要由分布式事务或者最终一致性方案接住。
5. 本地事务和分布式事务的分界线在哪
技术讨论到了一个阶段,一定会涉及分布式事务。很多人在单体应用里把 @Transactional 用得溜,一到拆库拆服务就懵。其实想清楚本地事务与分布式事务的边界,问题会清晰很多。
5.1 单库本地事务的适用范围
本地事务适用范围很明确:所有参与写操作的数据都在同一个数据库实例、同一套存储系统内。比如一个订单系统同时写订单表和库存表,只要这两张表在同一个 MySQL 实例中,本地事务就是最直接、最可靠的方案。
它性能好、代码简单、排查方便,数据库自己负责日志和锁,开发者只要正确地声明事务边界。
缺点是跨不过物理边界。一旦订单数据在订单库、库存数据在独立的库存库,或订单服务和库存服务分别部署,并且各自使用独立数据库,本地事务就管不住另一个数据库里的写操作了。
5.2 为什么订单与库存跨库后会引入分布式事务
当订单写订单库,库存写库存库,两个写操作分属不同数据库,二阶段提交,也就是 XA 事务,理论上能管这种场景,但锁范围大、协调成本高,多数互联网业务不会直接使用。
更常见的架构是引入分布式事务中间件,用 TCC、Saga、可靠消息最终一致性 等方案来处理。以订单与库存为例:
- 订单服务在本地事务中创建订单,同时发送一条“预扣库存”的可靠消息到 MQ。
- 库存服务消费消息后,在本地事务里执行库存扣减。
- 如果库存扣减失败,通过消息重试或补偿机制使得数据最终走向一致。
这里强调一个概念:分布式事务通常做不到强实时原子性,追求的是最终一致性。它属于另一个复杂度层级,需要引入更多中间件和人工补偿机制,和本地事务的简单直接有本质区别。
5.3 分布式事务的常规方案与成本
如果真要演进到分布式事务,常见的几种模式是:
2PC(两阶段提交):强一致性但阻塞风险高,比较适合短事务、对一致性要求苛刻的内部系统。TCC:Try、Confirm、Cancel 三个阶段,把业务操作拆成预留和确认,灵活性高,但侵入大,代码复杂。Saga:把长事务切成一串本地事务,通过编排或协同方式串联,任何一个失败则执行反向补偿。可靠消息最终一致性:最常用,配合本地消息表或事务消息,适合大多数异步解耦场景。
这些方案背后各有一套理论和大量实现细节,成本都不低。所以架构设计时我的建议是:能用本地事务解决问题的,不要强行引入分布式事务。先保证单库模型清晰,再考虑拆分。
6. 实践中的排查技巧与避坑实录
这部分内容基本是日志和报错现场教出来的。几次大半夜处理生产问题后,我把高频故障整理成了一套排查路径,希望对你有帮助。
6.1 日志里出现"try restarting transaction"的排查步骤
看到这条错误,我一般按下面的顺序排查:
- 查看事务对应的连接是否长时间未提交,用数据库的监控 SQL 查当前正在运行的事务和锁等待情况。
- 定位到持有锁的事务,看它的来源代码在哪里,是否有外部接口调用,是否执行过耗时操作。
- 确认事务是否合理结束,有没有异常被吞掉导致没有走到 rollback。
- 查看是否存在批量任务长事务,例如循环十万次更新操作,全程占用着一个大事务。
- 在代码里检查锁顺序,两个事务是否以相反顺序操作了同一组资源。
排查工具每个库都不一样,但核心思路一致:先找谁持锁,再看它为什么不释放,最后看代码路径是否合理。
6.2 检查事务是否真的生效的3个方法
防止“我以为事务生效了”的情况,我常用下面三个验证方法:
- 查看数据库日志:事务提交前,数据库会记录本次事务的所有写入;回滚时也会看到对应回滚日志。
- 在测试环境故意制造异常:向事务方法里塞入一个必定会抛错的代码,运行后查询数据库,如果数据没有发生变化,说明事务生效。
- 开启 Spring 事务日志:把
logging.level.org.springframework.transaction.interceptor=DEBUG打开,日志中能看到事务创建、提交、回滚的完整过程。比如Completing transaction for [xxxService.createOrder]后如果是afterCompletion状态为回滚,说明拦截器确实介入。
第二个方法最直观,也最适合做回归测试。注意不要在生产环境直接测试,用一套独立的测试数据库。
6.3 事务内不要做的几件事
经验告诉我,以下行为放进事务里迟早出事:
- 外部 HTTP / RPC 调用。网络抖动会让事务卡住,数据库连接长期占用,甚至拖垮连接池。
- 发送 MQ 消息。消息发出后事务回滚,消费方已经处理了不存在的业务数据,会出现不一致。
- 非常耗时的计算或大文件处理。事务越长,持有的锁越久,并发性能越差。
- 批量大数据量更新。一次性更新数万行且中间出错,回滚代价极高,还会让日志文件暴涨。
正确姿势是把这些操作移到事务提交后的回调里,或者采用可靠消息与本地事务结合。比如 Spring 中有 TransactionSynchronizationManager.registerSynchronization,可以在事务提交成功后发消息,但前提是消息发送本身要支持重试和幂等。
6.4 我踩过的坑:事务注解加在 private 方法上
这是一个很典型但很隐蔽的坑。某次代码 review 我注意一个 createOrder 方法没有事务生效,找了半天发现它在另一个类中被调用,但注解加在了一个 private 内部方法上。
java复制@Service
public class OrderService {
public void createOrder(OrderCreateCommand command) {
doInsertOrder(command);
doDeductStock(command);
}
@Transactional
private void doInsertOrder(OrderCreateCommand command) {
orderDao.insert(command.toOrder());
}
}
Spring AOP 不能代理 private 方法,所以事务注解完全没被处理。结果就是:doDeductStock 抛异常时,订单已经插入且不会回滚。
后来我给自己立了几条规矩:
@Transactional只加在 public 方法上。- 不要把注解拆到方法内部被本类调用的位置,除非通过注入的代理对象调用。
- 事务方法要尽量保持粒度合适,一个业务用例一个事务方法,避免方法内部嵌套过度。
还要提醒一个配套问题:自调用失效不仅存在于 private 方法,还包括同类方法的普通调用。哪怕被调方法是 public,用 this.xxx() 调用也不会经过事务代理。这个现象我在代码评审中提到无数次,非常值得重视。
最后再分享一个小技巧
事务这块没有太多花活,核心就是边界。写代码之前先问自己:哪些写操作必须同生共死?锁的粒度会不会覆盖这个边界?事务外面可以做哪些补偿?想清楚这三件事,很多事务问题都能在代码阶段被拦下来。
我个人还有一个习惯,每次建新接口都会在提交代码前跑一遍“异常注入测试”:在事务中间故意抛一个运行时异常,确认数据没有半截落库。这个习惯帮我在上线前发现了至少十次事务失效问题。你也不妨把这几个验证步骤固化到自己的开发流程里,比出事故后再查日志划算太多。
