Hello~各位读者新年好!这里楼下小黑哥给大家拜个年,祝大家蒸蒸日上烫烫烫,年年有余屯屯屯。
那年那 Bug
春节放假,小黑哥坐上高铁回家,突然想到一次生产问题。那是小黑哥参加工作第一年,那一年国庆假期,小黑哥提前一天请假回家办个护照。那时候刚开始负责一个生产系统,所以工作日请假,还是有点担心,就怕 问题 看小黑哥不在,悄然上门。
哎,真实越怕什么,就来什么。
高铁开到一半的时候,同事反馈系统不能获取最新的流水信息(流水信息通过 Spring
定时任务定时拉取)。小黑哥心里一惊,立刻拔出电脑,连上 V** ,准备登上生产机器,查看系统情况。可是,高铁上网络大家也懂,很不稳定,连了好久连不上 V** ,只好远程指挥同事看一下系统日志。通过同事反馈的日志,发现拉取流水定时任务没有执行,进一步查看,小黑哥发现整个系统其他的定时任务也都停止了。。。
这真是一个奇怪的的问题,这好端端的定时任务怎么会突然停止?
暂时想不到解决办法,只好指挥同事先重启应用。重启之后,暂时解决问题,定时任务重新开始执行,也获取到最新的付款流水信息。
问题排查
到家之后,小黑哥立刻登上生产机器,查看系统日志,发现重启之前某一定时任务运行到一半,并且在这之后其他定时任务就没有再被执行。
通过系统日志,定位到了有问题的代码。
这里采用 重试补偿策略 ,防止查询流水信息因为网络等问题发生偶发的失败。这个策略面对偶发的失败没什么问题,但是如果查询银行流水服务一直失败,这段代码就会陷入死循环。恰巧那段时间网络出现一些问题,导致这里查询一直处于失败。
增加最大重试次数,修复该 Bug
。
修复之后,立刻将最新版本代码部署到生产系统,暂时解决了这个问题。
知识点:面对一些失败,可以采用重试补偿策略,重新执行,最大可能保证执行成功,但是这里切记设置合适的的 重大的次数 。
深入排查
虽然问题解决了,但是小黑哥心里还是存在一个疑惑,为何一个定时任务发生了阻塞,就会影响执行其他定时任务。小黑哥最初的理解是不同的定时任务应该互相隔离,互不影响才对,真难到是 Spring
定时任务的 Bug 吗?
想到这里,小黑哥决定写一个 Demo ,复现问题,然后深入源码排查。
启动程序,日志输出如下:
从日志可以看到,fixDelayMethod
方法执行之后进入休眠,直到休眠结束,cronMethod
定时任务才有机会被执行。另外从上面可以看到,上述两个定时任务都由 pool-1-thread-1
线程执行。从这点可以看出 Spring
定时任务将会交给线程池执行。
知识点: 线程池中线程默认命名策略为 pool-%poolNumber-thread-%num。
如果线程池只有一个工作线程,该线程一旦被长时间阻塞,堆积的其他任务就没有机会被执行。
那么是不是这个问题导致的 Sping
定时任务停止执行?我们继续往下排查。
图上日志绿色部分, ScheduledAnnotationBeanPostProcessor
输出一个重要信息:
No TaskScheduler/ScheduledExecutorService bean found for scheduled processing
查看 Spring
文档,Spring
内部将会通过调用 TaskScheduler
执行定时任务,而另一个 ScheduledExecutorService
为 JDK
提供执行定时任务的执行器。记住这两者
通过这段日志,使用 IDEA 的强大的 关键字搜索功能 ,定位到 ScheduledAnnotationBeanPostProcessor#finishRegistration
方法。
这个方法比较长,大家重点关注图中标示的几处。
Spring
启动之后将会扫描所有 Bean
中带有 @Scheduled
注解的方法,然后封装成 Task
子类放置到 ScheduledTaskRegistrar
。
这段代码位于
ScheduledAnnotationBeanPostProcessor#processScheduled
,感兴趣的可以翻阅查看
如果此时 ScheduledTaskRegistrar
不存在定时任务或者 ScheduledTaskRegistrar
中的 TaskScheduler
不存在,finishRegistration
将会多次调用 ScheduledAnnotationBeanPostProcessor#resolveSchedulerBean
方法用以查找 TaskScheduler/ScheduledExecutorService
。
接下去将会把获取到 Bean
通过 setScheduler
注入到 ScheduledTaskRegistrar
中。
如果获取的为 ScheduledExecutorService
类型,将会将其封装到 taskScheduler
中。
最后还没找到,将会输出最刚开始见到的日志。然后 Spirng
将会在 ScheduledTaskRegistrar#afterPropertiesSet
创建一个单线程的定时任务执行器 ScheduledExecutorService
,注入到 ConcurrentTaskScheduler
中,然后通过 taskScheduler
执行定时任务。
交给TaskScheduler
的定时任务最后实际上还是通过 ScheduledExecutorService
执行。
这里可以得出一个 结论 :
Spring
定时任务实际上通过 JDK
提供的 ScheduledExecutorService
执行。默认情况下,Spring 将会生成一个单线程ScheduledExecutorService
执行定时任务。所以一旦某一个定时任务长时间阻塞这个执行线程,其他定时任务都将被影响,没有机会被执行线程执行。
Spring 这种默认配置,在需要执行多个定时任务的情况,可能会是一个坑。我们可以通过改变配置,使 Spring 采用多线程执行定时任务。
自定义配置
Spring 可以通过多种方式改变默认配置。
xml 配置
通过 xml
配置 TaskScheduler
线程数。
<task:annotation-driven scheduler="myScheduler"/>
<task:scheduler id="myScheduler" pool-size="10"/>
通过上面的配置,Spring 将会使用 TaskScheduler
子类 ThreadPoolTaskScheduler
,内部线程数为 pool-size
数量,这个线程数将会直接设置 ScheduledExecutorService
线程数量。
注解配置
在上面问题排查中,我们知道 Spring
将会查找 TaskScheduler/ScheduledExecutorService
,若存在将会使用。所以这里我们可以生成这些类的 Bean
。
以上方式二选一即可
SpringBoot 配置
上面两种配置适用于普通 Spring
,比较繁琐。相比而言 SpringBoot
配置将会非常简单,只需要在启动配置文件加入如下配置即可。
spring.task.scheduling.pool.size=10
spring.task.scheduling.thread-name-prefix=task-test
技术总结
下面开始技术总结:
Spring
定时任务执行原理实际使用的是JDK
自带的ScheduledExecutorService
Spring
默认配置下,将会使用具有单线程的ScheduledExecutorService
- 单线程执行定时任务,如果某一个定时任务执行时间较长,将会影响其他定时任务执行
- 如果存在多个定时任务,为了保证定时任务执行时间的准确性,可以修改默认配置,使其使用多线程执行定时任务
- 面对偶发的失败,我们可以采用重试补偿策略,不过这里切记设置合适的最大重试次数
随便聊聊
对于常用的开源框架,我们不仅要掌握怎么用,还要熟悉相关的配置,最后还应该去了解其内部的使用的原理。这样出了问题,我们也能很快定位问题,找到问题的实际原因。
帮助文档
Spring scheduling-task-scheduler
欢迎关注我的公众号:程序通事,获得日常干货推送。如果您对我的专题内容感兴趣,也可以关注我的博客:studyidea.cn