跳转至

服务通信、调用组织与流量治理

本章把一次跨服务调用拆到可以推演的机制层。重点不在记住“服务发现、负载均衡、重试、熔断、限流”这些名词,而是理解每个机制保存什么状态、收到一次请求后怎样执行、什么条件会触发状态变化、它解决什么问题以及它解决不了什么问题。

1. RPC 框架到底帮应用做了什么

RPC(Remote Procedure Call,远程过程调用)是一种调用抽象。业务代码希望写成 GetUser(id),而目标逻辑实际运行在另一台机器上。一次远程调用至少要完成参数编码、目标寻址、连接获取、网络发送、服务端解码、方法分发、业务执行、响应编码、网络返回、客户端解码和错误映射。RPC 框架把这些通用步骤封装起来,让业务代码主要面对“调用哪个方法、传什么参数、收到什么结果”。

以 gRPC 为例,接口通常先由 IDL(Interface Definition Language,接口定义语言)描述,再生成客户端桩和服务端骨架。客户端调用生成代码时,请求对象会被序列化成字节,框架找到目标服务实例,通过已有连接发送 HTTP/2 帧;服务端根据方法名将请求分发给对应处理函数,响应再反向返回。连接复用、流复用、状态码、截止时间、重试、健康检查等都可以由框架统一处理。

RPC 和 HTTP 处在不同层次。RPC 描述“如何像调用函数一样调用远程方法”,HTTP 描述“双方怎样交换应用层消息”。gRPC 就运行在 HTTP/2 上,工程上比较 RPC 与 HTTP 时,通常实际比较的是“带接口定义、代码生成和治理能力的 RPC 框架”与“直接设计 HTTP/REST API”的工程形态。

2. 服务注册、服务发现和负载均衡各自维护什么状态

假设 model-service 当前有三台实例:10.0.0.1:9000、10.0.0.2:9000、10.0.0.3:9000。如果调用方把这三个地址写死在配置里,扩容、缩容、迁移和故障都会要求人工更新配置。注册中心把“服务名 -> 当前实例集合”变成动态状态。

服务注册发生在服务端一侧。实例启动后向注册中心登记自己的服务名、地址、端口以及可能的机房、版本、权重等元数据,并通过心跳或租约维持存活状态。实例长期没有续约时,注册中心会把它标记为不可用或移除。服务发现发生在调用方或代理侧,调用方通过查询或订阅拿到当前可用实例集合,并在集合变化时更新本地状态。

负载均衡发生在已经有候选实例之后,它解决“这次请求具体发给谁”。客户端负载均衡把实例列表和选取算法放在客户端,客户端直接连接目标实例;代理侧负载均衡则让客户端只连接代理,由代理维护实例列表并做选择。前者少一次转发,后者把治理逻辑集中在代理层。两者的主要差异是状态放在哪里、谁承担更新和选择逻辑。

3. 轮询、加权随机和最少请求数怎样真正选节点

轮询维护一个当前位置,例如当前实例数组为 [A,B,C],计数器从 0 开始,每次请求选择 instances[counter % 3],再递增计数器。它隐含两个假设:各实例能力近似,请求成本近似。如果 A 是 32 核、B 是 8 核,或者请求耗时差异巨大,简单轮询就会失衡。

加权随机为每个节点设置权重。假设 A/B/C 权重分别为 5/3/2,可以把总权重看作 10,在 [0,10) 上随机取值,再根据累计区间选择节点:[0,5) 给 A,[5,8) 给 B,[8,10) 给 C。长期请求足够多时,请求比例会逼近 5:3:2。权重可以反映机器规格,也可以动态结合实例健康度或性能。

最少请求数会维护每个实例当前正在处理的请求数量。新请求到来时,优先发送给未完成请求数更少的实例。假设 A/B/C 当前分别有 20、3、8 个未完成请求,新请求优先去 B。这个算法对长短请求混合的场景更友好,因为它能感知实时占用;如果单个请求成本差异极大,“数量少”仍然可能对应“资源更忙”,所以工程实现常结合权重、延迟或 CPU 等信号。

4. 一致性哈希:从哈希环开始推演

普通取模哈希常写成:

node_index = hash(key) % N

当节点数 N 变化时,除非一个 key 恰好满足旧模数和新模数得到相同结果,否则映射会改变。比如从 3 台机器扩到 4 台,很多 key 的 hash(key) % 3 与 hash(key) % 4 都不同,所以大量缓存或会话状态需要迁移。

一致性哈希把哈希函数的整个输出空间首尾相接成一个环。为了手工推演,可以把环简化成 0~99。先把三个节点映射到环上:

A -> 10
B -> 40
C -> 80

再对业务 key 做同样的哈希。规则是:一个 key 从自己的位置开始沿顺时针方向走,遇到的第一个节点负责它;超过 99 后回到 0。于是:

key=5   -> A(10)
key=15  -> B(40)
key=35  -> B(40)
key=70  -> C(80)
key=90  -> 绕回 A(10)

这意味着 A 负责 (80,10],B 负责 (10,40],C 负责 (40,80]。区间跨过环末尾时继续从 0 计算。

现在新增节点 D,哈希位置为 60:

A=10, B=40, D=60, C=80

原来 (40,80] 全部属于 C;增加 D 后,只有 (40,60] 改由 D 负责,(60,80] 仍属于 C。A、B 原来的 key 完全不用迁移。这就是“一致性”真正来自哪里:节点变化只改变它在环上的相邻区间,而不是重新对所有 key 用新的节点总数取模。

删除节点也可以同样推演。假设 B=40 下线,原来 B 负责 (10,40],这些 key 顺时针找到的下一个节点变成 D=60,因此主要只有 (10,40] 迁移到 D。其他区间保持不变。

4.1 为什么真实节点少时会不均匀

一致性哈希只保证节点变化时迁移范围小,它并不会自动让每台机器负载完全均匀。仍看上面的环:A 负责 (80,10],区间长度约 30;B 负责 (10,40],长度 30;如果某次哈希结果变成 A=2、B=5、C=80,那么 C 可能负责非常大的区间,负载明显偏斜。

真实系统通常引入虚拟节点。每个真实机器不只映射一次,而是映射多个带编号的虚拟身份:

A#1 = hash("A#1")
A#2 = hash("A#2")
A#3 = hash("A#3")
...
B#1, B#2, B#3 ...

环上可能存在数百或数千个虚拟节点,这些位置再映射回真实节点 A/B/C。大量小区间随机散布在整个环上,某个真实节点偶然拿到一个大区间时,其他虚拟节点的区间会起到平均作用。节点扩容或缩容时迁移的也是许多分散的小区间,整体更平滑。

虚拟节点还可以表达权重。规格更高的机器可以拥有更多虚拟节点,从概率上承担更多区间。不过虚拟节点解决的是“哈希空间分布不均”,它解决不了“业务 key 本身极度热点”。如果一个超级热门 key 占 40% 请求,无论它落在哪个节点,这个节点仍会成为热点,需要复制热点数据、拆 key、读副本或其他专门手段。

4.2 一致性哈希适合什么

它特别适合需要 key 粘性的场景,例如本地缓存、会话状态、分片存储、按用户或任务固定路由。一个 key 尽量长期落在同一节点,节点变化时又只迁移局部区间。如果服务完全无状态,每个实例处理能力相同,请求落在哪里没有业务差异,轮询或随机通常更简单。

Dynamo 论文使用一致性哈希做数据分区,其核心规则也是把哈希空间视作环,key 顺时针找到负责节点;节点加入和离开只影响环上的邻近范围。这种机制后来被很多分布式缓存和存储系统采用。

5. 健康检查为什么需要“主动 + 被动”两类证据

服务发现给出的实例列表可能已经过时:实例仍注册着,但进程卡死、依赖异常或网络隔离。主动健康检查由客户端、代理或控制面周期性调用健康接口,能够在没有真实业务流量时发现异常。被动健康检查根据真实请求的连接失败、连续 5xx、超时率或成功率判断实例是否偏离其他节点。

摘除策略不能对一次偶发失败过度敏感。常见实现会维护连续失败次数、时间窗口成功率、摘除时间和最大摘除比例。实例被摘除一段时间后还要探测恢复状态,防止一个瞬时故障导致节点永久退出。Envoy 的异常实例检测就是一种被动健康检查,会根据连续失败、时间窗口成功率等指标临时把异常实例移出负载均衡集合。

6. 超时和截止时间:一个是时长,一个是时刻

超时通常是相对时长,例如“最多等 500ms”;截止时间是绝对时刻,例如“必须在 12:00:00.500 之前结束”。如果调用在 12:00:00 开始,500ms 超时可以换算成对应截止时间。工程上截止时间更适合跨调用链传播,因为它表达的是整个请求剩余的统一预算。

假设用户请求给 A 的总预算为 800ms。A 本地处理用了 200ms,再调用 B 时剩余预算只有约 600ms;B 又处理 150ms,再调用 C 时只剩约 450ms。如果 B 给 C 重新设置一个完整 800ms 超时,整条调用链就会越过用户最初的预算。gRPC 的截止时间传播会把已经消耗的时间扣除,再把剩余预算传给下游。

截止时间到达后,上游停止等待只是第一步。服务端可能已经开始 SQL、文件操作或模型推理,因此还需要取消传播。长任务要周期性检查上下文是否已经取消,避免请求结果已经没人接收,后台仍继续消耗 CPU、线程、数据库连接或 GPU。

7. 重试的核心难点是“结果可能已经发生”

网络调用超时不能直接推出“服务端没有执行”。一种典型情况是:服务端已经完成扣款并提交数据库,响应在返回途中丢失,客户端最终看到超时。此时盲目重试就可能重复扣款。所以决定是否重试前,要同时判断故障是否可能是暂态、操作有没有副作用、服务端能否识别同一逻辑请求。

连接建立失败、临时网络抖动、服务实例短时间不可用通常具有重试价值;参数错误、权限错误、确定的业务校验失败通常不会因为再次调用就成功。即便错误类型可重试,也要受总截止时间和重试次数限制。

幂等键是一种常见手段。客户端为同一逻辑操作生成固定 ID,例如 payment:order-123:attempt-group-1,服务端用唯一约束或幂等记录表确保相同 ID 只产生一次业务副作用。重试时必须复用同一个幂等键,否则服务端无法识别“这是同一次操作的重复尝试”。

8. 指数退避和随机抖动:为什么两个都需要

如果故障后所有客户端立即重试,下游刚刚过载又会立刻收到第二波请求。指数退避把第 n 次重试的基础等待时间逐渐增大,例如:

100ms, 200ms, 400ms, 800ms ...

一种简化公式是:

delay_n = min(max_delay, base_delay * 2^n)

它给依赖恢复时间,也降低连续重试频率。但如果 10 万客户端都在同一个时刻失败,并且严格按同样的 100/200/400ms 重试,它们仍会在同一批时刻重新汇聚。随机抖动会把实际等待时间随机化,例如在 [0, delay_n] 或以目标延迟为中心的区间中取随机值,从而把重试请求打散。gRPC 的重试机制会对退避延迟加入随机抖动。

多层重试还会产生乘法放大。假设 A 调 B 最多尝试 3 次,而 B 每次调用 C 也最多尝试 3 次,那么一次用户请求在最坏情况下可能给 C 制造 9 次尝试。如果 A、B、C 更多层都各自重试,放大更严重。因此成熟系统通常会限制“在哪一层重试”、设置重试预算,并在依赖已经明显过载时减少甚至停止重试。

9. 熔断器:它维护的是一段时间内的依赖健康状态

熔断器通常维护三个状态:关闭、打开、半开。关闭状态下请求正常发送,同时统计最近窗口中的失败率、连续失败次数、慢调用比例或其他信号。达到阈值时进入打开状态,后续请求直接快速失败或走降级路径,不再让每个请求都真实打到故障依赖。

打开状态经过冷却时间后进入半开状态。半开状态只允许少量探测请求通过。探测成功达到条件后回到关闭状态;探测仍失败时重新打开,并继续等待下一次恢复窗口。限制半开请求数量很重要,因为一个刚恢复的服务可能只能承受少量负载,直接恢复全量流量可能再次把它压垮。

熔断器关注“这个依赖最近整体是否值得继续调用”,超时关注“当前单次调用最多等多久”,限流关注“允许多少请求进入”,降级关注“核心路径失败时给用户什么替代结果”。它们经常组合使用,控制对象不同。

10. 固定窗口和滑动窗口:统计请求速率的两种思路

固定窗口只维护当前时间窗口编号和计数器。限制 100 QPS 时,可以用当前秒作为窗口:请求到来时,如果仍在同一秒就 count++,进入下一秒时把计数器清零。它实现简单,但边界处可能放大瞬时流量。例如上一秒最后 10ms 通过 100 个请求,下一秒最初 10ms 又通过 100 个请求,20ms 内实际通过 200 个请求。

精确滑动窗口可以维护最近一段时间内所有请求的时间戳。每次请求到来,先删除 now-window_size 之前的时间戳,再检查剩余数量是否小于阈值。它能准确回答“过去 1 秒到底来了多少请求”,但高 QPS 时保存和清理每个时间戳成本较高。

工程上常使用分桶近似:把 1 秒分为 10 个 100ms 小桶,每个桶只保存计数。判断时累加最近 10 个桶。桶越细,结果越接近真实滑动窗口,状态和更新成本也越高。

11. 令牌桶:令牌如何产生,为什么能允许突发

令牌桶维护两个核心状态:当前令牌数 tokens 和上次更新时间 last_refill_time,并配置生成速率 R 与桶容量 B。实现时不一定真的启动后台线程每毫秒添加令牌;请求到来时可以根据经过的时间“懒计算”应该补多少令牌:

new_tokens = elapsed_time * R
tokens = min(B, tokens + new_tokens)
last_refill_time = now

随后检查本次请求需要的令牌数。如果 tokens >= cost,扣除后放行;否则拒绝或等待。

假设 R=100/s, B=200。系统空闲两秒后桶最多积累 200 个令牌,因此可以在很短时间内连续放过最多 200 个请求。之后如果请求持续到来,长期平均通过速率会逐渐受 100/s 的补充速度限制。这就是令牌桶能同时做到“允许短时突发 + 控制长期平均速率”的原因。

桶容量 B 决定最大可吸收的突发量,生成速率 R 决定长期速率。只说“令牌桶每秒生成 N 个令牌”还不够,还需要解释状态更新和一次请求如何判定。

12. 漏桶:固定速率排出,突发被转化为排队

漏桶可以理解成一个有限请求队列,加上一个固定的出队速率。例如入口瞬间来了 100 个请求,而漏桶以 20 req/s 向下游发送,这 100 个请求先进入桶中排队,然后按固定速率逐步排出。桶容量满后,新来的请求会被拒绝或丢弃。

因此漏桶的核心状态是队列或当前积压量,以及固定排出速率。它强调让下游看到平滑流量。突发不会立即全部通过,而是转化为等待时间。令牌桶则可以利用之前积累的令牌让一批突发请求立即通过,所以两者在突发处理上差异明显。

可以把它们放到同一个例子里比较。限制长期 100 req/s,瞬间来 150 个请求:令牌桶如果当前存有 150 个令牌,可以很快放过全部 150 个;漏桶若固定按 100 req/s 排出,则大部分请求必须排队,输出仍接近 100 req/s。

13. 本地限流和全局限流的状态放在哪里

本地限流器的计数状态存在单个实例内。如果服务有 10 个实例,每台都限制 100 QPS,整个集群可能允许约 1000 QPS。它很适合保护单机线程、CPU 或连接数,因为要保护的资源本来就在本机。

如果业务要求“用户 42 在整个集群最多 100 QPS”,每个实例独立计数就会失真,因为用户请求可能被分到不同实例。此时需要共享状态,例如集中式限流服务或 Redis 中的原子计数。共享状态能得到更准确的全局额度,同时会增加一次网络调用和共享组件压力。

高流量系统还可以做分层配额:全局控制面把总额度按周期分配给各实例,本地再快速扣减。这样牺牲一些瞬时精确度,换取更低的中心状态访问成本。

14. 背压、负载丢弃和舱壁隔离

限流通常在请求进入某个系统或接口时直接限制速率。背压强调下游主动向上游表达“当前处理不过来”,上游因此减慢生产速度。消息流、流式传输和异步任务系统经常使用背压。

如果上游无法减速,就需要负载丢弃。系统稳定处理能力是 1000 QPS,入口持续 1500 QPS 时,无限接收只会形成越来越长的队列;主动拒绝一部分请求可以保护仍然有机会按时完成的工作。拒绝越早,浪费的下游资源越少。

舱壁隔离通过把资源分池来限制故障传播。例如关键支付请求和非关键推荐请求使用独立并发额度,推荐依赖卡住时不会占满支付所需的全部线程或连接。它会降低资源共享效率,但可以显著缩小故障影响范围。

15. 一条调用链怎样把这些机制组合起来

假设 A 调 B,B 调 C,总截止时间为 800ms。A 已使用 200ms 后调用 B,B 获得的预算只剩约 600ms;B 自己处理 150ms 后调 C,C 的预算进一步减少。每层都应该尊重剩余时间,并在上游取消时停止已经没有价值的工作。

如果 C 返回可重试的暂态错误,B 还要检查三个条件:剩余截止时间够不够再尝试一次、操作是否具备幂等性、当前熔断器是否允许真实调用。允许重试时使用有限次数、指数退避和随机抖动;如果 C 已经持续异常,熔断器可能直接快速失败。B 自己还可能受本地并发上限和限流器保护。

这些机制最终都在管理有限资源。截止时间限制请求能占资源多久;重试决定失败后是否再次消耗资源;熔断阻止持续失败依赖继续消耗资源;限流限制进入速率;背压让上游主动减速;负载丢弃在容量不足时提前拒绝;舱壁隔离限制某一依赖最多占用多少共享资源。


参考资料

  • Amazon Dynamo 论文:一致性哈希用于动态数据分区,key 沿哈希环顺时针寻找负责节点。
  • gRPC Deadlines:截止时间、服务端取消以及截止时间传播。
  • gRPC Retry:有限重试、指数退避、随机抖动和重试节流。
  • Microsoft Azure Architecture Center Circuit Breaker Pattern:关闭、打开、半开状态及恢复探测。
  • NGINX 官方限流文档:基于漏桶思想的请求速率限制。