返回博客

双层看门狗踩坑:用 start_time 判断任务存活,坑惨了

后台有一个全量数据同步任务,跑一次要两个多小时。为了防止进程意外死亡后状态行永远卡在 running,我们加了一个自愈 cron,每 10 分钟扫一次 sync_log 表,把"运行超过 30 分钟"的行标成 error。逻辑看起来没问题,部署当天却炸了:用户在管理后台看到同步报错,手动重试,结果两个进程同时在写同一批数据。

排查下来,根因令人哭笑不得——任务本身没有死,是我们自己把它"判死"了。

现象

  • sync_log 表中 status 在任务运行约 30 分钟时变为 error,error_msg 是 self-heal: stuck >30min
  • 此时 PHP-FPM 进程实际还在运行,数据还在同步
  • 锁文件已被 cron 删除,用户手动重试成功创建第二个进程
  • 任务最终以 status=done 结束,但 error_msg 字段依然残留 self-heal: stuck >30min(成功的记录携带了错误标记)

根因

这套系统有两层看门狗:

  1. 内层:任务自身的 try-catch-finally + register_shutdown_function,知道真实结果
  2. 外层*/10 * * * * 自愈 cron,只能靠外部信号推断

外层 cron 用的是这个逻辑:

-- 反模式:blanket start_time 截断
UPDATE sync_log
SET status='error', error_msg='self-heal: stuck >30min'
WHERE status='running' AND start_time < NOW() - INTERVAL 30 MINUTE

问题在于:这个任务的真实最长运行时间是 130 分钟,而阈值只有 30 分钟。每一次合法的全量同步,都会在第 30 分钟被 cron 判成"僵尸进程"。

坑:start_time 阈值必须大于任务最长运行时间,否则正常任务全被误杀

start_time < NOW() - INTERVAL N MINUTE 这个判断隐含了一个假设:「运行超过 N 分钟的任务一定是死的」。但实际上你根本不知道任务最长跑多久——特别是数据量随业务增长后,当初估的阈值随时会失效。这是一个随着数据规模增长必然爆炸的定时炸弹。

连锁伤害链:

  1. cron 把 status 改成 error,同时 unlink 锁文件(但没有 kill -9 进程)
  2. 进程还在运行,数据还在写
  3. 用户看到报错手动重试,因为锁已消失,第二个进程成功启动
  4. 两个进程并发写同一批数据,产生脏数据
  5. 任务最终 finally 成功,把 status 改回 done,但如果成功路径没有显式清空 error_msg,"self-heal: stuck >30min" 就永久残留在成功记录里

正解

1. 外层 cron 改用心跳文件

任务在业务循环里定期 touch 一个心跳文件(progress.json 或 lock 文件本身),cron 通过检查文件 mtime 判断进程是否真正失活:

#!/bin/bash
# */10 * * * * — 心跳判活,逐行处理,带绝对兜底
PROGRESS=/path/to/runtime/progress.json
NOW=$(date +%s)
 
mysql -N -e "SELECT id, UNIX_TIMESTAMP(start_time) FROM sync_log WHERE status='running'" \
| while read -r ID STARTED; do
    AGE=$((NOW - STARTED))
    HEARTBEAT_AGE=$((NOW - $(stat -f %m "$PROGRESS" 2>/dev/null || echo 0)))
 
    # 心跳鲜活(<5 分钟)且总运行时间合理(<4 小时)→ 放行
    if [ $HEARTBEAT_AGE -lt 300 ] && [ $AGE -lt 14400 ]; then continue; fi
 
    # 心跳超时 OR 超过绝对兜底阈值 → 标 error
    REASON="self-heal: heartbeat_age=${HEARTBEAT_AGE}s, total_age=${AGE}s"
    mysql -e "UPDATE sync_log SET status='error', end_time=NOW(),
              error_msg='$REASON' WHERE id=$ID AND status='running'"
    rm -f /path/to/runtime/sync.lock
done

关键:绝对兜底阈值(4 小时)必须远大于业务实测最长运行时间(130 分钟),留至少 1.5 倍余量。

2. 内层 finally 成功路径必须显式清空 error_msg

// finally 成功路径
Db::name('sync_log')->where('id', $logId)->update([
    'status'      => 'done',
    'end_time'    => date('Y-m-d H:i:s'),
    'duration'    => (int)(microtime(true) - $startedAt),
    'error_msg'   => '',   // 关键:显式清空,不能省略
    'total_count' => $total,
    'updated_count' => $updated,
]);
$bag['handled'] = true;

如果 error_msg 不在成功路径的更新字段里,cron 写入的 self-heal: stuck >30min 会永久残留。

正解:心跳 mtime 判活 + 绝对时长兜底 + 成功路径显式清空 error_msg

外层 cron 改看心跳文件的修改时间(mtime),鲜活则放行,超时才介入;绝对时长兜底处理心跳文件本身卡住的极端情况。内层 finally 成功路径必须把 error_msg 显式写成空字符串,防止自愈标记以「成功行带错误信息」的形式永久留存。

实测结果

修复后:绝对兜底 4 小时,任务真实运行 126 分钟,整个过程 cron 执行了多次,全部判为「心跳鲜活,放行」,零误杀。任务完成后 sync_log 记录:status=doneerror_msg='',干净。

一句话外卖

start_time 判断长任务是否存活,是在赌「任务能在你估的时间内跑完」——改用心跳文件 mtime,才是判断进程是否真正失活的正确信号。