Codex写定时任务为什么上线多实例后会执行多次?用分布式锁避免重复任务
使用 Codex 给项目增加定时任务时,开发环境通常很简单。
例如每天凌晨生成一次报表:
cron.schedule("0 0 * * *", async () => {
await generateDailyReport();
});
本地只有一个服务进程时,这段代码完全正常。
但真正部署到生产环境后,如果服务运行了:
instance-1
instance-2
instance-3
那么到了凌晨0点,三个实例都会执行同一个 Cron。
结果可能变成:
-
日报生成了3份;
-
同一批订单被同步3次;
-
用户收到多封相同通知;
-
数据清理任务重复运行;
-
第三方接口被重复调用;
-
一个实例还没执行完,另一个实例已经开始第二次处理;
-
扩容以后任务执行次数突然增加。
这不是 Cron 本身有问题,而是单机定时任务被直接放进了多实例架构中。
一、为什么本地永远发现不了?
本地通常只有:
1个Node进程
所以:
Cron触发一次
=
任务执行一次
生产环境为了高可用,往往部署:
3个
5个
甚至几十个实例
如果每个实例都有:
@Cron(...)
那么实际执行次数就是:
实例数量 × 1
也就是说:
扩容
会无意中变成:
增加定时任务执行次数
因此定时任务上线前一定要先问:
这个任务属于某个实例,还是属于整个系统?
绝大多数业务定时任务属于整个系统。
二、最简单的方法:使用分布式锁
可以在任务执行前先抢锁。
例如 Redis:
lock:daily-report:2026-08-14
伪代码:
const lockKey =
"lock:daily-report:2026-08-14";
const acquired = await redis.set(
lockKey,
instanceId,
{
NX: true,
EX: 600
}
);
if (!acquired) {
return;
}
await generateDailyReport();
三个实例同时触发:
instance-1 → 抢锁成功
instance-2 → 失败
instance-3 → 失败
最后只有:
instance-1
真正执行任务。
三、锁一定要设置过期时间
错误写法:
await redis.set(
lockKey,
instanceId,
{
NX: true
}
);
如果抢到锁的实例突然崩溃:
锁永远存在
后续所有实例都会认为:
任务正在执行
然后这个定时任务可能永久停止。
因此分布式锁必须有:
TTL
例如:
预计任务运行2分钟
锁TTL设置10分钟
即使进程异常退出,锁最终也会自动释放。
四、TTL也不能随便设
如果任务正常需要:
15分钟
但锁只设置:
5分钟
就会发生:
instance-1获得锁
↓
任务执行中
↓
5分钟后锁过期
↓
instance-2重新获得锁
↓
同一个任务同时执行两份
所以锁时间必须覆盖合理的任务执行周期。
如果任务耗时不稳定,可以考虑:
锁续期
例如 Worker 每隔一段时间刷新 Lease。
五、释放锁时不能直接DEL
假设:
instance-A获得锁
TTL=30秒
A执行很慢。
30秒后锁过期。
然后:
instance-B
获得了新的锁。
此时 A 执行完毕:
await redis.del(lockKey);
它会把:
B正在持有的锁
删除。
这就会产生严重竞态。
因此锁应该带唯一值:
lock value =
instance-A + unique-token
释放时先判断:
当前锁仍然属于自己吗?
只有属于自己才能删除。
实际项目可以通过 Lua Script 保证“检查 + 删除”的原子性。
六、分布式锁不是业务幂等的替代品
即使分布式锁写得很好,也不能完全假设任务永远只执行一次。
例如:
任务业务执行成功
↓
还没记录完成状态
↓
服务崩溃
↓
锁最终过期
↓
任务再次执行
所以关键任务还需要幂等。
例如日报任务可以使用唯一键:
report_date = 2026-08-14
数据库:
UNIQUE(report_date)
再次执行时:
发现当天日报已存在
→ 不重复创建
更可靠的设计是:
分布式锁
降低重复执行概率
业务幂等
保证重复执行也不会产生错误结果
七、任务要有业务Execution ID
例如每日账单任务:
billing:2026-08-14
可以生成:
executionId =
daily-billing:2026-08-14
执行前查询:
SELECT *
FROM job_executions
WHERE execution_id = ?
如果状态已经:
completed
直接退出。
如果没有,则记录:
{
"executionId": "daily-billing:2026-08-14",
"status": "running"
}
成功后:
completed
失败:
failed
这样不仅防重复,还能留下完整执行记录。
八、不要只记录“任务跑过了”
一个生产级定时任务至少应该记录:
jobName
executionId
startedAt
finishedAt
status
duration
workerId
processedCount
failedCount
error
例如:
job: daily-report
executionId: daily-report:2026-08-14
workerId: api-03
duration: 128s
processed: 18250
failed: 3
status: completed_with_errors
这比日志中只出现:
cron finished
有用得多。
九、定时任务不要和Web服务强绑定
很多项目直接:
API服务启动
↓
Cron一起启动
这会导致一个问题:
API扩容
=
Cron实例增加
更清晰的方式是拆成:
Web Service
和:
Job Worker / Scheduler
例如:
api
worker
scheduler
API可以扩容10个实例。
Scheduler仍然只运行:
1个或受控数量
职责就清晰很多。
十、可以使用Leader Election
如果希望多个 Scheduler 实例保持高可用,但只有一个真正执行任务,可以使用 Leader Election。
例如:
scheduler-1
scheduler-2
scheduler-3
通过选举确定:
scheduler-2 = Leader
只有 Leader 负责:
触发定时任务
如果 Leader 崩溃:
重新选举
↓
scheduler-1成为新Leader
这样既能避免重复执行,又不会因为单个 Scheduler 故障导致定时任务彻底停止。
十一、Kubernetes环境要特别注意CronJob
如果项目已经运行在 Kubernetes 中,很多场景并不需要自己在应用里写 Cron。
可以直接使用:
Kubernetes CronJob
例如:
apiVersion: batch/v1
kind: CronJob
metadata:
name: daily-report
这样调度职责由 Kubernetes 管理。
同时可以设置:
concurrencyPolicy
例如:
concurrencyPolicy: Forbid
表示上一个任务还没执行完时,不允许启动新的任务。
对于纯离线任务,这种方式通常比“每个 API 实例都内置 Cron”更加清晰。
十二、要防止任务重叠执行
假设任务:
每5分钟执行一次
但某一次运行耗时:
8分钟
流程可能变成:
10:00 任务A开始
10:05 任务B开始
10:08 任务A结束
A和B之间重叠了3分钟。
如果处理的是:
订单同步
库存校准
报表聚合
就可能产生数据竞争。
所以定时任务还要明确:
上一轮没结束
下一轮怎么办?
常见策略:
跳过
排队
并发执行
终止旧任务
不能默认直接启动新一轮。
十三、不同任务不要共用一个大锁
错误做法:
lock:cron
所有任务抢同一个锁。
结果:
日报正在执行
↓
数据清理也不能执行
↓
同步任务也必须等待
即使它们完全没有冲突。
更合理:
lock:job:daily-report
lock:job:cleanup
lock:job:order-sync
如果还需要按日期:
lock:job:daily-report:2026-08-14
锁的粒度应该与业务冲突范围一致。
十四、定时任务失败后不要无限自动补跑
例如凌晨任务失败:
00:00失败
系统立即重试:
00:01
00:02
00:03
...
如果失败原因是:
第三方服务长时间不可用
可能持续制造压力。
可以采用:
最大重试次数
+
指数退避
例如:
第1次:1分钟
第2次:5分钟
第3次:15分钟
仍然失败则:
标记failed
+
告警
不要让一个失败 Cron 永久循环。
十五、时区是定时任务最容易忽略的问题之一
开发者写:
每天00:00运行
需要问:
哪个时区的00:00?
服务器可能运行在:
UTC
业务使用:
Asia/Shanghai
如果没有明确配置,就可能出现任务实际在:
北京时间08:00
执行。
尤其涉及:
日报
月结
账单
每日额度
时,必须明确业务时区。
不要直接依赖服务器系统默认时区。
十六、夏令时也可能导致任务多跑或少跑
部分地区存在 Daylight Saving Time。
例如某一天:
02:00
可能被跳过。
另一时期:
01:00
可能出现两次。
如果业务运行在存在夏令时的地区,就不能简单认为:
每天固定24小时
始终成立。
对于财务或结算任务,更适合基于:
业务日期
设计 Execution ID。
而不是只相信 Cron 触发次数。
十七、任务触发和任务执行可以分离
一个更稳定的架构是:
Scheduler
↓
创建任务消息
↓
Queue
↓
Worker执行
Scheduler只负责:
到时间了
→ 创建 job
Worker负责:
真正执行
例如:
每日00:00
↓
创建 daily-report:2026-08-14
↓
进入队列
↓
Worker消费
这样:
-
任务可以重试;
-
可以水平扩展Worker;
-
可以记录状态;
-
可以做失败队列;
-
Scheduler本身非常轻量。
十八、让Codex先分析部署拓扑
遇到定时任务重复执行时,可以先要求:
请先不要修改代码。
分析当前定时任务:
1. 当前生产环境有多少服务实例;
2. 每个实例是否都会注册Cron;
3. 同一个任务能否并发执行;
4. 是否已经存在分布式锁;
5. 任务是否支持幂等;
6. 服务扩容后任务次数是否会增加;
7. 是否更适合拆成独立Scheduler;
8. 当前部署环境是否已有CronJob能力。
很多问题并不在 Cron 表达式,而在部署结构。
十九、测试一定要模拟多实例
本地单实例测试无法发现这类问题。
应该模拟:
instance-A
instance-B
instance-C
同时触发相同任务。
最终验证:
业务结果只产生一次
还应该测试:
持锁实例崩溃
确认:
其他实例最终能够接管
锁TTL过期
确认:
不会误删新Owner的锁
同一Execution ID重复执行
确认:
业务结果仍然幂等
上一轮任务未完成
确认:
下一轮按预期跳过或排队
二十、把定时任务规则写进AGENTS.md
# Scheduled Job规则
- 多实例环境禁止默认每个实例独立执行同一Cron
- 系统级任务必须评估分布式锁或Leader Election
- 分布式锁必须设置TTL
- 释放锁必须验证锁Owner
- 核心任务必须支持业务幂等
- 每次运行必须有唯一executionId
- 上一轮未完成时必须定义下一轮策略
- 定时任务必须明确业务时区
- 长任务优先考虑Scheduler + Queue + Worker
- 修改定时任务后必须执行多实例并发测试
这样 Codex 在新增 Cron 时,就不会只关注:
几点触发
而会同时考虑:
到底由谁执行。
二十一、Plus还是Pro?
如果主要使用 Codex 处理:
-
单机 Cron;
-
普通后台任务;
-
简单 Redis 分布式锁;
-
中小型后端项目;
Plus 通常已经能够覆盖多数开发任务。
如果长期维护:
-
Kubernetes 多实例服务;
-
大量 Scheduled Job;
-
Worker 集群;
-
多服务任务调度;
-
长时间日志和失败恢复分析;
则可以根据实际开发强度评估 Pro。
但版本不会自动解决任务重复执行。
真正重要的是部署上线前明确:
一个定时任务,在整个系统里到底应该同时存在几个执行者?
总结
Codex 写定时任务后,本地只执行一次,上线多实例却重复执行,本质上是因为每个服务实例都拥有自己的 Cron 调度器。
通过:
分布式锁
Leader Election
业务幂等
Execution ID
独立Scheduler
可以把任务执行权从“每个实例都有一份”收敛为“整个系统只认一个有效执行”。
真正可靠的定时任务系统,不只是到了时间能够运行。
它还必须能够回答:
谁执行?如果执行者挂了谁接管?重复触发怎么办?上一轮还没结束怎么办?
这几个问题设计清楚以后,多实例扩容才不会意外变成“任务执行倍增器”。
CSDN文章描述
本文介绍 Codex 编写定时任务后在多实例部署中出现重复执行的问题,并通过分布式锁、Leader Election、业务幂等、Execution ID 和独立 Scheduler,提高 Cron 任务的可靠性。
更多推荐




所有评论(0)