新闻详情

新闻详情

首页 / 资讯中心 / 详情

自研分布式任务调度系统实践:时间轮、分片与一致性设计

发布时间:2026/9/26 20:48:53来源:尧图网络
自研分布式任务调度系统实践:时间轮、分片与一致性设计
说实话第一次听到“ax调度”这个词的人十有八九会愣一下——它既不是某个大厂开源项目的名字也不是某本技术书里的标准术语。但在我负责的后端系统里ax从一个临时救火的脚本慢慢长成了支撑几十万日级任务的调度中枢。我并不是说每个团队都得自研调度器但把一套调度系统从0到1完整走一遍你会对分布式系统里的时间、状态、一致性这些概念有完全不一样的理解。这篇文章不打算做成一份“产品说明书”我想把当初为什么做ax、它的任务模型怎么设计、时间轮和分片是怎么落地的、以及上线半年踩过的那些坑全都摊开来说。如果你正打算选型调度框架或者自己动手写一个轻量调度器这些内容应该能帮你省下不少弯路。1. 凌晨四点的那场任务堆积让我动了自研调度的念头1.1 事故现场还原那是某个周末的凌晨我正睡得迷迷糊糊手机开始疯狂振动。打开一看告警群已经被刷屏了结算系统的对账任务延迟超过2小时下游的报表、推送、清结算全部跟着阻塞。当时我们用的是一套很“经典”的架构MySQL存任务表一个后台进程每隔10秒扫一次表把到期的任务捞出来丢进线程池执行。这套方案在前半年一切正常可业务一涨问题就藏不住了。我们那会儿有大概8万多个周期任务大部分是分钟级和小时级调度每次全表扫描要把几万行数据读出来比对时间。高峰时段锁竞争严重扫描一次从几十毫秒劣化到几秒钟。更要命的是一旦某个任务执行超时它就会一直占着线程池里的线程后面排队的任务全部饿死。那晚的对账任务实际上在凌晨1点就该跑了结果活活卡到了3点半。事后复盘的时候团队内部有过争论要不要直接换用现成的分布式调度框架我们真的评估过。但结论是当时几个主流的方案要么太重自带一套Web控制台和权限体系运维成本高要么调度模型太死板只能定周期不支持复杂的依赖关系要么底层依赖太多要引入新的存储组件DBA那边不好批。团队商量了一夜最后决定自己写一个足够轻、足够贴合业务模型、还能逐步演进成分布式的调度内核。项目代号就叫ax原意是“Adaptive Execution”后来大家叫顺口了就变成了“ax调度”。1.2 为什么现成的调度方案不够用我不是说现成框架不好而是在“调度”这个领域“够用”和“好用”之间往往差了一个业务模型的距离。下面这个表可能比较直观方案优势在我们场景里的问题Quartz轻量、成熟、单机可靠集群模式依赖数据库锁横向扩展有限没有任务编排能力XXL-JOB功能全、界面完善、社区活跃有中心调度节点万一挂了影响面大API偏“平台化”侵入性强AirflowDAG能力极强生态丰富偏重离线批处理调度延迟粒度是分钟级部署重量大Delta/内部平台云厂商托管运维简单黑盒没法定制触发策略数据主权和合规方面有顾虑我们当时的真实诉求其实很朴素支持秒级和分钟级任务能表达任务之间的依赖关系能够在节点故障时自动转移执行权并且这个调度器本身不要占用太多CPU和内存。翻了一圈没有既轻又能平滑演进到集群的方案那就只能自研。而这正好也是我认为值得记录的原因如果你正在遇到和我类似的问题至少可以参考一下我当时的取舍。1.3 ax要解决的三个核心问题动工之前我把需求收敛成了三个非解决不可的问题后面所有设计都围绕这三个问题展开触发效率不是每个任务都需要精确到秒但分钟级触发不能每10秒扫一次全表。我需要一个数据结构让CPU只处理“即将到期”的任务而不是把所有任务都翻一遍。执行隔离性一个任务OOM或卡死不能拖垮同一节点上的其他任务。调度器要负责分派但不应该在执行器里搞一锅炖。错过的任务怎么办调度器宕机了或者任务触发失败应该有一套明确的补偿机制而不是让数据就这么断在那里。很多调度系统只解决了“怎么准时触发”却没有仔细想过“触发之后乱了怎么收拾”。ax后面最花时间的部分反而不是调度本身而是对“混乱状态”的处理。2. ax的任务模型与触发链路从定义到执行的完整设计2.1 任务定义五类参数和一个回调协议ax里的任务我用一个JSON结构来表示核心字段如下{ task_id: settle_daily_001, name: 每日对账任务, type: cron, # cron | interval | event | manual schedule: { cron_expr: 0 1 * * *, # cron表达式每天凌晨1点 }, priority: 5, # 0-9越大越优先 timeout_ms: 300000, # 单次执行超时 retry_policy: { max_retry: 3, backoff_ms: 5000 # 重试退避 }, dependencies: [base_data_ready], # 依赖的任务ID列表 worker_tag: settle_group, # 指定执行节点分组 callback: { type: http, url: http://internal-worker/settle/run, headers: {x-api-key: xxx} } }有几个设计点我觉得值得展开说worker_tag是后加的。最初所有任务都可以落到任意节点后来发现有些任务依赖特定机器上的本地文件或GPU资源所以引入了执行节点分组。调度器只负责把任务派发给满足tag的节点。回调协议选了HTTP而不是RPC框架。好处是所有语言都能接入业务方只要暴露一个POST接口就能成为执行方坏处是每次调用多一次HTTP开销。实测下来在内网P99延迟约3ms完全可接受。timeout_ms是一等公民。我们见过太多因为下游锁等待导致执行线程越积越多的例子。ax会在执行方上报开始执行后启动一个计时器超时就触发强制隔离。2.2 触发器分类定时、周期、依赖与手动ax支持四种触发方式其中依赖触发是我最满意的部分。class TriggerType(Enum): CRON cron INTERVAL interval EVENT event DEPENDENCE dependenceCRON给到标准cron表达式即可秒级可选。INTERVAL适合“每30分钟同步一次”这种固定周期避免用户非要绕一圈去写cron表达式。EVENT业务方直接调用ax.trigger(task_id, payload)适用于实时性要求高、由上游状态变化驱动的场景。DEPENDENCE任务声明“我依赖哪个任务先完成”ax内部会维护一张DAG前置任务进入终态后后置任务才被唤醒。依赖触发这里有个容易翻车的地方如果依赖的是“任务执行结束”而不是“任务执行成功”下游会在上游失败时接着跑得到一堆错数据。所以我在DAG边上加了一个on_failure策略默认是BLOCK_DOWNSTREAM也就是上游失败时直接阻断下游宁可任务不跑也不要跑错了。2.3 执行链路的状态机ax的任务状态一共六个PENDING - READY - RUNNING - SUCCESS | FAILED | CANCELLED。PENDING任务已注册还没到该触发的时间。READY触发条件满足任务进入待执行队列等待调度器分派。RUNNING已经派发给了某个worker并且worker上报了开始执行。SUCCESS / FAILED / CANCELLED终态。这六个状态看起来简单但真正麻烦的是execute阶段的“中间态”。比如调度器把任务发给worker了worker还没来得及上报调度器自己重启了那么这个任务算什么算PENDING还是RUNNINGax的做法是引入一个DISPATCHING的隐式状态不体现在任务状态字段里而是靠租约来管理。任务在派发之前会先写入一条租约记录worker接受任务后拿这个租约去“认领”租约到期后如果worker没有续约调度器就认为派发失败重新对任务做补偿。这段逻辑会在后面的“租约续期”章节详细讲这里先提一句状态机一定要把“派发中”和“执行中”分开否则重启之后你根本分不清某个任务到底跑没跑。3. 调度核心时间轮、优先级队列与任务分片3.1 时间轮为什么比全表扫描高效老方案的问题在于它“看一遍才知道谁该跑”。而ax用的是时间轮的思路把任务挂在未来的某个时间槽上系统每走一格只需要处理那一格上面的任务。时间轮我直接用了一个环形数组class TimingWheel: def __init__(self, tick_ms1000, wheel_size3600): self.tick_ms tick_ms self.wheel_size wheel_size self.slots [list() for _ in range(wheel_size)] self.current_slot 0 def add(self, task, delay_ms): ticks delay_ms // self.tick_ms slot (self.current_slot ticks) % self.wheel_size self.slots[slot].append((task, delay_ms))一个秒级tick、3600个格子能覆盖延时1小时以内的准实时触发。超过1小时的延时任务扔进一个溢出轮round wheel每隔一段时间降级进主轮。这个结构非常省内存而且每次tick只需要处理当前槽里的链表CPU占用极低。有人会问那分钟级、小时级的cron任务怎么放难道也精确到秒确实不用。ax的优化是cron计算下一次触发时间后算出距今的毫秒差然后丢进时间轮。也就是说时间轮不只服务秒级任务它其实是一个统一的“到期时间索引”。所有任务在内存里只占一个节点到期后进入调度流程。8万个任务全量挂载内存占用不超过20MB。3.2 优先级队列与公平性任务到期之后不能直接执行。如果所有到期任务直接乱序执行高峰期高优任务可能被低优任务挤到后面。ax在时间轮后面接了一个优先级队列按priority从高到低出队同优先级的用FIFO保序。但这个策略有个副作用高优任务持续到来时低优任务会被饿死。我们的缓解方案是引入一个简单的“饥饿计数器”。每个任务在被跳过的轮次里计数每跳过一轮计数1计数超过阈值时就把它强行提到最高优先级执行。实际跑下来日常任务几乎没有触发过这个逻辑只有在大促压测时才会用到但必须有。然后是执行隔离。我支持一个节点上同时跑多个worker进程每个worker负责拉取和执行任务。之所以不搞线程池共享就是要解决“一个任务卡住拖垮全节点”的问题。worker之间完全隔离一个worker里的线程全是同一个任务内部的并行逻辑不跨任务共享线程。3.3 任务分片不靠锁靠分片老方案另一个痛点是数据库锁竞争。多个调度节点同时扫表必须靠SELECT ... FOR UPDATE抢任务扫得越多锁越严重。ax的做法完全不同任务不在多个节点之间抢而是提前分片。我们给每个节点分配一个分片ID例如有3个节点任务ID取哈希后按照task_id_hash % 3归属到固定节点。每个节点只处理自己分片内的到期任务天然无锁。def resolve_owner(task_id: str, total_shards: int) - int: return crc32(task_id.encode()) % total_shards这个设计的好处是节点数稳定时任务归属关系确定不需要任何协调。坏处是节点扩缩容时任务需要做一次再平衡。ax的做法是引入一个极小的协调器节点负责管理“分片配置”的版本号。每次扩容协调器把total_shards从3改成5并发布一个新的配置版本各个节点收到配置后按新分片规则重新加载任务整个过程对用户透明。这个分片策略是ax能跨过“伪分布式”门槛的关键。它不是靠锁去竞争而是靠确定性路由来做拆分。后面你会看到这套思路还顺带解决了很多一致性难题。4. 分布式扩展与一致性问题没有中心MySQL的调度是怎么做的4.1 为什么不用中心数据库做锁很多任务调度系统在分布式化的时候第一反应是引入数据库行锁作为互斥手段每个节点要执行某个任务前先抢一把锁抢到了就跑抢不到就跳过。这样做最大的问题是锁的粒度太粗如果锁是“任务级别”那么一个任务的并发度就被钉死了如果锁是“分片级别”那么分片迁移时容易产生短暂的“双主”窗口。我一开始也考虑过用MySQL做锁后来算了一笔账我们高峰期每秒到期任务超过2000个每个任务抢锁、持锁、释放要3次数据库往返那么每秒至少有6000次DB读写。这个量对一个小集群来说不算大但问题是锁的“等待时间”不可控一旦网络抖动锁等待栈会拉得很长最后导致任务触发延迟超过秒级。ax最后没有用数据库锁而是把“归属关系”当成锁的替代品。任务归属哪个分片就由哪个节点负责天然只有一个节点会处理它。节点之间的协调只需要维护一个极轻量的“分片配置”。4.2 基于Redis的租约续期机制那在执行侧怎么确保一个任务不会同时被两个worker执行我用的是租约机制不是永久锁。# 调度器派发任务时在Redis中写入一条租约 SET ax:lease:{task_id} {worker_id} EX 30 NX # worker续约的心跳 EXPIRE ax:lease:{task_id} 30逻辑是这样的调度器在派发前先尝试在Redis里创建一个租约NX保证同一时刻只有一个调度器能成功创建。拿到租约后任务就归这个调度器节点管理。worker启动执行后会每隔10秒续租一次。如果调度器节点宕机租约最多30秒后过期其他节点就能接管该任务。这套机制优点是实现简单缺点是没有真正意义上的“强一致”。万一调度器在租约过期前已经派发了任务但worker还没开始执行那么租约过期后另一个节点可能会再次派发同一个任务导致重复执行。所以ax在业务侧强制要求所有任务必须支持幂等。调度系统再怎么做也只能把“重复执行”的概率降到很低不能降为零。这个我们是写进接入文档的第一条原则的。4.3 重复触发的边界网络分区下的取舍网络分区是分布式系统里最尴尬的情况。调度器S1和S2之间网络断了但它们都能连上Redis。S1创建了任务T的租约S2因为网络问题读不到租约状态以为T是无主任务也尝试创建租约。这时两个节点同时认为自己在管理T实际上T被派发了两次。这个case在测试里真实遇到过。我们最后的选择是倾向于“可能重复”但绝不“可能丢失”。因为对业务而言丢任务比重复任务严重得多——重复了可以靠幂等滤掉丢了就得靠人肉补数据。ax的策略是如果调度器无法在超时窗口内确认租约状态它禁止把任务标记为“已派发”而是直接抛回队列并告警。宁可多跑一次不可不跑。这里想分享一个比较深的体会调度系统的一致性边界不是靠技术消灭的而是靠取舍定义的。你在设计之初就要想清楚“重复”和“丢失”哪个更不能接受然后围绕这个选择建设对外的承诺。5. 上线半年遇到的五个典型故障踩坑实录5.1 系统时钟回拨导致的cron重放这是我第一个想骂人的坑。某个凌晨一个节点的NTP服务异常系统时钟突然回拨了2分钟。时间轮的时间判断依赖于系统时钟结果cron任务在“到达触发时间”这个判断上连续触发了3次。下游写入了3份重复的对账记录。排查后发现cron表达式计算nextTime之后执行线程拿System.currentTimeMillis()判断是否到期时钟回拨后currentTimeMillis变小了于是任务再次进入“到期”状态。修复方式是两件事时间轮不再直接用墙钟改用单调时钟System.nanoTime它不受系统时间跳变影响只记录流逝时长。cron任务用“上次执行时间”去重。每次任务触发后记录last_trigger_at在下一次触发前对比当前时间和lastTrigger只有确实跨过了下一次触发时间才执行。5.2 执行器长时间GC任务被误判超时有段时间我们经常收到“任务超时被kill”的告警但业务方反复说执行时间其实很短。后来一查问题出在一个执行器上跑了一个内存吃紧的JVM任务Full GC时间过长导致worker的心跳线程没有及时上报状态。ax侧看到租约一直没有续期就把任务判成失败并触发重试结果双重执行。这个问题的本质是心跳和业务执行在同一个进程里互相影响。解决方法是把心跳上报做在独立的轻量线程里并缩短超时判定窗口从30秒调到15秒避免长时间等待。另外也提醒我们超时kill的阈值不能只看任务理论耗时还要考虑基础设施抖动。5.3 任务堆积的背压与熔断高峰期如果下游系统响应变慢worker的HTTP调用会越积越多任务虽然执行成功但回调响应迟迟不返回导致调度器侧认为失败触发重试堆积进一步加剧。ax里加了一套简单的背压逻辑每个worker维护一个“等待响应中的请求数”指标超过阈值后不再拉取新任务而是进入熔断状态等待积压消化后再恢复拉取。阈值我按worker并发数 × 3计算比如并发20等待响应超过60就熔断。这个数字不是拍脑袋拍的是通过压测得出的经验值后面会提到。5.4 重启导致的全量重跑最初版本ax重启时会从数据库加载所有任务并重新放进时间轮。但因为任务的last_trigger_at没有落库重启后ax丢失了每个任务上次执行的时间cron任务会把“从上次启动到现在”当成一个完整的周期直接执行一轮。比如小时级任务上一次执行是10点ax在10点05分崩溃11点05分恢复加载它判断当前时间11点05分 上次触发时间11点00分于是任务立刻执行。但如果它只是普通重启任务18分钟前已经执行过一次这就会变成重跑。修复方案任务每次进入READY状态后立即把触发状态写入本地磁盘做WAL日志启动时先replay部分日志恢复每个任务的last_trigger_at。这也是我在2.3节强调“状态一定要显式落盘”的原因。5.5 DB连接池耗尽这种低级问题这个坑不高级但特别容易复发。ax启动时会预检查任务定义如果任务数量多检查到一半连接池满了整个调度器就卡在初始化阶段。后来我们给配置加载加了一个超时和降级逻辑加载配置失败时调度器仍可用上一次加载成功的缓存配置启动只是会在日志里标记配置为“过期”。调度系统一秒钟都不能停这个策略让配置中心的故障对调度行为完全透明。6. 压测数据与调优参数到底能扛住多大的调度压力6.1 测试环境与方法我们压测用的配置是3台调度节点8C16G8台worker节点4C8GRedis用于租约存储没有数据库参与调度主链路。压测工具是自己写的模拟任务生成器每秒动态调整“到期任务数量”从500开始逐步爬到5000。压测指标主要看三个触发延迟从任务到期到进入READY的时间差。派发延迟从READY到worker开始执行的时间差。成功率执行成功且状态正确落库的比例。6.2 压测结果指标500任务/秒2000任务/秒5000任务/秒触发延迟P993ms8ms26ms派发延迟P9915ms38ms210ms成功率99.99%99.97%99.90%调度器CPU占用12%30%78%5000任务/秒时CPU占用已经快接近临界继续加压就会出现触发延迟抖动。增加一个调度节点后触发延迟立刻回到10ms以下说明分片扩容对水平扩展很有效。6.3 三个关键调优参数时间轮tick大小1秒tick对分钟级任务足够但对秒级任务会有1秒的固有延迟。我调成500msCPU涨幅不到3%但秒级任务延迟明显改善。worker拉取批量建议单次拉取任务数 并发数/2一次拉太多会导致任务在本地排队时间过长拉太少又会在高吞吐下产生太多HTTP往返。租约过期时间不要设太短太短会让GC等基础设施抖动频繁触发误判。我们的经验值不少于30秒业务超时上限不高于10分钟。7. 可观测性建设让每一次调度都有迹可循7.1 Trace链路从trigger到callback调度系统一旦出问题最让人抓狂的不是故障本身而是你根本不知道任务在哪个环节卡住了。ax早期只记日志排查问题要把调度器日志、worker日志、业务日志三处拼起来看效率极其低下。后来我给ax加了一条贯穿全链路的traceID流程如下# 任务触发时生成traceID并贯穿所有日志 trace_id generate_uuid() logger.info(task triggered, task_id, trace_id) # worker接受任务时从请求头中读取traceID # 所以业务方的日志只要带上traceID就能串起来现在排查问题基本是一把梭拿traceID一搜从触发到回调的完整时间线就出来了。这个能力不是锦上添花而是调度系统的基本功。7.2 指标埋点调度延迟、执行成功率、队列积压ax暴露了四类指标给Prometheus采集指标说明告警阈值ax_trigger_delay_ms任务到期到进入READY队列的延迟P99 100msax_dispatch_delay_msREADY到worker接受任务延迟P99 500msax_execution_success_rate执行成功率 99.5%ax_queue_pending_count待执行队列积压数 10007.3 告警分级告警不是越多越好alert疲劳之后真正严重的告警反而会被忽略。ax目前分三级P0调度器节点全部不可用、任务丢失率超过阈值。立即电话。P1触发延迟明显升高、单节点持续失败。页面告警。P2任务成功率低于目标但还在容错范围内只发工作群通知。8. 后续演进的想法ax从最初的临时脚本到现在已经跑了七个多月支撑了三十多万日任务。下一步我考虑做两件事一是把分片配置的协调器本身做高可用目前协调器还是单点虽然挂掉不影响已有分片的正常运行但扩缩容会受影响二是把任务编排的DAG做得更完整支持多分支和汇聚这样很多数据流程就可以直接在ax里表达不用业务方自己拼一条链。要说我最大的心得其实是这句话调度器最重要的能力不是“准时触发”而是在各种异常情况下仍然不丢任务、不乱次序。这比把时间算得多精确难得多也重要得多。如果你也想自研调度不用一上来就追求完美先把触发链路跑通再把异常处理填上最后再考虑分布式扩展一步步来反而更稳。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

OPC-Client-X64:64位OPC DA客户端工具,解决工控上位机通讯调试难题 2026/9/26 21:38:52

OPC-Client-X64:64位OPC DA客户端工具,解决工控上位机通讯调试难题

简介:这是一份面向工业自动化领域开发者的OPC DA客户端开发资源包,适用于需要在64位Windows环境下基于Visual Studio 2013构建OPC数据访问应用的工程师。资源围绕OPC DA协议展开,涵盖COM/DCOM通信机制、IOPCServer与IOPCItemMgt等核心接口调用…

阅读更多 →
Source Counter代码统计工具:统计口径、CI集成与技术债量化实战 2026/9/26 21:38:52

Source Counter代码统计工具:统计口径、CI集成与技术债量化实战

简介:Source Counter 是一款面向软件开发团队与项目管理者的代码统计工具,用于量化代码库规模、评估开发进度并辅助代码质量分析。它可识别 C、Java、Python、JavaScript 等多种语言的源码,区分实际代码行、注释行与空行,并支持按…

阅读更多 →
PHP 获取客户端真实IP地址 2026/9/26 21:38:46

PHP 获取客户端真实IP地址

PHP获取客户端真实IP地址,需要根据具体的服务器环境来确定使用哪种方法。目前搜索到的方法,大多是直接贴代码,没有针对不同情况作出说明,有可能导致系统被假IP骗过(IP欺骗)。很多文章都提到“无法保证获取到…

阅读更多 →
化工时序预测实战:从DCS数据清洗到工艺约束建模 2026/9/26 21:38:45

化工时序预测实战:从DCS数据清洗到工艺约束建模

简介:本资源是一个面向化工过程建模与智能预测方向的实战项目,适用于高校过程控制、化学工程及工业大数据相关专业的高年级本科生与研究生,以及从事化工生产优化的工程师。项目聚焦于利用历史检验数据构建产品质量多指标预测模型,…

阅读更多 →
Unity游戏内存防修改:八槽密文阵保护数值不被Cheat Engine篡改 2026/9/26 21:38:45

Unity游戏内存防修改:八槽密文阵保护数值不被Cheat Engine篡改

说实话,我见过太多 Unity 项目栽在同一个坑上:游戏上线第二天,玩家群里就有人晒出 999999999 金币的截图;后台一看,不是服务器流水里的正常数据,是客户端内存被内存修改器直接改了。在 Unity 里&#xff0c…

阅读更多 →
Sublime Text关闭自动更新提示的三种安全方案 2026/9/26 21:38:45

Sublime Text关闭自动更新提示的三种安全方案

1. 为什么Sublime Text的自动更新提示让人烦躁——从软件设计逻辑到用户真实痛点Sublime Text作为一款被程序员、前端工程师、技术文档写手长期信赖的轻量级代码编辑器,其核心魅力在于“快、稳、不打扰”。但自Sublime Text 4发布以来,尤其是4200系列&am…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉