☰
Spring事务实战:为什么老项目不用 @Transactional?手写 DataSourceTransactionManager 的坑与重构
2026/10/21 2:30:29 网站建设 项目流程

栏目:老炮踩坑录 · 翻车现场

项目:供应链(企业真实项目)

编号:SCM-F01

素材:PmsWorkflowManagerServiceImpl.java、PmsDemandDomainServiceImpl.java

👋 欢迎阅读

🏠个人主页:知守观
📘我的专栏:老炮踩坑录
💻当前内容:手写事务 vs @Transactional

文章目录

    • 现场
    • 为什么不贴注解
    • 摇摆过的证据
    • 这种写法的代价
    • 怎么收
    • 写在后面

前段时间翻供应链项目的代码,翻到一个让我停下来的地方。

PMS 模块有个PmsDemandDomainServiceImpl,四千多行,作者是个老同事。我顺手搜了一下DataSourceTransactionManager,结果在同一个类里蹦出来四处。四处手写事务,姿势一模一样:DefaultTransactionDefinition→PROPAGATION_REQUIRES_NEW→getTransaction→ try-catch-commit-rollback。

最让我愣住的是cancel方法。public、@Override、接口实现,标准得不能再标准的 Spring Bean 方法。这种位置贴一个@Transactional就完事了,天经地义。但它偏不,硬是自己 new 了一个事务定义,手工开、手工提、手工回滚。

我又翻到PmsWorkflowManagerServiceImpl,里面的saveBill也是同样的八股。同一个作者,两个类,四遍一模一样的写法。

这已经不是偶发了。

现场

先看PmsWorkflowManagerServiceImpl这条链。这是单据转换的入口,把订单申请转成配送单和采购单:

private void billConvert(BillConvertReq billConvertReq){ //校验 checkBill(billConvertReq); ​ //处理数据转换 BillConvertDataDTO billConvertData = handlerBill(billConvertReq); ​ //落库 saveBill(billConvertData); }

三段式,很干净。校验、转换、落库。问题出在最后一步saveBill:

private void saveBill(BillConvertDataDTO billConvertDataDTO){ DefaultTransactionDefinition def = new DefaultTransactionDefinition(); def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); TransactionStatus transaction = dstManager.getTransaction(def); ​ try { if(CollectionUtils.isNotEmpty(billConvertDataDTO.getPurchBillList()) && CollectionUtils.isNotEmpty(billConvertDataDTO.getPurchBillDetailList())){ pmsPurchaseOrderRepositoryService.saveBatch(billConvertDataDTO.getPurchBillList()); pmsPurchaseDetailRepositoryService.saveBatch(billConvertDataDTO.getPurchBillDetailList()); } ​ if(CollectionUtils.isNotEmpty(billConvertDataDTO.getDeliveryBillList()) && CollectionUtils.isNotEmpty(billConvertDataDTO.getDeliveryBillDetailList())){ wdDeliveryBillRepository.saveBatch(billConvertDataDTO.getDeliveryBillList()); wdDeliveryBillDetailRepository.saveBatch(billConvertDataDTO.getDeliveryBillDetailList()); } ​ dstManager.commit(transaction); } catch (BizException e) { Logs.error("PmsWorkflowManagerServiceImpl.saveBill.bizException", e); dstManager.rollback(transaction); BizLangExceptions.throwWithErrorCode(SysAppCodeEnum.PMS.getCode(), SysAppCodeEnum.DEFAULT_MODULE_CODE.getCode(),e.getError()); } catch (Exception e) { Logs.error("PmsWorkflowManagerServiceImpl.saveBill.error", e); dstManager.rollback(transaction); BizLangExceptions.throwWithErrorCode(SysAppCodeEnum.PMS.getCode(), SysAppCodeEnum.DEFAULT_MODULE_CODE.getCode(),PmsErrorCodeEnum.SC_PMS_999_B001); } }

每一个字符我都认识,但组合在一起就是很扎眼。事务定义、传播行为、状态对象、try、两组 saveBatch、commit、两个 catch、两次 rollback、两次重抛。

十几行业务,二十几行事务脚手架。

再看PmsDemandDomainServiceImpl.cancel:

@Override public void cancel(CancelDemandBillReq param) { lockDemandBill(param.getBillNo()); ​ DefaultTransactionDefinition def = new DefaultTransactionDefinition(); def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); TransactionStatus transaction = dstManager.getTransaction(def); ​ try{ // 几十行查询、判断、更新 // ... dstManager.commit(transaction); } catch (...) { // 同样的 rollback + 重抛 } }

这是一个 public 方法。@Override标在那,说明是接口实现。Spring 的声明式事务对这种位置是天然友好的——AOP 代理拦截 public 方法,事务边界由框架托管。写代码的人不可能不知道。

但他还是选择手写。

为什么不贴注解

我琢磨了一下,能想到的理由有四个,按可能性排序。

理由一:REQUIRES_NEW,外层的事务靠不住

注意两段代码都设了PROPAGATION_REQUIRES_NEW。这个传播行为的语义是:挂起当前事务,新开一个独立事务,内层提交与否跟外层无关。

单据转换这种链路,外层往往有调用方带来的事务。比如审批流驱动、事件驱动、消息驱动,外层事务的边界未必跟单据落库的边界对齐。写代码的人大概率担心:万一外层回滚了,把内层已经落库的采购单、配送单一起回滚掉,单据状态就乱了。

用REQUIRES_NEW把落库这段独立出来,是我能想到的最直接的防御手段。

@Transactional(propagation = Propagation.REQUIRES_NEW)也能干这事,但写在注解上语义不如手工代码直白——传播行为藏在元数据里,看代码的人得点进去才能看见。手写把这件事摆在明面上,谁读都看得见。

有个细节我没完全确认:文章里提到,如果外层挂了,REQUIRES_NEW会导致内层独立提交。但我这里没有实际去测过,在极端的容器被kill -9或者断电的情况下,不同数据库(比如 MySQL 的 InnoDB 和 Oracle)的强制恢复机制,对这个内层已提交数据的表现是不是完全一样?有踩过这种“极端宕机”坑的兄弟,评论区指点一下。

理由二:private 方法,代理进不来

billConvert和saveBill都是 private。Spring 的声明式事务走 AOP 代理,默认只能拦 public 方法。private 方法上贴@Transactional是不会有效果的——不是报错,是静默失效。这是 Spring 新手最常踩的坑之一。

saveBill要事务,但它是 private,贴注解没用。要事务又不改可见性,只能手写。

写代码的人应该是清楚这一点的。他选择把方法保持 private(这是对的,内部步骤不该暴露),然后在 private 方法里手工开事务。这个权衡本身没毛病。

理由三:链路太长,事务边界想自己控

单据转换这条链,校验、转换、落库三步。校验里要查库做业务判断,转换里要按规则组装数据,落库才真正写表。

如果用@Transactional包在入口的 public 方法上,那整个三步都在一个事务里。校验期间的查询、转换期间的内存计算,全都占着数据库连接。链路上任何一步慢,连接就一直被持有。

把事务压缩到saveBill这一步,只在真正写库的时候开连接,是一个合理的设计直觉。

理由四:习惯

同一个作者在两个类里写了四遍一模一样的八股。这已经不是技术选择了,是肌肉记忆。

我见过不少从老项目过来的同事都有这个习惯。早年的项目里@Transactional翻车过几次——self-invocation 失效、异常类型不匹配不回滚、嵌套调用传播行为不符合预期——摔过几次之后,干脆全手写。手写虽然啰嗦,但每一行都看得见摸得着,不会有"注解在但事务没了"的灵异事件。

“这种不信任框架、只相信自己双手的‘肌肉记忆’,不仅在这段代码里有,在这个供应链系统里,甚至我翻过的其他 10 个老项目里,比比皆是。这就是我开启「多项目版」老炮踩坑录的原因。”

摇摆过的证据

PmsDemandDomainServiceImpl的 L4186 附近,还有一段让我看笑了的代码:

private void saveBill(BillConvertDataDTO billConvertDataDTO, PmsDemandBillResultResp pmsDemandBillResultResp){ // DefaultTransactionDefinition def = new DefaultTransactionDefinition(); // def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); // TransactionStatus transaction = dstManager.getTransaction(def); // ... }

一整段手工事务被注释掉了,但代码留着没删。

我能想象当时的心理活动:第一次写完手工事务,回头一看觉得太重了,注解试试?试完不放心,又改回手写。改回手写之后那段注释也没删——留着当备份,万一哪天又想换回去呢。

注释里躺着的,是一次没走通的内心挣扎。

这种写法的代价

手写事务不是错。但散在两个类、四个方法里的手写事务,代价会累积。

脚手架稀释业务

saveBill这个方法,核心业务就两件事:保存采购单主表 + 明细,保存配送单主表 + 明细。两个 if,四个 saveBatch。但要读完这个方法,我得先穿过事务定义、try-catch、commit、rollback、错误码重抛这一整套八股。

业务代码和事务代码的比例是 1:2。任何来改这个方法的人,都要先在脑子里把脚手架剥离掉才能看到业务。

异常路径靠自觉

catch BizException在前、catch Exception在后——这个顺序是对的。但如果哪天来个新同事,加 catch 的时候顺序写反,BizException 会被 Exception 兜底掉,业务错误码变成系统错误码,前端拿到的提示就变了。

手写代码的每一个环节都依赖下一个维护者的自觉。注解把这些事交给了框架,手写把这些事交还给了人。人就会犯错。

REQUIRES_NEW 的暗坑

REQUIRES_NEW挂起外层事务,意味着内层独立提交。如果内层 commit 成功,外层后续步骤挂了回滚,内层的单据已经落库——就出现了"外层报错但单据已经生成"的脏数据。

这种脏数据在测试环境很难复现,因为外层很少真的挂。但生产上一旦遇到外层超时、外层被 kill、外层依赖的下游挂了,内层那几条采购单、配送单就成了无源之水。

我不是说这里一定会出问题。我是说,这个风险写在传播行为里,注释里看不到,代码评审也未必能揪出来。

没有 finally

严格来说这段代码不写 finally 是 OK 的——commit 和 rollback 都在 try/catch 里覆盖了。但如果 catch 块里的Logs.error或者BizLangExceptions.throwWithErrorCode自己抛异常(概率低,但理论上存在),事务就悬在半空。

@Transactional不会有这个问题,框架在 finally 里兜底。

怎么收

如果今天让我改这段代码,我会分两步走。

第一步:用 TransactionTemplate 替掉裸八股

private final TransactionTemplate requiresNewTemplate; ​ public PmsWorkflowManagerServiceImpl(PlatformTransactionManager txManager) { this.requiresNewTemplate = new TransactionTemplate(txManager); this.requiresNewTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); } ​ private void saveBill(BillConvertDataDTO dto) { requiresNewTemplate.execute(status -> { if (CollectionUtils.isNotEmpty(dto.getPurchBillList())) { pmsPurchaseOrderRepositoryService.saveBatch(dto.getPurchBillList()); pmsPurchaseDetailRepositoryService.saveBatch(dto.getPurchBillDetailList()); } if (CollectionUtils.isNotEmpty(dto.getDeliveryBillList())) { wdDeliveryBillRepository.saveBatch(dto.getDeliveryBillList()); wdDeliveryBillDetailRepository.saveBatch(dto.getDeliveryBillDetailList()); } return null; }); }

TransactionTemplate.execute内部已经处理了 try-catch-commit-rollback 八股,业务代码只关注自己要写什么。传播行为还是 REQUIRES_NEW,语义没变,但脚手架消失了。

第二步:把事务边界往上抬

更彻底的做法,是让billConvert不再是 private,或者把saveBill提到一个独立的 public 服务里,然后贴@Transactional(propagation = REQUIRES_NEW):

@Service public class BillConvertPersistenceService { ​ @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) public void saveBill(BillConvertDataDTO dto) { // ... } }

事务边界由专业类承担,业务编排类只负责调用。每一层的职责更清晰,注解和手写之争也不存在了——该用注解的地方用注解,该独立出来的类独立出来。

顺手的事:删注释

L4186 那段被注释掉的手工事务,留着只会让后来的人猜。删了。Git 历史里都有,想找随时找得到。

自查打卡:在你们项目里全局搜一下getTransaction或者DataSourceTransactionManager。 看看有多少个方法跟你一样,是在“手写八股”管理事务?打在评论区,我看看谁的项目里“老古董”最多。

写在后面

手工事务这种写法,怎么看都有点时代感。早年 Spring 事务翻车案例多,老一代工程师被教育过,留下了"能手写就不注解"的肌肉记忆。这份谨慎在当时救过命。

但时代变了。声明式事务的坑点都已经被翻烂,社区对失效场景的总结足够全,框架本身也越来越稳。继续全手写,收益没变,成本却一直在涨。

我又回去看了一眼cancel方法那行@Override。一个明明白白可以用注解的位置,硬生生写了二十行八股。

下次再看到手工事务,先问一句它当年为什么不敢用注解。问完再决定动不动它。

老炮的弹药库:
像这种“手写事务”的陈年旧账,我的硬盘里还躺着 78 篇。 从 P2P 网关到金融对账,从商品中台到低代码平台。我把这些年踩过的大坑、排过的雷,整理成了《老炮踩坑录 · 多项目版》。如果你现在的系统也有类似历史遗留问题,欢迎关注我的 CSDN 专栏《老炮踩坑录》,查看那 78 篇多项目实战复盘。


最后问一句:
你们项目里的事务管理,是坚定地拥抱@Transactional注解,还是像这位老同事一样,必须自己手写DataSourceTransactionManager才觉得踏实? 有没有因为加错注解或者传播行为没配好,导致线上数据没回滚的惨痛经历?评论区聊聊,我看看哪种做法的人更多。


下期预告:《Redis 更新失败被 catch 掉,缓存就这么悄悄歪了》

这篇讲的是,有些代码看似把异常处理了,实际上是给系统埋下了一颗定时炸弹。当一个更新 Redis 的操作失败,代码只是轻飘飘地打了一行log.error就继续往下走,数据库和缓存的数据从此分道扬镳。

下一篇,我们去促销系统里看看,这种“静默失败”是怎么把库存口径搞歪的,以及怎么用一套重试和告警机制把这个洞堵上。

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言

你的每一次互动都是我继续更新的动力,我们下一篇见!🚀

我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。


关联阅读:

  • 《缓存重建的分布式锁三段式》——同样是手写八股,同样的问题
  • 《策略、工厂、模板方法、观察者、AOP——五种模式在同一个系统里的落点》——供应链项目的设计模式全景

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询