02:00 排出的发布时刻,在同一天被执行了两次。复盘调度层的语义问题:平台只保证至少一次,幂等键该取「原计划时刻」而不是「当前时间」,抖动解决的又是另一件事。
02:00,规划器给当天排出几个发布时刻。当天 08:13,一篇文章被发了第二遍——两个时刻都认为该由自己动手。这不是脚本 bug,是语义问题。
设计是这样的:每天凌晨由一个规划器把当天的若干发布时刻写进队列,到点触发一次执行,执行时做四件事——扫一遍待发草稿、检查当天还有没有额度、挑出最旧的一篇已就绪稿件、发出去并推送收录。
问题出在第三步。「挑出最旧的一篇」不是原子操作:两个触发点相隔几十秒到几小时,各自查询时看到的是同一篇「最旧」。第一版实现里判断和写入之间留了窗口,第二个进入者做出了与第一个完全相同的判断。我们花了两天怀疑发布脚本,最后对比两次调用的日志,发现调用栈逐行一致——脚本没错,它只是被叫了两次。
更值得反省的是发现方式。整件事靠人工巡检比对标题才发现,而调度端一切正常:空转不报错,重复执行也不报错。
这不是自研代码独有的毛病。Kubernetes 的 CronJob 文档写得很直白:Job 的创建时刻是近似的,某些情况下同一个周期可能创建两个 Job,也可能一个都不创建,控制器会努力避免但无法完全阻止,所以它要求你定义的任务本身必须幂等。文档里还有三个常被跳过的细节:并发策略有 Allow、Forbid、Replace 三种取值且默认是 Allow;控制器每 10 秒轮询一次;自 v1.32 起创建出来的 Job 会带上一个记录「原本计划创建时刻」的注解。
最后一条是钥匙。平台知道这一次本该在哪个时刻发生,它把信息交给你,等于把幂等键也交给你了。
| 去重手段 | 能挡住什么 | 挡不住什么 |
|---|---|---|
| 并发策略:Forbid 或 Replace、单实例、leader 选举 | 同一时刻并发跑两份 | 前后错开几秒的串行重复 |
| 分布式锁、关系库排他锁 | 同时改写同一份数据 | 锁释放之后的第二次执行 |
| 持久化幂等键:唯一约束、登记表 | 重复执行本身,第二个进入者会撞约束 | 键选错时整体失效 |
修法很小。触发时不再问「现在几点」,而是先算出这一次对应哪个计划槽位,再以日期、槽位、业务类型作为唯一键插一行。插入成功才继续往下走,撞上唯一约束就直接退出。
INSERT INTO publish_slot (day, slot, kind, post_id)
VALUES ('2026-09-25', 2, 'article', $1)
ON CONFLICT (day, slot, kind) DO NOTHING
RETURNING id;
没有返回行,说明这个槽位已经有人干过,本次执行到此结束。改动把「两个时刻都认为自己该动手」变成「只有一个进得来」。
这种登记表的完整形态,在 Martin Fowler 整理的分布式模式里叫 Idempotent Receiver 模式(Unmesh Joshi,2023-11-23):调用方带唯一请求号,服务端保存已处理的请求号与当时的响应,遇到重复请求直接回放旧响应,不再执行。
同一类判断在对外调用时更常见:超时之后到底该不该重发、重发会不会把副作用执行两遍,我们在 超时与重试到底该不该重发 里单独拆过这道边界。
另一件容易被混为一谈的事是抖动。AWS 那段被频繁引用的实验(Marc Brooker,2015-03-04 发布、2023 年 5 月更新)结论很清楚:只用带上限的指数退避,冲突并没有消失,只是把同一时刻的调用变成了阵发簇;加上抖动之后,调用才被摊平到近似恒定的速率。在 100 个并发调用方的场景里,full jitter 把调用总数砍掉一半以上;equal jitter 略差,工作量更多、耗时更长。AWS 也已把这套退避加抖动的策略做进各语言 SDK 的标准重试模式。
搬回发布调度:如果所有触发点都压在整点,多个任务会在同一秒争同一把锁、同一份配额;给每个时刻加一个窗口内的随机偏移,压力就摊平了。它解决的是削峰。至于发布时间看起来是否自然,那不是抖动该管的事,靠的是稿子就绪才发、每天有明确上限。
事后补救的成本我们算过:一篇文章发出去之后才发现重复,能做的只有改写标题、摘要和正文,把第二篇重新定位成另一个角度。这种修补很难彻底,因为第一版的痕迹已经留在地址、标签和时间线里。
所以门禁挪到了发布函数入口:同日同类型已有发布记录,就拒绝本次执行。它不该放在规划器里——规划器只负责排出时刻,判重是执行端的责任。另一个改动是让静默变成出声:原先空转不报错,看起来优雅,实际上把问题藏了很久。现在跳过和失败分开统计,并且必须落一个原因——额度已满、没有就绪稿件,还是槽位已被占用。前两个是正常状态,第三个值得看一眼。
另外提一句,长任务跑久了,瓶颈常常从吞吐转到状态本身,工作流引擎的状态模型复盘 那个案例里的数字更极端。
因为平台层面只能保证至少一次。创建时刻本身是近似的,同一周期可能创建两个也可能一个都没有,加上重试与超时重发都会产生副本。恰好一次不是平台能力,是业务层用幂等换来的。
不够。锁只挡并发,挡不住串行重复:两个触发点前后错开几秒,锁已经释放,第二个照样执行。数据库排他锁还有个副作用,死锁会被自动检测并中止其中一个事务,而被中止的那个重试时又回到同一个问题,见 显式锁与死锁处理 一节的说明。
只能挡住同一个 CronJob 创建出来的 Job 之间的并发。文档写得很清楚,多个 CronJob 各自创建的 Job 之间始终允许并发。如果你的发布入口有多个调用方,比如规划器、人工触发、脚本回补,它们不在同一个并发策略管辖范围内。
把偏移限制在预设窗口内就不会。要控的不是第几分钟发,而是一天最多几篇、稿子是否就绪。
如果你手上也有定时发布、定时对账、定时归档这类任务,可以把现有的调度表和触发入口整理一份发过来,我们按上面五条逐项对着过一遍,半小时内通常能指到哪一处会重复执行。预约一次调度评审。