双层看门狗踩坑:用 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(成功的记录携带了错误标记)
根因
这套系统有两层看门狗:
- 内层:任务自身的
try-catch-finally+register_shutdown_function,知道真实结果 - 外层:
*/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 < NOW() - INTERVAL N MINUTE 这个判断隐含了一个假设:「运行超过 N 分钟的任务一定是死的」。但实际上你根本不知道任务最长跑多久——特别是数据量随业务增长后,当初估的阈值随时会失效。这是一个随着数据规模增长必然爆炸的定时炸弹。
连锁伤害链:
- cron 把
status改成error,同时unlink锁文件(但没有kill -9进程) - 进程还在运行,数据还在写
- 用户看到报错手动重试,因为锁已消失,第二个进程成功启动
- 两个进程并发写同一批数据,产生脏数据
- 任务最终 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 会永久残留。
外层 cron 改看心跳文件的修改时间(mtime),鲜活则放行,超时才介入;绝对时长兜底处理心跳文件本身卡住的极端情况。内层 finally 成功路径必须把 error_msg 显式写成空字符串,防止自愈标记以「成功行带错误信息」的形式永久留存。
实测结果
修复后:绝对兜底 4 小时,任务真实运行 126 分钟,整个过程 cron 执行了多次,全部判为「心跳鲜活,放行」,零误杀。任务完成后 sync_log 记录:status=done,error_msg='',干净。
一句话外卖
用
start_time判断长任务是否存活,是在赌「任务能在你估的时间内跑完」——改用心跳文件 mtime,才是判断进程是否真正失活的正确信号。