1. 为什么Spring Boot Task值得单独拿出来说
我先说个场景。你接手了一个老项目,里面但凡需要"到点干活"的逻辑,全是自己写一个线程,配个while(true)循环,再sleep一下,到了时间就触发。表面上没啥问题,但每次日志里看到十几个线程都叫Thread-3、Thread-7,你根本分不清哪个是干嘛的;偶尔任务执行慢了,两个周期叠在一起,数据重复处理,排查半天才反应过来是线程睡过头了,根本没做并发控制。
后来我在新项目里全面切到Spring Boot Task这套体系,定时任务用@Scheduled声明式搞定,异步用@Async加上线程池配置,再配合动态注册和分布式锁,顺手把之前那些"野路子"全部干掉。这篇文章就围绕Spring Boot Task这套东西展开,从基本用法到任务并发控制,从动态注册到分布式环境下的坑,全部基于我实际跑过的项目经验来写。
先回答一个常见疑问:Spring Boot Task到底是啥?它不是某个独立组件,而是Spring Framework自带的任务调度能力包装在Spring Boot自动配置里的统称。核心就是TaskScheduler、@Scheduled注解、@Async注解,以及配套的线程池、异常处理器、属性绑定。你用Spring Boot 2.x或3.x,只要引入spring-boot-starter基础包,不需要额外加依赖,就能直接用@Scheduled做定时任务,用@Async做异步执行。这一点对于很多从Quartz转过来的人来说会觉得很爽——零依赖、注解驱动、配置少,跑起来还够稳。
但这套东西的边界也很明显:默认是单机内存级的调度,没有持久化任务表,也没有分布式协调能力。服务器重启后任务状态靠代码恢复,集群环境下多个实例会重复执行同一条任务。很多人一上来就骂它"太弱了",其实是没搞清楚它的定位。Spring Boot Task的设计哲学就是:把调度这个事做得足够轻,把核心逻辑留给业务代码。等到你真需要分布式调度、任务追踪、失败重试策略这些企业级能力时,再引入额外的调度中心。所以这篇文章的核心线索就是:先用Spring Boot Task把80%的定时和异步需求做掉,然后在需要的地方用工具补齐剩下20%的能力。
2. @Scheduled注解从用法到参数陷阱
2.1 三种基础调度方式的正确选择
@Scheduled最常用的三种方式是@Scheduled(fixedDelay = 5000)、@Scheduled(fixedRate = 5000)和@Scheduled(cron = "0 0/5 * * * ?")。我见过不少人把fixedDelay和fixedRate理解反了,关键区别在于"计时起点"不同。
fixedDelay = 5000:上一次任务执行完毕后等待5秒,再执行下一次。这个方式天然避免了任务堆积,适合对实时性要求不高但对并发敏感的任务,比如批量同步数据。fixedRate = 5000:按固定频率每5秒触发一次调度,但实际上如果任务执行时间超过了5秒,下一次触发会等到执行完成后再立即补上(单线程调度器下)。适合监控类、心跳类的轻量任务。cron表达式:最灵活,支持秒级、分时、日月周等维度,适合时间规则明确的业务场景。
我建议优先用fixedDelay兜底,因为它对调度器的并发压力最小。用fixedRate时一定要保证任务自身执行时间远小于触发周期,否则你的任务节奏会被严重拖乱。踩坑最典型的就是:一个@Scheduled(fixedRate = 1000)的任务内部做了外部接口调用,某次接口超时10秒,调度线程被占住10秒,其他定时任务全被堵在那,日志一看起来像是系统卡死。
2.2 initialDelay和时区配置的细节
initialDelay表示首次执行前延迟多少毫秒,这个参数非常重要,很多人会忽略。应用启动后,如果某个定时任务立即触发,而此时数据库连接池还在预热、缓存还没加载完,任务很可能直接报错。所以我一般会给任务设置initialDelay = 10000到30000,给系统一个充分的启动窗口。
时区这一块也值得注意。@Scheduled(cron = "0 0 3 * * ?", zone = "Asia/Shanghai")可以显式指定时区。如果没指定,默认使用服务器所在时区。线上容器如果时区没设置正确,你以为是凌晨3点跑的任务,实际上可能在凌晨5点才触发。我碰到过一次比较典型的:某运营系统每天凌晨3点统计前一天的报表,上线后连续几天时间都不对,后来发现镜像里的时区是UTC,和东八区差了8个小时。排查日志时发现任务执行的Server时间戳是19点,对应本地已经是凌晨3点,改成Asia/Shanghai之后问题才解决。
2.3 一个方法里多个Scheduled注解的梳理
当你在同一个类里写多个@Scheduled方法时,实际上每个方法都会创建一个独立的调度任务,底层由配置好的TaskScheduler线程池管理。需要注意的是,默认情况下Spring Boot使用单线程的调度器,也就是说所有按@Scheduled注册的任务都跑在同一个调度线程上,一个一个地排队触发。
这在绝大多数场景下够用,但如果你有一个任务执行时间特别长,所有任务都会被拖住。我建议在配置文件里显式加一个调度线程池:
spring: task: scheduling: pool: size: 10 thread-name-prefix: schedule-task-这个配置非常管用。设置pool.size之后,调度器会用最多10个线程去触发不同的定时任务,A任务卡住了不影响B任务在另外一个线程上运行。再加上自定义线程名前缀,排查日志的时候一眼就能认出哪些是调度相关的线程,非常直观。
2.4 动态修改cron表达式的需求场景
@Scheduled注解里的cron是编译期写死的,如果业务需求是运营动态配置任务执行频率,比如后台页面上改一个下拉框,把"每5分钟同步一次"改成"每10分钟同步一次",直接改注解是做不到的。这时要么引入外部调度中心,要么自己在代码里做一层动态注册机制。
我处理过一个这种需求,思路不复杂。核心是利用ThreadPoolTaskScheduler自己来调度任务,把任务注册表放在内存或者数据库里:
@Autowired private ThreadPoolTaskScheduler taskScheduler; private ScheduledFuture<?> future; @PostConstruct public void init() { startDynamicTask("0 */5 * * * ?"); } public void startDynamicTask(String cron) { if (future != null && !future.isCancelled()) { future.cancel(true); } future = taskScheduler.schedule(() -> { // 实际的业务逻辑 }, new CronTrigger(cron)); }这种方式比@Scheduled更灵活,可以动态取消、修改调度时间。实现上要注意一点:取消任务时用future.cancel(true),底层会尝试中断正在执行的任务,但你的业务代码如果不响应中断信号,真正的工作线程还是会继续跑完。所以取消操作只是不再触发下一次调度,不是强杀任务。
3. @Async异步任务与线程池的深入理解
3.1 @Async的正确打开方式
定时任务在某些场景下需要异步执行,比如一个任务要分批处理几万条数据,每批扔到异步线程里并行处理,这样能显著缩短整体耗时。Spring Boot里用@Async注解就能实现方法级异步,前提是启动类或者配置类上加了@EnableAsync。
我见过好几个同事在这里翻车:@Async加上了,方法也标注了,但跑起来一看,还是同步的。原因基本一致——方法不是在Spring代理对象上调用的。@Async依赖Spring AOP代理,只有从外部调用被代理的Bean方法时,注解才生效。如果你在同一类里的另一个方法直接this.asyncMethod(),那调用的不是代理对象,异步不会生效。解决办法是注入自身代理(AopContext.currentProxy()配合@EnableAspectJAutoProxy(exposeProxy = true)),或者把异步方法拆到另一个Bean里。最省心的做法就是把异步方法独立到单独的Service类中,不要让它和其他方法混在一起。
3.2 线程池的配置逻辑与推荐参数
Spring Boot默认用SimpleAsyncTaskExecutor处理@Async方法,这个执行器非常坑,它每次执行都new一个新线程,没有线程池复用。高并发异步调用的场景下,线程数会不受限制地增长,很快把内存打爆。所以生产环境必须自定义线程池,我一般用ThreadPoolTaskExecutor,配置文件里这样写:
spring: task: execution: pool: core-size: 8 max-size: 16 queue-capacity: 100 thread-name-prefix: business-async-这几个参数怎么定?核心思路是结合你业务场景的峰值吞吐量来估算。
core-size:常驻线程数。如果你的任务基本是轻量IO操作,一般设8到16就够了。max-size:最大线程数,当队列满了之后才会扩容到这个值。设太大会导致线程频繁创建销毁,设太小又会触发拒绝策略。queue-capacity:队列容量。这个我最看重。如果任务量超过core线程数,多出来的任务会进入队列排队。队列设置得太大,max-size根本用不上,任务积压严重但线程数不涨;设置得太小,大量任务直接被拒绝。我的经验是:优先保证线上TP99耗时达标,从这个指标反推排队容忍时长,再结合单任务平均执行时间算出合适的队列容量。举个例子,任务平均耗时100ms,你希望任务排队不超过2秒,那队列容量就是2秒 / 0.1秒 = 20。再留一点余量,给到50~100比较稳妥。
3.3 异步任务的异常处理
@Async方法如果内部抛出异常,调用方默认是感知不到的,异常会被吞到线程池里打印或者直接被忽略。要系统化收集异步任务的失败情况,有几个方案:
- 用
Future作为方法的返回值,调用方future.get()时捕获ExecutionException,拿到真正异常。 - 实现
AsyncUncaughtExceptionHandler,配置到自定义AsyncConfigurer中统一处理。 - 在业务方法内部自己try-catch,把异常记录到日志或数据库表里。
我比较推荐第三种,因为异步任务本质上就应该自己兜底。外部捕获异常链路长、上下文信息容易丢失,不如方法内部直接处理,把上下文参数、失败原因一一记录,方便后续查日志或者做补偿重试。
3.4 自定义线程池时对核心线程参数的理解
这里有个容易被忽视的参数叫setThreadFactory。不设置的话,线程名字默认是pool-1-thread-1这种,多个线程池混在一起时日志根本分不清。我习惯这样构造线程池:
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(100); executor.setThreadNamePrefix("data-sync-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize();setRejectedExecutionHandler这里用了CallerRunsPolicy,意思是线程池满了之后,任务不退还给业务方报错,而是让调用者线程自己执行这个任务。这个策略在定时任务场景下非常合适——既不会丢任务,又能起到天然降级作用。虽然某次触发的时效性变差了,但至少任务执行了,不至于静默丢失。
4. 动态定时任务的完整落地实现
4.1 为什么静态注解不够用
前面提到了动态任务的需求,这里展开说一个完整的落地案例。某项目里用户可以在管理后台配置不同的监控规则,每条规则有自己的调度时间表达式,规则支持增删改。如果用@Scheduled注解,每条规则都得写一个方法,规则一变又要重新部署代码,这个模式完全行不通。
我的做法是构建一个"规则注册中心":用一个Map<String, ScheduledFuture<?>>维护当前生效的任务,规则变更时先取消旧的,再注册新的。规则的元数据放在数据库表里,应用启动时把enable状态的任务全部加载并注册进调度器。
4.2 完整代码骨架
@Component public class DynamicTaskManager { @Autowired private ThreadPoolTaskScheduler taskScheduler; private final ConcurrentHashMap<String, ScheduledFuture<?>> taskMap = new ConcurrentHashMap<>(); public void registerTask(String taskId, String cron, Runnable task) { if (taskMap.containsKey(taskId)) { cancelTask(taskId); } ScheduledFuture<?> future = taskScheduler.schedule(task, new CronTrigger(cron)); taskMap.put(taskId, future); } public void cancelTask(String taskId) { ScheduledFuture<?> future = taskMap.remove(taskId); if (future != null) { future.cancel(false); } } }4.3 动态任务的核心注意事项
这里有几个坑我得专门提醒。
第一,ThreadPoolTaskScheduler必须显式设置,不能直接用默认的TaskScheduler。原因在于默认调度器只有一个线程,所有动态任务都挤在同一个线程里排队,一旦某个任务超时执行,其他所有动态任务都会被阻塞。推荐配置:
@Bean public ThreadPoolTaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(20); scheduler.setThreadNamePrefix("dynamic-task-"); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(60); return scheduler; }setWaitForTasksToCompleteOnShutdown(true)和setAwaitTerminationSeconds(60)这两行是给应用优雅停机兜底的,避免容器关停时正在执行的任务被强杀,数据写到一半就没了。
第二,future.cancel(false)和cancel(true)的区别要搞清楚。false表示取消未开始执行的调度,已经执行中的任务让它跑完;true则是尝试中断正在执行的任务。修改任务规则时我建议用false,等当前任务自然结束,再注册新任务,这样能最大限度避免数据不一致。
第三,动态任务注册后要支持应用重启后的恢复。系统启动的时候从数据库读取所有启用的规则,逐个注册。这里有个小坑:数据库读取和注册的过程如果遇到某个规则的cron表达式非法,不能导致整个启动失败,应该try-catch单个处理,记录错误日志后继续。
4.4 动态任务与配置管理的结合
如果想让cron表达式配置化,还可以把规则放在application.yml里,用@ConfigurationProperties绑定到一个配置对象。不过我个人更推荐存数据库,因为运营人员直接改配置中心发布,不需要动代码。Spring Boot的@RefreshScope只对它管理的Bean生效,如果你的DynamicTaskManager没有刷新机制,配置中心改了也不会生效。这时候可以让DynamicTaskManager实现ApplicationContextAware监听刷新事件,或者在配置中心回调里主动调用registerTask重新注册。
我的项目做法是:管理后台保存规则时同时调用DynamicTaskManager的注册接口,界面交互和代码逻辑一条链路,简单直接。配置中心那个方案适合运维团队不想动数据库的场景,各有利弊。
4.5 动态任务的监控方案
动态任务跑起来之后,怎么确认每个任务确实按照预期定时执行了?我最常用的方式是在任务内部记录日志:
log.info("[dynamicTask] taskId={}, executeTime={}", taskId, LocalDateTime.now());另外,在registerTask方法里可以对传入的Runnable做一层包装:
public void registerTask(String taskId, String cron, Runnable task) { Runnable wrapper = () -> { log.info("task start: {}", taskId); long start = System.currentTimeMillis(); try { task.run(); } finally { log.info("task end: {}, cost={}ms", taskId, System.currentTimeMillis() - start); } }; // 其余注册逻辑 }这样所有动态任务的执行时长、异常情况都有了统一日志,排查问题时不用挨个任务去翻业务代码。
5. 分布式环境下使用Spring Boot Task的边界与对策
5.1 同样的定时任务在集群里被重复执行
单机场景下Spring Boot Task够用,一旦部署到多实例集群,问题立刻暴露:每台机器的JVM里都有独立的任务调度器,同一条@Scheduled任务会在每个实例上各执行一次。如果这个任务是做数据统计、重新计算缓存,执行两次还能忍;如果任务是给用户发短信、扣钱、关闭工单,重复执行就非常危险。
你要么直接换用专业的分布式调度组件,要么在Spring Boot Task之上做一层"分布式互斥"控制。很多团队不方便引入额外的调度平台,那分布式锁就是最直接的对策。
5.2 基于数据库锁的轻量实现
数据库分布式锁的原理很简单:多个实例竞争同一条数据库记录的更新,谁更新成功了,谁就是锁的持有者。我写过很多次这个模式,核心思路如下:
@Scheduled(fixedDelay = 60000) public void executeTask() { if (tryLock("data-statistic", 60)) { try { // 业务逻辑 } finally { releaseLock("data-statistic"); } } }tryLock的实现可以用一张独立的锁表,也可以用select ... for update。我喜欢用带过期时间的锁记录,避免任务崩溃后锁一直不释放。大致结构:
CREATE TABLE task_lock ( lock_key VARCHAR(128) PRIMARY KEY, owner VARCHAR(64), expire_time DATETIME );tryLock时尝试插入一条记录,insert成功代表拿锁;如果lock_key已存在,再判断expire_time是否已过期,过期则用update抢占。这是最朴素的分布式锁实现,不需要引入Redis,适合任务频率不高的场景。
5.3 基于Redis的分布式锁
如果业务本身就有Redis,那就用Redis分布式锁更省心:
public boolean tryLock(String key, long timeoutSeconds) { Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(key, UUID.randomUUID().toString(), timeoutSeconds, TimeUnit.SECONDS); return Boolean.TRUE.equals(success); } public void releaseLock(String key, String value) { String current = stringRedisTemplate.opsForValue().get(key); if (value.equals(current)) { stringRedisTemplate.delete(key); } }注意几个细节:setIfAbsent必须带过期时间,否则任务崩溃后锁永远不释放;释放锁之前先校验value,避免误删了别的实例刚刚获取的新锁;Redis锁的过期时间要大于任务可能执行的最长耗时,否则任务还没跑完锁自己就过期了,另一个实例会进来重复执行。
5.4 分布式环境下线程池和调度器的注意点
分布式环境下,Spring Boot Task本身的线程池配置道理一样,但要注意锁竞争带来的额外等待时间。如果多个实例同时触发了同一任务,只有一台能拿到锁,其他实例会跳过本次执行。这时候任务的实际运行分布可能不均匀,可能某台机器连续执行了好几次,另一台一直没执行。对于有全局状态的任务,比如处理未发送的通知,这种不均衡没问题;但如果每一台机器需要均衡承担负载,就要用分片策略,每个实例只处理自己分到的那一部分数据。分布式锁解决不了分片均衡的问题,这需要引入分布式调度中间件才能做得更优雅。
6. Spring Boot Task实战中必须避开的坑
6.1 @Scheduled方法不能有入参
@Scheduled标注的方法不允许有参数,否则启动时会直接报错。很多刚接触的人试图给定时任务传入业务参数,发现不行就觉得很奇怪。正确的做法是把参数放到方法内部去查询,而不是通过入参传进来。如果你想给任务传配置,可以在任务类上用@Value注解读取,或者用一个ContextHolder持有当前上下文。
6.2 调度任务类没有交给Spring管理
@Scheduled生效的前提是任务方法所在的类被Spring容器管理。如果你手动new了一个对象再调用它的方法,肯定不生效。我排查过好几次这种问题,最后发现是在@Configuration类里用new创建了一个任务对象并返回,但类本身没加@Component之类的注解,导致Spring根本没扫描到。检查时先看类上是否有@Component、@Service等注解,再看启动类的@ComponentScan包路径是否覆盖到了任务类。
6.3 异常不要抛出到调度线程外部
@Scheduled方法如果抛出了未捕获的异常,Spring Boot默认会吃掉它吗?不会,默认会打印异常日志,但不会中断后续调度。也就是说一个任务挂了,下一次调度照样触发。这其实是好事,但容易让人忽视一个问题:异常如果一直没处理,每次执行失败都只是日志里多一条,你的业务一直处于"隐性失败"状态。所以我的习惯是定时任务内部一定有try-catch,并且捕获后记录上下文和异常信息到专门的日志表,再配合监控告警。
6.4 避免任务执行时间超过调度周期
这个是最容易踩的隐藏坑。fixedRate的任务执行时间超过了周期,或者fixedDelay任务本身执行时间偶尔暴涨,都会导致后一次任务和前一次任务重叠。虽然调度器单线程下不会同时跑两条,但重叠意味着执行节奏失真,且对于fixedDelay来说任务之间的等待时间没了,你的定时节奏全部被打乱。
处理这类问题的通用思路是给任务加一个"运行中"标记:
AtomicBoolean running = new AtomicBoolean(false); @Scheduled(fixedDelay = 5000) public void execute() { if (!running.compareAndSet(false, true)) { log.warn("last task still running, skip this round"); return; } try { // 业务逻辑 } finally { running.set(false); } }这个模式在多实例部署时不一定靠谱(每个实例都有自己的AtomicBoolean),但单机场景能有效避免重叠执行。
6.5 服务启动期间的定时任务要留意
应用刚启动,Spring容器初始化尚未完成,数据库连接和缓存都还没准备好。如果定时任务在启动后立刻执行,很容易报空指针或者连接获取失败。我前面提到的initialDelay就是为这个场景准备的,另外也可以配合ApplicationReadyEvent来动态注册任务,在事件触发之后才注册第一个执行时间,这样能保证整个应用已经处于可服务状态。
7. 一个完整的实战案例:订单超时状态自动变更
写个相对完整的例子,把这块知识串起来。某电商项目中有一个需求:订单支付超时后,半小时内未支付自动变为"已关闭"状态。这是个非常典型的定时任务场景。
第一反应是每分钟扫描一次订单表,把超过30分钟未支付的订单改状态。但直接扫描全表对性能不友好,数据量大时每分钟全表扫一次压力很大。我做的是引入"延迟队列"辅助:下单时把订单号加上下单单时间存到Redis的有序集合(ZSet)里,score是超时时间点;定时任务每分钟取出当前时间之前的成员,批量处理。
@Component public class OrderTimeoutTask { @Autowired private StringRedisTemplate redisTemplate; @Autowired private OrderService orderService; private static final String ZSET_KEY = "order:pay:timeout"; @Scheduled(fixedDelay = 30000) public void checkTimeoutOrders() { long now = System.currentTimeMillis(); Set<String> orderIds = redisTemplate.opsForZSet() .rangeByScore(ZSET_KEY, 0, now, 0, 200); if (orderIds == null || orderIds.isEmpty()) { return; } for (String orderId : orderIds) { try { orderService.closeOrderIfNeeded(orderId); redisTemplate.opsForZSet().remove(ZSET_KEY, orderId); } catch (Exception e) { log.error("close order failed, orderId={}", orderId, e); // 失败的重试放到下一轮 } } } }这套方案的优点很明显:扫描数据量小,不会全表扫;任务频率可以很高,时效性好;超时时间变了只需改ZSet里成员的score,不用改代码。配合分布式锁之后,集群里多实例也不会重复处理同一个订单。
7.1 事务边界不要包住外部调用
在上面这个场景里,closeOrderIfNeeded里面通常要改数据库状态,还有可能发Mq消息。我踩过的一个坑是:事务内先更新了订单表,然后又发了Mq消息,紧接着Redis里的成员被移除。看起来一切正常,但如果事务提交失败,订单表回滚了,Mq消息却已经发出去了,下游消费方拿到的消息关联的订单还是未支付状态,处理逻辑就会出错。
所以这种"先本地事务,后外部副作用"的顺序要特别小心。建议是事务只负责数据库更新,提交成功后再移除Redis、发消息;或者利用Spring的TransactionSynchronizationManager在事务提交后回调里做这些操作。
7.2 定时任务里的数据分页处理
如果订单量特别大,一次性把超时订单全部捞出来处理,内存和耗时都扛不住。我用的是分批处理+循环控制,每次只取固定数量,处理完之后继续取下一批,直到处理完为止。但这个方式要警惕"活锁":如果某成员的score永远小于当前时间,每次都能被捞出来,但它处理失败一直重试,就会导致循环永远不结束,任务线程被占死。所以批量循环里必须加一个最大处理量限制,比如单次执行最多处理1000个,超出就留到下一轮。
7.3 监控与告警
定时任务一定要有监控,不然出了问题还不知道。我给定时任务建了一张任务执行日志表,记录每次执行的开始时间、结束时间、处理数量、异常信息。每天跑一个汇总去看任务耗时趋势,如果某个任务从平均耗时1秒涨到了10秒,就要提前排查数据库慢查询或者外部接口变慢的问题。这个习惯帮我提前发现了好几次隐患,比人工盯日志高效太多。
8. 从Spring Boot Task迁移到专业调度平台时的判断条件
很多团队在任务复杂度上来之后会问:是不是该换掉Spring Boot Task了?我的判断标准很实际,以下几条满足两条以上就可以考虑引入外部调度平台:
- 任务需要跨机器分片执行,不同实例处理不同数据段。
- 需要复杂的失败重试、超时控制、人工补跑能力。
- 多个业务方共用调度能力,需要一个统一的调度配置管理界面。
- 任务的执行轨迹、耗时、成功率需要在统一看板里展示。
- 集群规模大,任务数量上百个,需要精细到任务级别的权限管理。
Spring Boot Task本身没有能力边界,但它定位是"嵌入式调度",适合大部分中长尾业务。真的走到大规模分布式调度这一步,业界有POWERLESS调度模式,侧重任务编排;也有独立部署的调度中心,侧重执行器管理和分片;哪个合适由团队的运维能力、业务复杂度综合决定。我的建议是:别为了"专业"而过度设计,先把Spring Boot Task吃透,让团队每一个人都知道自己负责的调度任务跑的线程池是多少、并发上限在哪、失败策略是什么,这比盲目引入中间件更务实。
改了几年Spring Boot Task相关的项目后,我最大的体会是:大部分所谓"定时任务不好用"的问题,根源不是框架本身弱,而是写调度代码的人没有想清楚任务执行的边界条件——并发会不会怼在一起、异常会不会吞掉、重启后会不会丢失、多实例会不会重复跑。把这几个问题回答清楚,Spring Boot Task完全可以在很长一段时间内支撑起业务需求。