监控告警用了永远为真的条件:createtime 从不被 UPDATE 触碰
给某个定时刷新任务写了一套健康检查:token 快过期 且 记录超过 23 小时没刷新 → 触发告警。逻辑看起来无懈可击。上线一周后,同事反映告警每次都在 token 还剩十分钟时准时响,但手动确认 cron 任务其实一直在正常跑。
折腾了半小时才意识到:那条 AND createtime < now - 23*3600 从服务运行满 24 小时起就永远为 true。
现象
告警条件:
SELECT * FROM platform_token
WHERE expiretime < UNIX_TIMESTAMP() + 600
AND createtime < UNIX_TIMESTAMP() - 82800; -- 23h预期:两个条件同时成立才说明 cron 卡住了。 实际:停掉 cron,告警频率毫无变化。移除第二个 AND,告警频率同样毫无变化。
根因
去翻实际的写入代码(ThinkPHP 5 的 Model):
if ($existing) {
// UPDATE 路径——只更新业务字段
$this->save([
'token' => $result['token'],
'expiretime' => time() + $result['expires_in'],
'user_id' => 0,
], ['platform' => 'xxx']);
} else {
// INSERT 路径——才会写 createtime
$data['createtime'] = time();
$this->save($data);
}createtime 只在 INSERT 分支里出现。ThinkPHP 的 save($data, $where) 只更新 $data 里有的字段,不会自动 touch 其他列。
结果:行插入后,createtime 被永久冻结在创建时刻。服务运行超过 24 小时后,这一列对任何行都满足 < now - 23h,AND 条件退化为恒真——相当于直接写 WHERE 1=1。
createtime / created_at / inserted_at 这类命名暗示「写入即冻结」。ORM 的 partial-update 路径(ThinkPHP save($data, $where)、Laravel update([...])、Hibernate dirty-check)只写你显式传入的字段。依赖这些列来判断「最近有没有被刷新」,等于用一把永远不走的时钟来测速。
正解
选一个更新路径实际会写入的列作为新鲜度基准,优先级如下:
- 专用心跳列:
last_run_at/last_refreshed_at。在 UPDATE 路径里显式赋值,语义最清晰。 - 业务值倒推时间:token 场景里
expiretime每次刷新都会前移。用expiretime - TTL反推上次刷新时刻: (-- 如果 expiretime 距 TTL 前推超过 23h 仍没动,说明 cron 卡了 WHERE expiretime < UNIX_TIMESTAMP() + 600 AND (expiretime - 7200) < UNIX_TIMESTAMP() - 828007200为 token TTL,按实际值替换。) updatetime/updated_at:前提是 ORM 的自动时间戳已启用(ThinkPHP 需protected $autoWriteTimestamp = true;Laravel 默认开启)。不能假设,要确认。
在 DB 上跑一条聚合查询,看候选列的值分布:
SELECT
MIN(createtime), MAX(createtime),
MIN(updatetime), MAX(updatetime),
MIN(expiretime), MAX(expiretime)
FROM platform_token;如果某列的 MIN 和 MAX 几乎一样、且远早于今天,这列就是「冻结列」,不能用于新鲜度判断。选一个值域随时间正常散布的列,或者直接加一列专用心跳。
排查信号
以下任何一条成立,说明你可能落入了同一个坑:
- AND 条件看起来「是额外保护」,但删掉它告警频率不变
- 把 cron 停掉,告警行为不变
- DB 里该列的所有行都集中在同一个历史日期附近,尽管应用号称每天都在刷新它
一句话外卖
监控的新鲜度断言必须基于更新路径实际写入的列,列名是什么无所谓——先去读代码,确认哪条路径写了它。