返回博客

Cron 阈值小于周期,TTL 资源必有失效空窗

某天值班收到告警:后台运营看板显示「素材即将过期(4h)」,但定时刷新任务明明每天都在跑,日志也没有报错。深挖之后发现是一个纯数学问题——不是 bug,是设计缺陷,而且每次都会在同样的时间段触发,稳定复现

现象

  • 业务依赖一个 TTL 为 72 小时的外部资源(典型场景:企业微信临时素材、OAuth access_token、OSS STS 临时凭证等)。
  • 后台有一个 cron 任务每天 02:00 跑一次,逻辑是「如果剩余时间 < 12 小时才刷新」,目的是省 API 调用次数。
  • 用户报告某些时段调用必失败,查 DB 发现资源确实已过期,但 cron 日志显示任务正常执行。

根因

画一个时间轴就看清楚了:

资源创建时间:5-10 18:23,TTL 72h → 过期时间:5-13 18:23

5-11 02:00 cron:剩余 88h > 12h → 不刷新 ✅
5-12 02:00 cron:剩余 64h > 12h → 不刷新 ✅
5-13 02:00 cron:剩余 16h > 12h → 不刷新 ❌  ← 关键漏洞
5-13 18:23 资源真正过期(距下次 cron 还有 8h)
5-14 02:00 cron:剩余 -8h < 12h → 刷新(已经晚了 8 小时)

失效空窗 = 5-13 18:23 ~ 5-14 02:00 = 约 8 小时,这段时间内所有依赖该资源的请求全部失败。

核心算式可以更直观地表达为一条规则:

threshold < cron_period  →  必然存在失效空窗

cron 是离散的,不是连续轮询。每次 cron 跑时,它只能看到「此刻」的剩余时间;如果阈值比 cron 周期小,就一定会有某一次 cron 跑完时剩余时间 > 阈值,但在下次 cron 到来之前资源已经过期。

坑:阈值不是越小越省 API,而是越小越容易出空窗

「剩余 < N 小时才刷新」的 N 必须大于 cron 周期,否则数学上保证有一段资源失效但 cron 尚未到来的窗口。很多人直觉上认为「cron 每天跑,资源 3 天有效,随便设个几小时阈值就够了」——这是错的,实际窗口大小取决于资源创建时刻与 cron 触发时刻的相位差,最坏情况等于 cron_period - threshold

正解

正确阈值 ≥ cron 周期 × 1.5× 1.5 是给一次 cron 失败留的余量(服务器重启、OOM、容器漂移都可能漏一次)。

| TTL 资源 | Cron 周期 | 错误阈值 | 正确阈值 | |----------|-----------|---------|---------| | 临时素材(72h) | 24h | 12h | ≥ 36h | | OAuth token(7200s) | 600s | 300s | ≥ 900s | | SSL 证书(90d) | 7d | 7d | ≥ 11d | | 短缓存(60s) | 30s | 15s | ≥ 45s |

修复代码只需调整阈值常量,强制加 inline 注释说明倍数关系,防止未来维护者「优化」回去:

// ❌ 错误:阈值 12h,cron 周期 24h,必有失效空窗
$query->where('expire_at', '<', date('Y-m-d H:i:s', time() + 12 * 3600));
 
// ✅ 正确:阈值 36h = cron 周期 24h × 1.5,保留一次 cron 失败余量
// WARN: 修改此值前确认 threshold >= cron_period × 1.5,否则必有失效空窗
$query->where('expire_at', '<', date('Y-m-d H:i:s', time() + 36 * 3600));
正解:threshold ≥ cron_period × 1.5,并配 lazy refresh 兜底

双轨方案是金标准

  1. cron 健康路径:阈值 ≥ 周期 × 1.5,覆盖正常刷新;
  2. 调用方 lazy refresh 兜底:业务真正使用资源前,检查 expire_at - now < safety_margin(可以很小,如 5 分钟),若接近过期就立刻刷新再用。

两层互补:cron 失败时业务兜底,业务高并发时 cron 兜底。refresh 操作要设计为幂等(多次调用无副作用)。

排查

收到「资源过期但 cron 刚跑过」类告警时,用这个 SQL 快速定位:

SELECT
  resource_id,
  expire_at,
  TIMESTAMPDIFF(HOUR, NOW(), expire_at) AS hours_to_expire,
  TIMESTAMPDIFF(HOUR, last_refreshed_at, NOW()) AS hours_since_refresh
FROM your_ttl_resource_table
ORDER BY expire_at;

期望状态:hours_to_expire 应大于 cron 周期(例如 > 24);若有资源已进入 0 ~ cron_period 区间说明阈值设置不足,下次 cron 未必能及时覆盖。

一句话外卖

Cron 阈值必须 ≥ Cron 周期,TTL 类资源刷新永远配 lazy refresh 兜底——这不是「省 API 调用」和「可靠性」的权衡,而是两者都需要时应选的唯一正确模式。