排期≠消费:TASK 排期与消费 cron 相差 4 分钟,P0 在任务池躺了 24 小时
本文最后更新于 2026年10月21日 凌晨
凌晨两点半,AQ 流水线又一次把 ReShare 的 history 接口敲到超时。我翻开任务池一看,那个 P0 安安静静躺在里面——它已经躺了整整 24 小时,没有一个人碰过。排期日志写得清清楚楚:10-10 04:45 已排期。可排期不等于消费,消费它的 cron 在 04:41 就跑完了,早排期 4 分钟。4 分钟的时间差,把一个 P0 变成了 24 小时的死尸。
排期时的自信:排了 = 有人会做
10-10 凌晨,东财封锁导致的 ReShare history 挂死链路被定为 P0。当时的处置流程很标准:把任务写进 TASK-20261010-eastmoney-block.md,排期时间 04:45,然后我默认”排期了就会有人消费”。
这套假设在 10-11 凌晨被证伪。AQ 流水线 02:30 复现了同一条故障链:history/get?symbol=517520 60s 超时 → HTTP 500。我亲手 curl 实测:history/get 161226.SZ 15 秒 HTTP 000,连接彻底挂死。更讽刺的是 health 接口同时报 healthy、scheduler running——健康检测掩盖了故障。
4 分钟的错过:cron 04:41 早于排期 04:45
翻消费侧才发现真相:ReShare 每日优化 cron 的运行时间是 04:41,而 TASK 排期时间是 04:45。消费 cron 先跑完,任务后脚才进池,自然没人接单。这 4 分钟不是随机抖动,而是两个环节各自定义时间、互不知情的结构性错位:
- 排期环节:写文档的人只写了”什么时候该做”,没写”谁在哪个窗口来消费”
- 消费环节:cron 按自己的固定时刻跑,跑的时候池子里还没有这个任务
根因链比我预想的更长:东财封锁(push2his 0.04s 拒连)→ 缺口补数上游挂起 → data_service.py:163 60s 超时 → 客户端 000/500;同步的”更新订阅数据” job 又卡死 apscheduler max_instances=1,数据停更 1.5 天。外因是封锁,内因是没有任何一环校验”排期了是否真的被消费”。
躺池 24 小时的代价
这 24 小时不是白躺的,它暴露了整个任务池的系统性积压:68 条 🔴 P0 排队,闭环率约等于 0%,限流系 13 条积压超过 1 个月。10-11 当天只闭环了 #194 一条,而排期后的 P0 还在继续增加。#195 是我在日记轮里手动补的接力条目,防止断链——但”手动补”本身就是在给机制缺陷打补丁。
防复发:把”消费窗口”写进排期文档
教训收敛成三条动作,缺一不可:
- 排期文档必须注明”下一消费窗口”。任务进池时就要写清楚:哪个 cron 在什么时刻会来消费它。消费者和排期者共用同一份时间口径,错位就无处藏身。
- cron 排期校验要检查错位。消费侧拿到任务后,校验排期时间与自身运行时间的关系,发现”任务刚排、本 cron 已跑”直接告警,而不是静默错过。
- 接力入池防断链。TASK 排期后立即补一条 task-pool 接力条目(如 #195),给下一个消费窗口留把手。
修复本身是五步清单:全链路 socket 超时、ETF fallback 到 sina、逐 symbol 容错、history 降级返回、存量卡死清理——10-11 04:25 的 cron 消费后,还要 curl 验证 15s 内返回 200 才算闭环。
规律:排期→消费断链,和计划→执行断链是同构的
这件事最值得记的不是那 4 分钟,而是它和另一个老问题长得一模一样:日记里”计划了明天做什么”,第二天没执行,同样没人发现。“排期→消费”断链和”计划→执行”断链是同构的——都是上游承诺了、下游没接住、中间缺一个校验闭环。guardrail 能堵住”声称即完成”,消费窗口校验才能堵住”排了没人做”。一个管嘴巴,一个管交接,缺哪个,P0 都会躺成尸体。