小龙虾其实是我给一个小服务起的代号,干的事很固定:每天早上8点,把国内外科技圈的资讯源扫一遍,过滤、去重、抽取关键内容,自动生成一份标题叫“Cray Tech Daily”的晨报,然后推到钉钉群和订阅邮件里。这个项目上线快两个月了,期间被问得最多的一个问题就是:那个“每天早上8点”到底是怎么设置的?为什么有时候会晚几分钟?如果任务没跑完会不会重复推送?这些问题,本质上都指向同一个技术点——定时任务。
这篇文章不打算只给你扔一个 cron 表达式就完事。我会按自己实际动手的顺序,从方案选型、cron 表达式编写、并发控制、内容投递,到分布式改造和线上排查,把“每日科技晨报”这个场景里跟定时任务相关的细节都过一遍。你在自己的项目里可能不叫小龙虾,但凡是遇到“每天定时生成一份报告”“每周定期跑一次数据同步”“每隔一段时间推一条通知”这类需求,这里面的经验基本都能直接换过去用。
1. 先从需求说起:小龙虾的“定时”到底要满足什么
1.1 一个晨报任务不是一行cron就够的
先说一个常见误区。很多人一听到“每天定时跑一次”,第一反应就是给服务器写个 crontab:
bash复制0 8 * * * /usr/bin/python3 /opt/little_crayfish/daily_report.py
看起来没问题,实际上问题不少。这行 crontab 只解决了一个问题:到点触发脚本。但它完全没有回答另外几个更现实的问题:脚本跑到一半崩了怎么办?上次跑还没结束,下一次又开始了怎么办?今天跑失败了,晨报没发出去,我多久才能知道?以后我加了一台服务器,两个节点同时跑这个任务,群里是不是会收到两封一模一样的晨报?
这些不是假设,而是小龙虾上线第一个月真实遇到过的。当时我做的第一件事就是重新梳理需求,把“定时任务”拆成四个子需求:定时触发、任务防重、失败重试、执行可观测。拿这四条再去套方案,思路会完全不一样:任何定时任务方案,不管它宣传得天花乱坠,最终都得在这四个维度上接受检验。缺一个,这个任务就不能算真正合格。
1.2 六种常见定时任务方案怎么选
市面上的定时任务方案非常多,但真正常用的大致可以分成六类:操作系统级、应用内注解级、进程内框架级、分布式调度平台级、代码托管平台调度级,以及 Serverless 定时触发器。我用一个对比表整理过,你们感受一下差异:
| 方案 | 典型代表 | 适合场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 系统级定时 | crontab、systemd timer | 单机脚本、简单任务 | 零依赖、稳定 | 无失败重试、无状态管理 |
| 应用内注解 | Spring @Scheduled、Python APScheduler | 单体应用内跑任务 | 接入简单、与业务代码同进程 | 集群下会重复执行 |
| 进程内框架 | Quartz、Hangfire | 单机/小集群、需要调度API | 功能丰富、支持持久化 | 配置偏重、分布式能力弱 |
| 分布式调度平台 | XXL-Job、Elastic-Job | 集群环境、任务量大 | 分片、路由、失败重试、可视化 | 需要额外部署运维调度中心 |
| 代码托管平台调度 | GitHub Actions schedule | 开源项目、轻量任务 | 免服务器、天然有日志 | 触发可能延迟、不适合强时效 |
| Serverless触发器 | 云函数定时触发、SchedulerX | 低频任务、事件驱动 | 按量付费、免运维 | 有冷启动、受平台限制 |
看完这张表你会发现,没有绝对“最好”的方案,只有当下最合适的。小龙虾刚起步的时候就是单机跑,用 crontab 完全够。但我心里很清楚,后面如果加采集节点、做高可用,这套东西一定要往分布式调度迁移。所以我当初做单体版本时,特意把任务执行逻辑和触发逻辑解耦——crontab 只负责“到点喊一声”,真正干活的是后端服务里的同一个 Job 方法。这个设计后来迁移到 XXL-Job 时帮了大忙,我可以直接说,这是整个项目里最划算的一次预留。
1.3 我的最终选型:轻量优先,兼顾未来分布式
具体落到小龙虾上,我用的是两条线并行。
本地开发环境,直接上 Spring @Scheduled。原因是项目本身就是 Spring Boot 写的,晨报生成逻辑就在同一个应用里,加一个注解就能在 IDE 里跑起来调试,不用装任何额外组件。开发时 cron 写 0 30 7 * * *,每天 7:30 触发一次,方便我白天调试。
服务器生产环境,一开始用 crontab + systemd timer 的组合。crontab 负责触发,具体命令调用一个 shell 脚本,脚本通过 curl 打本机 HTTP 接口 /internal/cron/daily-report,由 Spring Boot 应用真正执行。这样做的核心原因是:任务执行的所有状态、日志、异常处理都在应用里,触发层即使出问题也不会丢失业务上下文。接口做了鉴权,token 放在请求头里,防止外部扫到触发。
后来加了第二台采集节点,单机 crontab 压不住“重复执行”这个问题,才迁到 XXL-Job。如果你用 C# 写这类服务,Hangfire 或 Quartz.NET 是同类选择;如果只是给 GitHub 仓库加个每日任务,GitHub Actions 的 schedule 事件最省事。工具可以换,但“触发和业务解耦、执行可观察、失败可重试”这套思想是相通的,这也是我最想强调的部分。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. cron表达式:从“每天早8点”到“每隔一周的周一”
2.1 一格格拆开看:cron的六个字段到底在说什么
聊定时任务绕不开 cron 表达式。很多人看到 0 8 * * * 就有点发怵,其实把它拆成五格就明白了。绝大多数 Linux 发行版和 Java 里的 Quartz 都支持类似语法,但字段数量和语义略有差异。先看最常见的五段式:
| 位置 | 含义 | 允许范围 | 常见写法示例 |
|---|---|---|---|
| 第1格 | 分 | 0-59 | 0 表示整分触发 |
| 第2格 | 时 | 0-23 | 8 表示早上8点 |
| 第3格 | 日 | 1-31 | * 表示每天 |
| 第4格 | 月 | 1-12 | * 表示每月 |
| 第5格 | 周 | 0-7(0和7都是周日) | 1 表示周一 |
每格里可以填四种东西:具体数值(8)、通配(*)、范围(9-18)、步长(*/15)。还有一个容易漏掉的点:日和周同时有值时,不同引擎的语义不一样。Cron 类工具大多把两者当“或”关系,Quartz 则按“与”处理。比如 0 8 1 * 1,在 Vixie cron 里是“每月1号或每周一的早上8点”,在 Quartz 里却要求“同时满足每月1号和周一”才会执行。同一个表达式在不同系统之间迁移,一定要先确认解析引擎,否则踩坑都不知道怎么踩的。
2.2 三个高频需求直接套公式
结合小龙虾的实际场景,我整理了三个最常用也最容易被问到的写法。
第一个,每天早上 8 点触发:
bash复制0 8 * * *
第二个,每周一早上 8 点触发:
bash复制0 8 * * 1
第三个,也是被问到最多的:如何设置“每隔一周的周一”执行?先说结论:cron 本身不支持“每隔 N 周”这种跨周语义,因为 cron 表达式的每个字段都是“单周期内”的匹配,它没有“周序号”的概念。写 0 8 * * 1 只能做到每周一,做不到“这周跑、下周不跑”。
业界常见解决方案有三种。第一种,在代码里判断当前日期是第几周,奇数周才执行。第二种,用表达式拼出指定日期组合,比如 0 8 1-7,15-21 * 1,但这种写法非常绕,可读性极差,不推荐。第三种,迁到 XXL-Job 这类支持自定义触发策略的平台,在调度后端写一段判断逻辑。
小龙虾用的是第一种,也是我认为最合理的一种。具体做法是在 Job 里加一个前置判断:取出当前时间,计算它是今年的第几周,逢偶数周直接跳过。伪代码如下:
java复制LocalDate today = LocalDate.now();
long weekOfYear = today.get(WeekFields.ISO.weekOfWeekBasedYear());
if (weekOfYear % 2 == 0) {
log.info("even week, skip daily report");
return;
}
// 真正的晨报生成逻辑
这样 cron 表达式保持简单的 0 8 * * 1,所有“间隔”的业务逻辑交给代码,既直观又方便测试。
2.3 时区、夏令时、秒级偏移:cron最容易被坑的三个点
第一个坑是时区。很多云服务器默认是 UTC,而小龙虾的目标读者在国内,早上 8 点必须是北京时间。如果直接写 0 8 * * *,脚本会在 UTC 早上 8 点跑,也就是北京时间下午 4 点,晨报的意义就没了。所以服务器上第一件事就是确认时区:
bash复制timedatectl
# 输出里 Local time 如果显示 UTC,就需要改
timedatectl set-timezone Asia/Shanghai
Spring 的 @Scheduled 还可以在注解上单独指定时区:
java复制@Scheduled(cron = "0 8 * * *", zone = "Asia/Shanghai")
public void dailyReport() {
// ...
}
第二个坑是夏令时。国内没有夏令时,但如果任务要处理全球用户,或者机器部署在有时区切换的海外节点,cron 在夏令时切换那天会出现“某天只有 23 小时”或“某天有 25 小时”,表现为任务跳过或触发两次。处理办法是尽量用 UTC 表达式,再在业务代码里换算本地时间。
第三个坑是秒级偏移。cron 的最细粒度是分钟,如果确实需要“每 30 秒执行一次”,crontab 做不到,Quartz 可以写 0/30 * * * * ?,注意 Quartz 是六段式,多了秒字段。Spring 的 @Scheduled 如果写了秒级表达式,也按六段式解析。这也是同一个表达式在 crontab 和 Java 里写法可能不一样的原因,互相迁移时一定要先确认引擎。
3. 任务还没跑完下一个触发就来了:并发控制与状态检查
3.1 一个网络卡顿就能让晨报发两次
小龙虾的晨报任务单次跑多久?正常情况下 20 秒到 1 分钟。听着很短,但有一次采集源响应超时,请求等了 90 秒才拿到数据,紧接着钉钉 webhook 又因为限流慢了一下,整个任务跑了 2 分多钟。如果这时候触发频率恰好是一分钟一次,第二次触发会直接顶上来,两个线程同时跑同一个 Job,晨报就会发两份。
这不是极端假设。定时任务的周期比任务实际执行时间短,是很常见的。尤其当任务里包含外部 HTTP 调用时,耗时不可控,网络一抖,任务时长就可能翻几倍。所以“任务还没跑完下一次触发就来了”是所有定时任务系统里都必须正面处理的问题。你要么在框架层面解决,要么在业务代码里解决,但不能不解决。
3.2 Quartz的防重开关与原理
如果你用的是 Spring @Scheduled,一个简单有效的办法是给方法加一个 JVM 内锁,比如 ReentrantLock,确保同一时间只有一个线程进来。但更规范的路子是升级到 Quartz,它原生提供了防重机制。
Quartz 的 Job 类上有两个注解经常被放在一起说:@DisallowConcurrentExecution 和 @PersistJobDataAfterExecution。前者解决的就是我们这个问题:它告诉 Quartz,同一个 JobDetail 的多个触发器不能并发执行同一个 job 实例。注意,这个约束的作用域是“同一个 JobDetail”,不是“同一个 Job 类”。所以想用这个机制防重,必须确保所有调度都指向同一个 JobDetail,而不是每次 new 一个。
@PersistJobDataAfterExecution 通常和它搭配使用,原因是如果并发被禁止,JobDataMap 里的数据修改也需要在下次执行前持久化,否则你刚写入的上下文状态在下次触发时可能还是旧值。这段原理在很多简化教程里被一笔带过,但实际踩坑时特别关键。我自己就曾经手动创建了多个 JobDetail 指向同一个 Job 类,导致 @DisallowConcurrentExecution 形同虚设,两个实例照样并行跑。
3.3 Redis锁+数据库状态兜底:我的混合方案
Quartz 的防重机制解决的是“单机调度器”层面的问题。但小龙虾的场景更麻烦:采集节点不止一台。两台机器上都部署了同一个应用,各自跑着一个调度器,Quartz 的进程内锁无法跨进程生效。这时候就需要一个外在的分布式锁。
我在项目里用的是 Redis 锁加数据库状态表的混合方案,思路很简单。Redis 锁负责“抢执行权”:任务开始时执行一次 SETNX,key 比如是 crayfish:daily-report:lock,value 存当前节点标识,过期时间 5 分钟(覆盖任务最长耗时)。只有抢到锁的节点才继续执行,抢不到的节点记录日志直接退出。
java复制// 伪代码
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent("crayfish:daily-report:lock", localIp, Duration.ofMinutes(5));
if (!Boolean.TRUE.equals(locked)) {
log.warn("another node is running daily report, skip.");
return;
}
try {
doReport();
} finally {
redisTemplate.delete("crayfish:daily-report:lock");
}
有几个细节必须注意。第一,锁的过期时间要大于任务最大耗时的预估,否则任务还没执行完锁就自动释放,另一个节点会趁虚而入。第二,finally 里删除锁之前要确认锁的 value 是不是自己的节点标识,防止因为锁过期后其他节点持锁,而我们误删了别人的锁。第三,Redis 锁只是第一道防线,如果 Redis 本身不可用,整个调度会退化成“每个节点都尝试执行”的最坏情况。
所以我又加了一张任务执行记录表,字段包括任务名、执行日期、执行节点、开始时间、结束时间、状态。晨报生成前先往这张表插入当日任务记录,如果发现当天已经有一条状态为 RUNNING 或 SUCCESS 的记录,直接放弃。数据库的唯一索引是最硬的兜底保障,它比任何分布式锁都更难出错。这套混合方案目前跑下来很稳,最明显的好处是:即使 Redis 抖动或者网络分区,也不会出现同一个任务在多个节点上并行执行。
4. 晨报怎么来的又怎么发出去:内容组装与投递通道
4.1 从抓取到渲染:晨报内容的流水线
定时触发只是第一步,真正决定晨报质量的是任务里的业务流水线。小龙虾这个名字就是“拾捡”的意思,它的工作是每天从一堆科技资讯源里把值得看的东西捞出来。具体分四步:
第一步,抓取。目前接了十几个 RSS 源和几个 API,早期用 Python 写采集脚本,后来收敛到 Java 里用 HttpClient 异步抓取。单个源超时时间 5 秒,避免一个源卡住整个任务。这里的原则是:宁可少采一个源,不能让整个任务停下。
第二步,解析与去重。RSS 源之间大量重叠,同一新闻可能出现在五六个源里。我用标题归一化哈希去重:标题经过小写化、去空格、去掉特殊符号后算 SHA-256,作为唯一键存库,历史记录保留 7 天,超过的清理掉。
第三步,过滤与打分。不是所有科技新闻都值得进晨报。我给每个条目标一个权重分,规则包括:是否包含 AI、编程、硬件等关键词;来源域名权重;发布时间是否在最近 24 小时内。综合得分低于阈值的淘汰,最后保留 8 到 12 条。
第四步,渲染。用 Thymeleaf 模板生成 HTML 版邮件,同时生成纯文本版 Markdown 给钉钉。这里强烈建议把“内容生成”和“内容投递”拆成两个独立步骤。这样同一个 Job 生成的报告,可以同时喂给钉钉、邮件、飞书,后续想生成 PDF 版本也很方便,核心逻辑不用动。
4.2 钉钉机器人通道:十分钟接入
投递通道我首选钉钉,原因只有一个:接入成本最低。在钉钉群里加一个自定义机器人,拿到 webhook 地址,POST 一段 JSON 就能发消息,不需要审核,不需要额外 SDK,前后十分钟能通。
最简 Java 示例:
java复制String webhook = "https://oapi.dingtalk.com/robot/send?access_token=xxxx";
Map<String, Object> text = new HashMap<>();
text.put("content", markdownContent);
Map<String, Object> body = new HashMap<>();
body.put("msgtype", "markdown");
body.put("markdown", text);
ResponseEntity<String> resp = restTemplate.postForEntity(webhook, body, String.class);
注意钉钉机器人有限制:每个机器人每分钟最多 20 条消息,每条消息正文最大 2 万字符。晨报这种一天一条的场景完全够用,但如果你的任务需要批量推送,比如每隔半小时推一次运维报表,就要考虑多个机器人或分群发送了。邮件通道我用 JavaMailSender,订阅者邮箱存在一张简单的 subscribers 表,发送时循环逐封发。这里踩过坑:订阅者超过几百人后,逐封发送会让 Job 耗时很长,一封失败还会影响后面的。后来改成异步发送,Job 只管把邮件丢进队列,真正的发送交给一个独立线程池。
如果你所在的公司有统一的内部通知中台,也可以考虑接入类似 Hermes Agent 这类通知投递代理,把钉钉、邮件、飞书都抽象成通道。晨报任务只需要调用统一的投递接口,底层走哪个渠道由代理决定。这个思路我在公司项目里用过,优点是不用自己维护每个渠道的对接细节。
4.3 投递失败重试的重试策略:最多三次,逐级退避
投递环节不能做成一次性梭哈。网络再稳定,钉钉接口也有 5xx 的时候,邮件服务器也有连接超时的时候。我在投递层做了统一的重试封装,策略是:最多重试 3 次,退避间隔依次是 10 秒、1 分钟、5 分钟。
java复制public void sendWithRetry(Runnable sendAction) {
int maxAttempts = 3;
long[] delays = {10_000, 60_000, 300_000};
for (int i = 0; i < maxAttempts; i++) {
try {
sendAction.run();
return;
} catch (Exception e) {
log.warn("send failed, attempt {}", i + 1, e);
if (i < maxAttempts - 1) {
try {
Thread.sleep(delays[i]);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
}
}
}
// 重试全部失败,进入告警逻辑
alertChannel.send("daily report send failed after 3 attempts");
}
这里的经验是:重试次数不要贪多。超过 3 次以后,大概率不是临时性网络抖动,而是配置错误或者接口被禁,继续重试只会加剧问题,不如直接告警让人介入。另外,重试必须做退避,不能写成间隔完全一样的轮询。连续快速重试很容易触发对方限流,反而把本来能恢复的临时故障变成持久失败。把退避时间拉开,反而给对方服务留出恢复窗口。
5. 单机到分布式:晨报任务在集群里怎么不打架
5.1 两台机器同时发晨报的翻车现场
小龙虾从一台服务器迁到两台服务器,是典型的“看起来没必要,实际迟早要遇到”的事。起因是采集源越来越多,一台机器既要跑
