Spring事务模板及afterCommit存在的坑

大家好,我是墨哥(隐墨星辰)。今天的内容来源于两个线上问题,主要和大家聊聊为什么支付系统中基本只使用事务模板方法,而不使用声明式事务@Transaction注解,以及使用afterCommit()出现连接未按预期释放导致的性能问题。

1. 为什么不使用@Transaction注解

以前写管理平台的代码时,经常使用@Transaction注解,也就是所谓的声明式事务,简单而实用,但是在做支付后,基本上没有使用@Transaction,全部使用事务模板来做。主要有两个考虑:

1)事务的粒度控制不够灵活,容易出现长事务

@Transactional注解通常应用于方法级别,这意味着被注解的方法将作为一个整体运行在事务上下文中。在复杂的支付流程中,需要做各种运算处理,很多前置处理是不需要放在事务里面的。

而使用事务模板的话,就可以更精细的控制事务的开始和结束,以及更细粒度的错误处理逻辑。

scss 复制代码
@Transactional
public PayOrder process(PayRequest request) {
    validate(request);
    PayOrder payOrder = buildOrder(request);
    save(payOrder);
    // 其它处理
    otherProcess(payOrder);
}

比如上面的校验,构建订单,其它处理都不需要放在事务中。

如果把@Transactional从process()中拿走,放到save()方法,也会面临另外的问题:otherProcess()依赖数据库保存成功后才能执行,如果保存失败,不能执行otherProcess()处理。全部考虑进来后,使用注解处理起来就很麻烦。

2)事务传播行为的复杂性

@Transactional注解支持不同的事务传播行为,虽然这提供了灵活性,但在实际应用中,错误的事务传播配置可能导致难以追踪的问题,如意外的事务提交或回滚。

而且经常有多层子函数调用,很容易子函数有一个耗时操作(比如RPC调用或请求外部应用),一方面可能出事长事务,另一方面还可能因为外调抛异步,导致事务回滚,数据库中都没有记录保存。

以前就在生产上碰到过类似的问题,因为在父方法使用了@Transactional注解,子函数抛出异常,去数据库找问题单据,竟然没有记录,翻代码一行行看,才发现问题。

2. afterCommit存在的问题及解法

有一次参与线上压测,在流量上去后,应用持续报获取数据库连接超时,排查很久才找到原因,问题非常经典,值得和大家聊聊。

无论在支付系统,还是电商系统,还是其它各种业务系统,都存在这样的需求:在一个事务中既保存多个数据库表,又要外发请求,且这个外发请求耗时很长。

比如:方法A保存数据库表A,方法B保存数据库表B,并且要外发给其它系统且耗时长,方法C要保存数据库表C。这三个方法需要在一个事务里面。

我见过三种方案:

方案一:不管三七二十一,就直接放在一个事务中。请求量不大时,看不出长事务的影响。

方案二:知道使用Spring提供的模板方法:TransactionSynchronizationAdapter.afterCommit()。外发请求耗时长过长时,在大并发下仍然有连接未能及时释放的问题。

方案三:自己实现事务模板方法,在Spring提交事务并释放连接后,再执行耗时长的外发。

第一种没什么好说的,下面介绍方案二和方案三,两者区别如下图所示:

在支付系统中,经常需要做一些流程编排,这些流程操作需要放在一个事务中,比如先保存主单据,再保存流水单据,然后外发银行请求扣款,有同学写的代码类似这样:

主方法伪代码(流程引擎入口):

scss 复制代码
public void process(FlowContext context) {
    // 获取流程处理链
    List<FlowProcess> flows = fetchFlow(context);
    for (FlowProcess flow : flows) {
        // 使用事务模板
        dataSourceManager.getTransactionTemplate().execute(status -> {
            // 执行子流程
            flow.execute(context); 

            // 更新主单信息
            context.getPayOrder().putJournal(context.getJournal());
            context.getPayOrder().transToNextStatus(context.getJournal().getTargetStatus());
            save(context.getPayOrder());
            return true;
        });
    }
}

其中一个外发银行子流程伪代码:

scss 复制代码
public void execute(FlowContext context) {
    Journal journal = buildJournal(context);
    // 子函数里面保存了3张表的数据
    save(journal);
    
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
        @Override
        public void afterCommit() {
            // 事务提交后,再发送给外部银行
            gatewayService.sendToChannel(journal);
        }
    });
}

预期是事务提交后再调用发给银行。

但是实际情况却是,Spring提交事务后,调用了afterCommit(),但是并没有释放连接,导致在外发银行的长达1000多毫秒的时间内,数据库连接一直在保持,而不是提交事务后马上归还了连接,加上线上服务器的连接数只分配了30个给每台应用。这就意味着最大并发也小于30。

通过查看AbstractPlatformTransactionManager.java,发现是先调用:riggerAfterCommit(status),然后才清理并释放连接:cleanupAfterCompletion(status)。

java 复制代码
    private void processCommit(DefaultTransactionStatus status) throws TransactionException {
        try {
            // 其它代码省略
            ... ...
            
    		// Trigger afterCommit callbacks, with an exception thrown there
    		// propagated to callers but the transaction still considered as committed.
    		try {
    			triggerAfterCommit(status);
    		}

            // 其它代码省略
            ... ...

		}
		finally {
			cleanupAfterCompletion(status);
		}
	}

解决办法:自己创建一个事务模板,实现afterCommit()。

ini 复制代码
public class FlowTransactionTemplate { 

    public static <R> R execute(FlowContext context, Supplier<R> callback) { 
        TransactionTemplate template = context.getTransactionTemplate();
        Assert.notNull(template, "transactionTemplate cannot be null"); 
        
        PlatformTransactionManager transactionManager = template.getTransactionManager();
        Assert.notNull(transactionManager, "transactionManager cannot be null"); 

        boolean commit = false;
        try {
            TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); // Corrected "TranscationStatus" to "TransactionStatus"
            R result = null;
            try {
                result = callback.get();
            } catch (Exception e) {
                transactionManager.rollback(status); 
                throw e;
            }
            transactionManager.commit(status);
            commit = true;
            return result;
        } finally {
            if (commit) {
                invokeAfterCommit(context);
            }
        }
    }

    private static void invokeAfterCommit(FlowContext context) {
        try {
            context.invokeAfterCommit();
        } catch (Exception e) {
            // 打印日志
            ... ...
        }
    }
}

FlowContext加上事务提交后的执行的钩子方法,在钩子方法中实现一些长耗时工作:

csharp 复制代码
public class FlowContext {
    // 其它代码不变
    ... ...

    private List<AfterCommitHook> afterCommitHooks = new ArrayList<>();

    public void registerAfterCommitHook(AfterCommitHook hook) {
        afterCommitHooks.add(hook);
    }

    public void invokeAfterCommit() {
        try {
            for(AfterCommitHook hook : afterCommitHooks) {
                hook.afterCommit();
            }
        } catch (Exception e) {
            // 异常处理
            ... ...
        } finally {
            // 钩子已执行完,清理掉
            afterCommitHooks.clear();
        }
    }

    public static abstract class AfterCommitHook {
        public abstract void afterCommit();
    }
}

主流程修改为直接调用:FlowTranscationTemplate.execute。

scss 复制代码
public void process(FlowContext context) {
    context.setTransactionTemplate(dataSourceManager.getTransactionTemplate());
    
    List<FlowProcess> flows = fetchFlow(context);
    
    for (FlowProcess flow : flows) {
        // 把Spring模板方法修改自己的模板方法,其它不变
        FlowTransactionTemplate.execute(context, () -> {
            flow.execute(context);

            context.getPayOrder().putJournal(context.getJournal());
            context.getPayOrder().transToNextStatus(context.getJournal().getTargetStatus());
            save(context.getPayOrder());
                
            return true;
        });
    }
}

子流程修改为把afterCommit要做的事注册到流程上下文中:

scss 复制代码
public void execute(FlowContext context) {
    Journal journal = buildJournal(context);
    // 子函数里面保存了3张表的数据
    save(journal);

    // 把外发动作注册到流程上下文中的钩子方法中,
    // 而不是直接使用Spring原生的TransactionSynchronizationAdapter.afterCommit()
    // 其它保持不变
    context.registerAfterCommitHook(() -> {
        // 事务提交后发给银行
        gatewayService.sendToChannel(journal);
    });
}

这样处理的优点有几个:

  1. 清晰的事务边界管理:通过显式控制事务的提交和回调执行,增加了代码的可控性。
  2. 资源使用优化:确保数据库连接在不需要时能够及时释放,提升了资源的使用效率。
  3. 灵活的后续操作扩展:允许注册多个回调,方便地添加事务提交后需要执行的操作,增强了代码的扩展性和复用性。

有个注意的点,就是确保invokeAfterCommit的稳健性,代码里是通过捕获异常打印日志,避免对其它操作有影响。

3. 扩展:长事务

长事务指的是在数据库管理和应用开发中,持续时间较长的事务处理过程。一般来说,在分布式应用中,每个服务器分配的连接数是有限的,比如每个服务器20个连接,这就要求我们必要尽量减少长事务,以便处理更多请求。

典型的方案有:

1)非事务类操作,就放在事务外面。比如前置处理,先请求下游获取资源,做各种校验,全部通过后,再启动事务。还有就是使用hook的方式,等事务提交后,再请求外部耗时的服务。

2)事务拆分。把一个长事务拆分为多个短事务。

3)异步处理。有点类似hook的方案。

4. 结束语

Spring事务管理提供了强大而灵活的机制来处理复杂的业务逻辑,但是每个特性和工具的使用都需要对其行为有深入的理解,而不能想当然。比如文中的afterCommit就是这样一个典型例子。

自定义事务模板的实践向我们展示了,虽然@Transcation注解很方便,但在一些特殊场景下,需要我们深入了解框架的工作原理并结合实际业务需求,既高效地利用Spring提供的工具,同时也规避潜在的坑点。

希望本文能够帮助读者更好地理解和应用Spring事务管理中的afterCommit钩子,以及如何在对资源或性能要求很严格的情况下,比如支付场景,如何定义自己的事务模板,帮助我们构建更健壮、更高效的应用。

这是《百图解码支付系统设计与实现》专栏系列文章中的第(30)篇。 和墨哥(隐墨星辰)一起深入解码支付系统的方方面面。

欢迎转载。

Github(PDF文档全集,不定时更新): https://github.com/yinmo-sc/Decoding-Payment-System-Book

精选

专栏地址 百图解码支付系统设计与实现
《百图解码支付系统设计与实现》专栏介绍
《百图解码支付系统设计与实现》专栏大纲及文章链接汇总(进度更新于2023.2.4)
领域相关(部分)
支付行业黑话:支付系统必知术语一网打尽
跟着图走,学支付:在线支付系统设计的图解教程
图解收单平台:打造商户收款的高效之道
图解结算平台:准确高效给商户结款
图解收银台:支付系统承上启下的关键应用
图解支付引擎:资产流动的枢纽
图解渠道网关:不只是对接渠道的接口(一)
技术专题(部分)
交易流水号的艺术:掌握支付系统的业务ID生成指南
揭密支付安全:为什么你的交易无法被篡改
金融密语:揭秘支付系统的加解密艺术
支付系统日志设计完全指南:构建高效监控和问题排查体系的关键基石
避免重复扣款:分布式支付系统的幂等性原理与实践
支付系统的心脏:简洁而精妙的状态机设计与核心代码实现
精确掌控并发:固定时间窗口算法在分布式环境下并发流量控制的设计与实现
精确掌控并发:滑动时间窗口算法在分布式环境下并发流量控制的设计与实现

相关推荐
计算机毕业设计小帅7 分钟前
【2026计算机毕业设计】基于Springboot的校园电动车短租平台
spring boot·后端·课程设计
调试人生的显微镜7 分钟前
Web前端开发工具实战指南 从开发到调试的完整提效方案
后端
静心观复7 分钟前
drawio画java的uml的类图时,class和interface的区别是什么
java·uml·draw.io
Java水解8 分钟前
【SQL】MySQL中空值处理COALESCE函数
后端·mysql
Laplaces Demon9 分钟前
Spring 源码学习(十四)—— HandlerMethodArgumentResolver
java·开发语言·学习
guygg8813 分钟前
Java 无锁方式实现高性能线程
java·开发语言
ss27314 分钟前
手写Spring第7弹:Spring IoC容器深度解析:XML配置的完整指南
java·前端·数据库
Python私教16 分钟前
DRF:Django REST Framework框架介绍
后端·python·django
间彧18 分钟前
Java HashMap如何合理指定初始容量
后端
用户40993225021231 分钟前
PostgreSQL全表扫描慢到崩溃?建索引+改查询+更统计信息三招能破?
后端·ai编程·trae