
1. 凌晨三点被叫醒的那次事故Cron 定时调度为什么会丢任务先说结论Cron 定时调度本身不保证可靠性它只负责“到点触发”不负责“任务一定跑完、跑一次、跑成功”。如果你把后台任务直接挂在 Cron 上中间任何一环抖动——容器重启、进程被 OOM Kill、网络分区、依赖接口超时——任务状态就丢了而且你往往第二天才发现。我遇到过的典型场景是这样的一个数据同步任务Cron 表达式写的是0 2 * * *凌晨两点跑。结果那天容器编排平台做滚动更新正好两点前后把 Pod 重启了。任务在内存队列里刚被标记为 Running进程就没了。重启后调度器重新加载发现这个任务既不在 Pending 也不在 Completed直接当成“没跑过”又触发了一次。而这个任务没有幂等设计重复执行导致数据重复写入第二天对账差了十几万。这个坑的本质是任务的生命周期状态没有持久化调度器重启后无法恢复现场。很多人以为写个 Cron 表达式就完事了其实那只是“触发器”真正的可靠性要靠一整套机制兜底。这篇文章面向的是需要稳定运行定时作业的服务端场景。我会把 TaoToken 后台任务与 Cron 定时调度的持久化、死信队列、失败重试、心跳健康检查这几块拆开讲每一步都给可复制的配置和验证动作。你跟着做能搭出一条可观测、可恢复的调度链路。先明确一个认知任务不是“运行/结束”二态而是三态甚至多态。我习惯拆成 Pending已注册待调度、Running执行中、Completed/Failed终态。踩坑点全在状态转换的边界上——Pending 到 Running 的瞬间调度器挂了怎么办Running 时进程被 SIGKILL 了状态怎么恢复这些都得靠持久化存储来回答。TaoToken 在这里的角色是提供统一的模型调用入口和任务编排能力。你可以把它理解成“调度中枢 模型网关”定时任务通过它触发任务里需要调用大模型时也走它。官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。下面所有配置都围绕这个入口展开。2. TaoToken 前置准备任务持久化与调度器接入的配置基线在写任何 Cron 之前先把“地基”打好。这一节解决的是任务状态存哪、调度器怎么连、模型调用凭证怎么配。很多人跳过这步直接写业务逻辑结果就是前面说的丢任务。2.1 任务存储选型Redis PostgreSQL 双写TaoToken 默认的任务队列是内存态的重启即丢。生产环境必须换持久化后端。我目前用的是 Redis PostgreSQL 双写Redis 做快速消费和延迟队列PostgreSQL 做可靠备份和审计。为什么双写因为 Redis 挂了可以从 DB 恢复DB 短暂不可用时 Redis 还能撑一阵。代价是写入延迟增加几毫秒但相比凌晨被叫醒这代价值得。先建表。任务队列表和执行记录表分开前者管调度后者管幂等CREATE TABLE task_queue ( id UUID PRIMARY KEY, task_type VARCHAR(64) NOT NULL, payload JSONB NOT NULL, status VARCHAR(16) NOT NULL DEFAULT pending, retry_count INT NOT NULL DEFAULT 0, execution_key VARCHAR(128), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE task_execution ( id BIGSERIAL PRIMARY KEY, task_id UUID NOT NULL, execution_key VARCHAR(128) NOT NULL, status VARCHAR(16) NOT NULL, result JSONB, error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (task_id, execution_key) );注意task_execution上的唯一约束(task_id, execution_key)这是幂等的第一道防线。同一个任务同一个业务键数据库层面就挡住重复写入。2.2 调度器配置文件TaoToken 的调度器支持 YAML 配置。下面这份是我在用的基线路径放在/etc/taotoken/scheduler.yamlscheduler: heartbeat_interval: 30 heartbeat_ttl: 60 timezone: UTC max_retry: 3 retry_backoff_base: 60 retry_backoff_max: 3600 storage: redis: url: redis://127.0.0.1:6379/0 pending_queue: task:pending delayed_queue: task:delayed dead_letter_queue: task:dead_letter postgres: dsn: postgres://user:pass127.0.0.1:5432/tasks?sslmodedisable model: base_url: https://taotoken.net/api api_key: ${TAOTOKEN_API_KEY} default_model: claude-sonnet-4-5 task_pools: io_pool: max_workers: 20 task_types: [http_call, db_query, model_invoke] cpu_pool: max_workers: 4 task_types: [data_processing, report_generation]几个关键点timezone显式声明 UTC别依赖宿主时区heartbeat_ttl设成心跳间隔的两倍留出容错窗口model.base_url指向 TaoToken 的 API 入口api_key用环境变量注入别硬编码。2.3 获取 API Key 并写入环境到 TaoToken 控制台创建 API Key地址是 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。创建后写入环境变量export TAOTOKEN_API_KEYsk-xxxxxxxxxxxxxxxx如果你用的是 Claude Code 这类工具配置方式略有不同。Claude Code 的接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面写了 Base URL、Key、Model ID 三件套怎么填。核心就是Base URLhttps://taotoken.net/apiAPI Key你刚创建的那串Model ID比如claude-sonnet-4-5这三件套在 Cline、Codex、CC Switch 里都是同样的填法只是入口位置不同。Cline 的 MCP 配置里Base URL 和 Key 填在 provider 设置里Codex 的auth.json里对应base_url和api_key字段。别只填 Key 忘了 Base URL那样会默认打到官方端点报 401。2.4 调度器启动与心跳调度器主循环里必须有心跳。没有心跳调度器 OOM 了但容器还在跑所有定时任务静默停止你根本不知道。import time import redis r redis.from_url(redis://127.0.0.1:6379/0) def scheduler_heartbeat(): while True: r.set(scheduler:heartbeat, time.time(), ex60) time.sleep(30)监控脚本检查心跳是否过期def check_scheduler_health(): last r.get(scheduler:heartbeat) if not last or time.time() - float(last) 90: alert_ops(调度器心跳丢失疑似宕机)这个机制救过我两次一次是节点网络分区一次是 Redis 连接池泄漏导致调度器卡死。没有心跳这些故障会静默持续到业务受损。3. 可复制配置Cron 表达式、幂等执行与死信队列接入这一节是核心给的是能直接抄的配置和代码。分三块Cron 时区处理、幂等执行、死信队列。3.1 Cron 表达式的时区陷阱TaoToken 的 Cron 调度默认用 UTC。如果你部署时没注意时区就会出现“设了凌晨 2 点结果下午 2 点跑”的诡异现象。我的做法是所有 Cron 表达式统一用 UTC任务内部再做时区转换。tasks: - name: daily_order_sync schedule: 0 18 * * * # UTC 18:00 北京时间次日 02:00 timezone: UTC task_type: http_call payload: url: https://internal.api/order/sync execution_key_template: order_sync_{{ .ScheduledDate }}execution_key_template是关键它按调度日期生成业务唯一键。同一天重复触发键相同幂等逻辑就能挡住。3.2 幂等执行先查执行记录再干活重试是补救幂等是根本。没有幂等的重试是灾难。下面这个函数是幂等执行的标准骨架import json import logging logger logging.getLogger(__name__) def execute_task(db, task_id, execution_key, payload): existing db.query_one( SELECT status FROM task_execution WHERE task_id %s AND execution_key %s, (task_id, execution_key) ) if existing and existing[status] completed: logger.info(f任务 {task_id} 已执行过跳过) return try: result do_business_work(payload) db.execute( INSERT INTO task_execution (task_id, execution_key, status, result) VALUES (%s, %s, completed, %s) ON CONFLICT (task_id, execution_key) DO UPDATE SET statuscompleted, resultEXCLUDED.result, (task_id, execution_key, json.dumps(result)) ) except Exception as e: db.execute( INSERT INTO task_execution (task_id, execution_key, status, error) VALUES (%s, %s, failed, %s) ON CONFLICT (task_id, execution_key) DO UPDATE SET statusfailed, errorEXCLUDED.error, (task_id, execution_key, str(e)) ) raiseexecution_key的设计要点它得是业务语义上的唯一标识比如“2024-01-15 的订单同步”而不是随机 UUID。这样即使调度器重复触发同一个键只会执行一次。3.3 死信队列接入不是所有失败都适合自动重试。依赖外部 API 的任务对方返回 403 权限错误重试一万次也没用。重试超过 3 次后自动进死信队列并告警。import time def retry_or_dead_letter(r, task, retry_count): if retry_count 3: delay min(2 ** retry_count * 60, 3600) r.zadd(task:delayed, {task[id]: time.time() delay}) else: r.lpush(task:dead_letter, task[id]) alert_ops(f任务 {task[id]} 重试 3 次失败请人工介入)死信队列的消费端要提供人工重放接口def replay_dead_letter(r, db, task_id): task db.query_one(SELECT * FROM task_queue WHERE id %s, (task_id,)) if not task: raise ValueError(任务不存在) db.execute(UPDATE task_queue SET statuspending, retry_count0 WHERE id %s, (task_id,)) r.lpush(task:pending, task_id) r.lrem(task:dead_letter, 0, task_id)3.4 任务超时与资源隔离后台任务最怕僵尸进程——卡住了但不退出占着资源不释放。TaoToken 的任务默认没超时必须显式设置import asyncio async def run_with_timeout(task_id, coro, timeout300): try: await asyncio.wait_for(coro, timeouttimeout) except asyncio.TimeoutError: logger.error(f任务 {task_id} 超时 {timeout}s强制终止) cleanup_task(task_id) mark_task_timeout(task_id)资源隔离方面IO 密集型和 CPU 密集型任务分池。上面scheduler.yaml里的task_pools就是干这个的。IO 池给 20 个 workerCPU 池给 4 个别混在一起否则 CPU 任务会被 IO 任务阻塞。4. 验证请求确认调度链路真的跑通了配置写完不算完得验证。这一节给的是可执行的验证动作从单次触发到失败重试全链路。4.1 手动触发一次任务先确认调度器能正常拉起任务。用 TaoToken 的模型对话入口做个最小验证地址是 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 。在对话里发一条消息确认 API Key 和 Base URL 配对了。然后手动往队列里塞一个任务redis-cli LPUSH task:pending test-task-001观察调度器日志应该能看到任务被消费、状态从 pending 变 running 再变 completed。查数据库确认SELECT id, status, retry_count FROM task_queue WHERE id test-task-001; SELECT task_id, execution_key, status FROM task_execution WHERE task_id test-task-001;4.2 验证幂等重复触发同一任务把同一个execution_key的任务再塞一次观察是否被跳过redis-cli LPUSH task:pending test-task-001日志里应该出现“任务已执行过跳过”数据库里task_execution不会多出记录。这一步验证的是幂等逻辑真的生效了。4.3 验证失败重试与死信故意让任务失败比如把 payload 里的 URL 改成一个不存在的地址。观察重试次数递增SELECT id, status, retry_count FROM task_queue WHERE id test-task-001;重试 3 次后任务应该进死信队列redis-cli LRANGE task:dead_letter 0 -1同时告警应该发出来。如果告警没发检查alert_ops的实现和 webhook 配置。4.4 验证调度器心跳停掉调度器进程等 90 秒看监控是否告警redis-cli GET scheduler:heartbeat心跳过期后check_scheduler_health应该触发告警。这一步验证的是调度器自身的可观测性。4.5 验证 Cron 触发时间把 Cron 表达式设成*/5 * * * *每 5 分钟观察任务是否按 UTC 时间触发。对比系统时间和任务创建时间确认时区没跑偏。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth这一节对照真实报错给排查路径。这些错我都踩过按顺序查基本能定位。5.1 401 Unauthorized最常见。原因通常是 API Key 没配对或者 Base URL 填错导致请求打到了官方端点。排查步骤先确认环境变量TAOTOKEN_API_KEY有值echo $TAOTOKEN_API_KEY看输出。然后确认scheduler.yaml里model.base_url是https://taotoken.net/api不是别的。如果用的是 Claude Code检查auth.json里base_url和api_key两个字段都填了。只填 Key 不填 Base URL请求会默认打到官方端点官方不认你的 Key就 401。5.2 local proxy failed这个错通常出现在本地开发环境调度器尝试连本地代理但代理没起来。检查redis.url和postgres.dsn是否可达。用redis-cli ping和psql -c SELECT 1分别验证。如果 Redis 或 PG 没起来调度器启动时会报这个错。还有一种情况是容器网络配置问题。调度器和 Redis 不在同一网络DNS 解析失败。检查docker network ls和容器的网络配置。5.3 reading choices 相关报错这个错一般出现在模型调用返回体解析阶段。TaoToken 的 API 返回结构里choices字段是模型输出。如果解析时报“reading choices”说明返回体不是预期的 JSON 结构或者choices为空。排查先用 curl 直接打 API看返回体长什么样curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d {model:claude-sonnet-4-5,messages:[{role:user,content:ping}]}如果返回体正常说明是代码解析逻辑问题。检查你的解析代码有没有处理choices为空的情况以及有没有对返回体做 JSON 解析异常捕获。5.4 OAuth 相关报错如果你用的是 Claude Code 的 OAuth 流程报错通常和 token 过期或回调地址不匹配有关。Claude Code 的接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面写了 OAuth 的配置步骤。排查确认回调地址和 TaoToken 控制台里配置的一致。token 过期的话重新走一遍授权流程。如果用的是 API Key 模式就不涉及 OAuth直接检查 Key 即可。5.5 任务卡在 Running 不结束这个不是报错但比报错更隐蔽。任务状态一直是 Running既不完成也不失败。原因通常是任务没有超时设置卡在某个 IO 操作上。排查查task_queue里statusrunning且updated_at超过 10 分钟的记录。这些就是僵尸任务。手动标记超时并触发重试UPDATE task_queue SET statusfailed, retry_countretry_count1 WHERE id xxx AND statusrunning;然后往task:pending重新塞一次。长期方案是给所有任务加超时用上面run_with_timeout那套。6. 长期编码与 Agent 场景把调度链路接到 Coding Plan前面讲的都是单机调度。如果你的场景是长期编码、Agent 自动化、多任务编排单机调度器不够用得考虑 Coding Plan。入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。Coding Plan 解决的是几个单机调度搞不定的问题任务跨节点分发、模型调用配额管理、Agent 长会话状态保持。比如你有一个 Agent 任务需要连续调用模型几十次中间还要读写数据库、调外部 API单机调度器一旦重启整个会话就断了。Coding Plan 会把会话状态持久化重启后从断点继续。接入方式还是那三件套Base URL、Key、Model ID。在 Coding Plan 的控制台里配置好然后调度器里把model.base_url指向 Coding Plan 的端点。任务类型里加上agent_session调度器会自动走长会话通道。我实测下来Coding Plan 对 Agent 类任务的可靠性提升明显。之前单机调度跑一个 20 步的 Agent 任务中途容器重启就全丢了得从头跑。现在重启后从第 15 步继续省了 75% 的模型调用。最后给个实用技巧死信队列的告警别只发一次。我现在的做法是任务进死信队列后每 30 分钟重发一次告警直到有人认领。这样避免告警被淹没在消息流里。认领后停止重发人工修复完从死信队列重放。这套机制跑了一年多没有一次任务丢失是超过 2 小时才发现的。