定了时间就得准时发:vibe 项目的定时发布与排期实战
定时发布看起来只是“到点发出去”五个字,做起来却是一串工程决定。本文讲排期数据模型与六态状态机、三种调度器选型、时区翻车点、日历视图与冲突规则、失败重试与兜底,最后给一份 13 项上线检查清单。

先讲一个虚构的教学例子
小满做了一个叫“晨报匣”的小工具:用户写好一期内容,设定早上七点半发给订阅者。产品上线第一周,有用户半夜两点写好周一的晨报,设好周一早上七点半,安心去睡了。周一早上七点半,什么都没发生。八点,用户在群里问:我的晨报呢?小满查后台,发现那篇内容的状态还停留在“已排期”,调度器那一晚根本没有跑起来——服务器在凌晨重启过一次,存在内存里的定时任务跟着进程一起消失了。
这个例子是虚构的,人名、产品名和经过都是编出来讲道理用的。但它戳中的问题是真实的:定时发布这个功能,看起来只是“到点发出去”五个字,做起来却是一串工程决定。时间存在哪、谁来触发、失败了怎么办、用户改主意了怎么撤回,每一个环节都有坑。本文就把这些决定一个一个摊开讲。

先划界:这篇讲什么、不讲什么
定时发布容易和几个邻近功能搅在一起,先把边界划清楚,免得你做到一半发现做串了。
第一,草稿自动保存不是定时发布。自动保存解决的是“别丢稿”,定时发布解决的是“到点发”。两者的状态机可以共用一张表,但不要共用一个状态字段,否则一篇稿子既“已保存”又“已排期”,出了问题你连它现在到底处于哪一步都说不清。
第二,时区调度不是定时发布本身。时区是定时发布里最容易翻车的一环,本文会单开一节讲,但时区显示、相对时间那套东西另有文章在讲,本文只讲定时发布必须处理的那几处。
第三,后台定时任务也不是定时发布。清理过期数据、每天凌晨跑统计,这些是给你自己跑的任务;定时发布是替用户跑的任务,失败了要让用户知道、要能补发、要能撤回,标准完全不一样。
第四,生命周期邮件序列也不是定时发布。邮件序列讲的是“在用户旅程的第几天发什么”,定时发布讲的是“用户亲手定了一个时间点”。前者的时间是系统算出来的,后者的时间是用户定下来的,改时间、取消、提前发的权利都在用户手里。
划完界,本文只讲一件事:用户定了一个发布时间,系统到点准时把内容发出去,中间不出岔子,出了岔子有交代。
定时发布到底在解决谁的问题
先别急着写代码,先问一句:你的用户为什么需要定时发布?答案通常是三种人,三种完全不同的用法。
第一种是内容创作者。他们半夜灵感来了,写完就想睡,但读者早上才看。没有定时发布,他们要么熬夜等着点发送,要么第二天早上爬起来发,两种都很蠢。对他们来说,定时发布买的是睡眠,是“写完就能放下”的确定性。
第二种是做运营的人。他们要排一周的内容:周一发更新公告、周三发教程、周五发活动预告。排期对他们来说是工作计划的可视化,重点不是“到点发”,而是“一眼看清这周发了什么、还缺什么”。这类用户最需要的是日历视图,而不是一个孤零零的时间选择器。
第三种是普通用户给自己定时间。比如生日祝福、纪念日提醒、到点发给某个群的消息。他们一年用不了几次,但每一次都不能出错。对他们来说,定时发布买的是“不会忘”,可靠性比花哨的功能重要十倍。
三种人,三种验收标准。创作者要的是“准时”,运营要的是“看得清”,普通用户要的是“别出错”。如果你只按其中一种人的需求做,另外两种人迟早会来找你麻烦。所以做之前先定下来:你的产品主要服务哪一种人?另外两种人你打算怎么兼顾?这个判断做在前面,比后面返工便宜得多。
数据模型:一次定时发布最少需要哪些字段
定时发布最容易犯的错,是把它当成一个时间字段来做。有人在内容表上加一个 publish_at,到了时间把状态一改,就以为做完了。这样做上线第一天就能跑,但第三天一定会出问题:用户想取消排期怎么办?发送失败了怎么重试?同一篇内容被重复发了两次怎么解释?
正确的做法是把“一次排期”当成独立的记录来建模。内容是一回事,排期是另一回事。一篇内容可以有多次排期历史:第一次排期周一七点半,用户改到周二七点半,第一次排期记录作废,第二次生效。排期记录至少要有这几个字段:归属的内容、目标发布时间、当前状态、创建时间、最近一次尝试发送的时间、失败次数、下一次重试时间。
状态机是最值得花时间设计的部分。我建议的状态只有六个:草稿、已排期、发送中、已发送、发送失败、已取消。不要贪多,状态越多,流转的分支越多,出 bug 的地方越多。关键是每一条流转都要有明确的触发者:用户可以做的只有排期、改期、取消;系统可以做的只有开始发送、标记成功、标记失败、重试。用户不能直接把状态改成“已发送”,系统不能直接把状态改成“已取消”,权限边界写在代码里,不要只写在文档里。
时间字段只存一个标准:UTC 时间戳。不要存“用户本地时间字符串”,不要存“带时区的文本”,不要存“服务器本地时间”。所有比较、排序、触发判断都用 UTC 做,显示的时候再换算成用户时区。这个决定看起来琐碎,但它是后面整节时区讨论的前提,先在这里定死。
还有一个字段新手最容易漏掉:幂等键。发送这个动作必须能安全重试,同一篇内容、同一个排期,无论调度器触发几次,最终只发出一次。幂等键可以是排期记录的 id,也可以是单独生成的一串字符,关键是发送接口在执行前先查这个键,发过了就直接返回成功,不再发第二遍。没有幂等键的重试,就是定时给用户制造重复发送事故。

调度器怎么做才靠谱
数据模型定好了,下一个问题是:到了时间,谁来触发发送?这里有三条常见的路,选哪条取决于你的体量和预算,选错了要么浪费钱,要么半夜出事。
第一条路是定时轮询:每分钟跑一次任务,查出“已排期且发布时间已到”的记录,逐条发送。这是最简单、最推荐起步的做法。一个一分钟跑一次的 cron,加上一个状态字段,就能撑起几千个用户的排期。它的缺点是精度只有分钟级,如果你的场景要求“秒级准时”,这条路不够用。但说实话,绝大多数内容发布场景,分钟级精度完全够用,用户根本分不清七点半零分发和七点半零四十秒发的区别。
第二条路是延迟队列:排期创建时就往队列里扔一条“到时间再处理”的消息,到时间队列把它吐出来触发发送。精度高,扩展性好,但要多维护一个队列服务,而且排期被取消或改期时,要记得把队列里的旧消息作废,否则旧消息到点还是会触发一次发送。队列方案的坑不在发送,而在取消。
第三条路是外部调度服务:把触发交给云厂商的定时服务,到时间调你的一个接口。这条路最省心,但把命脉交给了别人,而且每次触发都要做好鉴权,接口被别人扫到会很麻烦。
不管选哪条路,有三件事必须做到。第一,发送过程要加锁。同一条排期记录,同一时间只能有一个 worker 在处理,否则两个进程同时查到“已到时间”,同时发出去,用户就收到两份。锁可以用数据库的行锁,也可以用分布式锁,关键是“查到”和“标记发送中”必须是原子操作,中间不能留空隙。
第二,千万不要把定时任务存在内存里。文章开头的虚构例子就是这个错:setTimeout、进程内队列、内存里的待办列表,进程一重启全没了。排期记录必须落盘,调度器每次启动先从数据库里恢复,不要相信任何存在进程里的状态。
第三,调度器本身要可观测。每次轮询查到几条、发出几条、失败几条、耗时多久,都要记下来。没有这些数字,某天早上用户说“我的内容没发”,你连从哪里开始查都不知道。调度器的日志不是给你自己看的,是给未来的事故复盘准备的。
时区是最容易翻车的地方
时区问题有个特点:开发的时候一切正常,上线三个月后突然集中爆发。因为时区 bug 只在特定条件下出现:用户跨时区旅行、夏令时切换、用户搬家改了时区设置。平时测不出来,出事就是大事。
第一条规则前面已经定了:存储用 UTC,显示用用户本地时区。所有人都要遵守,包括你自己写后台管理工具的时候。后台看排期列表,显示的也应该是“用户当时定的那个本地时间”,而不是 UTC 时间,否则客服接到用户电话,两个人说的根本不是同一个时间。
第二条规则:用户定时间的时候,要让他明确知道自己定的是哪个时区的时间。最好的做法是在时间选择器旁边直接写清楚,比如“2026 年 11 月 9 日 07:30(北京时间)”。如果用户在国外旅行,系统按他设备的时区显示,也要把时区名字写出来。最糟糕的做法是只显示一个裸时间,让用户自己猜这是哪个时区。
第三条规则:夏令时切换那两天要特别小心。每年春秋两季,总有一天是 23 小时,有一天是 25 小时。用户定的“凌晨两点半”在夏令时切换那天可能根本不存在,或者会出现两次。处理办法是:遇到不存在的时间,往后顺延到存在的最近时间,并明确告诉用户;遇到出现两次的时间,默认取第一次,并告诉用户。不要静默处理,静默处理等于把决定权从用户手里拿走。
第四条规则:用户改了时区设置,已有的排期怎么办?我的建议是:排期一旦创建,锁死它当时对应的 UTC 时间,用户改时区只影响显示,不影响已经定好的发送时刻。如果用户希望按新时区重新算,让他自己改期。这个规则要在界面上写明白,否则用户改完时区,发现排期“变了时间”,会以为是你的 bug。
排期冲突与日历视图:发得多的人最需要什么
一个人一周排一篇内容,时间选择器就够了。一个人一周排十篇,时间选择器就是灾难。他需要看到整周的排布:周一发了两篇、周三空着、周五撞了三篇。这种时候,日历视图不是锦上添花,是刚需。
日历视图要回答三个问题。第一,这周发了什么、几点发,一眼看清。第二,哪个时间段已经满了,哪个还空着。第三,改期的时候能不能直接拖过去,而不是删了重排。拖拽改期看起来是个小功能,但对运营来说,它是每天要做几十次的操作,每次省十秒,一天就省出半小时。
排期冲突要不要拦截,是个需要判断的产品决定。我的建议是分两级处理。第一级是硬冲突:同一分钟、同一个发送渠道、同一批接收者,排了两篇内容。这种情况直接拦截,告诉用户“这个时间已经有一篇了”,让用户换时间或者合并内容。第二级是软冲突:同一天排了五篇,或者连续三天每天都排了三篇。这种情况不要拦截,只给提示,比如“今天已有 4 篇排期,读者可能会觉得太多”,把决定权留给用户。硬拦截防的是事故,软提示防的是疲劳,两个的力度不一样。
频率上限也值得单独定一条规则。很多产品在这里栽过跟头:用户一口气排了五十篇,每天发五篇,一周后订阅者批量退订。不是用户恶意,是他不知道这样做的后果。产品应该在排期超过某个阈值时提醒一句,比如一周超过七篇就提示“发送频率高于你的历史平均水平”。这条提醒不是限制用户,而是帮他避免自己坑自己。
还有一个细节:已排期的内容还能不能改?答案是能改,但要分情况。改标题、改正文、改配图,随时可以改,改完排期不变。改发布时间,就是改期,要走改期的流程,旧排期作废、新排期生效。改接收范围,比如从“全体订阅者”改成“付费订阅者”,要明确告诉用户这次发送的范围变了,最好让用户二次确认。最忌讳的是用户改了内容却不知道排期还在按旧内容发,或者改了时间却不知道旧的排期记录还在队列里。
失败了怎么办:重试、通知与兜底
定时发布最能看出一个产品靠不靠谱的地方,不是它平时多准时,而是它失败的时候怎么处理。发送失败的原因五花八门:邮件服务商抽风、短信网关超时、目标平台接口限流、你自己的服务器在发的那一刻重启。失败一定会发生,问题是你准备好了没有。
重试策略要先定好再上线,不要等到第一次失败才临时想。我的建议是:第一次失败后等五分钟重试,第二次失败后等半小时,第三次失败后等两小时,三次都失败就停下来,转人工处理。重试间隔递增,是为了不给已经出问题的下游服务继续加压。重试次数封顶,是为了不让一条失败的排期无限占用调度器的资源。
重试的时候要区分失败的类型。临时失败,比如网络超时、服务商 500,值得重试。永久失败,比如接收地址不存在、内容被平台拒绝、账号权限被收回,重试一百次也没用,要立刻停下来,直接走失败处理流程。区分这两类失败,需要发送接口返回明确的错误码,不要只返回一个笼统的“失败”。
失败了一定要告诉用户,而且要用用户能看懂的话说。不要写“发送失败:错误码 500”,要写“周一早上七点半那篇晨报没有发出去,原因是邮件服务商当时连接不上,我们已经重试了三次。你的内容还在草稿箱,你可以现在手动发,或者重新排一个时间。”用户不需要知道技术细节,但需要知道三件事:发生了什么、现在内容在哪、接下来他可以做什么。
兜底方案要有两层。第一层是系统内的兜底:失败的排期进入一个待处理列表,运营人员每天看一眼,手动补发或者联系用户。第二层是产品外的兜底:如果整个调度器挂了,要有告警直接通知到值班的人,而不是等用户在群里问“我的内容呢”才发现。告警的阈值要设得合理:一条失败不用半夜叫醒人,但一小时内失败超过十条,必须有人立刻处理。
给 vibe coding 的建议:这个功能怎么做才不翻车
定时发布是个很适合 vibe coding 的功能,因为需求清晰、边界明确,AI 写起来不容易跑偏。但有几个地方,AI 特别容易给你挖坑,需要你亲自把关。
第一个坑是 AI 喜欢用 setTimeout 做定时。你跟它说“到点发出去”,它很可能给你写一个 setTimeout,或者一个内存里的任务队列。开发环境跑起来一点问题没有,部署到服务器,进程一重启全没了。你必须在需求里写死:排期落盘、调度器从数据库恢复、不要用进程内状态。
第二个坑是 AI 容易把时间比较写错。拿字符串比时间、拿本地时间跟 UTC 时间直接比、忘了夏令时,这些错在代码评审里都不显眼,但上线后会在某个凌晨集中爆发。验收的时候专门测三个场景:跨时区用户排期、夏令时切换日前后排期、用户改时区后旧排期是否还准时。
第三个坑是 AI 写的重试逻辑经常没有上限。失败了就重试,重试失败再重试,写成无限循环,最后把下游服务打垮。需求里要写明重试次数和间隔递增,并且要求重试之前区分临时失败和永久失败。
我的建议是:把定时发布拆成三个验收里程碑。第一个里程碑是“能排、能发、能取消”,先把主流程跑通。第二个里程碑是“失败能重试、能通知、能看到状态”,把异常流程补齐。第三个里程碑才是“日历视图、拖拽改期、频率提醒”,把运营体验加上去。不要一口气全做完,第一个里程碑上线跑两周,收集真实反馈再往下做,比一次做全然后返工便宜得多。
上线检查清单
最后是一份可以直接拿去打勾的清单。上线之前逐项确认,缺哪项补哪项,不要凭感觉上线。
第一,排期记录独立建表,状态机只有六个状态,流转权限写在代码里。第二,时间统一存 UTC,显示换算用户时区,时间选择器旁边标明时区。第三,发送接口有幂等键,同一排期重复触发只发一次。第四,调度器每分钟轮询或等效方案,发送过程加锁,查到和标记发送中是原子操作。第五,排期落盘,进程重启后调度器能从数据库恢复。第六,重试策略写死:三次封顶、间隔递增、区分临时失败和永久失败。第七,发送失败后用户能在产品里看到状态,并且收到看得懂的通知。第八,夏令时切换、用户改时区、跨时区排期三个场景都测过。第九,日历视图能看到整周排布,硬冲突拦截、软冲突提示。第十,调度器有监控:每次轮询的数量、成功数、失败数都有记录,失败超阈值会告警。第十一,取消和改期流程走通,旧排期作废后队列里的旧消息不会再触发。第十二,手动补发入口存在,运营能在后台看到待处理列表。第十三,写清楚文档:用户在哪里看排期状态、失败了怎么办、找谁帮忙。
十三项看着多,但每一项背后都至少对应一个真实踩过的坑。定时发布这个功能,用户平时感觉不到它的存在,只有在它出问题的时候才想起你。把它做扎实,就是在你睡觉的时候,替你守住产品的口碑。
评论 (0)
相关文章

礼品卡不是换了名字的优惠券:送礼人先付钱、收礼人后花钱,钱在花掉之前是负债不是收入。本文讲三种形态、购买五步、兑换码防暴力破解设计、余额账本、防欺诈、退款与客服,最后给一份 14 项上线检查清单。

用户说想停两个月,你手里却只有一个取消按钮。本文把暂停、降级、取消分成三条路讲清,讲 Stripe 两种暂停与三种账单模式、暂停期只读与数据保留、恢复那一刻最容易翻车的三个点,最后给一套暂停挽留漏斗和 12 项上线检查清单。

用户注册完 30 天没人理他,他就悄悄走了。本文补上这块空缺:注册后 0、1、3、7 天的欢迎与激活系列,aha 时刻后、额度到 80%、沉睡 14 天时的三封习惯养成信,流失预警与召回的执行层、频率上限与退订底线,最后讲怎么用激活转化而不是打开率来衡量这套序列有没有用。