Spring事务机制与保险业务一致性问题
1. 对应简历段落
这篇文章对应简历中“处理保险核心业务交易一致性,熟悉 Spring 声明式事务、传播行为、回滚规则和补偿机制”的项目经历。简历可以写成:
负责保险销售与订单流转链路的一致性治理,基于 Spring 声明式事务规范本地数据库操作,梳理事务传播、异常回滚、MyBatis 会话绑定和外部接口调用边界,并通过业务流水、幂等键、补偿任务和对账机制解决跨系统最终一致性问题。
面试官通常会追问:@Transactional 为什么有时不生效?默认回滚哪些异常?传播行为怎么选?同一个类内部方法调用为什么没有事务?MyBatis 和 Spring 事务怎么绑定?保险出单过程中数据库写成功、下游核保失败怎么办?能不能把外部 HTTP 调用放在事务里?分布式事务和补偿事务怎么取舍?
回答这类问题时,要把 Spring 事务机制和业务一致性分开讲。Spring 本地事务解决的是单数据源或单事务资源内的原子性;保险业务一致性经常跨数据库、第三方接口、消息、任务和人工处理,不可能只靠一个 @Transactional 解决。资深回答要能说明事务边界、失败分类、回滚策略、幂等设计、补偿和对账。
2. 业务背景
保险业务链路天然复杂。以投保为例,用户提交订单后,系统可能要保存投保单、客户信息、被保人信息、险种责任、保费试算结果、支付流水、营销活动信息;随后调用核保、风控、支付、短信、电子保单、佣金系统;还要支持撤单、退保、批改、状态回写和人工复核。每一步都可能失败,而且失败后业务状态不能混乱。
在老系统中,常见问题包括:Service 方法没有事务导致部分表写入成功;异常被 catch 后没有抛出导致事务不回滚;@Transactional 加在 private 方法或同类内部调用上不生效;事务范围过大,把外部接口调用也包进去导致数据库连接长时间占用;多数据源操作只回滚了一个库;定时任务补偿重复执行造成状态错乱;MyBatis 批量执行异常后定位困难。
保险场景对一致性的要求不是所有步骤都必须强一致,而是不同环节要采用不同策略。保单主状态、订单金额、支付流水属于强一致要求高的本地数据,必须在本地事务中保证。短信通知、邮件、日志、报表、佣金试算可以最终一致。调用外部核保、支付、保司接口时,需要业务流水和状态机支撑,因为外部系统不可由本地事务回滚。
3. 核心原理
Spring 声明式事务基于 AOP 代理。方法调用进入代理后,TransactionInterceptor 根据事务属性获取或创建事务,执行业务方法,成功则提交,异常则按规则回滚。底层事务由 PlatformTransactionManager 实现,常见的是 DataSourceTransactionManager、JpaTransactionManager 和 JTA 事务管理器。
事务传播行为决定当前方法如何参与已有事务。REQUIRED 最常用,有事务就加入,没有就新建;REQUIRES_NEW 挂起当前事务并新建事务,适合独立审计日志或失败记录;NESTED 使用保存点,依赖数据库和事务管理器支持;SUPPORTS 有事务就加入,没有就非事务;MANDATORY 要求必须已有事务;NOT_SUPPORTED 挂起事务;NEVER 禁止事务。
默认回滚规则是运行时异常和 Error 回滚,受检异常默认不回滚。保险业务中很多异常是业务异常,如果继承 Exception 而不是 RuntimeException,又没有配置 rollbackFor,就可能出现“抛异常但提交了”。推荐明确异常体系:参数校验、业务拒绝、下游失败、系统异常分层,并在事务注解中明确回滚规则。
事务失效常见原因有:同类内部自调用绕过代理;方法不是 public;对象不是 Spring Bean;异常被捕获没有继续抛出;数据库引擎不支持事务;使用了错误的事务管理器;多线程中事务上下文不传播;@Transactional 加在接口或类上但代理方式和调用方式不匹配。
MyBatis 与 Spring 事务通过 SqlSessionTemplate 和 TransactionSynchronizationManager 集成。同一事务内获取到的 SqlSession 会绑定到当前线程,Executor 使用同一个 JDBC Connection。事务提交时才真正提交连接,回滚时回滚连接。因此 MyBatis 本身不单独提交,除非开发者绕开 Spring 管理手动打开 SqlSession。
4. 项目落地
项目落地首先要划清事务边界。以投保订单创建为例,本地事务内只做必须原子提交的数据库操作:创建订单主表、订单明细、客户快照、保费结果、业务流水初始记录。外部核保和支付调用不要长时间包在数据库事务里。更好的方式是先落库为“待核保”或“处理中”,提交本地事务后调用外部系统,再根据结果推进状态。
第二,建立状态机和业务流水。每个关键动作都要有流水号和幂等键,例如投保单号、支付流水号、核保请求号。状态从 INIT 到 UNDERWRITING、PAYING、SUCCESS、FAILED 要有合法流转约束。外部接口超时不能简单当失败,因为可能对方已经成功,需要通过查询或补偿确认。
第三,规范异常和回滚。业务校验失败可以回滚本地事务并返回明确错误;下游接口失败要看发生在事务前还是事务后;如果本地已提交,则不能依赖回滚,而要进入补偿。所有事务方法避免吞异常,确实需要捕获时要设置回滚标记或重新抛出。
第四,处理跨系统最终一致。可以采用本地消息表、补偿任务、对账任务和人工处理队列。比如订单创建成功后写入消息表,事务提交后异步发送核保请求;发送失败由任务重试;核保回调根据幂等键更新状态;日终对账扫描长时间处理中订单,调用保司查询接口确认最终状态。
第五,压缩事务时间。事务内不要做远程 HTTP、文件上传、大报表生成、复杂循环调用和用户交互。保险系统高峰期数据库连接是稀缺资源,长事务会带来锁等待、连接池耗尽和死锁风险。
5. 关键代码或流程
本地事务示例:
@Service
public class PolicyOrderService {
@Transactional(rollbackFor = Exception.class)
public CreateOrderResult createOrder(CreateOrderCommand command) {
validate(command);
PolicyOrder order = orderFactory.create(command);
orderMapper.insert(order);
applicantMapper.insertSnapshot(order.getOrderNo(), command.getApplicant());
premiumMapper.insertResult(order.getOrderNo(), command.getPremium());
flowMapper.insert(new BizFlow(order.getOrderNo(), "CREATE_ORDER", "SUCCESS"));
return new CreateOrderResult(order.getOrderNo());
}
}
事务提交后再触发外部动作:
@Transactional(rollbackFor = Exception.class)
public void submit(String orderNo) {
orderMapper.updateStatus(orderNo, "WAIT_UNDERWRITE");
outboxMapper.insert(new OutboxMessage(orderNo, "UNDERWRITE_REQUEST"));
}
补偿任务读取 outbox:
public void handleUnderwriteMessage(OutboxMessage message) {
if (requestLogMapper.exists(message.getBizKey())) {
return;
}
requestLogMapper.insertProcessing(message.getBizKey());
UnderwriteResult result = underwriteClient.submit(message.getBizKey());
policyStateService.applyUnderwriteResult(message.getBizKey(), result);
outboxMapper.markDone(message.getId());
}
状态推进要幂等:
@Transactional(rollbackFor = Exception.class)
public void applyUnderwriteResult(String orderNo, UnderwriteResult result) {
PolicyOrder order = orderMapper.selectForUpdate(orderNo);
if (order.isFinalState()) {
return;
}
orderMapper.updateStatus(orderNo, result.success() ? "UNDERWRITE_PASS" : "UNDERWRITE_REJECT");
flowMapper.insertIgnore(orderNo, "UNDERWRITE_CALLBACK", result.requestNo());
}
核心流程是:本地事务落订单和流水、提交后异步调用外部系统、回调或查询更新状态、补偿任务处理超时、对账任务发现差异、人工队列处理无法自动判定的异常。
6. 常见坑
第一个坑是同类内部调用导致事务不生效。this.innerMethod() 不经过 Spring 代理,事务注解不会触发。
第二个坑是受检异常不回滚。业务异常如果继承 Exception,必须配置 rollbackFor 或调整异常体系。
第三个坑是事务里调用外部接口。接口慢或超时会长期占用数据库连接,还可能造成锁等待。
第四个坑是把 REQUIRES_NEW 当万能解。它会挂起外层事务,独立提交,使用不当会导致主事务回滚但日志或子记录已提交,需要业务上能接受。
第五个坑是多数据源误用一个事务管理器。只配置了主库事务,另一个库不会跟着回滚。
第六个坑是没有幂等。补偿任务、回调、消息重试都可能重复,没有幂等键就会重复扣费、重复更新或重复发通知。
第七个坑是状态无约束。任意状态都能更新到成功,会掩盖并发和乱序回调问题。
7. 面试追问
- Spring 声明式事务为什么依赖代理?
@Transactional默认回滚哪些异常?REQUIRED和REQUIRES_NEW有什么区别?- 同一个类内部方法调用事务为什么不生效?
- MyBatis 如何参与 Spring 事务?
- 事务中能不能调用外部 HTTP 接口?
- 支付成功、本地更新失败怎么办?
- 外部接口超时应该按成功还是失败处理?
- 分布式事务和最终一致性怎么取舍?
- 补偿任务如何保证幂等?
8. 推荐回答
如果问“Spring 事务为什么会失效”,可以回答:
Spring 声明式事务是基于代理的,只有外部调用进入代理对象,TransactionInterceptor 才会开启事务。同类内部自调用、非 public 方法、对象不是 Spring Bean、异常被 catch 吞掉、受检异常未配置 rollbackFor、使用错误事务管理器,都会导致事务不符合预期。排查时我会先看调用路径是否经过代理,再看异常和事务管理器。
如果问“保险出单怎么保证一致性”,可以回答:
本地强一致和跨系统最终一致要分开。本地事务内保存订单、客户快照、保费结果和业务流水,保证数据库原子性。外部核保、支付、短信不放在长事务里,而是通过状态机、业务流水、幂等键、消息表、补偿任务和对账保证最终一致。接口超时不会直接判失败,会通过查询或补偿确认最终状态。
如果问“为什么不直接用分布式事务”,可以回答:
保险链路涉及第三方保司、支付、短信和人工流程,很多资源不支持 XA,强行分布式事务会增加耦合和可用性风险。对核心本地数据用数据库事务保证强一致,对外部系统用可靠消息、状态机、幂等和对账实现最终一致。这更符合业务现实,也更容易灰度和排障。
9. 延伸学习路线
第一阶段学习 Spring 事务源码,重点看 TransactionInterceptor、PlatformTransactionManager、事务属性、传播行为和回滚规则。
第二阶段学习 MyBatis-Spring 集成,理解 SqlSessionTemplate、线程绑定、事务同步和 Executor 缓存。
第三阶段学习数据库事务基础,包括 ACID、隔离级别、锁、死锁、MVCC、select for update 和连接池。
第四阶段学习分布式一致性模式,包括本地消息表、TCC、Saga、事务消息、补偿任务、对账和幂等。
第五阶段结合保险业务建模,掌握订单状态机、支付流水、核保回调、撤单退保、日终对账和人工复核。资深面试里,能承认单个事务解决不了所有一致性问题,反而更专业。