一个看似普通的定时任务,如何优雅地毁掉整台服务器

0 阅读4分钟

本文适合所有写过 @Scheduled 的朋友阅读,写完一个定时任务,别急着部署,看完这篇文章你可能会救下自家的生产服务器。

一、故事从一个人畜无害的注解开始

你可能也经历过这样的场景:

“这个任务每10秒执行一次,查一下数据库就行,很简单。”
于是你信心满满地写下了:

@Scheduled(fixedRate = 10000)
public void checkOrderStatus() {
    List<Order> orders = orderService.getPendingOrders();
    for (Order order : orders) {
        orderService.syncOrderStatus(order);
    }
}

你写完后一拍大腿:我真是个天才!

结果上线3分钟后,数据库报警、CPU飙高、服务超时、老板电话响了,你还没来得及捂住耳朵。

二、定时任务的“阴间设计”现场

表面上这个任务简单到不能再简单,实际它踩的坑,一个比一个致命:

1. fixedRate 是啥意思你真的理解了吗?

@Scheduled(fixedRate = 10000) 的意思是:任务开始后,过 10 秒再触发下一次不管上一次有没有执行完

也就是说,如果 checkOrderStatus() 执行了 15 秒,那下一个任务会在第 10 秒强行开始。这就像两个汉堡没吃完,第三个已经送来了。

后果:线程堆积,任务重叠执行,争抢资源,服务器爆炸💣。

正确姿势:

@Scheduled(fixedDelay = 10000)

它的意思是:上次执行完之后再等10秒,再执行下一次。

如果你还想更稳妥一点,建议用 ScheduledExecutorService 或加个分布式锁(后面详细讲)。

2. 没有线程池配置 = 自杀式多线程爆破

Spring 默认的 @Scheduled 背后是 TaskScheduler,没配置线程池的话,它就用单线程跑所有任务。

如果你有多个 @Scheduled,它们会排队执行,一旦某个任务执行太慢,其他任务就开始挤牙膏。

你以为它们在“并发执行”,其实它们在“排队自杀”。

正确姿势:

@Configuration
@EnableScheduling
public class SchedulerConfig {
    @Bean
    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5); // 根据业务合理配置
        scheduler.setThreadNamePrefix("my-task-");
        scheduler.initialize();
        return scheduler;
    }
}

3. 日志打得飞起?磁盘表示已卒

很多人为了调试方便,会在定时任务里狂打日志:

log.info("正在同步订单:{}", order.getId());

同步 10 万个订单,每次执行一次,生成 10 万条日志。

后果:日志爆炸,磁盘爆满,甚至影响其他服务写日志。

正确姿势:

  • 打点式日志:只记录关键指标,比如每轮执行的时间、处理条数、异常数。
  • 加限频器:用布隆过滤器、计数器等手段,只记录代表性异常。
  • 日志分级写到不同的文件,Info 和 Error 分离。

三、如果你还在分布式环境下……

那事情就更“有趣”了。

你有两个节点都部署了这个服务,每个节点都跑了一遍定时任务,然后它们在数据库里“撞单子”,你后台发起的请求都被“并发插入”冲爆。

这时候你终于意识到——我不是在写任务,我是在模拟DDoS攻击。

正确姿势:加锁!

基于 Redis 的分布式锁(推荐)

boolean locked = redisLock.tryLock("sync_order", 30);
if (locked) {
    try {
        // 你的逻辑
    } finally {
        redisLock.unlock("sync_order");
    }
}

或者你用数据库实现也可以,每次执行任务前插一条对应的记录,并且用执行批次来作唯一索引,如果插入成功就执行任务,插入失败就跳过执行,当然具体情况还得视项目而定。

四、任务太慢怎么办?排队还是并发?

如果任务耗时特别长,考虑以下优化方式:

拆成多个小任务异步处理

别在定时任务里干重活儿,而是只负责“发号施令”。

for (Order order : orders) {
    executorService.submit(() -> orderService.syncOrderStatus(order));
}

结合线程池、消息队列甚至事件驱动,定时任务可以干得更轻松。

加监控,别做“盲盒任务”

定时任务不报错不代表没问题,你需要:

  • 记录耗时、处理数量、失败数量
  • 打点到 Prometheus / Grafana
  • 加报警:比如连续执行失败3次就钉钉/企微报警

五、最后来点警句

“定时任务不出问题,是因为你还没上线。”
“定时任务不该做太多事,它只是个闹钟,不是个保姆。”
“没有监控的定时任务,就像睁眼走夜路。”
“默认配置的 @Scheduled,是定时埋雷的第一步。”

六、总结

定时任务就像厨房里的燃气灶,用得好能煮饭,用不好就把房子烧了。看似简单的活,实则暗藏杀机。

一起来回顾一下关键点:

误区正确姿势
使用 fixedRate,忽略任务耗时考虑 fixedDelay 或自行控制调度逻辑
不配线程池,任务串行排队配置合适的 TaskScheduler 线程池
每个节点都执行任务加 Redis 锁或指定主节点
日志无限输出控制日志量、打点记录、异常聚合
没有监控添加任务运行指标,设立报警机制

写定时任务不是问题,问题是你把它当成“定时执行的 main 方法”来用

别让一个看似温柔的注解,成为你下一个线上事故的罪魁祸首。