1. 凌晨被电话叫醒之后,我才开始认真看待单机任务调度
事情发生在很多个季度之前。当时我们的订单对账任务跑在一台固定机器上,每天夜里十一点半靠 crontab 拉起,日志输出到本地文件,跑完把结果推到财务系统的某个目录。听起来没什么问题,这也是绝大多数业务系统刚起步时的通用姿势。但那天晚上,那台机器磁盘被另一个项目的日志写满,crontab 到了时间点没有执行,任务“消失”了。第二天早上财务反馈对账文件缺失,我才意识到,过去这段时间我们对单机任务调度其实一直没有做过真正的事故预案。
事后复盘时,我把“单机任务调度”的问题归纳成几个层次,而不只是“机器挂了怎么办”这么简单。第一层是资源单点,任务依赖的进程、机器、磁盘、网络都可能成为故障点,crontab 只负责到点执行一次,执行失败与否它并不关心,更不会自动把任务迁移到其他可用节点。第二层是任务状态不透明,跑完没有统一记录,失败没有重试队列,人工排查时只能翻日志,甚至翻不到日志。第三层是并发和争抢问题,业务起来之后,多个任务都集中在夜里跑,谁先谁后、谁能占用多少内存或数据库连接,单机的调度器很难精细化控制。第四层才是真正让我下定决心重构的:多个团队、多种编程语言都在往同一个任务域里加东西,你没办法让 Python 团队、Go 团队、Java 团队都去改同一台机器上的 crontab。
那次事故之后我画了一张非常粗糙的现状图,发现我们体系里贴着 crontab、Quartz、python-apscheduler、Node 的 node-cron,还有两个自己写的“死循环 + sleep”脚本,各自为战。有的任务没有幂等,跑重了会产生重复数据;有的任务没有超时控制,卡住之后会占用数据库连接池不释放;有的任务互相之间没有上下文隔离,一个任务里换了环境变量,另一个任务读到的配置就飘了。看起来是一个调度问题,实则是一个系统性问题:调度逻辑和任务执行逻辑耦合在一起,任务状态散得到处都是,缺少统一的生命周期管理。
也就是说,单机任务调度作为起点没有任何问题,问题是业务在往前走,调度体系如果还停留在“找一台机器写 crontab”的阶段,它就会从工具变成隐患。我们后来常说一句话:单机调度适合管理“测试环境脚本”和“容错要求不高的日常运维动作”,一旦涉及结算、对账、推送、数据同步这些下游强依赖的场景,就必须把调度当作一个独立的分布式基础设施来设计。
那段时间我刷了很多分布式任务调度平台相关的资料,也对照了我们自己的现状。xxl-job 这类平台让我比较早地意识到,互联网系统里任务调度和业务应用本身其实可以完全解耦。传统做法里,业务应用启动时如果需要做定时动作,通常就直接塞一个调度模块在进程里,比如 Java 应用里嵌 Quartz;这种方式在应用单实例时很顺手,但当应用部署多份副本之后,你就要处理“同一时刻多个副本都触发同一任务”的重复执行问题。解决方式要么靠分布式锁,要么把调度功能单独拆出去。后者无疑是更治本的方向。
所以在那次复盘会议的结论里,我们定了一个基调:调度器是一个独立组件,它能下发触发信号,能维护任务注册信息,能处理失败重试,能记录执行历史;业务方不再自己管“什么时候跑”,只管“接到任务之后怎么干”。这样任何一个任务背后对应的执行进程,都变成了无状态的消费者——中间那层调度消息把生产方和消费方彻底分离了。这也是后来整套高可用体系能落地的第一块基石。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从“进程内定时器”改造为“中心调度 + 分布执行”时的关键取舍
改造过程中我见过不少团队走到一半就卡住,最常见的原因是舍不得进程内定时器带来的那点简洁感。比如 Java 服务里写一个 @Scheduled 注解,启动即生效,本地开发非常省事;但一旦多副本部署,你必须面对一个很尴尬的问题:如何在多个副本之间保证同一时刻只有一个副本执行任务。有人引入 Redis 分布式锁,在任务方法第一行加锁,执行完释放,遇到节点崩溃就靠锁过期兜底。这套方案不是不行,而是引入了新的复杂性:锁过期时间怎么定、任务执行超过锁过期时间怎么办、Redis 主从切换时锁会不会失效,每一条都有一堆边界场景。
我们最终做的不是消灭分布式锁,而是把锁的职责缩小到“触发层”,不再让锁介入业务执行过程。整个改造大致遵循这样的思路:任务调度平台负责按 cron 表达式计算触发时间点,然后把一次触发动作变成一个“调度记录”,这个记录有唯一 ID,执行节点消费到之后,必须用它自己的执行 ID 回执结果。这样一来,任务到底有没有执行、哪个节点在执行、什么时候开始的、什么时候结束的,全部有据可查。业务方不需要自己抢锁,调度平台已经保证同一个调度记录不会被同时发给多个可用执行节点。
但这里有一个非常容易被忽略的原则:调度平台只能保证触发语义,无法替你保证任务处理的幂等。分布式系统的第一课就是网络不可靠。调度平台把任务发给执行节点后,可能出现三种情况:节点处理成功但回执丢失;节点处理超时但实际已经完成;节点还没开始处理就宕机了。所以任何被调度触发的任务,都必须设计成“可以重复执行而不会产生副作用”的幂等任务。最笨但最有效的幂等策略是任务开始时记录一个批次号,处理过程中把所有写入操作都带上这个批次号,落库前检查是否已经存在相同批次号的处理结果;存在就跳过,不存在才执行。用这个策略替换掉朴素思维锁之后,我们甚至能把任务重试粒度从“整个任务重跑”缩小到“失败分片重跑”。
在这个阶段,处理执行节点要怎么拆分,比调度器本身更考验架构功底。我倾向于把执行节点拆分成两层:一层是常驻的 worker,专门接收调度指令,处理要放到进程池或线程池里去跑;另一层是真正的业务处理容器,可能是 Java 服务、Go 服务、Python 脚本,甚至是一个跑在容器里的临时命令。常驻 worker 负责解决“任务进程会不会死掉”的问题,业务容器负责解决“任务代码怎么维护”的问题。有人会说,为什么不把任务直接做成 HTTP 接口让调度平台周期性调用呢?可以,但要明确一点:调度平台调用业务接口时,只适合触发那些响应时间可预估、失败后可重试的轻任务;那些跑十几分钟甚至数小时的批量任务,用同步 HTTP 会占用大量连接资源,也容易在容器扩容、重启时造成请求丢失。
把任务变成事件、把任务结果变成回执之后,我们还顺手解决了一个历史问题:不同语言写的任务终于有了统一的接入门面。不管是 Java、Python 还是 Go,只要实现一套简单的回调协议,就能被同一个调度平台管理。这里要特别说一句,如果你所在团队是多语言开发,不要试图让每种语言都去直接依赖调度平台的客户端 SDK。SDK 版本会漂移,语言生态各有各的启发式,很容易出现某语言升级了依赖导致任务不触发。我更推荐的做法是:调度平台与执行节点之间用一套基于标准数据的协议,执行节点用那个自己最熟的语言写一个非常薄的适配层。薄到什么程度?薄到它只负责三件事——接收指令、拉起子进程/调用回调、回传结果。业务逻辑永远留在业务进程里,不塞进调度客户端。
3. 高可用调度平台的选型逻辑:自研、开源、还是半成品改造
提到分布式任务调度平台,很多人的第一反应是“直接用开源项目”。这个思路就互联网工程而言没有错,但真正落地时会遇到一个必须回答的问题:你想要的到底是一个调度平台,还是一个任务调度整套解决方案?这两个词看起来差不多,实际差异极大。调度平台只负责运行触发机制,它假设任务已经存在,只要有节点能执行就行;而整套解决方案会附带任务分片、工作流编排、失败重试策略、执行日志聚合、权限管理甚至数据血缘。选择之前先确认自己处在哪个阶段,可以避免很多过度设计。
我们当时列过一张对比表,把自研、使用开源平台、在两个极端之间取一条折中路线分别写了出来。自研的优点是能完全贴合内部技术栈和运维习惯,缺点是调度器这种东西的坑极其隐蔽,单测里看不出问题,必须靠长时间压测和故障演练才能暴露。开源平台的优点是社区沉淀了大量成熟方案,比如它已经处理过“调度器自身高可用”“执行节点注册与心跳”“任务日志如何存储”这些问题,缺点是使用方通常需要二次开发才能接入统一的权限体系和监控告警体系。第三条路线是使用开源平台的管理面和执行面,但自己写触发持久化和故障转移相关的插件。我们最后选择的近似于第三条路线,但更现实的做法是:先用相对轻量的方案验证流程,跑通之后再逐步增加能力,而不是一次性把一个功能堆满的开源系统抱回家。
高可用这个词,落到任务调度里到底意味着什么?按我们后来沉淀的检查清单,核心包含五点。第一,调度器自身不能是单点,要有主备或者多活机制。第二,任务元数据必须持久化,不能存在调度器进程的内存里。第三,执行节点要能动态上下线,调度器要能感知并剔除不健康节点。第四,调度记录和执行记录要分开存储,避免执行日志过多把调度元数据拖垮。第五,失败重试不能无限重试,必须设置最大重试次数,并支持超时告警、人工介入的通道。
自研时最容易低估的是第三点。任务调度平台里,调度器需要知道现在有哪些执行节点可用,才能真正把任务分发出去。节点脑裂、网络分区后,调度器可能以为某个节点还活着,实际上它已经和数据库断开连接。这时候如果把任务发过去,节点要么没法持久化执行记录,要么执行完回执丢失。我们解决的方案很朴素:让执行节点每隔一段时间主动上报心跳,心跳里带上自身负载和正在执行的任务数;调度器端采用“连续 N 个心跳周期没收到才算节点离线”的容忍策略,避免一次抖动就把节点摘除。这个 N 不能太小,网络抖动在跨可用区或容器频繁重调度时并不罕见;也不能太大,否则节点真挂了,任务会被长时间困在不可用节点上。我们花了很长时间调出来的经验值是三个周期,每个周期五秒,也就是十五秒没收到心跳才判定节点离线。这个值不是死标准,而是要看你的任务最长允许延迟多久。
另一件让我印象深刻的事情是任务时间轮转的精度问题。单机调度时代,任务触发误差几十毫秒根本无所谓;但到了分布式调度平台,如果调度器用纯内存计时器管理所有触发点,调度器重启一次就可能有几百个任务在重启窗口内被漏掉。这也是为什么调度平台一定要把任务的触发记录先持久化,再放入内存时间轮处理。打个比方,内存里的时间轮是现场导游,数据库里保存的任务计划才是旅行团的花名册。导游临时换人没关系,花名册在,下一个导游马上就能接管,游客不会丢。我们实现时甚至先写触发日志再触发任务,确保“已经产生了调度意愿”这个事实先落在库里,执行结果另说。宁可节点重复执行一个任务,也不能因为调度器故障而漏执行,这正是很多业务场景下对账、结算之类任务能保持最终数据完整的原因——漏跑比多跑可怕得多,多跑还能靠幂等兜底,漏跑直接就是数据缺失。
4. 任务执行链路里的分片、重试、幂等和背压处理
任务从单机走向分布式之后,最直接的变化是单个任务的处理能力不再受限于一台机器。拿我们经常做的数据同步任务举例——每天要把用户表里几百万行增量数据从业务库同步到分析库,单机版的做法往往是查出一批、写入一批,完了再查下一批;数据量大了以后,单机模式会越跑越慢,数据库连接被长时间占用,其他业务查询也会受影响。分布式调度平台天然支持把这种大任务拆成多个分片,每一片独立执行,但分片逻辑必须由业务方自己考虑清楚,调度平台只提供分片上下文参数,比如当前是第几片、共有多少片。曾有人跟我说“调度平台已经支持分片了,所以我们任务自动就能分片执行”,这话其实只说对了一半。调度平台能派活,但活怎么切分,仍然得看你对数据的理解。
一个稳妥的分片设计是按照数据主键的哈希范围做水平切分,或者按业务已经存在的分区键,比如机构 ID、用户 ID 段。这里有一个很容易踩的坑:如果你的业务数据不是均匀分布的,按主键取模切分会造成某些分片数据量巨大、某些分片几乎没有数据。我们后来改成“按主键范围 + 动态拉取”的方式,每个分片不再固定处理某段 ID,而是到总任务里拉一批任务项,处理完再拉下一批,直到队列清空。这种方式本质上是把分片从“静态切割”变成“动态领取”,大大缓解了数据倾斜问题。
分片粒度足够细之后,重试和背压的策略才有意义。有人会天真地认为,重试无非就是失败了重新跑一次。可真在分布式环境里跑任务,重试要考虑的是:下游系统是否已经部分写入了数据?如果是网络超时,重试会不会造成下游重复写入?任务执行到一半进程被杀,下次重试是接着上次的进度跑还是从头跑?这些问题不加控制,重试就会变成雪崩放大器。我们在每个任务分片里都会先处理成一个独立的“任务批次”,批次里记录了开始时间、候选数据键集合、已完成键集合、失败键集合;重试时只重跑失败键集合里的数据,不重跑整个分片。这样既收窄了影响范围,也让任务执行进度可视化了。
另一个工程细节是背压。调度平台的任务触发频率和下游系统的消费能力很可能不匹配。有一类错误非常诡异:调度器没有加任何限制,而是由下游仓库或消息队列堆积时才意识到,原来上游任务每五分钟触发出几万条消息,下游处理器每批只处理几百条,还经常失败。单纯提高消费并发很容易把数据库打死。更合适的做法是引入“限流 + 排队”的机制:任务执行节点通过控制并发线程数限制同时在跑的批次数量,多余触发记录进入等待队列,在队列里动态调整延迟。调度器端也要配合,如果发现某个任务最近几次执行都是超时未完成,应自动降低触发频率或者直接进入“暂停-告警”状态,等业务方处理。把任务调度平台当成一个只会准点响铃的闹钟是不够的,它还必须能感知到任务长期执行不退出、大量重试失败这些异常体态。
我自己的实操心得是,任务执行链路里最需要优先建设的是“可观察性”。不是在任务日志里打几行字就算完,而是要把调度记录、执行记录、分片进度、失败原因全部暴露成指标。调度平台自带管理界面当然很好,但你最好还是把指标接到公司统一的监控系统里,因为告警规则、值班逻辑、权限体系都在那边。没有统一监控的任务执行,就像在黑夜里开车,只有在撞到东西之后才知道方向错了。我们后面专门做了一张任务大盘,按业务线、执行节点、任务类型三个维度展示成功率、平均执行时长、失败重试次数、积压数量,任何一条指标出现明显波动,值班同学都能第一时间定位到是某个任务的某个分片出了问题,而不是等业务反馈“今天数据好像不太对”。
5. 高可用落地中的一个隐秘角落:失锁、脑裂与触发记录的顺序性
高可用调度体系建起来之后,表象上最吸引人的部分是“调度器不再成为单点故障”。但真正压测和故障演练时,我发现单点转移并没有消失,而是转移到了几个更隐蔽的角落。第一个隐蔽角落是分布式锁。如果调度器主备切换后,新主没有等旧主的锁完全释放就继续分发任务,会导致两个调度器同时为一个任务生成触发记录。这不一定意味着任务会重复执行,因为执行节点可以通过任务批次 ID 去重,但如果执行节点在执行前依赖某个分布式锁来确保资源不冲突,旧任务的锁可能残留,新任务拿不到锁就卡住。所以我们对分布式锁的使用规定了统一的纪律:每个锁必须带唯一的持有者标记,释放锁时只能由持有者释放,不能简单地先 GET 再 DEL,否则可能误删新持有者刚设置的锁。这里面用到的技术其实就是 Redis 官方文档里强调过的 Lua 脚本校验,语言层面没什么高深的,难的是每个人都能坚持按规范写。
第二个隐蔽角落是执行节点和调度器之间的双向感知问题。调度器以为自己已经把任务派给了某个执行节点,但执行节点所在容器此时正在被平台重新调度,进程可能已经终止,只是 TCP 连接还没断。这种情况下,任务记录的最终状态会一直停在“已派发”,直到调度器通过心跳超时认定节点离线,再触发超时重派。从调度器角度看,这只是任务变慢了;从业务视角看,关键路径上的任务可能因此延迟了执行窗口。解决这个问题的核心不是把超时时间调到最小,而是要让执行节点在启动时主动和调度器做一次注册握手,明确自己支持哪些任务类型、当前是否有残留的未完成任务,让新节点能主动接管。也就是说,高可用不能只靠调度器端单方面探测执行节点,执行节点也要有能力在恢复后主动报告自己的状态。
第三个隐蔽角落是触发记录的顺序性。调度平台为了高可用,会把调度记录写到数据库或消息队列中。但数据库主从切换后,如果某个从库延迟较大,新主库上可能读不到最新写入的几条触发记录;消息队列虽然可用性高,却存在消息重试和乱序的可能。如果一件任务既需要周期性触发,又依赖上一次触发的结果,这种顺序错乱会造成很离谱的后果。比如一个“先全量拉取数据,再执行增量合并”的任务组,如果全量任务结果还没回执成功,增量任务就先被触发了,合并逻辑可能会读到不完整的全量数据。为了避免这种问题,我们把存在依赖关系的任务放在同一个“任务组”中,任务组里的下一个任务必须等上一个任务成功后才会被调度器放行;任务组之间允许并行,任务组内部强制串行。用这种方式把顺序问题从调度器底层抽离成了业务编排的一部分,逻辑更清晰,也更容易测试。
回看这一路,高可用调度不是一个可以一次性部署到位、之后再也不用管的功能,它更像一个需要持续打磨的底盘。我现在最常用的一句话是:“如果一个分布式任务系统让你感觉一切正常,那只是因为你还没有把故障塞进去演练过。”有些团队上线了调度平台,却不敢随意杀节点,不敢模拟数据库断连,不敢把任务执行超时调到很小然后看系统怎么应对。这些不敢做的事,恰恰是高可用体系里最重要的事。我们后来把所有调度节点纳入混沌演练范围,每个月挑一个业务低峰期随机重启某个执行节点,还专门做过一次“调度器主节点宕机五分钟后自动恢复”的演练。那五分钟里确实暴露出了告警不够灵敏、部分任务触发失败后重试间隔不够合理的问题,但都是当场发现、当场修正。比起在凌晨四点被真实故障叫醒,这种主动制造的故障成本低太多。
6. 多语言任务接入的工程现实:你不需要统一语言,需要统一协议
团队大了之后,任务场景一定是多语言的,这不是技术洁癖能改变的。我们内部的实际情况是:Java 团队维护核心交易链路,Go 团队做网关和高性能数据处理,Python 团队做算法模型和数据分析脚本,前端团队偶尔也要触发一些预热任务。曾经有一段时间,每个团队各自定义自己的任务描述、参数格式、重试规则,调度平台上能看到任务在跑,但不同任务之间的日志格式完全对不上,排查一次跨团队任务就像做一次翻译工作。后来我们达成了一个共识:团队可以继续用自己熟悉的语言,但接入调度系统的“那层皮”必须统一。
这个统一的皮具体包括四部分。第一是任务的元数据格式,定义一个全局统一的数据结构,包括任务 ID、任务名称、任务类型、触发时间、执行超时时间、重试次数上限、回调地址或命令。第二是执行结果的回传格式,统一用结构化结果描述成功、失败、部分成功,以及失败时的错误码、可读信息和可用于重试的上下文。第三是日志规范,每个执行节点打印日志时必须带上任务 ID 和分片信息,这样即使用不同语言打日志,也能在日志中心里按同一维度聚合检索。第四是配置管理,任务里需要的所有动态配置不要硬编码在业务代码里,而是通过统一配置中心下发,避免出现“Python 任务读的配置和 Go 任务读的配置不是同一份”的窘境。
这部分现在看起来像是在讲流程,实际上背后有一个非常现实的语法问题:不同语言处理时间、字符串、序列化这些问题时,默认行为差异很大。举一个最基本的例子,Java 里 System.currentTimeMillis() 返回的是自 Unix 纪元以来的毫秒数,Python 的 time.time() 返回的却是浮点数秒,Go 的 time.Now().UnixNano() 又是另一套精度。调度系统里,触发时间的记录如果我们不做统一标准化,就会出现 Python 执行节点把时间错当成秒发送给 Java 调度器,导致任务提前几百倍触发——这种问题在分布式调度中不是段子,而是真实发生过。我们在协议层强制约定:所有跨语言传递的时间字段,一律使用带时区信息的标准化字符串,比如 ISO 8601 格式;所有内部计算用的时间戳统一为毫秒;所有 cron 表达式统一附带时区字段,不允许使用执行节点本机默认时区。这几条规矩写起来简单,执行时却避免了无数诡异的“任务在八点整没跑,却在十六点跑了两遍”之类的线上事故。
另外一个容易被忽略的语法问题是字符串匹配和转义规则。任务系统经常需要接收用户传入的关键词或路径,如果这些内容要在多个语言之间传递,而某个环节使用了类似 Shell 拼接的方式去执行外部命令,很容易被特殊字符破坏。我们曾经接过一个 Python 脚本,它内部调用 Shell 命令处理文件名,文件名是外部传入的,包含空格和特殊字符时脚本直接爆炸。最后修法也很工程化:不允许业务任务通过 Shell 拼接外部命令,所有需要执行外部命令的场景,统一通过参数列表方式调用,不经过 shell 解释。在 Java、Go、Python 里这都有对应的 API,只是很多团队默认不去用。这个教训扩展到更大范围就是:多语言网关里的每一条边界,都应该按“最小可执行单元”来约束,而不是给业务同学一个自由度过大的回调通道。
多语言环境下的另一个实际痛点是依赖版本和运行环境不一致。同样的任务脚本,在 Python 3.6 的开发机上是好的,上了 Python 3.9 的执行节点就报语法警告;Java 任务在本地用 JDK 11 编译,执行节点如果跑在 JDK 8 上,很可能类版本不支持。调度平台的执行节点如果要直接跑业务代码,就必须连这个也管理起来。我们最后做的不是把所有执行环境强制统一成一种,而是让每个任务声明自己的运行环境,调度平台根据环境标签把任务派发给对应节点。任务执行环境本身通过容器固化,镜像打好标签,节点注册时上报自己支持的环境标签,两者匹配才允许派发。多语言没有变成阵营隔阂,反而促成了我们建立一套更清晰的环境治理规范。
7. 落地过程中最值得警惕的三个误区:重平台、轻任务、高估自动化
写到这里,其实整套分布式高可用调度体系的技术框架已经比较完整了:中心化调度器负责触发,多执行节点负责消费,协议统一托底多语言接入,分片与幂等保证数据正确性,监控与演练保障高可用效果。但真正从“能跑”到“跑得稳”,中间还有一段纯粹靠项目经验才能踩出来的距离。我想重点说说三个误区。
第一个误区是“重平台,轻任务”。有些团队花了很大力气部署调度平台,为调度器本身做了高可用、给执行节点做了容器化,但平台上接的第一个业务任务,还是那种“跑一次就结束、失败了也不重试、日志随便打”的旧逻辑迁移。结果调度平台本身没有任何问题,却因为任务代码质量太差,导致整条链路看起来水土不服。调度平台做的是把任务生命周期管起来,但它不能把一个本来就没想清楚怎么幂等、怎么降级、怎么记录结果的任务变好。平台只是骨架,任务本身的代码质量才是血肉。所以在设计接入规范时,应该反过来先要求每个任务准备一份极简说明:任务边界是什么、哪些操作不可重复、失败时如何补偿、预期耗时多久、是否需要串行。没有这份说明的任务,不应被允许注册进调度平台。
第二个误区是“高估自动化,低估人工介入”。任务平台确实能自动重试、自动告警、自动摘除异常节点,但在比较复杂的线上故障里,自动化只能帮你止血,要恢复业务还是要靠人去判断。自动重试如果无限重试,下游系统已经被打挂了还在继续打,等于火上浇油。所以我们的默认策略是:自动重试最多三次,重试间隔时间递增;超过三次后任务置为“失败待确认”状态,推送到值班群,由值班同学决定是补数据还是调整参数后手动重跑。这个看似不智能的设计,反而让系统的稳定性更可控。自动化的目标应该是把人从重复操作中解放出来,而不是把人的判断力也替代掉。平台越自动化,越要配套一个明确的人工介入流程,否则故障时最可怕的不是系统不干活,而是系统看起来很忙但没人知道它在忙什么。
第三个误区是“只关注任务运行时刻,不关注任务未运行时刻”。调度平台做了主备、做了故障转移,于是大家觉得只要调度器活着任务就会跑,但调度器活着只代表有触发能力,不代表所有该触发的任务都按预期触发了。很多平台有“漏触发”问题,它们不会主动暴露,只会让你发现某个报表今天没生成、某条同步链路断了几个小时。针对这一点,我们专门做了一个“心跳探活任务”,每五分钟运行一次,它不做任何业务逻辑,只检查最近一批核心任务是否有符合预期的执行记录;如果某项任务在预期窗口内没有产生执行记录,立刻发告警。这个探活任务本身也是一个任务,跑在同一个调度平台上,等于用系统自身监测系统自身。这样的设计很土,但非常有效,因为我们真正想要的不是调度平台有多先进,而是业务侧需要的数据、文件、同步动作都能准时出现。
我个人做完这套体系之后最大的体会是:任务调度这件事,单机时代拼的是“会写脚本”,分布式时代拼的其实是“怎么设计契约”。人和机器之间要约定任务怎么描述、结果怎么回传、重试怎么进行;服务和服务之间要约定时间格式、日志规范、错误码;平台和业务之间要约定生命周期、权限边界、人工介入方式。每一层约定都是边界,边界清楚之后,各种语言、各种团队才能在同一个调度体系下协同而不互相踩脚。这也是后来我和不同团队协作时,最看重的一项能力——不是把某个数据结构设计得特别优雅,而是能让所有参与者都清楚地知道,自己的那部分代码应该在哪个边界内工作。
