7.3 负载均衡算法、健康检查与重试边界
负载均衡器从一组后端中选择请求或连接的去向。目标是让容量、延迟、可用性、会话亲和性和成本可控,而不是强求每台机器每秒接到完全相同的请求数。
1. 四层与七层可见的信息
| 维度 | L4 | L7 |
|---|---|---|
| 主要观测 | 源与目标地址、端口、传输连接 | HTTP 方法、主机、路径、字段或 gRPC 方法 |
| 常见转发单位 | 连接或包流 | 请求、RPC 或流,取决于协议 |
| TLS | 可透传 TLS 密文 | 按加密的应用内容选路时通常需终止 TLS |
| 能力 | NAT/DSR、连接分配 | 内容选路、头处理、认证集成、请求重试 |
| 成本与风险 | 应用解析较少 | 更多 CPU、内存、协议状态和配置复杂度 |
L4/L7 不是产品的绝对分类,一条服务路径可以同时包含四层入口和七层代理。TLS 透传保留密文,代理看不到加密的 HTTP 路径;按可见 SNI 做有限选路,也不等于能理解完整 HTTP 请求。
2. 调度算法与负载匹配
轮询与加权轮询
轮询实现简单,适合后端容量与请求成本相近的情况。权重可以表达容量差异,却不能反映上一批请求是否仍然很慢。
最少连接与最少请求
最少连接或最少请求把活跃工作量作为信号,适合处理时长差异较大的负载。但 HTTP/2 或 gRPC 一条连接可承载很多流,连接数未必代表请求数,请求数也未必代表 CPU 或 I/O 成本。
例如 A 有 1 条 HTTP/2 连接,承载 100 个活跃 RPC;B 有 8 条连接,各只有 1 个活跃 RPC。最少连接会选 A,最少请求会选 B。即使选择 B 更符合当前计数,也还要看这 8 个 RPC 是否在做昂贵计算。
延迟感知与 EWMA
延迟感知算法用近期延迟的移动估计识别慢后端。还需考虑错误、活跃请求和预热,否则样本很少的新节点可能看起来格外空闲。
随机二选一
二选一算法随机取两个候选,再选择当前负载较小的一个。它降低了逐个查询全体后端的成本,效果仍取决于样本选择和负载指标是否可靠。
一致性哈希与 Rendezvous 哈希
一致性哈希或 Rendezvous 哈希让同一租户、缓存键或分片键尽量落在同一后端,并减少成员变动时的重新映射。它们用于亲和性或分片,热门键仍可能集中制造高负载;不同容量的节点还需要权重设计。
3. Rendezvous 哈希实验
Rendezvous 哈希对每个键与节点的组合计算稳定分数,选择最高者。移除节点时,只有原本属于它的键需要重新映射;加入节点时,变化的键只会移到新节点。下面用 Python 3 检查这两个性质和成员顺序无关性:
from collections import Counter
import hashlib
def score(key: str, node: str) -> int:
key_bytes = key.encode("utf-8")
data = len(key_bytes).to_bytes(8, "big") + key_bytes + node.encode("utf-8")
digest = hashlib.sha256(data).digest()
return int.from_bytes(digest, "big")
def choose(key: str, nodes: list[str]) -> str:
if not nodes:
raise ValueError("nodes must not be empty")
if len(set(nodes)) != len(nodes):
raise ValueError("node identities must be unique")
return max(nodes, key=lambda node: (score(key, node), node))
keys = [f"tenant-{i}" for i in range(10_000)]
before_nodes = ["a", "b", "c"]
after_nodes = ["a", "c"]
before = {key: choose(key, before_nodes) for key in keys}
after = {key: choose(key, after_nodes) for key in keys}
remapped = {key for key in keys if before[key] != after[key]}
assert remapped == {key for key in keys if before[key] == "b"}
assert all(choose(key, list(reversed(before_nodes))) == before[key] for key in keys)
added = {key: choose(key, before_nodes + ["d"]) for key in keys}
assert all(added[key] == "d" for key in keys if added[key] != before[key])
print("before:", Counter(before.values()))
print("after: ", Counter(after.values()))
print("remapped:", len(remapped) / len(keys))结果不保证每个节点精确获得 1/N 的键,键数接近也不保证流量或计算成本接近。上线前还应看键的热度、节点权重、副本和故障处理。
编码使用明确长度前缀,避免 key 或节点名含分隔符时产生相同拼接;极少见的分数相同用节点名稳定打破平局。相同节点集合的输入顺序不应改变映射。该实现选一次要对每个节点算哈希,成本随节点数线性增长;它不负责数据复制或迁移。
4. 健康检查的用途与延迟
主动检查
负载均衡器主动查询健康端点时,需要区分这些信号:
- 存活:进程是否需要重启;
- 就绪:能否接收新流量;
- 深度依赖检查:用于诊断,不一定适合直接决定选路。
若就绪检查把每个共享下游都当成强依赖,下游的一次抖动就可能让所有后端同时退出服务,把部分降级扩大为完全不可用。
存活探测通常由进程管理器或编排系统用于重启判断;负载均衡器主要关心是否能接新请求。检查还存在间隔、阈值与传播延迟,探测刚成功也不能保证下一条请求成功。若所有后端都异常,应明确返回错误、限流还是采用受控的应急路由,不能任由默认策略决定。
被动检查与异常剔除
被动检查依据实际请求的连接错误、状态与延迟,临时剔除异常后端。它需要足够样本、剔除比例上限和恢复策略;全局过载时不断剔除节点,只会让剩余节点更忙。
5. 启动、下线与成员变更
新后端通过健康检查后,JIT、连接池、缓存和延迟初始化未必已完成。慢启动逐步增加分配权重,让预热跟得上流量。
计划下线时应给现有工作保留完成机会:
- 将后端标为未就绪或排空中;
- 停止分配新请求或连接;
- 等待进行中的工作完成,并设置最长宽限期;
- 按应用约定终止未完成的长流,并让客户端重连或恢复;
- 再退出进程。
WebSocket 与 gRPC 的长连接使停止新连接不等于停止新业务操作,需要结合协议进行排空并通知客户端重连。
HTTP/2 可用 GOAWAY 配合停止接收新流,给已有流留出完成时间;WebSocket 则需关闭通知与应用恢复机制。普通负载均衡器重新选择后端,不会自动转移既有 TCP 连接状态,重连后的业务恢复也不自动等于重放全部消息。
6. 超时、重试与负载放大
负载均衡器重试可以隐藏暂时故障,也会增加后端工作量。多层独立重试时,最大尝试次数可能相乘。
先区分“重试 3 次”和“最多尝试 3 次”。前者含首次共 4 次,两层独立用尽时,下游最多承受 4 × 4 = 16 次尝试;后者是 3 × 3 = 9。这些是未考虑截止时间、退避和取消的上界。若系统为 100 个首次请求只允许额外 20 次尝试,重试预算必须跨各层共享或分配,不能每层都额外加 20%。
审查重试策略时还应检查:
- 操作是否幂等,是否具备幂等键;
- 总截止时间与每次尝试的超时;
- 请求正文或响应头已部分传输后,哪些失败仍可重试;
- 最大尝试次数、退避、抖动与重试预算;
- 熔断与负载丢弃;
- 原始尝试是否仍可能在后端运行。
Hedging 在首个尝试尚未失败时就发出额外尝试,需要确认并发重复执行安全,并限制触发阈值和额外负载。无副作用的读取是常见起点;带幂等键的写入也只有在服务端正确协调并发去重时才可能适用。取消落后的尝试不保证它已经停止,更不会撤销副作用。
7. 会话亲和性与迁移成本
cookie、源 IP 或一致性哈希可以把调用者固定到某个后端,也会带来负载不均、故障重映射和缩容成本。NAT 或代理后的大量用户还可能共用一个源 IP。
将持久会话状态放入合适的共享或有状态存储后,无状态计算实例更容易调度。确需会话亲和性时,要明确有效期、故障后行为、再均衡和数据一致性。
8. 验收问题
- HTTP/2/gRPC 负载下,最少连接策略为什么可能选错后端?
- 区分存活、就绪和深度依赖检查。
- 为一次部署设计慢启动与连接排空。
- 两层各重试 3 次,与每层最多尝试 3 次,各产生多少下游尝试?如何用重试预算约束?
- 哪些负载适合 Rendezvous 哈希,哪些更适合最少请求策略?