4. 调度频率设计:Cron表达式陷阱与动态调度策略

调度频率,说白了就是「任务多久跑一次」。

很多人觉得这有什么好设计的?写个 Cron 表达式不就完了?

嗯,我以前也这么想。直到有一次线上事故——凌晨三点,几百个任务同时涌上来,数据库连接池直接被打爆。那晚我盯着报警短信,才意识到:Cron 表达式看着简单,坑是真不少。

4.1 Cron 表达式的常见陷阱

Cron 表达式是调度系统的「通用语言」。但越是通用,越容易踩坑。

4.1.1 整点并发风暴

我见过最典型的场景:

  • 任务A:0 0 * * * ?(每小时整点执行)
  • 任务B:0 0 * * * ?(也是每小时整点)
  • 任务C:0 0 * * * ?(还是每小时整点)

几十个任务全挤在 00:00 这一秒触发。服务器瞬间压力爆表。

我曾经踩过的坑: 某次大促活动,数据同步任务全部配置在整点。结果整点那一分钟,数据库 CPU 冲到 99%,活动页面直接卡死。后来排查才发现,光是整点触发的任务就有 47 个。

解决方案: 给每个任务加一个随机偏移量。

// 不推荐:整点触发
0 0 * * * ?

// 推荐:随机秒级偏移
// 任务A:第15秒触发
15 0 * * * ?
// 任务B:第37秒触发
37 0 * * * ?
// 任务C:第52秒触发
52 0 * * * ?

你想想看,每个任务错开几十秒,压力就分散了。这个习惯我后来一直保留着。

4.1.2 跨天与跨月边界

Cron 表达式在处理跨天、跨月时,容易出逻辑问题。

场景 错误写法 正确写法 说明
每月最后一天 0 0 31 * ? 0 0 L * ? 2月没有31号,任务会跳过
每周最后一个工作日 0 0 0 * 5 0 0 0 ? * 5L 普通写法只匹配周五,不是最后一个
每季度第一天 0 0 0 1 1,4,7,10 ? 建议用代码计算 Cron 无法表达「季度」概念
我的个人习惯: 遇到「最后一天」「最后一个工作日」这种边界场景,我倾向于在代码里计算日期,然后动态生成 Cron 表达式。纯靠 Cron 硬写,容易出 bug。

4.1.3 时区与夏令时

这个坑我印象特别深。有一次部署到海外服务器,任务本该每天凌晨 2 点执行。结果夏令时切换那天,任务要么没跑,要么跑了两次。

原因很简单:Cron 表达式默认使用服务器本地时区。夏令时切换时,凌晨 2 点可能变成 3 点,或者出现两个 2 点。

避坑指南:

  • 统一使用 UTC 时区配置 Cron
  • 在应用层做时区转换
  • 避免在夏令时切换窗口(凌晨 1-3 点)安排关键任务

4.2 动态调度策略

固定 Cron 表达式只能解决「定时执行」的问题。但真实业务场景中,调度频率往往是动态的。

4.2.1 基于负载的自动调节

我记得有个数据同步任务,平时每小时跑一次就够了。但大促期间数据量暴增,需要缩短到 5 分钟一次。

手动改 Cron 表达式?太慢了。而且大促结束后还得改回来。

更好的做法: 让调度系统根据负载自动调整频率。

// 伪代码:动态调整调度间隔
public class DynamicScheduler {
    private int currentInterval = 60; // 默认60分钟

    public void adjustInterval() {
        long queueSize = getQueueSize();
        if (queueSize > 10000) {
            // 队列积压,缩短间隔
            currentInterval = 5;
        } else if (queueSize < 100) {
            // 队列空闲,延长间隔
            currentInterval = 60;
        }
        // 更新调度器
        scheduler.reschedule(currentInterval);
    }
}
核心思路: 调度频率不是写死的,而是根据系统状态动态计算。这样既能保证数据及时性,又能避免资源浪费。

4.2.2 基于时间的多级频率

有些任务在不同时间段的执行频率不同。比如:

  • 白天(8:00-22:00):每 5 分钟执行一次
  • 夜间(22:00-8:00):每 30 分钟执行一次
  • 周末:每 1 小时执行一次

用单个 Cron 表达式很难表达这种逻辑。我一般会拆成多个调度规则:

// 多级频率配置示例
[
  {
    "timeRange": "08:00-22:00",
    "weekdays": "1-5",
    "cron": "0 */5 * * * ?"
  },
  {
    "timeRange": "22:00-08:00",
    "weekdays": "1-5",
    "cron": "0 */30 * * * ?"
  },
  {
    "weekdays": "6,7",
    "cron": "0 0 * * * ?"
  }
]

调度系统在运行时,根据当前时间匹配对应的规则。这样既灵活,又容易维护。

4.2.3 事件驱动的触发

定时调度有个天然缺陷:它不知道数据什么时候准备好。

举个例子:上游系统每天凌晨 3 点生成报表文件。但有时候生成得早(2:30),有时候生成得晚(4:00)。

如果 Cron 写死 3:00 执行,要么等不到文件,要么文件还没写完。

我建议的做法: 用事件驱动替代定时轮询。

  • 上游系统生成完文件后,发送一个 MQ 消息
  • 调度系统收到消息后,立即触发任务
  • 同时保留一个兜底的定时任务(比如每 30 分钟检查一次)
经验之谈: 事件驱动 + 定时兜底,是我用过最稳的组合。事件驱动保证实时性,定时兜底防止消息丢失。两者互补,基本不会出问题。

4.3 调度频率设计全景图

下面这张图,是我总结的调度频率设计核心逻辑。你可以对照着检查自己的系统。

调度频率设计核心逻辑 调度频率设计 Cron 表达式陷阱 动态调度策略 最佳实践 整点并发风暴 跨天跨月边界 时区与夏令时 基于负载调节 多级频率 事件驱动触发 随机偏移分散压力 代码计算边界日期 事件+定时兜底 核心原则 1. 避免整点并发,加随机偏移 2. 边界日期用代码计算,别硬写 Cron 3. 动态调度比固定频率更适应真实业务

4.4 避坑清单

最后,我整理了一份调度频率设计的避坑清单。你可以直接拿来当检查表用。

我曾经踩过的坑,都列在这里了:
  1. 整点并发: 所有任务不要用相同的整点 Cron,至少错开秒级或分钟级
  2. 边界日期: 每月最后一天、每周最后一天,用 L 或代码计算,别硬写数字
  3. 时区问题: 统一用 UTC 配置 Cron,应用层做时区转换
  4. 夏令时: 避免在凌晨 1-3 点安排关键任务
  5. 固定频率: 考虑用动态调度替代固定 Cron,适应业务波动
  6. 事件驱动: 能用事件触发就别用定时轮询,实时性更好
  7. 兜底机制: 事件驱动一定要配合定时兜底,防止消息丢失

调度频率设计,说白了就是「在正确的时间,用正确的频率,做正确的事」。Cron 表达式是工具,不是目的。别让工具限制了你的设计思路。