适用场景

本文适用于在 Kubernetes 中运行数据库备份、账单汇总、定时同步、报表生成等周期任务,并遇到以下问题的团队:

  • 上一次任务尚未结束,下一次任务已经启动,两个 Pod 同时修改同一批数据;
  • 控制面短暂不可用或 CronJob 暂停后恢复,任务突然补跑;
  • 配置了 concurrencyPolicy: Forbid,仍然发现某些时间点没有执行记录;
  • Pod 退出后被重试,业务操作被重复提交;
  • 历史 Job 和 Pod 太多,排障时难以区分“没调度”和“执行失败”。

CronJob 解决的是“按计划创建 Job”,不是严格的一次性业务调度器。生产环境必须同时处理调度并发、迟到策略、Job 超时和业务幂等。

现象描述

一个每 5 分钟执行一次的数据同步任务,正常耗时约 2 分钟。某天上游接口变慢,单次执行超过 8 分钟,随后出现两类异常:

  1. 多个 Job 同时处于 Running,重复写入导致唯一键冲突;
  2. 改成 Forbid 后,监控又提示某些计划周期没有成功记录。

先查看 CronJob、Job 和事件,不要只盯着当前 Pod:

kubectl -n data get cronjob order-sync -o wide
kubectl -n data get cronjob order-sync -o yaml
kubectl -n data get jobs --sort-by=.metadata.creationTimestamp
kubectl -n data describe cronjob order-sync
kubectl -n data get events --sort-by=.lastTimestamp | tail -n 30

重点关注:

  • LAST SCHEDULE:控制器最近一次处理的计划时间;
  • .status.active:当前由该 CronJob 管理的活动 Job;
  • Job 的 CompleteFailedactive 状态;
  • 事件中是否出现错过调度、创建失败、镜像拉取失败或资源不足;
  • CronJob 是否被设置为 suspend: true

常见原因

1. 默认策略允许重叠

concurrencyPolicy 默认是 Allow。只要到达新的计划时间,控制器就可以再创建一个 Job,不会等待前一个 Job 完成。

2. 把 Forbid 误解为排队

Forbid 的含义是:同一个 CronJob 的前一次 Job 仍在运行时,跳过本次计划,而不是把本次任务排队等待。长任务持续跨越多个周期时,出现“漏跑”是预期行为。

3. 迟到任务没有明确边界

控制面故障、CronJob 暂停、资源不足都可能使 Job 未能按时创建。若未设置 startingDeadlineSeconds,恢复后的补偿行为可能不符合业务预期;设置过小也会让轻微延迟直接变成跳过。该值小于 10 秒时尤其危险,因为 CronJob 控制器通常以约 10 秒的周期检查计划。

4. 调度不重复不等于业务只执行一次

即使没有两个活动 Job,Pod 重启、节点故障、Job 重试或客户端超时后的重试,仍可能让同一批业务操作执行多次。因此,支付、发券、库存扣减等动作不能只依赖 Forbid 防重。

排查思路

第一步:确认计划表达式与时区

kubectl -n data get cronjob order-sync \
  -o jsonpath='{.spec.schedule}{"\n"}{.spec.timeZone}{"\n"}{.spec.suspend}{"\n"}'

建议显式设置 .spec.timeZone,避免维护人员按本地时间理解,而控制器按另一时区执行。不要在 schedule 中写 CRON_TZTZ,应使用专门的 timeZone 字段。

第二步:区分未创建、运行失败和仍在执行

kubectl -n data get jobs \
  -l app=order-sync \
  -o custom-columns='NAME:.metadata.name,START:.status.startTime,ACTIVE:.status.active,SUCCEEDED:.status.succeeded,FAILED:.status.failed'
  • 没有对应 Job:优先检查 CronJob 事件、暂停状态、截止时间和控制器日志;
  • Job 存在但 Failed:查看 Job 条件和 Pod 退出原因;
  • 多个 Job 为 ACTIVE=1:检查并发策略是否为 Allow,或是否存在多个不同 CronJob;
  • Job 长期不结束:检查外部调用超时、锁等待和 activeDeadlineSeconds

第三步:确认活动 Job 的归属

concurrencyPolicy 只约束同一个 CronJob 创建的 Job。两个 CronJob 即使执行相同程序,也可以并发运行。

kubectl -n data get job order-sync-12345678 \
  -o jsonpath='{range .metadata.ownerReferences[*]}{.kind}{"/"}{.name}{"\n"}{end}'

如果线上同时存在 order-syncorder-sync-v2,需要在业务层使用同一把租约锁或幂等键,而不能指望两个对象共享并发策略。

推荐配置

下面的配置适合“允许偶尔跳过,但禁止同一任务重叠;单次执行最多 20 分钟”的同步任务:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: order-sync
  namespace: data
spec:
  schedule: "*/5 * * * *"
  timeZone: "Asia/Shanghai"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 120
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    metadata:
      labels:
        app: order-sync
    spec:
      activeDeadlineSeconds: 1200
      backoffLimit: 2
      template:
        metadata:
          labels:
            app: order-sync
        spec:
          restartPolicy: Never
          terminationGracePeriodSeconds: 30
          containers:
            - name: worker
              image: registry.example.com/order-sync:2026.09.18
              args: ["python", "-m", "order_sync"]
              resources:
                requests:
                  cpu: 200m
                  memory: 256Mi
                limits:
                  memory: 512Mi

几个字段必须结合业务解释:

  • Forbid:上一轮未完成就跳过新一轮,适合不能并发但允许少量周期缺失的任务;
  • Replace:终止旧 Job 并启动新 Job,只适合可安全中断、支持断点恢复的任务;
  • startingDeadlineSeconds: 120:计划时间过去 120 秒仍未启动,就放弃该轮;
  • activeDeadlineSeconds: 1200:Job 总运行时间超过 20 分钟后终止,防止永久占用并发槽位;
  • backoffLimit: 2:失败后最多重试两次,但每次重试仍可能重复执行部分业务;
  • 历史保留数量只影响已结束对象的清理,不影响业务审计数据。

如果业务要求“每个周期都必须完成”,不要简单使用 Forbid。更合适的设计是 CronJob 只负责投递一个带时间窗口的消息,由常驻消费者串行处理并记录进度。

用幂等键兜住重复执行

以每 5 分钟一个同步窗口为例,使用窗口起始时间作为幂等键。数据库先抢占执行权,再处理业务:

CREATE TABLE job_execution (
    job_name varchar(64) NOT NULL,
    window_start timestamp NOT NULL,
    status varchar(16) NOT NULL,
    updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (job_name, window_start)
);
from datetime import datetime, timezone


def acquire_window(connection, job_name: str, window_start: datetime) -> bool:
    """原子占用一个调度窗口,已存在时返回未获得执行权。"""
    with connection.cursor() as cursor:
        cursor.execute(
            """
            INSERT INTO job_execution (job_name, window_start, status)
            VALUES (%s, %s, 'running')
            ON CONFLICT (job_name, window_start) DO NOTHING
            """,
            (job_name, window_start.astimezone(timezone.utc)),
        )
        return cursor.rowcount == 1

幂等记录与核心业务写入应放在可控的事务边界内。若任务包含外部接口调用,还应把请求幂等键传给下游,或使用 outbox 表记录待发送事件,避免“数据库已提交但进程在回执前退出”造成重复副作用。

发布与验证

先暂停创建新 Job,等待当前任务结束或人工确认可以终止:

kubectl -n data patch cronjob order-sync \
  --type merge -p '{"spec":{"suspend":true}}'
kubectl -n data get jobs -l app=order-sync

应用配置后恢复调度:

kubectl apply -f order-sync-cronjob.yaml
kubectl -n data patch cronjob order-sync \
  --type merge -p '{"spec":{"suspend":false}}'

解除暂停前必须确认 startingDeadlineSeconds。暂停期间的计划会被视为错过的执行;若没有合理截止时间,恢复时可能立即创建补跑 Job。

用手工 Job 验证镜像、权限和业务逻辑,不必等待下一个周期:

kubectl -n data create job \
  --from=cronjob/order-sync \
  order-sync-manual-$(date +%s)
kubectl -n data logs -f job/order-sync-manual-1234567890

手工 Job 不受该 CronJob 的 concurrencyPolicy 保护,验证时要避开正式执行窗口,或依赖业务幂等机制。

预防措施

  1. 为 CronJob 监控“距上次成功时间”“活动 Job 数”“失败 Job 数”和执行耗时分位数,而不只监控 Pod 是否存活。
  2. 让任务输出稳定的 job_namewindow_startexecution_id 和处理数量,固定日志文案使用中文,禁止记录密钥和完整敏感请求体。
  3. 为所有外部请求设置连接、读取和总超时,确保 Job 能在 activeDeadlineSeconds 前自行清理资源。
  4. 在压测中故意让执行时间超过调度周期,验证 AllowForbidReplace 的行为是否符合预期。
  5. 对无法容忍漏跑的任务建立业务进度表,按时间窗口补偿,不用遍历历史 Pod 日志猜测缺口。

总结

CronJob 重叠或漏跑通常不是单一参数错误,而是四层边界没有一起设计:concurrencyPolicy 决定同一 CronJob 是否并发,startingDeadlineSeconds 决定迟到多久仍值得执行,Job 的超时和重试决定单次执行如何收敛,业务幂等保证重复尝试不会产生重复副作用。生产配置的目标不是追求“绝不重复”的假象,而是让每次重复、跳过和补偿都有明确且可验证的规则。

参考资料