每天 20 点发文,却有两个"今天":流水线时区边界怎么咬出重复一天
这条内容流水线每天 20:00(SGT)发一篇文章,slug 前缀是 article-YYYYMMDD-,发之前要查一遍 CMS:今天这个前缀已经有行没有,有就跳过,没有才写新的。听起来是个两行的逻辑。

每天 20 点发文,却有两个"今天":流水线时区边界怎么咬出重复一天
这条内容流水线每天 20:00(SGT)发一篇文章,slug 前缀是 `article-YYYYMMDD-`,发之前要查一遍 CMS:今天这个前缀已经有行没有,有就跳过,没有才写新的。听起来是个两行的逻辑。
上个周期它出了问题:同一天入了两条记录。没有报错,没有告警,两次执行各自都觉得自己是当天第一篇。
根子不在代码写错,而在"今天"这个词有两个答案。SGT 的 20 点到 24 点,对应 UTC 是中午 12 点到 16 点,同一天;但 UTC 0 点到 8 点这一段,SGT 已经是新一天了。我们调度器按 SGT 算 `YYYYMMDD`,数据库里存的 `published_at` 是 UTC,前端列表按 UTC 日折叠分组。调度器说"今天是 2026-08-27",数据库说"这条属于 2026-08-26"——两边各自没错,拼起来就错。
具体咬法有两种。第一种:SGT 23:50 的一次补发,UTC 还在前一天,入库存的是前缀旧日期,但因为执行时按 SGT 查"今天",查重查的是新日期前缀,查不到,于是新日期前缀又发了一篇,补发那篇留在了旧日期下面。第二种更隐蔽:SGT 零点后的查询窗口里,"今天"的前缀缺口还没被 20:00 的正式任务填上,任何一个手动触发的补跑都可能抢着把这个前缀占了。
复盘之后的改法,四条,都不涉及数据库迁移:
**一、日期只在一个地方算。** 调度器、脚本、入库前统一调用同一个 `day_id(utc_offset)` 函数,写死 offset=+8。任何代码里不允许出现裸的 `date.today()` 或 `now().date()`——这两个函数用的是机器本地时区,容器和 mac 上答案不一样。CI 里加了条 grep 规则,命中裸调用直接挂。
**二、查重和入库用同一个键。** 修复前"查重用的前缀"和"入库用的前缀"是两次独立取时间,中间隔了写稿几分钟。改成先取一次时间戳,算出 `day_id`,查重、生成 slug、写入 `published_at` 全部引用这一个值,用变量传下去,不重新取。
**三、唯一约束兜底。** 光靠"先查后插"永远有窗口期。表上补了 `(slug, locale)` 唯一索引,重复插入直接 500,调度器看到就判 HOLD 跳过,不再静默放行。唯一约束是最便宜的业务规则执行器。
**四、告警看"日聚合",不看单行。** 监控改成按 `day_id` 聚合:同一天同 category 文章数大于 1,直接告警。单行看每一行都合法,聚合看才露馅——这类边界的特征是"局部全对、全局多一"。
还有一点是踩完才明白的:时段窗口越大,边界问题越容易被平均掉掩盖。我们 20:00 发一次,一次 200ms 的取时间抖动根本激不活窗口;真正把它激活的是断网后的手动补跑——补跑把任务从"准点"挪到了"任意时刻"执行,边界附近的行为就全变了。所以规则加了一条:补跑必须显式传 `--day-id`,不允许补跑自己猜今天。
这类问题分享出去经常被问"上分布式锁或者用 UTC 统一不就行了"。我们最后还是留在 SGT 算日子,因为读者、运营、告警值班过的是本地时间的日子,换成 UTC 只是把缝从午夜挪到凌晨 8 点,缝本身还在。把日期收成一个函数、让唯一约束替你吵架、让聚合告警替你看全局,这三件事做完,时区边界就从"随机翻车的分布式事故"退化成"一年有两天需要留意的固定现象"。
留言区
欢迎分享你的想法!
加载留言中…