关于我们

质量为本、客户为根、勇于拼搏、务实创新

< 返回新闻公共列表

2026 定时任务漏跑和重复跑:分片、幂等和补偿该怎么配服务器

发布时间:2026-09-28

凌晨两点那次对账跑了两个实例

凌晨两点那次对账作业,同一分钟里起了两个实例。两个实例各自扫了一遍前一天的流水,各自算出一份结果,各自往结算表里写了一遍。因为中间有一张汇总表是按"累加"的方式更新的,两份结果叠进去之后,第二天早上财务部打开报表,借贷差了一笔不该存在的钱;而就在三天后的另一个凌晨,同一个作业压根没起来——调度中心所在的那台机器在做例行重启,重启之后没有人再去动那个时间点的任务,直到早上九点有人盯看板,才发现昨天的结算表是空的。

这两件事是同一套东西的两面:定时作业的可靠性从来不靠多部署几个实例堆出来,它靠的是三件事——分片(把大任务切成能单独重试的小块)、幂等(重跑一遍不产出第二份结果)、错过补偿(misfire 能被发现、能被按策略重跑);服务器层面真正相关的是时钟一致性与单点故障,不是算力。

(以上为典型场景,并非特指某一真实客户。)在往下看之前,先把这几条当成全文的骨架:

漏跑和重复跑根因完全不同。漏跑是"触发链断了",重复跑是"多个执行者都以为自己是唯一执行者",用同一套办法治这两个病,必然治好一个加重另一个。

多实例部署不等于高可用。没有分布式锁、没有租约、没有全局唯一的触发权,多实例只会把"一次没跑"变成"跑两遍"。

幂等的落点在数据库和业务代码里,不在调度框架上。买多少个调度器插件都替代不了那张唯一索引。

真正让人难受的是"没跑但没人知道"。所以告警必须对"本该在几点完成却没有完成记录"报警,而不是只对"跑失败了"报警。

调度节点要小而稳,执行节点按作业的资源画像配。前者看可用性和时钟,后者才看 CPU、内存和磁盘。

先分清四类失败:没跑、超时中断、重复跑、跑一半崩了

很多团队把所有这些情况都归到"定时任务不稳定"这一个筐里,于是解法往往是一句"再加一个实例"。问题是这四类失败的可观测信号都不一样,处理动作也不一样,混在一起就只能乱投医。

第一类,没跑(misfire)。特征是:调度表里有这个任务,但期望触发的那个时间点没有触发记录,也没有任何日志。常见原因三条——调度节点当时不可用且没有主备;机器时钟向前跳了一跳,直接把那个分钟跨了过去;cron 表达式写错或者被改成了一个不存在的时间。它的危险在于完全静默,没有任何报错。

第二类,跑了但超时被中断。特征是:有触发记录,有开始时间,没有结束时间,也没有成功或失败的结果。作业跑到了一半被外部力量掐掉——调度中心判定超时后 kill 掉了进程,或者容器编排平台因为内存超限把 Pod 杀掉。它的产物是一份写了一半的数据,以及一把可能没释放的锁。

第三类,重复跑。特征是:同一个期望触发时间点在结果表里有两份数据,或者有两条 execution 记录,或者外部接口被调用了两次。这一类通常有明确证据可查,但它造成的业务损失往往是最大的,因为重复的是"钱"或者"消息"。

第四类,跑一半崩了(部分完成)。特征是:任务本身报错失败了,但写到一半的东西没有被回滚——本质是作业内部没有事务边界,或者说它的边界跨越了多个无法放在同一个事务里的系统(数据库 + 消息队列 + 外部 HTTP 接口)。这类事故最难收拾,因为既不能直接重跑(会重复前面的部分),又不能放着不管(数据是残缺的)。

区分这四类的最快办法,是看三张表的记录组合:调度表(有没有触发记录)、执行表(有没有开始与结束时间)、结果表(有几份数据)。三个地方的信息对不上,错在哪一段就清楚了。

为什么会重复跑:四种典型成因

多实例都持有调度器,没有全局锁

这是最常见的一种。为了"高可用",团队把调度中心部署了三个节点,每个节点都加载了同一份任务配置,各自跑各自的本地时钟触发。三个节点之间没有选主,也没有任何互斥机制,于是每个周期的每一分钟,这个任务就被触发三次。有些框架会在执行前尝试写一张锁表,但如果锁表本身是按节点而非按"任务 + 期望触发时间"建的,那三把锁互不冲突,照样一起跑。

正确的互斥粒度是 (任务名,期望触发时间) 这个二元组,而不是任务名。这个二元组上建唯一索引,插入成功的人才有资格执行,其余自然失败退出——锁是数据库的唯一约束给的,不依赖任何节点的自觉。

调度器把任务分给了 A,A 超时未回报,又分给 B

调度中心和执行器之间通常有一套心跳或回调约定:执行器开始上报 running,结束上报 done,调度中心在一段时间内收不到汇报就认为这个执行者死了,把任务重新分给另一个实例。问题在于"A 真的死了吗"这件事,调度中心是判断不了的。A 可能只是被 IO 卡住了几十秒,正卡在全表扫描上,但它马上就要写结果了。这时候 B 被拉起来,两边一起往前跑。

这种成因的隐蔽之处在于它只在负载高峰期出现。平时一切正常,一到月末结算那几天数据量大、机器 IO 打满,超时判定就被触发,重复执行就冒出来了。修的办法不是把超时时间调长——调长只会让真正的死节点更晚被发现,正确的做法是让执行本身幂等,同时对"A 还活着但没回报"的情况用带租约的 fence token:每一次调度给一个自增的执行令牌,A 拿到令牌 1,B 拿到令牌 2,写数据的时候带上令牌,令牌小的写入直接被拒绝。祖师爷级别的 setnx 之争在这里的解法是 fencing token,不是更长的超时。

应用重启后补跑与当前执行撞车

发布过程中,正在运行的实例被打断,进程重启后框架发现"上一周期的执行没有完成记录",于是触发补跑;而与此同时,这一次本来就该跑了,或者是上一个被打断的实例其实还没真正死透(比如容器在优雅退出之前还有一段处理逻辑在跑)。两条执行路径在同一份数据上撞车。

这里的关键在于:应用重启前的"优雅退出"必须是真的等待或标记。收到 SIGTERM 之后,正在执行的任务应该立即把自己的执行记录标成 interrupted 并把锁释放掉,而不是让它在后台继续跑到下一个信号到来。

容器/物理机迁移时旧实例没退出干净

物理机迁移、宿主机故障后的自动重建、Kubernetes 的驱逐重调度——这些操作都存在一个"旧实例还在、新实例已起"的重叠窗口。如果新起的实例不知道旧实例还在做什么,且没有任何互斥,重叠窗口里就是两份计算。虚拟机热迁移尤其麻烦:源主机和目标主机在切换的瞬间可能同时执行过一段指令。

把执行节点的本地时钟、本地文件缓存、本地临时数据都当成"可以被随时丢弃"的东西,是化解这一类问题的前提。任何不能丢的状态都不放在本地。

为什么会漏跑:四种典型成因

调度节点故障且没有高可用

调度中心部署在一台机器上,这台机器做维护重启、内核升级、电源故障、网卡抖动,整个时间窗内的所有任务全部丢失。很多团队在这里有一个误解:以为"执行是由多台机器干的",所以触发中心单点也没关系。恰恰相反,触发中心是整个链路里最不能单点的那一个,因为它一旦沉默,下游所有节点都在健康地空转。

缓的办法有两个层次:调度中心本身做主备或选主集群(Raft、ZooKeeper、etcd 都可以承担选主角色);调度表所在的数据库做主从或集群。注意第二点比第一点更容易被忽略——调度中心三节点选主,结果它们连的是同一个单实例 MySQL,这个 MySQL 一挂,三节点集体失忆。

错过触发窗口后没有 misfire 策略

大部分调度框架都对"错过了怎么办"有默认行为,问题在于默认值往往不是你想要的那个。以常见实现举例:Quartz 对不同 trigger 类型有一组 misfire 指令;XXL-JOB 在任务配置里有"调度过期策略",可取"忽略"或"立即执行一次";Kubernetes CronJob 用 startingDeadlineSeconds 控制"落后多久之后就不再补",用 concurrencyPolicy 控制并发(Allow / Forbid / Replace)。这些选项如果没人显式设置,行为就由框架默认决定——而出事之后复盘,往往发现团队压根不知道有这一项。

这一步的最低要求是:每一个重要任务,都要有显式的、写在文档里的过期策略,而不是留空。

上次执行没结束,下一次被跳过

作业从 40 分钟涨到了 90 分钟,但它还是每小时跑一次。有一次没在窗口内跑完,下一次触发时间到了,框架发现上一次还是 running,按"丢弃后续调度"的策略直接把这一次丢掉了。账面上看不出任何异常——既没有报错也没有重复,只是那一天的数据少了一个批次。

这种情况其实是被作业变慢悄悄逼出来的:作业变慢带来的第一个后果往往不是超时告警,而是静默漏跑。要命的是它会在很长时间里只漏一部分,等到被人发现的时候已经积了几十次。

机器时钟漂移或时区设置不一致

cron 表达式是本机事件。同一句 "0 2 * * *",在一台时钟快了 47 秒的机器上和在另一台慢了 20 秒的机器上,触发的物理时刻不一样;在一台时区设为 UTC 的机器上和一台设为本地时区的机器上,更是完全不同的一件事——UTC 的凌晨两点,对应东八区的上午十点。

跨机房部署的时候这种情况尤其普遍。五个机房各放一台调度节点,各跑各的本地 cron,看起来"部署得很分散很稳",实际上同一个任务在同一份数据上被触发了五次,或者恰好在某一台上整个 skip 掉。分散部署解决的是单点问题,但如果没有统一时钟和统一触发权,它制造的问题比解决的多。

分片:把大作业切开,某片失败只重那一片

一个跑三百多万笔流水、耗时 90 分钟的对账作业,天然不适合做成单个执行单元——跑一次太慢,失败一次代价太大。分片的目的是把"一个大作业的成败"变成"N 个小作业各自的成败"。

分片键怎么选

分片键的第一原则是跟着业务键走,而不是跟着行号走。常见的选择:按商户号尾数、按账户哈希值取模、按渠道 + 日期组合、按机构编号区间。跟着业务键走的好处是,同一笔业务的所有数据永远落在同一片上,重跑这一片不会牵扯到别的片的数据一致性。

跟着行号 LIMIT 分页(第 1 到 10 万行是第一片)是最容易踩的做法:跑批期间上游还在写数据,一页的边界会漂,于是有数据被跳过去,或者有数据被算两次。非要用范围分的话,得先把要处理的数据集合固化下来——先把待处理的业务主键落到一张中间表或者 enumerate 成一个静态列表,再对这个列表切。

第二个原则是分片键要分散。按商户号分的好处很多,但要考虑到大商户——一个头部商户占了全部流水的一成以上,那么它所在的那一片的运行时间会远高于其他片,"切了跟没切一样"。遇到这种情况,对头部商户做二次拆分(头部商户单独成一片,或者按交易日期再切一层),或者干脆用哈希而不是前缀。

分片数怎么定

分片数不要等于执行节点数。等于节点数的后果是:一台节点掉线,挂在它名下的那几片就没人领,得等到迁移完成后才能处理;而且扩容的时候要重新洗数据。

比较稳的做法是把分片数定成执行节点数的三到五倍或者更多,让每片的执行粒度落在"几分钟"这个量级。粒度定在这个量级的理由是重试成本——一片失败重跑只需要几分钟,重跑一次的代价可以接受;如果一片要跑四十分钟,重跑一次的代价会让人在失败时犹豫要不要走手工,这一犹豫就是数据不一致的来源。

分片数也不宜过多。每片都有一份额外的开销:一次任务申领、一次状态更新、若干次索引写入、可能还有一次 JVM 或者容器的启动。如果单片执行时间压到几十秒,调度和状态管理的开销占比就会开始反超实际计算。一个折中的口径是:单片执行时间目标落在 2 到 10 分钟。

某片失败怎么单独重试

分片的收益只有在"能单独重试某一片"时才成立。实现上需要一张分片执行记录表,每一行记录:job_name、fire_time、shard_no、status(pending / running / success / failed)、attempt、owner(哪个实例领走了)、start_time、end_time、rows。

有了这张表,"重跑"这个动作的粒度就可控了:整批重跑 = 把这个 fire_time 下所有片置回 pending 再触发;重跑某一片 = 只置回那一行。失败片进重试队列时用指数退避(第一次 1 分钟后,第二次 5 分钟后,第三次 30 分钟后),重试上限建议设在 3 到 5 次,超过就停止并升级告警。

还有一点容易漏:成功片不应该被重跑。整批重入的时候,必须跳过已经 success 的片。即便如此,仍然要依赖后面讲的幂等来兜底——因为"它认为自己成功了"和"它的数据真的完整写进去了"之间,可能隔着一次没来得及提交的崩溃。

幂等:跑两遍必须只出一份结果,四种落地方式

幂等这个词说得很抽象,落到实处就是一句话:同样的输入跑两遍,数据库里必须只有一份正确的结果,外部副作用必须只发生一次。 下面四种方式按推荐程度从基础往上排。

方式一:唯一键与去重表

最直接也最可靠的办法:在结果表上建唯一索引 (job_name, fire_time, biz_key),写入时用 INSERT ... ON DUPLICATE KEY UPDATE 或者先 INSERT IGNORE 再更新。第二次执行撞上唯一键,数据库替你把重复干掉。

这里有一个很多人做错的地方:唯一性应该由数据库的唯一约束保证,而不是应用层先 SELECT 查一下再决定插不插。SELECT 之后到 INSERT 之间的那几毫秒,就是另一个实例插进来的窗口。应用层判断只能用来减少无效的写入尝试,不能用来保证唯一。

去重表是同一思路的另一种形态:单独建一张 job_dedup 表,主键就是幂等键,处理每一条数据之前先尝试插主键。它适合那些"结果表本身不好加唯一索引"的场景——比如结果是一张由多个来源混合产出的宽表。

方式二:状态机推进(pending → processing → done)

把每一笔待处理的业务数据都带上状态,作业的处理动作是推进状态,而不是产生新的东西。典型的写法是一条带条件的更新:

UPDATE task SET status='processing', owner=?, lease_until=?, attempt=attempt+1 WHERE id=? AND status IN ('pending','failed') AND attempt < 5

这条语句的影响行数是 0 还是 1,决定了这个实例有没有抢到处理权。抢不到就跳过。这条 UPDATE 在数据库层面是原子的,天然互斥,不需要额外加锁。lease_until 用来解决"占了茅坑不拉屎"——进程拿到之后崩了,那行会卡在 processing 上,到了租约时间再由兜底扫描任务重新置回 pending。

状态机的另一个好处是它天然支持断点续跑:崩了之后重启,所有 processing 且租约过期的行会被回收重新处理,已经 done 的行不受影响。跑一半崩了这一类失败,靠的就是这个。

方式三:先写日志再落库

思路是给每一次执行发一个身份。执行开始时,先往 job_execution 表插一条记录,这张表上有唯一索引 (job_name, fire_time):插不进去说明已经有人在跑,当前实例直接退出。插进去之后拿到一个 execution_id,后面所有的写入操作——明细表、汇总表、发出的每一条消息——都带上这个 id。

这样做的好处是排查的时候能把所有东西串起来:某一天的数据有问题,顺着 execution_id 就能找出那天到底起过几次执行、每次写了哪些行。它还顺手解决了"重复写"——如果下游表上也带上 execution_id 的唯一约束,重复执行的写入会全部撞键失败。

注意顺序不能反:必须是先拿到 execution_id、再动手写数据。如果先把结果算完再写日志,中间崩溃的时间窗口就没人记录了。

方式四:对账类作业用重算,不要用累加

这是专门针对结算、余额、统计类作业的一条,也是最重要的一条。累加式更新写成这样:UPDATE account SET amount = amount + 100 WHERE id=?。这条语句跑两次就是累计加了两百,跑三次就是 300,而且事后几乎没法从数据本身判断它到底跑过几次。

改成"以对账结果为准的重算":先把这一次应该得到的全额结果算出来,再整体替换(先写新版本、再把指针切过去,或者用 job_date 做分区替换)。同一天跑三次,结果都是同一份全额数据,最后一次覆盖前面两次,数据完全一致。

顺着这条再往前一步:所有针对差额的处理,都应该是"重算出差额然后全额替换"而不是"再补一笔"。跑出来对不上就重算,不要在旧数字上手工打一块补丁。

外部副作用怎么办

幂等最难的地方从来不在数据库里,而在数据库外——发了一条短信、调了一次第三方代扣、推了一条 MQ 消息。这些动作重复一次,钱或者用户体验就真的重复了一次。

处理办法是给每个外部调用带幂等号(通常就是 biz_key + fire_time 拼出来的那个唯一键),并且要求下游支持按幂等号去重。下游不支持的话,自己在这一侧建一张外部调用记录表,用同样的唯一键 + 状态机拦一道。这两道保险都要有,不要只靠下游自觉。

错过补偿:调度器要能记住"本该几点跑"

判断一个调度体系是不是成熟,看一件事就够了:它能不能回答"这个任务本该在几点跑、实际几点跑的"。 只有触发记录没有期望时间,就查不出漏跑;只有期望时间没有实际记录,也一样。

该记什么

执行记录里至少要有这几个字段:schedule_time(期望触发)、actual_start_time、actual_end_time、status、trigger_type(正常触发 / misfire 补跑 / 手工重跑)、duration、missed_seconds(actual_start_time 减 schedule_time)。有了 missed_seconds,才能配一条"倾斜超过 5 分钟就告警"的规则。

Kubernetes CronJob 的 startingDeadlineSeconds、XXL-JOB 的调度过期策略、Quartz 的 misfire 指令,本质上都是对这个差值的一个策略化处理。

三种补跑策略怎么选

立即补跑:适合于"数据是幂等重算的、晚一点比没有强"的作业,比如日终汇总、缓存刷新、报表生成。补跑的价值是让数据最终正确。

直接跳过 + 告警:适合那些时间窗口过了就失去意义的作业,比如"每小时检查一次是否需要止血",两小时前的那一次检查补跑毫无意义。补它只会制造混乱。

窗口内补、超窗跳过:startingDeadlineSeconds 就是这一类。落后 10 分钟以内照常补,落后两小时就别补了,直接告警让人介入。按窗口补是最实用的默认选择——它避免了"机器停机一晚上,早上九点一起来同时补跑八次,把数据库直接打死"这种二次事故。

补跑要限流,要有上限

补跑的两个硬约束:并发上限与次数上限。同时进行的补跑任务数必须卡住,一般按正常并发的一半到三分之一;补跑次数上限设在 3 到 5 次,超过就停,转人工。

还有一个容易被忽略的顺序问题:停机很久之后恢复,应该按时间顺序串行补,而不是几个补跑任务齐头并进。因为后一天的对账可能依赖前一天的成果数据,一起补出来的结果很可能是错的,而且这种错很难被发现。

告警要独立于调度器

调度器自己挂了,它是没法报告 misfire 的。所以必须有一个站在外部的守夜人:一个部署在不同机器(甚至可以是一台很小的云主机)上的守护脚本,它不干活,只做一件事——每隔一段时间去查一次"每个关键任务最近一次成功执行的时间",如果 now 减去最近成功时间超过了预期周期的一倍半,就报警。

这条告警比"任务执行失败"的告警重要得多。任务失败是有声音的,任务没跑是没声音的。

时钟与时区:跨机器时"凌晨 2 点"可能不是同一时刻

这一节是本文唯一一处直接落到服务器日常运维上的内容,也是最容易被跳过去的一节。

时钟漂移

廉价晶振的日漂移量级是秒级的,不加校正的话跑上几个月,两台机器之间差出几十秒很正常。听起来不多,但对 cron 来说足够致命:任务在 A 机器上 01:59:58 触发,在 B 机器上 02:00:03 触发,如果这两台机器都持有触发权且脚本的功能还依赖于"读某一分钟内产生的增量数据",两边读到的数据窗口就不一样。

更重要的是时钟跳变。ntpd / chronyd 校正时钟有 step(直接跳)和 slew(慢慢拉)两种方式。如果发生一次向前的 step,比如一次性跳了 3 秒,那么 01:59:58 到 02:00:01 之间的那一分钟可能被整个跨过去,任务压根不触发;如果发生一次向后的 step,同一分钟可能被触发两次。

所以对于承载调度中心的那批机器,建议:开启 chronyd 并配置为 slew 为主的方式;监控 chronyc tracking 里的 offset,超过阈值(一般设 100ms~1s,看业务敏感度)就报警;时钟偏移量应该是一类独立的监控项,不要挂在 CPU 负载那一堆指标里被淹没。

时区混乱

时区坑有三个常见来源:

一是容器与宿主机不一致。宿主机是东八区,容器基础镜像默认 UTC,容器里没有挂 /etc/localtime、也没有设 TZ 环境变量,那么同一个 cron 表达式在容器里比在宿主机上晚 8 小时执行。

二是编排平台本身的时区。Kubernetes 的 CronJob 调度由 controller-manager 执行,历史上是以 UTC 解析 schedule 的(新版本开始支持 timeZone 字段,但默认行为取决于集群配置),很多人写完 0 2 * * * 以为是国内的凌晨两点,实际是 UTC 的凌晨两点,也就是东八区的上午十点——业务高峰时段。

三是跨机房不统一。五个机房五台调度节点,两台 UTC、三台本地时区,任务的行为就完全无法预测了。

给一个明确的建议:机器和调度层统一用 UTC,业务层需要本地时区的东西在应用里用明确的时区对象换算(不要依赖系统默认时区);数据库里的时间戳统一存储标准,展示层再转。 这样做的额外好处是:没有人需要争论"半夜两点到底是哪个两点"。夏令时的那点痛苦也一并绕过去了。

闰秒

闰秒相对少见,但调度系统里确实有两种处理方案:step 掉,或者把这一秒分摊(smear)。无论选哪一种,要保证同一个集群里所有机器的策略一致,否则会出现部分机器认为这一分钟有 61 秒、部分认为是 60 秒的一致性问题。这是属于"一年可能发生一次的漏跑/重复跑"。

服务器怎么配:调度节点看可用性,执行节点看资源画像

把调度层和执行层分开配的主要原因很简单:调度层要的是"一直醒着且时间准",执行层要的是"跑得动",这两件事对硬件的要求几乎是正交的。

调度节点:小而稳

调度中心本身干的事很轻:扫待触发的任务、写几张表、把任务分发出去。一个典型的中等规模部署(几百个任务、几千次触发/天),调度进程加它依赖的注册中心,双核四核、8~16GB 内存已经绰绰有余。真正要在它身上花钱的地方是:

一是能不能做双机。两台机器加一个虚拟 IP(keepalived 之类)或者依赖上层协商选主,成本远低于单点挂掉一次带来的损失。调度节点的成本在整个集群里占比极小,"只买一台"是性价比最低的省钱方式。

二是时钟。上一节讲的内容在这里落地:NTP 源要在网络策略里放行、要有冗余的上游、要把 offset 纳入监控。

三是它背后的数据库。调度表和执行记录表所在的库才是真正的单点。它要做主从或者集群,它的磁盘要有足够的 IOPS 承载几千次小事务写入,它有它自己的备份策略。很多时候"调度系统不稳定",不稳定的是这个库。

执行节点:按作业的资源画像配

执行节点的配置不能拍脑袋,要先搞清楚作业是哪一类:

CPU 密集型(批量校验、加密解签、压缩解压、规则引擎评分、大规模正则匹配):看核心数,也看单核性能(主频与架构)。这类作业并发度一般是核数的 1~1.5 倍,核数直接决定单片能跑多快。扩容方向是加更多同等规格的节点,堆分片数。

内存密集型(全量比对、把去重集合装进 JVM 堆、维表 join、大批处理对象):看内存容量与内存带宽,也要看 NUMA 拓扑。给这类作业配内存时要算清楚:JVM 堆 + 堆外 + 操作系统 page cache + 同机器上其他进程的余量,实际可用量往往要比堆配置多留出一半。这种机器一旦内存不够,症状是长时间 GC 甚至 OOM 被杀,而 OOM 被杀之后容器编排平台会把 Pod 重新拉起——如果你的幂等没做好,就等于重跑了一次。

IO 密集型(扫全表、写明细、落日志文件、建临时表):看存储介质本身的参数,NVMe SSD 的随机写 IOPS 与 SATA SSD、机械盘完全不是一个量级。还要看 fsync 策略与 RAID 卡的掉电保护(电容/电池)——没有掉电保护的写缓存,在断电时会丢掉已确认的写入,而这个丢失可能会破坏你以为已经落地的作业记录。

现实里很多作业是混合型的:一边做批量校验要吃 CPU,一边又要把结果全量写回要吃 IO。这种两头都占的作业,配置上按短板最大的那一项给。

调度与执行要不要分开部署

规模小的时候合设很常见,也有它的合理性。但当下面任意一条成立时,就该拆开:

作业的执行会把机器 CPU 或内存打满到影响其他进程的程度;作业有 OOM、Segfault、跑飞的风险,你不希望它把调度进程一起带走;执行层需要频繁扩缩容而调度层需要保持稳定;作业要读的数据量极大,网络流量会挤压调度心跳。

拆开之后还有一个隐含好处:执行层可以做无状态化。节点的本地磁盘只放可以丢的临时文件,作业的状态全部在执行记录表和去重表里,那么任何一台执行节点掉线,带来的影响只是"它领走的那几片需要等租约过期后被别的节点接管",而不是"这台机器上有不可恢复的作业中间态"。这时候机器的维护、搬迁、重装、替换,都不需要排作业。

迁移执行节点这台物理操作也因为无状态而变得简单——不再需要"迁移前先确保没有正在跑的任务"这种协调动作。

日志与留痕要留多久

作业执行的记录不是"日志"意义上的东西,它是排查数据问题的唯一依据,要当成数据来管理。给一个估算的方法(以下为典型部署思路,并非特指某一真实客户):假设每天 2000 次作业执行,每次平均 100 条分片记录,每条记录加上明细摘要约 1KB,那么一天就是 200MB,一年约 70GB;再算上索引膨胀(通常一倍到两倍)和 Binlog,那么预留 200~300GB 的存储才够撑一年。如果作业人员还要保留每次执行的 stdout/stderr 文本日志,那这部分要单独放在日志系统里算,量级通常是结构化记录的 5 到 10 倍。

磁盘预算要按"峰值 IOPS + 一年的留存容量 + 索引膨胀"三项一起算,而不是只看 gzip 之后的文本大小。这一块往往是跑批集群里唯一容易被算漏的成本。

从硬件选型角度落一句

把上面两种角色落到物理机采购上,思路就是两种规格分别下单,而不是按统一高配买一批:调度层两台入门级机型做主备,配置重点是双电源、可靠的时钟源、带掉电保护的 RAID 卡,CPU 和内存都不需要高;执行层 N 台多核机型横向堆,按上面说的资源画像选取对应侧重。像一万网络(深耕 IDC 19 年,成立于 2007 年)这类支持单独指定 CPU 型号、内存容量与 SSD 组合的服务商,作为这一类"按角色分规格"的物理机选型参考对象之一比较合适——它的价值在于能分别配出不同规格,而不是某种统一套餐。具体机型与报价以官网实时价为准。

一份上线前的检查清单

下面这些条目是针对"会不会漏跑、会不会重复跑"这件事逐条追问的,每一条答不上来就是一处风险:

1. 执行记录表上有没有 unique(job_name, fire_time, shard_no) 这个唯一索引? 没有它,所有的互斥都是纸糊的。

2. 触发权是不是全局唯一的? 三个节点是选主之后由一台触发,还是各自触发,这件事要有确定答案。

3. 阻塞策略(上次没跑完时怎么办)是不是显式设置的? 留默认值等于把决策权交给框架。

4. misfire 策略是不是显式设置并写进文档的? 补跑、跳过、按窗口补,三选一。

5. 补跑有没有并发上限和次数上限? 没有上限的补跑在长时间停机后会二次打死数据库。

6. 所有写操作是不是幂等的? 特别是三类:发 MQ、调用外部接口、累加式 UPDATE。

7. 处理中的行有没有租约和租约过期回收? 没有回收机制的话,一次崩溃会永久卡住一批数据。

8. 有没有一个独立于调度器的守夜人,按"最近成功时间"告警? 这是发现静默漏跑的唯一手段。

9. NTP 开了吗、TZ 统一了吗、cron 基于哪个时区有没有写在文档里? 三问缺一不可。

10. 时钟偏移有没有独立的监控项? 它不该混在一堆机器指标里被淹没。

11. 杀掉一台执行节点,作业能不能自愈? 直接做一次演练,答案比文档可靠。

12. 有没有做过这几項故障注入:kill -9 执行进程、断网 30 秒、手动把机器时钟拨快 5 分钟、停掉主调度节点? 这四项分别对应四类失败。

13. 执行记录的归档和清理策略是什么? 磁盘写满也会让作业失败,而且这种失败同样表现为"没跑"。

14. 月末、季末、大促这类数据量峰值日,作业时长会不会超过调度周期? 如果会,这一天的漏跑几乎注定会发生。

失败类型与防护手段对照

失败类型 典型成因 检测方式 防护手段 对服务器的要求
没跑(misfire) 调度节点单机无主备;错过窗口后无过期策略;时钟向前跳变跨过触发分钟;cron 表达式错误 比对 schedule_time 与 actual_start_time,期望有触发记录但实际无;外部守夜人检查"最近成功时间"是否超期 调度中心选主或主备;显式设置 misfire 策略(立即补/跳过/窗口内补);补跑限流并设次数上限;告警独立于调度器部署 调度节点双机消除单点;开启 NTP 并对时钟 offset 单独告警;机器上带可靠时钟源的持续可用性,CPU 算力基本无用武之地
跑了但超时被中断 调度侧超时判定过短;容器内存超限被 OOM kill;高峰期 IO 打满导致心跳回传延迟 有触发记录、有开始时间、无结束时间与无结果;attempt 频次异常升高 按单片粒度拆分压缩单次时长;用带租约的状态机而非长锁;超时后的重分配带自增 fence token,旧令牌写入被拒;内存余量按峰值给足 内存按作业峰值×1.5 预留避免被杀;NVMe SSD 保证慢盘不会拖出超时;峰值时段不与在线业务抢 IO
重复跑 多实例各自持有触发权且无全局锁;A 超时未回报被重发给 B;重启补跑与当前执行撞车;旧实例未优雅退出 同一 fire_time 出现两份结果或两条执行记录;下游接口调用次数与业务单数不符 (任务名,期望触发时间) 唯一索引做互斥;执行令牌递增保证后到者胜;外部调用携带幂等号;去重表兜底;处理 SIGTERM 时标记中断并释放 执行节点无状态化、本地不留不可丢状态;调度与执行分离部署避免执行侧满载拖垮触发
跑一半崩了(部分完成) 作业内部无事务边界;副作用跨越数据库与消息/外部 HTTP;进程被杀时锁与租约未回收 状态为 failed 但存在部分已写入行;processing 状态行数堆积且 lease_until 已过期 先写执行日志拿 id 再落库;pending→processing→done 状态机配合租约回收;对账类作业用全额重算替代累加;孤儿锁与超时 processing 行由守护任务定期扫描回收 存储带掉电保护(RAID 电容/电池)避免写入丢失;日志与执行记录所在磁盘留出足够 IOPS 与一年以上留存容量
分片中某片失败 分片键热点导致单片超时;上游数据在跑批期间仍在写入导致范围漂移;单片数据本身有脏数据 分片记录表中该 shard_no 的 status 为 failed 且 attempt 递增;整体作业因单片卡住而整体超期 分片键按业务哈希取模而非行号分页;分片数取节点数 3~5 倍、单片 2~10 分钟;失败片单独重试,成功片跳过;头部大商户二次拆分 执行节点横向可加,支持分片数与并发度同步上调;记录表所在的库 IOPS 要能承载"分片数×执行次数"的小事务写入

七个工程上真会被反复问的问题

多实例部署是不是就等于高可用?

不等于,而且恰恰相反——在没有互斥机制的前提下,多实例部署是把"没跑"的风险换成了"跑两遍"的风险,而后者的代价通常更高。高可用要满足两个独立条件:一是触发权唯一(选主或全局锁),二是执行结果幂等。缺了第一个,会重复;缺了第二个,重复了就救不回来。真正的高可用是"任意一台机器挂掉,任务仍然被触发且只被触发一次",检验方法很简单:把主调度节点的网线拔掉,看那一分钟的作业是不是恰好跑了一次。

任务执行到一半要重启机器怎么办?

流程上应该分三步走。第一步,先让调度层把这个节点摘除(或者对应的队列停投),不再给它派新活;第二步,等待它手上的任务自然结束,或者等租约过期后由执行记录表回收——这要求任务本身幂等,且不要指望旧实例还能继续把活干完;第三步,确认这个节点上已经没有 running 状态的执行记录之后再重启。真正重要的是第二步能不能自动完成:如果每次重启都要人工去执行记录表里改状态,那说明作业本身的幂等和租约没做扎实。

幂等一定要改业务代码吗?

不一定,但绝大多数情况下要。可以在不碰业务逻辑的前提下做到幂等的场景只有一类:结果本身就是一张可以用唯一键覆盖写的表,那么加唯一索引 + ON DUPLICATE KEY UPDATE 就够了,属于纯 DDL 层面的改动。但凡涉及状态推进、累加更新、发消息、调外部接口,都躲不开在业务代码里加幂等号或者改 SQL 语义。一个务实的做法是先把最容易出事的三五个作业改造掉——通常是涉及钱和涉及对外通知的那些——而不是一次性把所有作业推倒重来。

分片数定多少合适?

给一个可直接落地的口径:先按单片执行时间反推。比如一个作业整体跑 90 分钟、目标是单片 3 分钟,那分片数就是 30 左右;再往上取到执行节点数的整数倍(比如有 8 台执行节点,就取 32 或 40),保证每台节点领到的片数均匀。不建议分片数少于节点数,也不建议单片跑超过 15 分钟——超过之后重试的决策成本会让人开始走手工流程。还有一点要留:分片数一旦上线,改起来要洗历史数据,所以初值宁可变大一点,别取刚好。

错过的时间点要不要补跑,补几次?

按作业性质分。日终汇总、余额快照这类"结果可被全额重算"的作业,补;而且由于幂等,多补一次也无害。 hourly 巡检、实时性强的告警类作业,过了窗口就没意义,直接跳过并记一条 missed 记录。至于补几次,比较稳的默认值是:3 次以内自动重试,超过就停止并升级告警——自动重试次数设得越大,越容易在故障恢复期制造雪崩。

服务器时区该设 UTC 还是本地时区?

建议机器和调度层统一 UTC,业务层要用本地时区的地方在代码里用明确的时区对象换算。理由有三:跨机房、跨可用区部署时不会各跑一套;不受夏令时切换的影响(倒拨那一小时会重复、正拨会跳过);排查问题时不用猜"这个时间戳是哪一个两点"。代价是运维看日志的时候要在脑子里加八小时,这个可以用日志系统统一转换解决,远比排障时才发现时区不一致便宜。

执行记录和日志要留多久?

结构化执行记录建议留 12 个月以上,至少覆盖一个完整的审计与年结周期:很多数据问题是隔了很久才被发现——半年后发现某一时段的结算不平,此时若执行记录已被归档删除,就只剩"改没错但不知道为什么"的结果。文本日志可以短一些,热存储留 30 天,之后转对象存储冷存 6~12 个月。容量上按前面给过的算法预留:日增量 × 天数 × 2~3 倍的索引与 Binlog 膨胀。还有一条容易被忽略——磁盘写满会让作业失败,而这类失败同样表现为"没跑",所以留存策略和磁盘水位监控要一起做。

先保证只跑一次,再谈跑多快

跑批慢是难受,跑漏和跑重是要命。慢了至少还有结果,只是晚一点;漏了是没结果但看起来正常,重了是有两份结果而且看起来也很正常——后者会让财务在一个月之后发现账不平,届时要把这期间的每一次执行都重新捋一遍,成本远高于当初做幂等的那几天。

所以资源投放的顺序应该是明确的:先用唯一索引和状态机把"只跑一次"钉死,再用分片和错过补偿把"一定跑"钉死,最后才轮到加核加内存把"跑得快"搞定。这个顺序反过来做——先把机器加上去,作业跑快了、数量也多了,此时再回头补幂等,改造面会比当初大一个量级。

文中场景是典型情况不是真实客户

本文出现的对账作业、流水规模、分片数量、记录条数与硬件配置口径,均为说明问题用的典型部署思路与估算方法,并非特指某一真实客户的系统或实际采购方案。文中提到的调度框架行为(如过期策略、并发策略、misfire 指令)以各项目官方文档与版本为准,升级版本前请核对对应版本的默认行为。涉及的机型配置、规格组合与报价,请以官网实时价为准。


上一篇:2026 Gitea 自建仓库服务器租用选型手册:裸仓库目录与备份盘的容量避坑全攻略

下一篇:2026 GlusterFS 分布式存储服务器租用手册:副本卷与纠删卷的磁盘网络选型避雷全解