0%

SpringBoot异步进程池的ThreadContext复用

记一次工作项目遇到的进程池ThreadContext复用问题。

遇到的问题

最近在进行某个SpringBoot项目改造中,遇到了需要动态切换数据源的操作。这部分交给我的徒弟来做,她的实现方式如下:继承org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource,实现了一个自定义动态数据源DynamicDataSource,重写determineCurrentLookupKey()这个方法用于从ThreadContext获取当前进程所需要使用的数据源Key。

查看源码发现,ThreadContext是公司内自研框架对于ThreadLocal的封装,用于快速存取进程内上下文。

由于项目原有功能使用的都是主数据库,这次项目改造需要为新增模块配置一个副数据库,因此在改造时设置了默认数据源为主库,只在副库的逻辑代码里添加了对ThreadContext赋值的操作。实际测试中也未发现问题,项目原有功能因为不指定数据库,从ThreadContext中获取的数据库Key为null,默认使用主数据库;新增模块内因为全部显式指定了副库的Key,都正常获取了副库内的数据。

然而业务上线后不到一周的时间内就出现了问题:项目新增模块运行都正常,但是原有功能模块会向副库发起查询。万幸的是因为两个库的表名表结构均不相同,请求库错误时会直接抛出异常,没有造成客户的资金损失。

问题排查与复现

因为项目原有代码在改造时无任何变化,从新增的多数据源切换代码部分进行排查。

一开始的思考方向是数据库事务造成的多数据源切换失败。但是经过排查,涉及事务的方法并未受影响,报错集中在特定几个使用了@Async的异步方法上。因为新增模块也涉及到和原有功能使用同一个异步进程池,因此思考可能是异步进程数据源共用的问题。

经过查找公司内自研框架的材料,看到开发人员的如下解答:

  • “使用者必须考虑线程内部的清理工作,可以通过调用ThreadContext#clear方法显式清理线程内部存储,否则存在极其严重的内存泄露隐患”
  • “…提供了一个交易后自动清理的Servlet Filter,但是该过滤器只对基于Servlet的同步HTTP交易开发有用,对基于TCP的交易开发无效,因此基于TCP的交易开发者必须自行处理内存清理!”

因为框架内封装了对于HTTP请求结束后的ThreadLocal清理,所以同步方法不受影响,每次不指定数据源的请求都会使用默认数据源。而异步进程使用了独立的进程池,由于没有在使用后进行ThreadLocal清理,如果某个进程上一次被指定使用了副数据源,下一次使用该进程的未指定数据源方法就会随之使用副数据源,造成串库的现象。

尝试复现,将自定义的异步线程池降低到单个线程,先后启动以下三个进程:

  1. 使用默认数据源的同步方法A,该同步方法调用一个同样使用默认数据源的异步方法a
  2. 使用指定副数据源的同步方法B,该同步方法调用一个同样使用指定副数据源的异步方法b
  3. 使用默认数据源的同步方法A,该同步方法调用一个同样使用默认数据源的异步方法a

发现使用默认数据源的同步方法A能够一致保持使用默认数据源,方法B和b由于显式指定了副数据源,实际使用的数据源也是正确的。在第二次执行异步方法a时,由于复用了方法b使用的线程且未显式指定数据源,ThreadLocal中标注的仍为副数据源,执行发生了错误。

问题修复

由于原始项目内使用的异步方法不多,计划给所有的异步方法加上显式指定数据库,上线后待观察。

欢迎在Weibo和Twitter关注我