定时任务静默跳过、日志里啥也没有

定时任务到点没跑、日志也没报错。修复方向:时区统一 UTC、加心跳指标、对漏跑次数告警。

你定的任务凌晨 2 点跑,今早一看下游报表是空的、邮件也没来、日志里干干净净——没有报错、没有堆栈,就是没跑。最常见的几个原因:调度器的时区跟你以为的不一样;上一轮还占着锁;at-most-once 语义下租约丢了没人接。修法是统一走 UTC,每轮跑都打心跳指标,对”应该跑但没跑”直接告警。

常见原因

按踩坑频率排序。

1. 时区对不上

cron 写的是 0 2 * * *,你以为是本地时间,容器或调度器其实是 UTC。任务在 UTC 02:00 跑,对应本地是前一天晚上 21:00。

怎么判断:容器里执行 date,跟你期望的时间对一下;或者看调度器日志的时间戳。

2. 夏令时切换把这次跑吞了

cron 写的是本地时间 02:30。春令时那个周日 02:30 不存在,这次就静默跳过了。秋令时反方向有些调度器会跑两次。

怎么判断:漏跑总是踩在 DST 切换边界上。

3. 上一轮还占着锁

第 N 轮跑了 25 小时还没结束,第 N+1 轮调度器去拉的时候,数据库 advisory lock 或者文件锁还没释放。如果是非阻塞拿锁,N+1 直接退出。

怎么判断:在下一轮调度时间附近,pg_stat_activity 或进程列表里能看到长时间没结束的前一轮。

4. at-most-once 模式下租约丢了

分布式调度器(Kubernetes CronJob 加 concurrencyPolicy: Forbid,或者 Temporal、Airflow 的 lease)丢了 worker 的状态。没重试、没告警。

怎么判断:worker pod 在调度点之前刚崩过;controller event 显示 “missed schedule”。

5. 调度被禁用或暂停了

有人在 UI 上点了暂停或者 suspend: true 忘了恢复。或者上线时把 schedule 重置回了默认值。

怎么判断kubectl get cronjob 显示 SUSPEND: True,或者 Airflow DAG 处于 paused 状态。

最短修复路径

Step 1: 全栈走 UTC

所有调度表达式统一用 UTC,并把这件事写进文档。本地时间只在 UI 层显示时转一次。

# Kubernetes CronJob
apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "0 9 * * *"   # UTC 09:00 = PDT 02:00、PST 01:00
  timeZone: "Etc/UTC"     # Kubernetes 1.27+ 才支持 timeZone
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5

systemd timer 用 OnCalendar=*-*-* 09:00:00 UTC

Step 2: 每轮打心跳指标

任务开始和成功结束各打一个计数。心跳不来就是信号。

# Python prometheus_client
from prometheus_client import Counter, push_to_gateway, CollectorRegistry

registry = CollectorRegistry()
runs = Counter('cron_runs_total', 'Cron run count', ['job','phase'], registry=registry)

def heartbeat(job, phase):
    runs.labels(job=job, phase=phase).inc()
    push_to_gateway('pushgateway:9091', job=job, registry=registry)

heartbeat('nightly-report', 'start')
do_work()
heartbeat('nightly-report', 'success')

更轻量的方案:用 Healthchecks.io、Cronitor 这类 dead-man switch 服务——心跳没到就报警。

Step 3: 对漏跑直接告警

# Prometheus 告警规则
- alert: CronMissedRun
  expr: |
    time() - max(cron_last_success_timestamp{job="nightly-report"}) > 90000
  for: 5m
  labels:
    severity: page
  annotations:
    summary: "nightly-report 已经超过 25 小时没成功跑过了"

窗口比节奏稍大:日任务用 25h,小时任务用 75min。

Step 4: 用显式锁防并发重叠

-- Postgres advisory lock,已被持有则立即返回 false
SELECT pg_try_advisory_lock(hashtext('nightly-report'));
import psycopg
with psycopg.connect(DSN) as conn:
    acquired, = conn.execute("SELECT pg_try_advisory_lock(hashtext(%s))", ['nightly-report']).fetchone()
    if not acquired:
        print("上一轮还在跑,跳过本轮")
        return
    try:
        run_job()
    finally:
        conn.execute("SELECT pg_advisory_unlock(hashtext(%s))", ['nightly-report'])

“锁冲突跳过”另起一个指标——静默跳过才能被看见。

Step 5: 把调度状态纳入上线检查

CI/CD 加一步冒烟:上线后检查没有意外被 suspend 的 CronJob。

kubectl get cronjob -o json \
  | jq -r '.items[] | select(.spec.suspend==true) | .metadata.name'

输出非空且不在白名单里就阻断上线。

预防

  • 代码和配置全栈 UTC,仪表盘里再换成本地时间给人看。
  • 每个任务都打 start 和 success 心跳,对漏脉冲告警。
  • concurrencyPolicy: Forbid(Kubernetes)或显式 advisory lock 把重叠语义写明白。
  • 别把任务定在 02:00-03:00 本地时间——地球某地正好在 DST 切换边界。
  • 维护一份单一来源的 cron 清单,每月 review。

标签: #后端 #排查 #cron