朕喜技术(ZhenxiJS)
2026.09.22
1 分钟阅读

定时任务分支名时刻戳全漂了:CRON_TZ 时区换算偏 8 小时 + 命名取自实际开始时刻 + 重试不幂等

日期:2026-09-22 场次:00:00 期数:第74题 分支:2026-09-22-0000 技术栈:Linux crontab / CRON_TZ / Python datetime / Docker / 定时调度 / CI 流水线 关键词:crontab 时区、CRON_TZ 不生效、时区偏 8 小时、replace(tzinfo=) 陷阱、 datetime 时区换算、YYYY-MM-DD-HHMM 时刻戳漂移、分支命名、定时任务幂等、 重试产生重复分支、跨进程去重、排队延迟、调度漂移、NTP 时钟不准


一、问题背景

有一套自动化流水线「每天 3 轮」,crontab 表达式 0 0,8,16 * * *。 每轮会创建一个独立分支 YYYY-MM-DD-HHMM,分支里放同名场景目录,再发一条标题里带同名时刻戳的 Issue。

需求方要求:按东八区,每天 0 点 / 8 点 / 16 点各一轮, 且 分支名 / 目录名 / Issue 标题里的时刻戳必须与场次一一对应(0 点场就该叫 -0000)。

运维发现问题:

  1. 时刻戳跟场次对不上:历史分支里出现 2026-09-05-0001、2026-09-18-0045, 本该是 -0000。
  2. 同一天的分支会跨天:16 点场跑得慢,戳变成次日的 2026-09-23-0046。
  3. 一个场次冒出两个分支:重试后又建了个 -2 后缀的分支。

最迷惑的是:调度器退出码 0、排期 JSON 结构合法、自检脚本还输出两条 [OK]。 一开始被误判成「时区没配」「机器时钟不准」「NTP 有问题」,改来改去症状不变。

复现环境(免安装,只用 Python 标准库):

git clone <repo> && cd <repo>
git checkout 2026-09-22-0000
cd scenario/2026-09-22-0000
docker compose up -d --build
docker compose exec app bash ops/verify.sh     # 问题态:PASS=5 FAIL=7
docker compose exec app bash ops/accept.sh     # 业务侧:RESULT: BAD

二、复现步骤

docker compose exec app bash ops/repro.sh

输出(问题态):

场次    计划(本地)                实际开始(本地)              时刻戳                 分支名
0000  2026-09-22 00:00:00   2026-09-22 08:01:58   2026-09-22-0801     2026-09-22-0801
0800  2026-09-22 08:00:00   2026-09-22 16:46:58   2026-09-22-1646     2026-09-22-1646
1600  2026-09-22 16:00:00   2026-09-23 00:46:58   2026-09-23-0046     2026-09-23-0046

3 次场次 + 1 次重试:

state/published/ 里最终有 4 个分支:
  - 2026-09-22-0801
  - 2026-09-22-1646
  - 2026-09-23-0046
  - 2026-09-23-0046-2

判定脚本:verify.sh 结果: PASS=5 FAIL=7;业务侧 RESULT: BAD。


三、根本原因

三个症状指向同一个设计错误:

名字是「这一轮是什么场次」的函数,却被写成了「这一轮几点才真正跑起来」的函数。

拆开来是 4 处独立缺陷:

1. 命名依据取错了:用「实际开始时刻」推时刻戳

# conf/scheduler.env: STAMP_SOURCE=start
def stamp_for_start(started_local, conf):
    return started_local.strftime("%Y-%m-%d-%H%M")   # 00:00:19 → -0000;00:01:05 → -0001

排队延迟(实测 0~31 秒)、预热耗时(最大 99 秒)、撞场排队(实测 45 分钟) 全都只影响「几点跑起来」。名字粒度卡在「分钟」而延迟是「秒」级 → 漂移必然发生。

2. datetime.replace(tzinfo=...) 只贴标签,不做换算(偏 8 小时的元凶)

# 错误:把「本地 00:00」这个裸时间直接贴上 UTC 标签
started_utc = actual_start(planned_naive, conf).replace(tzinfo=dt.timezone.utc)

replace(tzinfo=...) 不会做时区换算。本地 00:00 贴成 UTC 后, 字面语义就成了「UTC 0 点」=本地 8 点,整整偏 8 小时。

>>> import datetime as dt
>>> LOCAL = dt.timezone(dt.timedelta(hours=8))
>>> dt.datetime(2026,9,22,0,0).replace(tzinfo=dt.timezone.utc).astimezone(LOCAL)
datetime(2026, 9, 22, 8, 0, tzinfo=...)     # ❌ 偏 8 小时
>>> dt.datetime(2026,9,22,0,0,tzinfo=LOCAL).astimezone(dt.timezone.utc)
datetime(2026, 9, 21, 16, 0, tzinfo=UTC)    # ✅ 本地 0 点 = UTC 前一天 16 点

记法:replace 是「改标签」,astimezone 是「换算」。语义是「本地时间换算成 UTC」时, 必须先用 tzinfo=本地时区 构造,再 astimezone(utc)。

3. 重试不幂等:重试被当成新场次

crontab 每次响动都是新进程,进程内的 taken 集合永远是空的。 同一场次失败重试时,撞名逻辑直接派生了 -2 后缀:

if base not in taken:
    return base
n = 2
while f"{base}-{n}" in taken: n += 1
return f"{base}-{n}"        # → 2026-09-23-0046-2,多出第 4 个分支

4. 跨进程没有真正的注册表

REGISTRY= 空值时,代码退回拿「已发布目录」当清单,而目录不是清单文件, save_registry() 直接 return —— 磁盘上没留下任何「名字已用过」的记录。

附加:自检指标本身选错了对象

问题态下自检居然有两条 [OK]:

[OK] 三个时刻戳互不相同                 ← 确实互不相同(0801/1646/0046),但没查「是否等于场次」
[OK] 三个场次的计划时刻都落在东八区整点   ← 只看了 planned_local,没看 UTC 换算
通过 2 项,不通过 4 项 / RESULT: BAD

「结构合法 + 值不重复」完全可以在数据全错时通过。 自检一定要断言「等于期望值」, 而不是「不等于别人」。


四、解决办法

修复 1:命名一律只看计划场次

def planned_slots(day):
    # 直接构造 aware 时刻,避免 naive 被误贴标签
    return [(slot(h), dt.datetime(day.year, day.month, day.day, h, 0, 0, tzinfo=LOCAL_TZ))
            for h in SLOT_HOURS]

def stamp_for_slot(planned_aware):
    # 三个场次永远对应 0000 / 0800 / 1600
    return f"{planned_aware.strftime('%Y-%m-%d')}-{slot(planned_aware.hour)}"

修复 2:时区换算用 astimezone,不用 replace

planned_utc = planned_aware.astimezone(dt.timezone.utc)          # ✅ 真换算
started     = actual_start(planned_aware, conf, queued=queued)   # aware + timedelta = aware
started_local = started.astimezone(LOCAL_TZ)

并且让 CRON_TZ 解析失败时显式报错,不要静默回退 UTC:

raw = (conf.get("CRON_TZ") or "").strip()
if not raw:
    raise SystemExit("CRON_TZ 未配置:无法确定 crontab 表达式按哪个时区解释")
if _fixed_offset(raw) is None:
    raise SystemExit(f"CRON_TZ 无法解析:{raw!r}")

修复 3:漂移要「观测」,不要「改名」

保留 started_local,新增 drift_sec 字段用于监控告警:

"drift_sec": int((started - planned_aware).total_seconds()),

修复 4:命名幂等 —— 「用过」不等于「换个新名字」

这是最关键的一条。把重试/重跑当成复用同名覆盖续写,而不是新场次:

owner = f"{day.isoformat()}#{slot_mark}"   # 名字的归属:哪一天的哪个场次
name = base                                # 同场次重试 → 直接用 base
owners = load_owners(conf)                 # state/owners.json: {名字: 归属场次}
if owners.get(name) not in (None, owner):  # 只有被「别的场次」占用才算真撞名
    name = branch_name(name, taken)

同时把注册表缺省值改成落盘文件 state/registry.json,跨进程去重才有依据。

配置侧

-RETRY_CREATES_NEW_BRANCH=1
+RETRY_CREATES_NEW_BRANCH=0
-STAMP_SOURCE=start
+STAMP_SOURCE=planned
-STAMP_GRANULARITY=minute
+STAMP_GRANULARITY=slot
-REGISTRY=
+REGISTRY=state/registry.json

五、验证

docker compose exec app bash ops/verify.sh      # PASS=12 FAIL=0
docker compose exec app bash ops/accept.sh      # RESULT: OK

修复后(漂移 118s / 2818s 依然存在,但名字不再受影响):

场次    计划(本地)                实际开始(本地)              时刻戳                 分支名
0000  2026-09-22 00:00:00   2026-09-22 00:01:58   2026-09-22-0000     2026-09-22-0000
0800  2026-09-22 08:00:00   2026-09-22 08:46:58   2026-09-22-0800     2026-09-22-0800
1600  2026-09-22 16:00:00   2026-09-22 16:46:58   2026-09-22-1600     2026-09-22-1600
3 次场次 + 1 次重试 → state/published/ 里最终有 3 个分支:
  - 2026-09-22-0000
  - 2026-09-22-0800
  - 2026-09-22-1600

额外边界验证:

场景结果
连续跑 roundd.py --simulate 3 次(不清 state/)始终 3 个分支 ✅
排队延迟拉到 24 小时(跨到次日)名字仍为 -0000/-0800/-1600 ✅
跨月/跨年 --day 2026-12-31名字正确 ✅
CRON_TZ 为空或写成 Asia/Shanghai显式报错,不再静默偏 8 小时 ✅

六、经验与避坑

  1. 命名要与「语义标识」绑定,不要与「执行时刻」绑定。 凡是有排队、重试、预热、重放的调度系统,执行时刻一定会漂。 让「名字 = f(场次)」,而不是「名字 = f(实际开始时间)」。
  2. datetime.replace(tzinfo=...) 是陷阱。 它只改标签不换算。要「本地时间 → UTC」,必须 datetime(..., tzinfo=本地tz).astimezone(utc)。 排查时区问题先打印三个值:naive 原值、aware 换算值、astimezone 回来的本地值。
  3. 重试语义必须幂等。 「这个名字用过」不等于「换个新名字」。同一业务键的重试应复用同名资源 (覆盖/续写),只有不同业务键算出同一个名字才是真撞名。
  4. 跨进程去重一定要落盘。 crontab 每次响动都是新进程,进程内 set 必空。注册表要落到磁盘文件, 并且要能区分「同键重放」与「真冲突」。
  5. 自检要断言「等于期望值」,不要只断言「互不相同」。 「结构合法 + 值不重复」可以在数据全错时全绿 —— 这正是这次故障长时间没被发现的原因。 正确写法:assert stamp == f"{day}-{slot}"。
  6. 漂移是要报警的事,不是要改名的事。 保留 started_local、drift_sec 等字段做监控, 而不是让漂移污染标识符。
  7. 测试要覆盖「重入」。verify.sh 每次都从零清场,掩盖了「同一天重复触发不幂等」的问题。 连着跑两次脚本,是发现这类 bug 最便宜的办法。

附:一键复现命令

git checkout 2026-09-22-0000
cd scenario/2026-09-22-0000
docker compose up -d --build
docker compose exec app bash ops/verify.sh     # 问题态 PASS=5 FAIL=7 → 修复态 PASS=12 FAIL=0
docker compose exec app bash ops/accept.sh     # RESULT: OK
docker compose exec app bash ops/repro.sh      # 逐步看三个现场
docker compose down -v

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-22-0000

分享这篇文章