4. 请求路由与负载均衡:基于模型类型的路由、基于负载的路由、一致性哈希

好,咱们进入第四章。这一章聊的是多模型流水线的「交通指挥」问题。

你想想看,一个生产环境里,可能同时跑着十几个模型。有做文本分类的,有做图像识别的,还有做语音合成的。每个模型的算力需求不一样,响应时间要求也不一样。如果请求来了随便分配,那系统很快就会乱成一锅粥。

我个人习惯把路由策略分成三类:按模型类型按负载情况按一致性哈希。咱们一个一个说。

4.1 基于模型类型的路由

这是最直观的做法。说白了就是:什么类型的请求,就发给什么类型的模型实例

举个例子。你的集群里有三台机器:

  • 机器A:部署了BERT文本分类模型
  • 机器B:部署了ResNet图像识别模型
  • 机器C:部署了TTS语音合成模型

请求来了,网关根据请求头里的 model_type 字段,直接转发到对应的机器。简单粗暴,效率也高。

核心配置示例(Nginx + Lua):

-- 基于模型类型的路由
local model_type = ngx.var.arg_model_type

if model_type == "text_classification" then
    ngx.exec("@backend_text")
elseif model_type == "image_recognition" then
    ngx.exec("@backend_image")
elseif model_type == "speech_synthesis" then
    ngx.exec("@backend_speech")
else
    ngx.exit(400)
end

我在项目中遇到过一个问题:某个文本分类模型特别火,请求量是其他模型的10倍。结果那台机器CPU打满,其他机器却闲着。嗯,这就是纯类型路由的短板——它不考虑负载

我的建议:类型路由适合模型数量少、请求量均匀的场景。如果某个模型是热点,一定要配合负载均衡使用。

4.2 基于负载的路由

这个就聪明多了。它不看请求是什么类型,只看哪台机器现在最闲

常见的负载指标有:

  • CPU使用率
  • 内存占用
  • 当前活跃连接数
  • 请求队列长度
  • 平均响应时间

我一般推荐用最小连接数算法。为什么呢?因为它最直接反映当前压力。

负载均衡策略对比:

策略 优点 缺点 适用场景
轮询 实现简单 不考虑机器性能差异 同配置机器
加权轮询 考虑机器性能 权重需要手动调整 异构集群
最小连接数 动态感知负载 需要维护连接状态 长连接场景
响应时间加权 自动适应性能变化 计算开销稍大 延迟敏感场景

我曾经踩过一个坑:用CPU使用率做负载指标。结果发现,模型在加载权重时CPU飙高,但实际推理还没开始。请求被路由到其他机器,导致那台机器一直闲着。后来我改用请求队列深度,问题就解决了。

注意:负载均衡器本身不能成为瓶颈。我见过有人用Python写了个负载均衡器,结果它自己先撑不住了。生产环境建议用Nginx、HAProxy或者Envoy。

4.3 一致性哈希

这个稍微有点绕。但如果你做过缓存系统,一定不陌生。

先说说为什么需要它。假设你有3个模型实例,每个实例都缓存了部分请求的结果。如果某个实例挂了,或者你扩缩容了,传统的哈希取模会导致大量缓存失效。所有请求都得重新计算,系统性能瞬间暴跌。

一致性哈希就是为了解决这个问题。它把服务器和请求都映射到一个环上,请求只路由到环上最近的服务器。当服务器增减时,只有少量请求需要重新路由。

一致性哈希的核心代码(Python实现):

import hashlib

class ConsistentHash:
    def __init__(self, nodes=None, virtual_nodes=150):
        self.virtual_nodes = virtual_nodes
        self.ring = {}
        self.sorted_keys = []
        
        if nodes:
            for node in nodes:
                self.add_node(node)
    
    def _hash(self, key):
        return int(hashlib.md5(key.encode()).hexdigest(), 16)
    
    def add_node(self, node):
        for i in range(self.virtual_nodes):
            virtual_key = f"{node}:{i}"
            hash_val = self._hash(virtual_key)
            self.ring[hash_val] = node
            self.sorted_keys.append(hash_val)
        self.sorted_keys.sort()
    
    def remove_node(self, node):
        for i in range(self.virtual_nodes):
            virtual_key = f"{node}:{i}"
            hash_val = self._hash(virtual_key)
            del self.ring[hash_val]
            self.sorted_keys.remove(hash_val)
    
    def get_node(self, key):
        if not self.ring:
            return None
        hash_val = self._hash(key)
        for ring_key in self.sorted_keys:
            if hash_val <= ring_key:
                return self.ring[ring_key]
        return self.ring[self.sorted_keys[0]]

这里有个关键点:虚拟节点。为什么需要它?

你想想看,如果只有3个物理节点,它们在环上的分布可能很不均匀。有的节点负责的区域大,有的负责的区域小。虚拟节点就是把每个物理节点复制成150个虚拟节点,均匀分布在环上。这样负载就均衡了。

我的经验:虚拟节点数量一般设为100-200。太少分布不均匀,太多增加内存开销。我曾经试过500个虚拟节点,结果环上全是节点,查找效率反而下降了。

4.4 三种策略如何选择

说实话,没有银弹。我一般这样选:

  • 模型类型差异大(比如文本和图像混跑):先用类型路由做第一层分流,再用负载均衡做第二层
  • 模型类型相同(比如都是BERT):直接用负载均衡,最小连接数优先
  • 有缓存需求(比如模型推理结果可复用):用一致性哈希,保证相同请求落到相同实例

一个生产级的配置示例(Envoy):

static_resources:
  listeners:
  - name: model_gateway
    address:
      socket_address: { address: 0.0.0.0, port_value: 8080 }
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          route_config:
            name: model_routes
            virtual_hosts:
            - name: model_services
              domains: ["*"]
              routes:
              - match: { headers: [{"name": "model_type", "exact_match": "text"}] }
                route: { cluster: text_cluster }
              - match: { headers: [{"name": "model_type", "exact_match": "image"}] }
                route: { cluster: image_cluster }
          http_filters:
          - name: envoy.filters.http.router
  clusters:
  - name: text_cluster
    type: STRICT_DNS
    lb_policy: LEAST_REQUEST
    load_assignment:
      cluster_name: text_cluster
      endpoints:
      - lb_endpoints:
        - endpoint: { address: { socket_address: { address: text1, port_value: 5000 } } }
        - endpoint: { address: { socket_address: { address: text2, port_value: 5000 } } }
  - name: image_cluster
    type: STRICT_DNS
    lb_policy: RING_HASH
    load_assignment:
      cluster_name: image_cluster
      endpoints:
      - lb_endpoints:
        - endpoint: { address: { socket_address: { address: image1, port_value: 5000 } } }
        - endpoint: { address: { socket_address: { address: image2, port_value: 5000 } } }

你看,这个配置里:文本模型用了最小连接数,图像模型用了一致性哈希。这就是根据业务需求灵活组合。

最后说一句:路由策略不是配完就完事的。我建议每两周检查一次负载分布,看看有没有热点。如果有,及时调整权重或者增加实例。毕竟,生产环境的变化,永远比你想象的快。