Spring 定时任务,我遇到过的那几个坑
起因
项目里有个对账任务,每天凌晨跑一次,把前一天的交易记录和第三方流水比一遍。用 @Scheduled 写上去,测试环境跑得好好的,上线之后出了问题:对账结果重复了两遍。
查下来才发现,生产环境部署了两个实例,两个实例各自跑了一遍定时任务。@Scheduled 只在单机内保证不重复,跨实例它管不了。
单机下的坑也不少
默认单线程。 加了好几个 @Scheduled 方法之后发现它们是串行执行的,一个任务跑得慢,后面的全在等。原因是 Spring 默认给调度器配了一个单线程的 ThreadPoolTaskScheduler。要并行得自己配:
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("sched-");
return scheduler;
}
线程池大小按任务数量和耗时来定,别一拍脑袋写个 50,线程多了调度开销也上来了。
cron 表达式写错,不报错。 有一次我把 0 0 2 * * ? 写成了 0 0 2 * * *,不报错,但执行时间和预期不一样。Spring 的 cron 用的是 CronExpression,格式不对在启动时能抛异常,但“格式对、语义错”的情况不会提示,只能对着文档一个字段一个字段看。秒、分、时、日、月、周,六个字段,记不清就查,别凭感觉。
任务抛出异常,后续不执行。 @Scheduled 方法里抛了未捕获的异常,这个任务的下一次执行就停了。不是整个调度器停,是这一个任务停。日志里有个 Unexpected error occurred in scheduled task,不注意看根本发现不了。我现在所有定时任务方法体最外层都包一层 try-catch,记日志,不让异常往外冒。
多实例怎么办
回到开头那个问题。方案有这么几个:
一是加分布式锁。 用 Redis 的 setIfAbsent,任务开始前抢锁,抢到才执行,执行完释放。简单直接,但要处理锁超时和任务执行时间的关系,任务跑得比锁的 TTL 长,锁过期了另一个实例就能进来。
二是用数据库做标记。 任务开始前往一张表里插一条记录,唯一索引冲突就不执行。适合任务本身就是数据库操作的场景。
三是把定时任务抽出去。 用 XXL-JOB 或者 ElasticJob 这种调度中心,任务只在一台机器上触发。项目里实例多了之后我倾向这个方案,省得每个任务都写一遍锁的逻辑。
我们最后用的是 Redis 锁,因为只对账这一个任务需要互斥,不值得为此引入一整套调度框架。
还有几个零碎的
@Scheduled 的方法不能有参数,也不能有返回值。 写错了编译不报,启动的时候才抛 IllegalStateException。
cron 的时区。 容器里默认时区可能是 UTC,0 0 2 * * ? 指的是 UTC 凌晨两点,不是北京时间。要么在容器里配 TZ=Asia/Shanghai,要么在 cron 里显式指定时区,Spring 5.3 之后支持 zone 属性。
长时间运行的任务和优雅停机。 停机的时候如果任务跑到一半,默认会直接中断。Spring Boot 2.3 之后可以配 spring.task.scheduling.shutdown.await-termination=true 和 await-termination-period,等任务跑完再停,避免数据写一半。
小结
单机场景下,注意线程池、异常捕获、cron 写法这几件事就够了。多实例场景,@Scheduled 本身不解决互斥问题,得自己加锁或者换调度框架。我是从单机一路踩到多实例的,每一步都以为“这次没问题了”,然后被打脸。
先记这些。
- 点赞
- 收藏
- 关注作者
评论(0)