工具大全
踩坑实录作者:Coocon2026年9月1日5 次阅读约 4 分钟阅读

PM2 cron_restart 是「先杀再起」:调度进程自杀断更两天的复盘

现象

一条每天早晨定时发布的内容管线(生成 → 发公众号 → 发邮件),08-28、08-29 连续两天全部没发。两天的日志特征一模一样:

  • 生成步骤(intel-generate)的输出停在 07:04:xx,没有任何报错,就是没有下文了
  • 07:05 整,日志里出现两条 dispatcher 进程的启动头行
  • 之后一整天,调度器每次唤醒都打同一句:「今天已有记录(running),跳过」
  • 07:40 下游的公众号草稿步骤报 guard:no-today-queue——没稿可发

没有 crash 日志,没有 OOM,进程列表里 dispatcher 活得好好的。任务就像凭空蒸发了。

根因

这条管线的调度器 pipeline-dispatcher 当时是一个一次性 PM2 进程:跑一轮判定、该执行的步骤执行完就退出,靠 PM2 的 cron_restart: '*/5 * * * *' 每 5 分钟拉起来一次。

坑就在这里:cron_restart 到点时如果发现实例还在运行,不是「等它跑完」也不是「跳过这轮」,而是先杀再起——SIGINT 掐掉在跑的进程(连同它 spawn 的子进程一起),然后启动新实例。

平时无感,因为 generate 一轮只要 42~92 秒,永远赶在下一个 5 分钟边界前退出。出事那两天,候选池 24 小时上限打满后老条目在 07:00 前后滚出窗口、配额突然释放,现场补写量暴增(一次 LLM 聚类 1 分 43 秒 + 6 条简报校对),generate 被拖过了 07:05——于是:

  1. 07:05 整,PM2 按 cron 表把 dispatcher 连同正在写稿的 generate 子进程一起杀掉
  2. 数据库里的执行记录 PipelineRun 停在 running 状态,没人给它收尸
  3. 幂等键(step + 当天日期)看到「今天已有记录」,全天不再重试
  4. 下游每一步都等不到上游产物,整条链路静默瘫痪

一句话:给「每 N 分钟唤醒」的调度进程挂 cron_restart,等于给它所有子任务设了 N 分钟硬超时——而且这个超时不报错、不告警,杀完还留下一条僵尸 running 记录把重试通道也堵死。

解法

三步,前两步是修复,第三步是当日抢救:

  1. dispatcher 改常驻进程:脚本加 --loop 模式(内部每 5 分钟一个 tick,按墙钟对齐),PM2 配置去掉 cron_restart、改 autorestart: true。tick 串行执行,长任务只会推迟下一个 tick,永远不会被杀
  2. 僵尸记录自动收尸:tick 里对 running 超过 30 分钟无结果的记录判定为「进程被杀」(多为部署/容器重启),标记 failed。这个判定放在时刻判断之前,不受补跑窗口限制——否则窗口过后才发现的僵尸会以 running 挂一整天。
  3. 当日抢救:容器内 docker exec 直接跑各步骤脚本(不经 dispatcher),修正僵尸 PipelineRun 后,当天后续步骤按点恢复自动执行。

修完在 PM2 配置里留了行注释,防止未来有人「优化」回去:

// ⚠ 不要改回 cron_restart 一次性进程:cron_restart 对在跑实例是先杀再起,
// 08-28/08-29 两天 intel-generate 跑过 07:05 被下一班连子进程一起杀死,
// PipelineRun 卡在 running,当天整条发布链路瘫痪。

验证:本机 8 分钟对照复现

「先杀再起」不是文档里的显眼行为,值得本机实锤一次。最小 demo:一个 90 秒的长任务(起跑/心跳/完成/被杀各打一条带时间戳日志),分别交给两种调度形态跑(PM2 7.0.4,独立 PM2_HOME 隔离):

  • 事故组 demo-cron-dispatcher:一次性进程 spawn 长任务,cron_restart: '* * * * *'(每分钟,对应生产的每 5 分钟,等比缩小)
  • 对照组 demo-loop-dispatcher:常驻进程,tick 串行 spawn 同一个长任务,跑完才排下一个 tick(修复后的形态)

同时起跑,跑 8 分 06 秒(10:10:48 ~ 10:18:54)。事故组日志:

cron_restart 组:每到整分连子进程一起被 SIGINT 杀掉

每到整分准时一轮:任务被 SIGINT 掐死(第一次死在 12s——起跑点离整分只有 12 秒;之后每轮都死在 60s/90s),dispatcher 本体跟着被杀,新实例立刻起跑,循环往复。90 秒的任务在 60 秒的 cron 周期下,永远到不了 DONE。

对照组同一时段:

--loop 常驻组:同一个 90s 任务,每个 tick 都跑满 90s 正常 DONE

8 分钟统计(grep -c 原始输出):

两组 START/DONE/KILLED 计数对比

事故组(cron_restart */1) 对照组(--loop 常驻)
任务起跑(START) 9 次 4 次
跑完(DONE) 0 次 4 次(每次 90s 跑满)
被杀(KILLED) 9 次(8 次 cron 整分击杀 + 1 次实验收尾手动停止) 0 次
PM2 面板重启计数 ↺ 8 0

对照组的 tick 周期同样设 60 秒,长任务只是把实际班次自然推迟成约 2 分钟一班——推迟,而不是被杀,这就是两种形态的全部区别。

带走的教训

  1. cron_restart 只适合「跑得远比周期短」的任务。 它的语义是「到点保证有个新实例在跑」,不是「到点触发一次」。任务时长与周期之间必须留出数倍余量,且要按最坏情况(偶发的慢路径)估,不是按日常均值估。
  2. 调度器和任务执行器不要共生死。 dispatcher 自己带着子任务被杀,是「调度」和「执行」耦合在同一个进程树里的直接后果。常驻调度 + 串行 tick 的形态下,慢任务的代价只是推迟,不是全灭。
  3. 幂等键必须配套「僵尸记录」的收尸机制。 「当天已有记录就跳过」防重复执行没错,但记录卡在 running 时它就从保险变成了封条。任何用状态记录做幂等的系统,都要回答「写记录的进程死在半路怎么办」——超时判 stale 是最低配。
  4. 排查这类「跳过」死循环,先看 running 记录的 startedAt。 一条起始于几小时前、至今没有结果的 running,基本就是进程被杀的尸检报告。

相关文章

ANTHROPIC_BASE_URL 设了却不生效

在 zshrc 里 export 了 ANTHROPIC_BASE_URL 指向第三方中转站,Claude Code 却依然走 Google Vertex;同一台机器上,launchd 启动的 Web UI 干脆说未认证。两个坑的根子都不在中转站,而在「你以为环境变量设上了」。

claude-code中转站+2
pitfalls2026年8月24日3 min
164

蜗牛在你点开的瞬间原地调头:一帧的时钟误差,被取模回绕放大成了最大误差

SwiftUI 里用 TimelineView 驱动一张吉祥物场景卡,两个毛病:第一次点开音色时蜗牛原地镜像翻转一下,停止播放时背景硬切。前者查了两轮——第一轮以为是 timeline 暂停导致时间源错位,改完仍在;真正的根因是相位取模时那句 `raw < 0 ? raw + 1 : raw`,它把「timeline.date 比 Date() 早了一帧」这个几十毫秒的误差,回绕放大成了 0.9999 的相位,而朝向恰好是相位在 0 处的不连续函数。这篇复盘讲清楚为什么在 TimelineView 里不能用 .transition、怎么把动画状态写成时间的纯函数、以及交叉淡化为什么要设计成「只有淡入没有淡出」。

bug-复盘swiftui+4
pitfalls2026年8月20日8 min
158

AI 视频的字幕越到后面偏得越多:别再修对齐算法了,问题出在「先生成整段音频」这一步

用 TTS + 自动字幕做科普视频,字幕前半段完美、越往后漂移越大,尾部时间戳甚至退化成 00:00:00。第一反应是修对齐算法——归一化数字、调相似度阈值——都是打补丁。这篇复盘给出根因:「整段 TTS + whisper 转写 + 事后匹配」这类方案在结构上必然漂移,因为它把本该由生成过程直接产出的时序信息,交给了一个会出错的事后重建环节。解法是逐句 TTS + 采样级拼接:字幕时间戳等于每句真实 wav 采样数的累加,句边界天然重置误差,物理上不存在漂移。附完整切句规则、缓存设计与适用边界。

ttsbug-复盘+3
pitfalls2026年8月20日4 min
170

Fable 5 明明是 1M 上下文,状态栏却显示 200k:别急着换工具,先抓一份现场数据

Claude Fable 5 官方确认 1M token 上下文窗口,但 Claude Code 底部状态栏的分母一直是 200k。第一反应是「换个更好的 statusline」——错了。本文复盘完整排障过程:用一行 tee 抓下 statusline 的 stdin 现场,实锤官方字段对新模型误报 200000,最后用一张模型表修正。附赠一个通用教训:换工具修不了数据源的错。

llmclaude-code+3
pitfalls2026年8月14日3 min
416