资讯动态

自适应调度组件ax:动态权重与一致性哈希的上游请求分发实践

发布时间:2026/10/17 5:05:25 来源:尧图企业网站定制
ax这个内部项目代号最初源于我在一次大促压测前临时起意写的调度脚本。当时场景很简单上游挂了几个HTTP服务流量分配全靠Nginx的默认轮询眼看着一部分请求在压测中被慢节点拖死另一部分节点却闲着没事做我就想着手写一个更聪明的调度策略。结果没想到这个名叫“ax”的小模块后来一路长成了独立的多上游请求调度组件也让我把“动态权重”“自适应调度”这套逻辑彻底吃透了。如果你也在做网关、微服务入口、消息推送聚合层或者单纯被“上游到底该选谁”这个问题困扰过那ax调度的设计思路和落地过程应该能给你不少参考。先说清楚ax不是一个能和Nginx、Envoy一较高下的完整网关它更像是一个嵌入在业务网关里的调度引擎。它的核心任务只有一个在多个上游节点之间以尽量低的延迟和尽量高的成功率把请求派发给“此刻最应该处理它”的那个节点。听起来简单但真要在生产环境里稳定跑起来涉及健康检查、权重计算、流控、热更新、连接管理一堆问题。这篇文章就按我实际开发这个组件的顺序把完整思路、核心代码、压测数据、踩坑过程都梳理一遍。1. 项目缘起为什么我非要自己写一套“ax调度”模块1.1 从Nginx和LVS的“够用但不够灵活”说起很多团队的第一反应是调度Nginx自带的upstream不就是干这个的吗LVS不是更专业吗确实如果你只需要最基础的轮询、最少连接、IP哈希Nginx的upstream配置几分钟就能搞定性能也足够好。但我遇到的两个真实需求把这条路堵死了。第一个需求是按业务特征做精细调度。比如某个请求带了用户ID我们希望同一个用户尽量命中同一个上游节点这样本地缓存利用率能上去同时还要考虑当前节点负载。Nginx的ip_hash只能按IP做哈希业务字段完全看不到least_conn又完全不管哈希规则。两者没法叠加使用只能在“缓存友好”和“负载均衡”之间二选一。第二个需求是动态调整节点权重。大促期间有些节点需要临时摘流量有些节点要临时加权重但我希望这个调整不是靠人工改配置reload而是让调度器根据实时延迟、错误率、CPU负载自动算出一个权重。Nginx的weight是静态配置做不到这一点。LVS在转发性能上确实强悍但它是四层转发拿到不HTTP层的业务字段。我们场景里还要做超时重试、请求体大小限制、Header改写这些都不是四层能解决的。所以在“要业务感知”和“要动态自适应”这两点上Nginx和LVS的通用方案都满足不了我。这就是ax调度模块存在的直接原因。1.2 ax的命名与产品定位ax这个名字没什么高深的含义就是Adaptive eXchange的缩写代表“自适应交换调度”。在当时的项目命名表里它排在字母A区同事们随便叫就叫开了。后来我发现这个名字反而很贴切因为模块的核心能力确实是两个自适应的权重调整以及请求与上游节点之间的“交换”匹配。产品定位上ax从一开始就定了三个边界不做流量入口只做调度决策引擎。它不监听端口、不处理TLS终止、不解析复杂协议只接收一个“当前请求的基本信息”和一份“上游节点状态”然后返回一个节点ID。这样它可以被嵌入任何语言写的网关也可以被其他调度系统调用。面向HTTP层以上业务不做四层转发。因为我们需要请求里的业务字段参与路由所以最低工作层就是HTTP。必须以库的形式存在而不是独立服务。独立调度服务会引入额外的网络跳数和新的单点调度决策必须发生在网关进程内部延迟越低越好。这个定位给我后续开发省了大量麻烦也让ax调度可以直接被放进线上网关的请求主链路里不会成为性能瓶颈。2. ax调度的核心模型从哈希环到自适应策略2.1 主流调度算法的一轮对比开工前我把常见调度算法拉出来过了一遍核心关注四个指标调度延迟、请求成功率、上游负载均衡度、缓存命中友好度。算法调度成本负载均衡度缓存友好度对上游异常感知能力轮询Round Robin极低好差无只能靠被动超时加权轮询极低好差无权重静态最少连接Least Connections低非常好差一般只看连接数源地址哈希IP Hash低一般好无异常节点会导致局部失败一致性哈希Consistent Hash低~中一般好无需配合故障摘除加权最少连接低非常好差一般同样只看连接数延迟/错误率自适应加权中好可配置强能及时调整流量分配看完对比就明白了传统算法其实是在“负载均衡”和“缓存友好”之间做取舍而我希望两者兼得。ax调度的设计思路是先用一致性哈希保证业务字段的亲和性再根据上游的健康状态动态调整虚拟节点的权重分布。这样同一个用户ID在正常情况下总会落到同一组节点上而当某个节点变慢或报错增多时它对应的虚拟节点权重会降下来部分流量自动流到其他节点实现“缓存友好”和“故障规避”的折中。2.2 ax调度器的代码骨架与数据结构核心数据结构其实不复杂就是一个哈希环加一个权重表。我先把最基础的调度器接口定义出来package ax // Node 表示一个上游节点 type Node struct { Name string Weight int // 基准权重范围1~100 Alive bool // 健康状态 Region string // 可选同区域优先 } // RequestMeta 携带参与调度的业务字段 type RequestMeta struct { Key string // 用于一致性哈希的key比如user_id Addr string // 客户端IP可做备用哈希key RetryCount int // 当前这是第几次重试 } // Scheduler 调度器核心接口 type Scheduler interface { AddNode(node *Node) error RemoveNode(name string) error UpdateWeight(name string, weight int) error Pick(meta *RequestMeta) (*Node, error) }一致性哈希环的实现我直接参考了常见的Ketama算法思路每个物理节点生成160个虚拟节点分布在环上。为了支持动态调整权重我没采用“不同权重生成不同数量虚拟节点”的方式——那样每次调权重都要重建环成本太高。而是用一个单独的weightIndex数组记录每个虚拟节点上的权重叠加type ringNode struct { hash uint32 physical string virtualIdx int weight int // 当前生效权重来自物理节点 } func (s *scheduler) pickNode(keyHash uint32) *Node { idx : sort.Search(len(s.ring), func(i int) bool { return s.ring[i].hash keyHash }) if idx len(s.ring) { idx 0 } return s.nodes[s.ring[idx].physical] }这样调度复杂度就是O(log N)N是虚拟节点数量。我压测时4万个虚拟节点的环单次Pick平均耗时大约1.2微秒完全可以放在主链路里。但这里有个问题如果只是根据哈希直接落到环上那“自适应权重”体现在哪答案在虚拟节点的权重判定上。pickNode不是只看哈希命中的那一个虚拟节点而是从命中位置往后扫描一小段区间默认取32个虚拟节点按它们当前的权重做加权随机。这样某个节点权重被调低后它被选中的概率会平滑下降而不是出现“一刀切”的流量断裂。2.3 为什么要引入自适应权重自适应权重的本质是把“上游节点的实时状态”变成“流量分配比例的调节信号”。我最初是用静态权重Hand配置但很快就发现大促场景下人工根本来不及反应。比如某个上游Pod因为JVM Full GC卡了两秒流量还在按老权重继续灌进去等人工发现时超时率已经上去了。ax的自适应权重计算分三路信号P99延迟指标节点过去60秒内P99延迟偏离整体P99越多权重惩罚越大。错误率指标5xx错误率超过1.5%开始线性降权超过5%直接摘除该节点。连接数指标当前活跃连接数超过节点容量的90%时额外施加限制。权重更新的具体公式我简化成node_score 0.5 * (1 - p99_ratio) 0.3 * (1 - error_ratio) 0.2 * (1 - conn_ratio) dynamic_weight base_weight * max(0.2, min(1.5, node_score))也就是说一个表现优异的节点最多能拿到1.5倍的基准权重一个表现差的节点最低只能拿0.2倍再差就直接标记为不健康。这个公式没什么高深的数学核心思路是让权重变化平滑且可预期不要在秒级时间内剧烈抖动。3. 生产落地细节健康检查、流控与热更新3.1 上游健康检查的几种方式与取舍调度器再聪明如果健康检查本身是错的一切都白搭。这块我踩过不少坑经历过“健康检查全挂但实际能请求”“健康检查正常但实际请求全超时”两种极端情况。健康检查我最终保留了两种模式可以并行开启主动探测Probe调度器自己每2秒向上游节点发送一个轻量级HTTP请求通常是GET /healthz判断是否返回2xx。适合流量较少的场景成本可控。被动监测Passive通过真实业务请求的返回结果来判定节点状态。连续失败3次标记为“可疑”累计失败率达到阈值就摘除节点。适合流量很大的场景不需要额外的探测请求。两种模式的取舍很清楚主动探测能及时发现节点宕机但依赖上游提供的专用健康检查接口而且如果接口本身写得有问题很容易误判被动监测反映的是真实业务质量但需要连续若干失败才能触发摘除反应速度稍慢。我在生产里是两者都开主动探测负责发现硬故障被动监测负责发现慢故障。有一个比较隐蔽的问题很容易被忽略健康检查接口和真实业务接口可能跑在两条完全不同的路径上。比如上游是Java应用健康检查走了Web容器线程池业务请求走了业务线程池结果业务线程池满了返回超时健康检查却依然200。为了规避这个问题ax的健康检查请求里会带一个特殊的Header让上游在应答时附带线程池水位和队列深度调度器只有同时满足“HTTP 200”和“线程池水位85%”才认为节点健康。3.2 全局限流与单上游流控调度器往往只负责“把请求分给谁”但一旦某个上游开始变慢光靠降权是不够的因为降权只是把新请求导向别人已经进到队列里的请求还是在耗时间。ax里实现了两层流控全局限流针对入口请求的整体QPS做控制超过阈值直接返回503保护整个网关不被打垮。单上游流控每个节点设置最大并发数如果某个节点当前并发数超过设定值调度器直接跳过这个节点。这个“跳过”逻辑和权重调整不同。权重调整是减少分配概率而流控跳过是硬拒绝哪怕整条链路上只剩这一个节点可用也不会继续把它塞满而是直接返回“上游过载”的错误。这个设计最初被业务方质疑过“那岂不是可用性更差了”但实际线上经验是与其让请求挂着超时拖垮整个链路不如快速失败让客户端去做降级整体可用性反而更高。流控参数我一般这样设置参数默认值说明global_qps_limit10000网关入口整体QPS上限max_concurrency_per_node500单节点最大并发请求数max_overflow_ratio0.1允许单节点并发超限的上浮比例cool_down_seconds30节点因过载被临时摘除后的冷却时间3.3 配置热更新的实现思路调度配置如果只能靠重启生效那就谈不上“自适应”。ax的配置热更新方案是把配置源挂在配置中心比如etcd节点信息的变更事件会驱动调度器内部的哈希环重建。哈希环重建是个技术活。如果直接全部重建再整体替换会有短暂的服务不可用如果逐步替换又可能在重建过程中出现节点遗漏。最终的方案是采用双缓冲环type scheduler struct { mu sync.RWMutex activeRing *hashRing standbyRing *hashRing } func (s *scheduler) rebuild(newRing *hashRing) { s.mu.RLock() s.standbyRing newRing s.mu.RUnlock() // 原子切换指针 atomic.StorePointer(s.activeRingPtr, unsafe.Pointer(newRing)) }这种交替重建的好处是新环构建期间老环依然服务所有请求切换瞬间通过原子操作保证任意时刻只有一个环可见。实测在4万虚拟节点规模下重建一次需要约50毫秒切换过程零请求中断。后续节点状态更新比如权重变化则不需要重建Ring直接操作物理节点的weight字段就行——这也是我第2章里坚持用weightIndex数组而不是重建虚拟节点的原因。4. 压测与调优不同场景下的实测数据4.1 压测环境与工具选型ax调度器本身是个纯内存操作压测重点其实是验证它在网关主链路里带来的额外开销以及不同调度策略在典型业务场景下的表现差异。我的压测环境是8核16G的虚拟机网关进程使用Go语言实现使用wrk和ghz两个压测工具。wrk用于掌握整体QPS和延迟分布ghz用于模拟带用户ID的HTTP请求这样测试一致性哈希比较方便。压测的时候有个很关键的细节一定要同时启动一批模拟上游服务而且要故意给其中几个节点设置不同的处理延迟。不然测出来的只是“调度器的空转快慢”测不出“调度算法对端到端延迟的影响”。4.2 实测结果与调度算法对比我在相同硬件环境下用相同请求总量对比了轮询、最少连接、一致性哈希基础版、以及加了自适应权重的ax调度。压测时长10分钟上游共6个节点其中1个节点被我人为注入延迟P99从10毫秒涨到80毫秒其他5个节点保持正常。策略整体QPSP99延迟(ms)慢节点成功率正常节点成功率最大负载差轮询153208761%98%低最少连接149807869%97%高一致性哈希(基础)151008358%97%高ax调度(自适应)162404988%99%中数据很有意思。轮询和基础一致性哈希因为闷头把请求继续发给慢节点整体P99被带到了80毫秒以上慢节点成功率也只有六成左右。最少连接稍微好一点但连接数不能完全反映CPU忙闲延迟改善有限。ax调度因为能感知到慢节点P99升高并快速降权整体P99几乎压回了50毫秒以内慢节点也保住了大部分请求——毕竟它只是变慢不是挂了让它少接一点请求是正确的而不是直接不接。4.3 几次关键调优动作第一次跑压测时ax调度表现没那么理想。我复盘后发现几个问题第一个是指标窗口太短。最初采集最近10秒的P99结果节点一抖动权重就剧烈震荡流量在节点间来回横跳性能反而下降。改成60秒滑动窗口后权重的变化平滑了很多但也带来了新问题——慢节点被纠正的速度变慢了。所以我最终把窗口拆成了长短两个短窗口5秒负责发现突然变坏的节点并快速降权长窗口60秒负责稳定决策降低误判。第二个是虚拟节点扫描区间太大会丢热点。最初命中哈希环后我扫描128个虚拟节点做加权随机结果在单用户大流量一个user_id占整体流量30%的情况下这个热点用户会被分散到多个节点缓存命中率下降。把扫描区间从128缩到32后单个用户最多只会在两三个节点间分发缓存命中率明显回暖。第三个是重试请求不能重新参与调度优化。第一次调度失败的请求重试时如果再走一遍同样的哈希逻辑大概率又会命中同一个坏节点。ax里对重试请求做了一次特殊处理RetryCount大于0时跳过一致性哈希直接走最少连接策略并且禁止再选同一个节点。这个小改动对P99下降贡献非常大。5. 真实踩坑记录从故障到教训5.1 连接泄漏与TIME_WAIT风暴ax调度上线后的第一个生产事故和调度算法本身没关系是连接池管理的问题。刚开始做单上游流控时我只统计了“发起中的请求数”没有考虑到HTTP连接池里每个连接会被多个串行请求复用。结果并发一上来连接池里的连接被疯狂创建QPS越大新建连接越多最后整台工作机的端口被TIME_WAIT占据新连接建不出来系统吞吐整体雪崩。排查链路很典型。先是看到网关的TIME_WAIT数量从几千涨到十多万然后发现每个上游节点的socket fd数量也在持续增长最后定位到是连接池的最大连接数设置过小连接被频繁回收重建。解决方式是直接把HTTP客户端的连接池调大同时启用连接复用并引入了空闲连接自动清理。这个坑我后来总结成一句话在调度层做并发控制之前先确认连接池参数是不是能支撑目标并发。否则控制越严连接新建越频繁反而加剧问题。5.2 健康检查误判导致的雪崩还有一次故障特别值得记录。上线初期我把“被动监测”的失败阈值设得非常激进只要连续3次请求失败就判定节点不健康。结果某个上游发布新版本时启动阶段返回了几个500健康检查机制直接把它摘了流量全部压到其余节点上剩余节点也陆续出现超时进而也被摘除最终整组上游全被摘光。事后复盘问题出在我把“瞬时失败”和“持续不健康”混为一谈。现在ax的实现里摘除一个节点必须同时满足两个条件短窗口错误率超过阈值且持续异常超过30秒。瞬时抖动会被容忍只有持续恶化才会触发摘除。另外还加了一个最少存活保护机制只要调度池里的节点数少于3个就算全部不健康也不允许全摘强制保留至少一个节点继续承担流量。这个保护虽然会让个别请求失败但至少保住了整体服务的存活。5.3 时钟跳跃导致的一致性哈希失效这个坑算是比较冷门的。一致性哈希在环上定位节点时依赖hash值而我的hash算法里有个混合了当前时间戳的因子用来打散同一用户在不同时间窗口下的热点分布。本意是避免某个时间段内同一批key集中落在同一个节点上结果某台机器在做NTP时间同步时时钟向前跳了几百毫秒导致所有虚拟节点的hash位置集体偏移。那一刻过来的请求几乎全部被打散到不同节点上大量用户出现缓存穿透P99飙升。这个问题排查了很久才定位到原因是基础库的hash seed在初始化时读取了系统时间。解决方式很彻底调度关键路径上的hash函数必须是无状态的纯函数不依赖任何全局时间、随机种子或者进程内可变状态。一致性哈希环构建完成后除非节点增删或权重变更否则同一key的调度结果必须是绝对确定的。混乱的时间源不应该参与调度决策。5.4 一些经验教训整个ax调度模块从写第一行代码到稳定运行前后迭代了三轮最后沉淀下来几条我认为比代码本身更重要的经验调度算法的第一原则是可解释。你在生产环境里遇到问题的时候能快速回答“刚才这一秒流量为什么全打到了A节点上”比什么都重要。纯黑盒的机器学习调度模型现阶段真的不建议上主链路。所有的“自适应”调节都要有速率限制。权重每次最多调整多少、隔多久才能再调整一次这些参数比调节公式本身更能决定稳定性。故障摘除一定要带冷却时间。节点恢复健康后不要立刻放回流量池让它先低权重跑一段时间确认稳了再逐步恢复。AX里默认冷却时间是60秒线上效果很好。最后再分享一个小技巧如果你想在自己项目里复刻ax调度的这套思路先别急着一上来就写自适应权重。把一致性哈希、最少连接、加权轮询这几个基本算法先实现好压测数据记录下来然后再加上动态权重层。多做对比你的调度器才能从“能跑”进化到“好用”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑