定时任务开发:@Scheduled 与分布式定时方案
定时任务开发:@Scheduled 与分布式定时方案
凌晨三点数据库自动备份、超时订单自动取消、每小时生成一份销售报表——这些不该是人工半夜爬起来做的事,交给定时任务吧。
一、定时任务的四大应用场景
定时任务在后端开发中无处不在,堪称"后勤部队":
- 数据同步:每天凌晨从外部 API 拉取数据同步到本地库
- 报表生成:每周一早 8 点生成上周运营周报并邮件发送
- 订单超时关闭:30 分钟未支付的订单自动取消,释放库存
- 库存检查:低库存商品自动生成补货提醒
场景虽多,但核心问题就一个:定时触发 + 逻辑执行。SpringBoot 从轻量到重量,有三级方案。
二、Spring 内置 @Scheduled:最轻量方案
2.1 启用定时任务
@SpringBootApplication@EnableScheduling// 开启定时任务支持publicclassApplication{publicstaticvoidmain(String[]args){SpringApplication.run(Application.class,args);}}@EnableScheduling是总开关,不加这个,所有 @Scheduled 都不会生效。
2.2 @Scheduled 的四种参数
@ComponentpublicclassDemoTask{@Scheduled(fixedRate=5000)publicvoidfixedRateTask(){System.out.println("fixedRate:"+newDate());// 从任务开始算,每5秒执行一次,不管上次是否执行完}@Scheduled(fixedDelay=5000)publicvoidfixedDelayTask(){System.out.println("fixedDelay:"+newDate());// 从任务结束算,等5秒后再执行}@Scheduled(initialDelay=10000,fixedRate=5000)publicvoidinitialDelayTask(){// 首次延迟10秒启动,之后每5秒执行}@Scheduled(cron="0 0 3 * * ?")publicvoidcronTask(){// 每天凌晨3点执行System.out.println("凌晨3点跑批任务");}}2.3 Cron 表达式语法详解
cron 表达式有 7 个字段,从左到右是:
| 位置 | 含义 | 取值范围 |
|---|---|---|
| 1 | 秒 | 0-59 |
| 2 | 分 | 0-59 |
| 3 | 时 | 0-23 |
| 4 | 日(月) | 1-31 |
| 5 | 月 | 1-12 |
| 6 | 星期 | 1-7 (1=周日) |
| 7 | 年(可选) | 1970-2099 |
特殊字符:*任意值、?不指定(日和星期互斥使用)、,多值、-区间、/步长。
常用 cron 示例:
| 表达式 | 含义 |
|---|---|
0 0 2 * * ? | 每天凌晨 2 点 |
0 0/30 * * * ? | 每 30 分钟 |
0 0 9-18 ? * MON-FRI | 周一至周五 9:00-18:00 每小时执行 |
0 0 8 ? * 1 | 每周一早 8 点 |
0 15 10 1 * ? | 每月 1 号 10:15 |
0 0/5 * * * ? | 每 5 分钟 |
三、单机定时任务的致命问题:多实例重复执行
@Scheduled 写起来是很爽,但生产环境一部署 3 个实例,同一个定时任务执行了 3 遍——订单关闭了 3 次、报表发了 3 份、数据同步了 3 遍。
这就是单机定时任务的天花板:它不知道还有其他实例也在跑。
四、分布式定时方案一:数据库唯一索引抢锁
最朴素的方案:建一张schedule_lock表,用一个唯一索引做排他抢锁。
CREATETABLEschedule_lock(task_nameVARCHAR(100)NOTNULL,lock_timeDATETIME,PRIMARYKEY(task_name));publicvoiddistributedTask(){StringtaskName="closeOrder";try{// 尝试插入抢锁记录,利用主键冲突实现互斥scheduleLockMapper.insert(newScheduleLock(taskName,newDate()));// 抢到锁,执行业务closeTimeoutOrders();// 执行完删除锁记录scheduleLockMapper.deleteById(taskName);}catch(DuplicateKeyExceptione){// 没抢到锁,其他实例正在执行log.info("任务已由其他实例执行,跳过");}}优点是不引入额外中间件,缺点也很明显:没有过期机制,如果执行实例挂了,锁永远不会释放。
五、分布式定时方案二:Redis 分布式锁(Redisson)
用 Redisson 代替数据库做锁,利用看门狗机制和过期时间解决死锁问题:
@ComponentpublicclassOrderScheduleTask{@AutowiredprivateRedissonClientredissonClient;@Scheduled(cron="0 0/1 * * * ?")// 每分钟执行一次publicvoidcloseTimeoutOrder(){StringlockKey="task:closeOrder";RLocklock=redissonClient.getLock(lockKey);try{// tryLock 只传一个参数:不等待,锁30秒自动释放(看门狗自动续期)if(lock.tryLock(0,30,TimeUnit.SECONDS)){// 查询超时未支付的订单并关闭List<Order>timeoutOrders=orderService.findTimeoutOrders();for(Orderorder:timeoutOrders){orderService.closeOrder(order.getId());}log.info("超时订单处理完成,共{}笔",timeoutOrders.size());}}catch(InterruptedExceptione){Thread.currentThread().interrupt();}finally{if(lock.isHeldByCurrentThread()){lock.unlock();}}}}tryLock(0, 30, SECONDS)三个参数解读:
- 参数 1 (0):不等待,抢不到直接返回 false
- 参数 2 (30):锁过期时间 30 秒
- 看门狗会在锁未释放时每 10 秒自动续期,业务执行多长都不怕锁提前过期
六、分布式定时方案三:XXL-JOB 简介
当定时任务数量多、需要可视化管理时,自建方案就吃力了。XXL-JOB 是国产开源的分布式任务调度平台,提供:
- Web 管理界面:可视化创建任务、查看执行日志、手动触发
- 自带故障转移:执行器宕机自动转移到其他节点
- 分片广播:支持数据分片并行处理
- 任务依赖:子任务在父任务成功后自动触发
- 失败重试:失败邮件告警
架构简单:调度中心(Scheduler)+ 执行器(Executor),中间通过 HTTP 通信。中小企业足够用,不必一上来就搞太重的中间件。
七、定时任务注意事项清单
| 注意点 | 说明 |
|---|---|
| 线程池配置 | @Scheduled 默认单线程,多任务会排队。需配置线程池:spring.task.scheduling.pool.size=4 |
| 异常处理 | 定时方法中务必 try-catch,否则一个异常导致该任务后续不再触发 |
| 任务串行 | fixedRate 不会等上一次结束,小心任务堆积耗尽线程池 |
| 幂等性 | 任务失败重试可能导致重复执行,业务逻辑必须有幂等设计 |
| 监控告警 | 关键任务必须有执行结果通知(日志+告警),不能"挂了都没人知道" |
| 数据量 | 避免一次查询全部数据,每次处理固定条数,循环推进 |
定时任务本质是"自动化运维",简单场景 @Scheduled 足够,多实例部署加层 Redis 锁,任务量大起来再上 XXL-JOB。技术选型没有最好,只有最合适的。
