执行器模块标识,比如>class SimpleTimeWheel: def __init__(self, tick_ms=100, slot_count=60): self.tick_ms = tick_ms self.slot_count = slot_count self.slots = [[] for _ in range(slot_count)] self.current_slot = 0 def add(self, delay_ms, job): ticks = delay_ms // self.tick_ms idx = (self.current_slot + ticks) % self.slot_count rounds = ticks // self.slot_count self.slots[idx].append((job, rounds)) def advance(self): self.current_slot = (self.current_slot + 1) % self.slot_count for job, rounds in self.slots[self.current_slot]: if rounds > 0: self.slots[self.current_slot].append((job, rounds - 1)) else: execute(job)这段代码只是骨架,实际还要考虑任务取消、持久化恢复等等。但核心思路就是这样:用少量线程、零阻塞的方式实现高频率触发。
2.2 调度器与执行器的分离设计 接下来是最关键的架构决策:调度器和执行器拆成两个独立进程。调度器是“大脑”,负责判断“现在哪个任务该跑”;执行器是“双手”,负责“这个任务在哪个节点上跑”。如果不拆,一旦任务逻辑引发OOM或者线程池崩溃,调度的源头也挂了,整个系统就瘫痪了。
两个进程之间的通信,我们用了HTTP加本地队列。执行器启动时向调度中心注册,说“我支持哪些executor类型,我现在有多少个线程可用”;调度中心收到注册信息后,把节点记录在内存中。每个执行器还会定期发送心跳,上报自己的负载。
当时间轮到点,调度中心拿到任务,根据任务的executor类型筛选出能执行该任务的节点,接着走负载均衡策略选中一个节点。真正发送任务的方式,我们考虑了“执行器拉取”和“调度中心推送”两种模型。推送模型对调度中心要求高,需要知道每个节点的实时状态;拉取模型更省心,但任务从到点到真正执行之间会有微小的轮询间隔。我们最终采用了“调度中心推送 + 执行器缓冲队列”:调度中心通过HTTP把任务发给执行器的本地队列,执行器立刻返回“已接收”,再由本地worker线程去消费。这样调度中心不会因为执行器执行慢而阻塞。
2.3 一致性协调与任务分片 调度中心不能只部署一台,否则依然是单点。于是调度中心做多副本。多副本最麻烦的是选主:同一时刻只能有一个副本在分发任务,否则任务会重复分配。我们的选主方式很朴素:把调度中心表里的一行“调度锁记录”用数据库update影响行数来判断,update成功就说明抢到了锁,同时记录持有锁的实例ID和过期时间。实例启动后都会尝试抢锁,抢到锁的实例开始调度;其他实例进入热备状态,每分钟检查锁是否过期。如果主实例宕机超过锁过期时间,备实例就能顶上。
这里最大的坑是:锁过期时间不能设太短,否则主实例只是遇到一次长GC,锁就被抢走,两个调度器同时工作,任务就会重复下发。我们当时设了30秒,并且加了一个单调递增的“调度轮次”字段,备实例接管后能感知到轮次变化,从而拒绝处理旧的调度产物。
任务分片这块,我们做得很轻量。大任务比如“全量用户数据重算”,拆成10个分片,每个分片由不同执行器跑同一份代码、传入不同分片序号。实现方式:请求里带shard_index和shard_total,执行器拿到后自行从数据库捞对应分片的数据。这样执行器天然支持水平扩展,节点挂掉也只是部分分片失败。
3. 负载均衡在任务分发中的真实落地 3.1 任务分发需要什么样的负载均衡 很多人想到负载均衡,第一反应是网关的轮询、最小连接数之类。但任务分发和HTTP服务负载均衡有本质区别。HTTP请求通常短平快,轮询就够了;定时任务则可能执行几十秒甚至几十分钟,一个轮询策略可以完美地把“饿汉”节点喂饱,让“懒汉”节点闲着。我们需要的是按实时负载去选节点:如果某个节点正在跑一批耗时的数据重算任务,就不应该再给它派新任务,哪怕它刚发来的心跳看起来正常。
所以我们给每个节点维护了三个关键指标:空闲worker线程数(缓冲区还能接收多少个任务)、CPU近1分钟负载、任务队列积压量。心跳上报的时候,这些指标会一起带过来。调度中心在选节点时,优先选空闲worker多、CPU低、队列空的节点。极端情况下,如果所有节点都忙,调度中心会把任务缓存一段时间,而不是硬塞给某个节点。
3.2 一致性哈希取节点与权重分配策略 负载均衡策略我们实现了一致性哈希 + 动态权重。一致性哈希的好处是:节点数量发生变化时,原本路由到A节点的任务不会大面积迁移到B节点。虽然任务框架里的“路由”不像缓存那样是强状态,但一致性哈希能让同一类任务尽量落在同一批节点上,对缓存预热、本地连接池复用有一点帮助。
具体做法:先给每个节点配置基准权重,比如4核机器设置100,8核机器设置200。每次心跳后,调度中心根据实时负载调整权重的修正系数。节点CPU超过80%就把权重乘上0.7,空闲线程数为0直接不给分发。然后把“节点ID + 权重区间段”映射到哈希环上的大量虚拟节点。任务分发时,用任务的job_id作为哈希键,顺时针找到第一个虚拟节点对应的实体节点;如果该节点不可用,再沿环走一步。
简化的选节点逻辑如下:
def select_node(task, nodes): circle = build_hash_ring(nodes) h = hash(task.job_id) for node in circle.clockwise_nodes(h): if is_node_available(node): return node return None注意:权重越高,哈希环上的虚拟节点就越多,被选中的概率越大,这样从概率上实现了按权重的负载分配,而实时负载又通过修正系数调节了权重。算是一个兼顾稳定和灵敏的方案。生产环境中我们还加了同机房优先的策略,避免跨机房网络延迟。
3.3 节点故障转移与心跳机制 心跳是分布式系统最典型的“你发我收”协议。执行器每5秒上报一次心跳,携带节点ID、当前运行的job列表、负载指标。调度中心如果连续3个心跳周期没收到某个节点的心跳,就标记该节点为“可疑”;再等一个周期仍没有,标记为“离线”,从可用节点列表里摘除。
摘除节点时,正在该节点上运行的任务怎么办?这是最容易出问题的场景。我们整体策略是“重新调度 + 业务幂等”。任务进入RUNNING状态之后,调度中心会记录“任务当前运行在哪个节点上”。当节点离线,这些任务的状态会被统一改为“待重试”。随后调度中心重新把它们放入时间轮,重新走负载均衡选一个新节点执行。
危险在于:旧节点可能并没有真的挂,只是网络抖动,此时旧任务还在跑,新节点又重新执行,就会产生双份执行。所以我们要求所有业务任务接口必须支持幂等,用业务唯一键去重,比如“跑批日期+业务类型”。这是框架无法替你兜底的,必须写进任务开发规范里。
另外一个小细节:任务如果已经执行了大部分,重新执行确实浪费算力。我们也支持“检查点续跑”,但只对少数核心任务做了,一般任务就简单重新跑。这个根据团队精力来决定,不要一上手就做复杂的断点续传。
4. OpenAPI异步调用的设计思路 4.1 为什么异步调用而不是同步?——请求超时与长耗时 设计OpenAPI是整套框架里比较出彩的部分。一开始有合作方来对接,他们说:“我们有个系统每天下午6点需要触发一次数据同步,你们能不能提供一个HTTP接口?”这个问题看似简单,但同步接口有一个天然悖论:如果接口是同步的,它必须等数据同步跑完才返回,而数据同步可能要跑10分钟,网关默认超时只有30秒,大量调用会失败。
所以必须做成异步调用。我理解的异步不是简单地“立即返回成功,然后让任务自生自灭”,而是要形成一整套生命周期管理机制:提交时返回任务ID,调用方可以拿着这个ID随时查询进度和结果,跑完后还能收到通知。这样调用方不需要一直阻塞等待,体验也更好。
与此同时,我们保留了同步接口作为“快任务”入口,但明确规定:预估执行时间小于3秒的任务才允许走同步接口,否则返回400并要求改成异步接口。这种“快慢分离”让调用方和我们的边界都清晰。
4.2 任务提交与回调:提交接口、任务ID、查询接口 具体接口设计是这样的:
POST /openapi/v1/tasks:提交任务。请求体是执行器标识、参数、回调地址。返回体是taskId。GET /openapi/v1/tasks/{taskId}:查询任务状态和执行结果。POST /openapi/v1/tasks/{taskId}/cancel:取消尚未开始的任务。任务状态机可以用一张表表达:
状态 含义 下一步可能状态 PENDING 已提交,等待调度 RUNNING / CANCELLED RUNNING 正在执行 SUCCESS / FAILED / CANCELLED SUCCESS 执行成功 终态 FAILED 执行失败 PENDING(如果还有重试次数) CANCELLED 已取消 终态
调度中心收到提交请求后,并不会马上把任务发给执行器,而是先写数据库,再放入内部待调度队列,然后立刻返回taskId。后台调度线程扫描到该任务后,走正常的负载均衡和分发逻辑。这样提交接口的响应时间基本小于50ms,调用方体验非常好。
回调通知也值得一提。有的任务跑几分钟,调用方不可能一直轮询,所以我们支持webhook。调用方提交任务时可以传callback_url,任务到达终态后,调度中心向该地址发送一个POST请求,内容包含taskId、状态、结果详情。发送失败会退避重试,最多3次。
这里要特别小心一个问题:回调地址如果是公网的,容易造成服务端请求伪造(SSRF)。所以我们要限制回调地址必须是指定的域名白名单,不能随便填。我们因为这个专门被安全团队提醒过,后来老老实实加了白名单。
4.3 API签名与安全设计 OpenAPI一旦对外,就不能只靠内网IP信任。我们采用AK/SK + HMAC签名:每个调用方在平台上申请一对AccessKey和SecretKey。调用时在请求头里带上AccessKey、时间戳、随机数,并用SecretKey对请求体和时间戳做HMAC签名。服务端验签,防止请求被篡改和重放。
限流也是必须的。每个AK有一个令牌桶,默认每秒20个请求。超过限流返回429,调用方自己按退避策略重试。我们在测试阶段发现,有合作方的脚本写崩了,无限循环提交任务,直接把任务表塞满。加了限流后,单个AK每秒最多2个提交请求,这种事故就再没发生过。
还有几个安全细节:如果API参数里包含回调地址,一定要校验URL的正则格式和域名白名单;taskId不要用简单自增数字,用UUID或雪花算法,避免调用方通过任务ID探测他人任务数据。
5. 手写过程中的关键坑与性能实测 5.1 时钟跳跃与时间轮实现 时间轮在线上跑了一周之后,我们遇到一个诡异问题:某天凌晨所有任务集中延迟执行,平时延迟不到200ms,那天最长的延迟了整整1分钟。一开始怀疑数据库慢查询,排查后发现问题出在时钟跳变上。云服务器有NTP自动校准,某个时刻系统时间往前跳了一秒,但我们的时间轮是用“系统当前时间”来计算递进量的,跳变后指针一下子多走了几十个槽位,本该后续触发的任务全部挤在同一个槽位爆发。
解决方案:时间轮指针递进不再依赖墙钟时间差,而是用单调时钟(类似System.nanoTime的语义)来保证推进间隔均匀;真正执行任务时,再用任务的原定触发时间和数据库里的绝对时间做最终校验。简单说:推进用单调时钟,判定触发用绝对时间。这样即使系统时钟回拨或跳变,时间轮也不会“抖”。
5.2 任务重复执行与幂等性保障 重复执行是分布式定时任务框架绕不开的坎。除了前面说的节点故障转移导致的双跑,还有调度中心主从切换、HTTP重试,任何一环都可能触发同一任务执行两次。我们的兜底方案分三层:
第一层是调度中心内部的任务状态机锁。任务从PENDING变成RUNNING时,用数据库update ... where status='PENDING',如果影响行数是0,说明别人已经抢跑,当前调度器直接放弃。第二层是执行器本地去重。同一个job_id在短时间窗口(比如5分钟)内,如果已经执行过且成功,本地记录就忽略重复请求。第三层是业务幂等,要求任务代码内部做事务去重,比如数据同步任务以“日期+表名”为唯一键,重复执行不会产生重复数据。
很多人以为框架层做到第一层就够了,其实真正的生产环境,三层缺一不可。我们有一次模拟测试,故意让调度中心主从切换3次,任务重试了5次,数据最终只生成了一份,就是因为三层幂等同时在起作用。
5.3 高并发下的存储选型:MySQL还是本地内存表 任务元数据和执行日志必须持久化,我们选了关系型数据库。但一开始有个错误做法:每个任务执行完都立刻更新数据库状态,导致数据库写压力巨大。后来改成“异步落库”:任务执行日志先写本地内存队列,由批量线程每5秒统一刷一次。查询任务状态时,如果内存队列里有最新状态就返回;没有再去查数据库。
调度中心在拿到库里“待调度任务”后,为了避免全表扫描,我们会记录一个last_id,只扫描比它大的新任务,以及一个next_trigger_time索引来查未来5分钟内要跑的任务。实测下来,单张任务表百万级数据,调度扫描延迟仍然在毫秒级。只有当任务表膨胀到千万级时,才需要考虑分区或归档。
5.4 实测数据与调优记录 我们做了两轮压测。第一轮10个执行节点,30个worker线程,压了5万个延时任务,发现调度中心TPS一开始只有每秒300。排查发现瓶颈在网络连接池,节点心跳用的HTTP连接没有复用,每次都在创建新连接。改用长连接连接池后,TPS直接翻倍,稳定在800左右。
第二轮加入OpenAPI异步提交模拟:同时每秒有200个外部请求提交,每个任务执行耗时1秒。最终结果:提交接口P95响应35ms,任务成功率达到99.9%。资源开销方面,调度中心大概只用了2核CPU、1GB内存,整体算很轻量。
调优过程中最值得说的是线程池参数:执行器消费任务的线程数不要直接取CPU核数,因为任务多半是IO密集型,建议线程数 = 2 × CPU核数,并且用有界队列。如果队列太大或线程数太多,反而会因为频繁上下文切换导致吞吐下降。
6. 扩展与思考:如何把框架演进成平台 6.1 动态编排与任务依赖 MVP阶段我们的任务都是孤立的,但业务很快就提出了新需求:一个“凌晨跑任务A,A成功后再跑任务B,A失败就通知人”的场景。这其实就是DAG编排。我们目前做了一版很基础的依赖:任务配置里加一个depends_on字段,调度器在决定执行B之前,先查依赖任务A是否已经到达SUCCESS状态。如果A失败,B直接标记为SKIPPED并发送告警。
为了性能,我们不用递归去查,而是把依赖关系拉平,用一张依赖表存父子关系,每次只查一层。这个方案应付50个任务以内的DAG足够了。真要到复杂的跨任务条件分支,我建议还是引入专门的工作流框架,不要硬在自己定时任务框架里做。
6.2 监控告警与失败重试 自研框架最容易被吐槽的就是没有监控。我们第一版全靠查日志,每次任务失败,都是业务方先发现再跑来问。后来补了一个非常轻量的监控告警模块:每个执行器内置一个指标收集器,每秒记录任务执行总数、失败数、平均耗时,通过心跳上报给调度中心;调度中心落库后,用一组内置规则判断是否需要发告警。比如失败率超过10%、任务连续失败2次、节点离线等等,都会触发办公IM机器人消息。
重试策略我们做了指数退避:第一次失败等10秒、第二次等30秒、第三次等1分钟,最多重试3次。这里有个经验:不要对所有任务默认开启无限重试。有些业务任务重试反而会造成数据叠加上报,默认调成2次就好。
6.3 给想动手的你几点建议 如果你也想自己写一套分布式定时任务框架,我有几条很实际的建议。
第一,先把最小闭环跑起来,不要一上来就设计几十个微服务。我们最终代码量并没有想象中那么大,但真正做到能稳定跑任务是在第一次压测之后,前面一直在踩坑。第二,幂等设计要前置到每个任务里,框架的保证很有限。第三,别忽略运营成本,日志规范、监控告警、API文档这“三大件”必须在第一版就做好,否则后面补很痛苦。第四,如果业务规模不大,可以用“调度中心+数据库锁”这种简单方案,没必要上沉重的一致性协议,复杂度和团队维护能力不匹配反而会出事。
最后说一点感受。写完这套框架,我最大的体会是:分布式定时任务的难点不在于“定时”,而在于“分布式”三个字带来的不确定性——节点会挂、网络会抖、时间会跳,系统要能从各种意外中恢复,还要保证任务不丢、不重。这个过程没有银弹,靠的是对每一处可能出错的细节都较真。希望这篇实战记录能帮你在动手前少走几段弯路。
RELATED READING 延伸阅读 更多一线实战笔记与深度复盘,助您持续精进