手写事务 vs @Transactional:从供应链项目的一段代码说起

42 阅读11分钟

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

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

编号:SCM-F01

素材:PmsWorkflowManagerServiceImpl.java、PmsDemandDomainServiceImpl.java

👋 欢迎阅读

scm01.jpg 🏠个人主页:知守观
📘我的专栏: 老炮踩坑录
💻当前内容:手写事务 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 网关到金融对账,从商品中台到低代码平台。我把这些年踩过的大坑、排过的雷,整理成了《老炮踩坑录 · 多项目版》。 如果你现在的系统也像这样“没人敢动”,或者需要老系统重构、线上故障排查,欢迎加我微信:知守观(备注“公众号”秒过)。咱们直接聊痛点。


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


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

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

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

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

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

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

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


关联阅读:

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