ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Codex写定时任务为什么上线多实例后会执行多次?用分布式锁避免重复任务

Codex写定时任务为什么上线多实例后会执行多次?用分布式锁避免重复任务 1. 多实例下 Codex 定时任务重复执行的真实场景你让 Codex 帮忙加一个每天凌晨生成报表的定时任务它给出的代码大概率长这样cron.schedule(0 0 * * *, async () { await generateDailyReport(); });本地跑起来一切正常一个 Node 进程Cron 触发一次任务执行一次。问题出在上线之后。为了高可用服务通常部署了 instance-1、instance-2、instance-3 三个实例每个实例都加载了同一份代码于是凌晨 0 点三个实例同时触发日报生成了 3 份订单同步了 3 次用户收到 3 封重复通知第三方接口被重复调用 3 次。扩容本来是为了扛住流量结果无意中把定时任务变成了执行次数倍增器。这个问题的本质不是 Cron 表达式写错了而是单机定时任务被直接塞进了多实例架构。Cron 调度器是进程级的每个进程都有一份自己的调度器它不知道其他实例的存在。本地只有一个进程所以触发一次等于执行一次这个假设成立生产环境有 N 个实例实际执行次数就变成了 N 次。很多团队在压测时只关注接口 QPS完全没测过定时任务在多实例下的行为直到线上出现重复数据才发现。判断一个任务该由谁执行先问自己一句话这个任务属于某个实例还是属于整个系统绝大多数业务定时任务——日报、账单、数据清理、订单同步——都属于整个系统同一时刻只应该有一个执行者。想清楚这一点后面的分布式锁、Leader Election、幂等设计才有落脚点。这篇内容会先复现多实例并发执行的现象再给出可复制的 Redis SETNX 锁代码、数据库唯一约束方案、Cron 配置示例最后说明怎么把 Codex 生成的调度逻辑统一到 TaoToken 的 Key/API 通道方便集中观测调用。2. TaoToken 前置准备统一 Key 与 API 通道在动手改锁之前先把调用通道理顺。Codex 生成的定时任务里往往会内嵌模型调用比如生成日报摘要、做数据分类、写通知文案。如果每个实例各自配置 Key出问题时你根本不知道是哪个实例、哪次调用出的错。把调度逻辑和模型调用都收敛到 TaoToken 的统一通道观测和排障会轻松很多。TaoToken 是一个面向开发者的模型 API 聚合入口你可以把它理解成一个 Key 打通多家模型的网关。它适合谁适合正在用 Codex、Claude Code、Cline 这类工具做开发又希望把模型调用集中管理、统一计费、统一看日志的团队和个人。它不替代你的编辑器也不替代你的调度框架它解决的是调用入口分散、Key 满天飞、出问题查不到这件事。接入前你需要准备三样东西我把它叫做三件套缺一不可配置项说明获取位置Base URL统一请求地址https://taotoken.net/apiAPI Key身份凭证控制台 API Keys 页面Model ID具体模型标识模型列表或文档Base URL 固定用https://taotoken.net/api注意这里不加任何查询参数。API Key 到控制台的 API Keys 页面创建创建后立刻复制保存页面刷新后就不再完整显示。Model ID 按你实际要用的模型填比如做文本生成、代码补全、摘要提取选对应的模型标识即可。如果你用的是 Claude Code 这类工具配置方式略有不同需要设置 Anthropic 兼容的 Base URL 和 Key。具体路径参考官方文档的 ClaudeCodeAnthropic 章节那里有完整的 settings 示例。如果你用的是 Cline 并且挂了 MCP同样要把 Base URL、Key、Model ID 三件套填全MCP 的配置文件里这三项一个都不能少。为什么要先做这一步因为定时任务重复执行时你排查的第一现场往往是调用日志。如果三个实例各自打各自的日志你得登录三台机器分别看如果统一走 TaoToken调用记录集中在一处哪个时间段、哪个模型、调了多少次一目了然。这为后面验证锁生效后只调用一次提供了直接证据。想先体验一下模型对话效果可以直接打开模型对话页面试几条请求确认 Key 和 Base URL 没问题再往代码里集成。长期做编码和 Agent 任务的可以了解 Coding Plan它更适合高频、长时间的调用场景。3. 可复制配置Redis SETNX 锁与数据库唯一约束这一节给可直接粘贴的代码。先看 Redis 方案核心是SET key value NX EX ttl这一条原子命令。NX 表示 key 不存在才设置EX 表示过期秒数两者组合保证抢锁 设过期是原子的。// lock.js const Redis require(ioredis); const redis new Redis(process.env.REDIS_URL); const { randomUUID } require(crypto); // 每个实例启动时生成唯一标识用于释放锁时校验 Owner const instanceId ${process.env.HOSTNAME || local}-${randomUUID()}; async function acquireLock(key, ttlSeconds) { const token ${instanceId}:${randomUUID()}; const result await redis.set(key, token, NX, EX, ttlSeconds); if (result ! OK) { return null; // 没抢到锁 } return token; // 抢到了返回 token 用于后续释放 } // 释放锁必须校验 Owner用 Lua 保证检查 删除原子 const releaseScript if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end ; async function releaseLock(key, token) { if (!token) return; await redis.eval(releaseScript, 1, key, token); } module.exports { acquireLock, releaseLock, instanceId };注意几个关键点。第一锁的 value 不是随便写个1而是instanceId randomUUID组成的 token这样释放时能判断这把锁还是不是我的。第二释放锁用 Lua 脚本把 GET 和 DEL 合成一个原子操作避免检查通过后、删除前锁过期被别人抢走的竞态。第三TTL 必须设置否则持锁实例崩溃后锁永远不释放任务永久停摆。再看怎么在定时任务里用这把锁// daily-report.js const cron require(node-cron); const { acquireLock, releaseLock } require(./lock); const { generateDailyReport } require(./report); const LOCK_TTL 600; // 10 分钟要覆盖任务正常执行周期 cron.schedule(0 0 * * *, async () { const today new Date().toISOString().slice(0, 10); const lockKey lock:job:daily-report:${today}; const token await acquireLock(lockKey, LOCK_TTL); if (!token) { console.log([skip] 未抢到锁本实例不执行: ${lockKey}); return; } try { await generateDailyReport(today); } finally { await releaseLock(lockKey, token); } }, { timezone: Asia/Shanghai });锁的粒度要跟业务冲突范围一致。日报用lock:job:daily-report:2026-08-14数据清理用lock:job:cleanup订单同步用lock:job:order-sync不要所有任务抢同一个lock:cron否则日报执行时清理任务也得干等明明它们互不冲突。如果你不想引入 Redis数据库唯一约束是更简单的兜底方案。建一张执行记录表CREATE TABLE job_executions ( execution_id VARCHAR(128) PRIMARY KEY, job_name VARCHAR(64) NOT NULL, status VARCHAR(16) NOT NULL, started_at DATETIME NOT NULL, finished_at DATETIME, worker_id VARCHAR(64), processed_count INT DEFAULT 0, failed_count INT DEFAULT 0, error TEXT );任务开始时尝试插入execution_id daily-report:2026-08-14插入成功说明抢到了执行权插入冲突说明别的实例已经在跑直接退出。这个方案的好处是执行记录天然落库后面查今天到底跑了几次非常方便。如果你在 Kubernetes 环境其实可以不用在应用里写 Cron直接用 CronJob 资源并设置concurrencyPolicy: Forbid让 K8s 保证同一任务不并发apiVersion: batch/v1 kind: CronJob metadata: name: daily-report spec: schedule: 0 0 * * * timezone: Asia/Shanghai concurrencyPolicy: Forbid jobTemplate: spec: template: spec: containers: - name: report image: your-registry/daily-report:latest restartPolicy: OnFailureconcurrencyPolicy有三个值Allow允许并发Forbid上一个没跑完就跳过新的Replace终止旧的启动新的。离线任务通常选Forbid。4. 验证请求与成功结果多实例压测步骤配置写完不算完必须模拟多实例并发才能确认锁真的生效。本地单实例测试永远发现不了这类问题因为单实例下触发一次执行一次永远成立。第一步本地起三个进程模拟三个实例。把上面的daily-report.js复制三份或者用环境变量区分HOSTNAMEinstance-1 node daily-report.js HOSTNAMEinstance-2 node daily-report.js HOSTNAMEinstance-3 node daily-report.js 第二步把 Cron 表达式临时改成每分钟触发一次方便快速验证cron.schedule(* * * * *, async () { /* ... */ });第三步观察日志。预期结果是三个实例同时被触发但只有一个打印开始执行报表另外两个打印未抢到锁本实例不执行。如果三个都执行了说明锁没生效回去检查redis.set的 NX 参数是不是写成了普通 set。第四步验证持锁实例崩溃后其他实例能接管。在任务执行中途手动 kill 掉持锁进程kill -9 持锁进程PID等 TTL 过期后下一轮触发时应该由另一个实例抢到锁并执行。这一步验证的是锁不会因为进程崩溃而永久卡死。第五步验证不会误删新 Owner 的锁。这个场景比较难手动构造可以写个单元测试实例 A 抢锁 TTL 设 5 秒A 执行 8 秒期间锁过期实例 B 抢到新锁A 执行完后调用releaseLock断言 B 的锁还在。用 Lua 脚本的版本应该能通过这个测试用裸DEL的版本会失败。第六步验证业务幂等。即使锁写得再好也不能假设任务永远只执行一次。任务业务执行成功、还没记录完成状态、服务崩溃、锁过期、任务再次执行——这个链路是真实存在的。所以关键任务要有业务层幂等比如日报表加UNIQUE(report_date)重复执行时发现当天记录已存在就跳过。验证通过后把 Cron 表达式改回0 0 * * *时区显式写成Asia/Shanghai。不要依赖服务器系统默认时区服务器可能跑在 UTC你以为的每天 0 点实际是北京时间早上 8 点。5. 本篇常见错误排查报错一401 Unauthorized或invalid api key。这是 Key 没配对。检查三件套Base URL 是不是https://taotoken.net/apiKey 是不是从控制台 API Keys 页面复制的完整字符串Model ID 是不是填了实际存在的模型。常见坑是 Key 复制时带了空格或者 Base URL 末尾多加了斜杠。如果你用 Claude Code检查 settings 里的 Anthropic Base URL 配置如果用 Cline MCP检查 MCP 配置文件里 Base URL、Key、Model ID 三项是否齐全。报错二local proxy failed或连接超时。这类报错通常是网络出口或代理配置问题。先确认你的运行环境能正常访问taotoken.net用 curl 测一下curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $YOUR_KEY \ -H Content-Type: application/json \ -d {model:your-model-id,messages:[{role:user,content:ping}]}如果 curl 通但代码不通检查代码里的 Base URL 是不是被某个环境变量覆盖了。报错三reading choices或cannot read property choices of undefined。这是响应结构解析出错通常是因为请求根本没成功返回的是错误对象而不是正常的 completion 结构。先打印完整响应体看看到底返回了什么再决定是改解析逻辑还是修请求参数。很多情况下是 Model ID 填错服务端返回了错误信息代码却直接去读response.choices[0]。报错四OAuth相关报错。如果你用的是需要 OAuth 的工具链检查 token 是否过期、回调地址是否配置正确。这类问题跟分布式锁无关但会干扰你判断任务到底执行了几次所以先把调用通道修通再验证锁逻辑。报错五锁没生效三个实例都执行了。按这个顺序查redis.set是不是用了 NX 参数锁 key 是不是每个实例算出来不一样比如把 instanceId 拼进了 keyTTL 是不是设得太短任务还没跑完锁就过期了释放锁是不是用了裸 DEL 导致误删。把这几个点逐一排除基本能定位。报错六任务执行了两次但日志显示只有一个实例抢到锁。这种情况多半是任务重叠执行上一轮还没结束下一轮已经触发。比如任务每 5 分钟跑一次但某次跑了 8 分钟10:00 的 A 还没结束10:05 的 B 就开始了。解决办法是明确上一轮没结束下一轮怎么办选跳过、排队还是终止旧任务不能默认直接启动新一轮。6. 把调度逻辑收敛到统一通道排查完锁的问题回头看一下你的定时任务里有多少处模型调用。如果每个任务各自读环境变量、各自拼 Base URL、各自管 Key时间一长必然混乱。把 Codex 生成的调度逻辑统一改到 TaoToken 通道好处是调用记录集中、Key 轮换只改一处、出问题能快速定位是哪个任务哪次调用。具体做法是在项目里建一个统一的客户端封装// llm-client.js const BASE_URL https://taotoken.net/api; const API_KEY process.env.TAOTOKEN_API_KEY; const DEFAULT_MODEL process.env.TAOTOKEN_MODEL_ID || your-model-id; async function chat(messages, options {}) { const res await fetch(${BASE_URL}/v1/chat/completions, { method: POST, headers: { Authorization: Bearer ${API_KEY}, Content-Type: application/json, }, body: JSON.stringify({ model: options.model || DEFAULT_MODEL, messages, ...options, }), }); if (!res.ok) { const text await res.text(); throw new Error(LLM request failed: ${res.status} ${text}); } return res.json(); } module.exports { chat };定时任务里只调chat()不再关心 Base URL 和 Key 从哪来。这样无论你有多少个任务、多少个实例调用入口只有一个观测点也只有一个。对于长期跑编码和 Agent 任务的场景可以了解 Coding Plan它更适合高频调用。需要创建和管理 Key 的去控制台 API Keys 页面。想先验证模型效果的打开模型对话页面直接试。完整的接入参数和示例参考接入文档。最后留一个真实经验定时任务上线前先问清楚这个任务在整个系统里应该同时存在几个执行者。答案是一就用分布式锁或 Leader Election 收敛答案是多个就明确并发策略。把这个问题写进 AGENTS.md让 Codex 下次生成 Cron 时不再只关注几点触发而是同时考虑到底由谁执行。
返回列表