系统的高可用性不仅体现在系统架构设计上,还体现在系统日常对外提供服务的稳定性上。
本章讨论的是微服务架构下的服务可用性治理:

  • 3.1节介绍微服务架构与网络调用的问题。
  • 3.2节介绍请求重试及需要考虑的技术问题。
  • 3.3节介绍熔断与隔离,也就是上游服务如何更好地保护下游服务、如何防止上游服务被下游服务拖垮,以及如何防止一个服务内各个接口之间因质量问题而相互影响。
  • 3.4节与3.5节介绍限流,也就是下游服务如何更好地保护自己。
  • 3.6节介绍服务降级策略及相关示例。

3.1 微服务架构与网络调用

单体服务拆成微服务后,原来进程内的方法调用会变成多个服务之间的网络调用。服务越多,依赖链路越复杂,一个服务正常工作往往依赖多个下游服务共同完成。

以个人页服务为例:为了展示完整的用户主页,个人页服务需要对多个其他微服务发起 RPC 请求。

image-20260526110946023

微服务架构和单体架构最大的区别之一就是:

单体架构:进程内调用为主
微服务架构:跨服务网络调用为主

网络是不稳定的,RPC 请求可能遇到:超时、抖动、连接断开等问题,这些问题都会直接影响微服务的可用性。


上游服务和下游服务

如果服务 A 调用服务 B,那么:

A 是 B 的上游服务
B 是 A 的下游服务

一个微服务的服务质量会同时受上游和下游影响:

上游请求太多:可能把本服务打垮
下游质量太差:本服务可能拿不到结果,进而不可用

服务可用性治理的两个方向

书中把服务可用性治理归纳为两个方向。

1. 容错性设计

容错性设计的前提是接受两个事实:

网络本身脆弱
下游服务质量不一定可靠

所以在服务设计时,要提前考虑请求失败、超时、下游不可用时怎么处理。

本章中对应的方案主要是:重试、降级

2. 流量控制

流量控制既包括保护自己,也包括适当保护下游:

面对上游:防止上游请求量过大,把自己打垮
面对下游:感知下游质量下降,及时减少或停止调用

本章中对应的方案主要是:熔断、隔离、限流


本节总结

微服务架构下,一个业务请求往往会经过多级 RPC 调用。网络异常、上游流量暴增、下游服务质量下降,都会影响服务可用性。

服务可用性治理的核心思路:
1. 容错性设计:接受失败,并设计失败后的处理方式
2. 流量控制:控制请求量,防止服务之间互相拖垮

本章后续主要围绕 5 种通用方案展开:重试、熔断、隔离、限流、降级

一句话总结:微服务的可用性问题本质上来自复杂网络调用,所以要同时做好容错和流量控制。

3.2 重试

RPC 请求遇到网络抖动时,最直接的处理方式就是重试。重试可以提高单次 RPC 请求的最终成功率,从而增强服务面对网络抖动时的可用性。

但重试不是“失败了就再来一次”这么简单,它至少要考虑三个问题:

1. 这个接口能不能被安全重试?
2. 什么错误适合重试?什么时候重试?
3. 怎样避免重试放大流量,形成重试风暴?

3.2.1 幂等接口

当 RPC 请求超时时,调用方并不知道请求到底执行到了哪个阶段。有三种可能的情况:

1. 请求发送超时:下游服务可能根本没收到请求
2. 请求处理超时:下游服务已经收到请求,但处理时间过长
3. 响应报文超时:下游服务已经处理完请求,但响应没有及时返回

调用方无法准确判断请求是否已经被下游成功处理,所以只能按最坏情况理解

下游已经成功处理了请求
但调用方没有收到响应

这时如果调用方要重试,下游接口就必须保证重复处理同一个请求仍然符合用户预期。

例如扣款接口:用户购买 100 元商品,请求第一次实际已经扣款成功,只是响应超时。如果重试后再次扣 100 元,用户最终被扣 200 元,这就不符合预期。

所以,能被安全重试的接口必须满足幂等性。

幂等接口:同一个请求执行一次,和重复执行多次,最终结果应该相同。

读接口和写接口

读接口天然是幂等的,因为读操作不会改变数据。

写接口需要具体分析。以 SQL 为例:

覆盖写:UPDATE table1 SET col1 = X WHERE col2 = Y
结果始终是 col1 = X,属于幂等写操作

累加写:UPDATE table1 SET col1 = col1 + 1 WHERE col2 = Y
每执行一次都会改变结果,不是幂等写操作

插入写:INSERT INTO table1(col1, col2) VALUES(X, Y)
重复执行会插入多条数据,不是幂等写操作

幂等接口:非通用方案


基于数据库唯一约束实现幂等

(适用于上面提到的插入写)

核心思想:通过数据库唯一键约束保证同一条业务数据只能被插入一次。

适用场景: 如果接口涉及数据库插入操作,可以提前给相关业务数据表设置唯一键。重复调用此接口时,数据库会报 “键重复错误”,这说明该数据已经被插入过。

例如:订单号唯一、业务流水号唯一、或者 unique(user_id, product_id) 联合唯一索引也非常常见

注意:其与下面后面那种数据库防重表看上去相似(都基于唯一约束),但是这里是把唯一索引加在业务表本身上。而数据库防重表是额外建一张表,把唯一索引加在请求记录表上。


基于 CAS 思想实现更新操作幂等

(适用于上面提到的累加写)

适用场景:如果接口涉及数据库更新操作,可以借鉴 CAS 思想。

核心思想是:更新前先判断数据状态是否符合预期,只有符合预期才执行更新。

具体做法

可以为数据库行数据引入一个版本号字段,例如:

UPDATE table1 SET col1 = col1 + 1 WHERE version = x AND col2 = y;

其中:

  • version = x:表示只有当前版本号符合预期时才更新
  • col2 = y:表示业务条件也必须满足
  • col1 = col1 + 1:表示真正要执行的更新操作

重复调用时的效果

第一次调用接口时,version 还是原来的值,SQL 可以正常命中数据并执行更新。

重复调用接口时,version 字段已经发生变化,因此 SQL 无法命中原来的数据行。

结果是:重复调用不会真正执行更新操作,从而满足幂等性。


以上保证幕等性的方案要求写操作必须是数据库操作,通用性较差。更直接的做法是采用判断请求是否已处理的思路:对于每个请求,使用分布式唯一 ID 作为 UUID,同时在接口侧保存已处理的请求记录;如果找到相应的记录,则说明它处理过此请求,直接返回成功即可,具体内容如下展开。


幂等接口设计方案一:Redis 分布式锁

核心思路是:为每个请求生成一个全局唯一 ID,用 Redis 记录这个请求是否处理过。

SET UUID EX 3600 NX

其中:

NX:只有 key 不存在时才写入成功
EX 3600:设置 3600 秒过期时间

流程可以理解为:

请求进入接口
→ 尝试写入 UUID
→ 写入成功:说明请求没处理过,继续处理
→ 写入失败:说明请求处理过,直接返回成功

设置过期时间是为了控制 Redis 存储空间。但过期时间也不能太短,因为它决定了接口保证幂等性的有效期。


幂等接口设计方案二:数据库防重表

数据库防重表利用的是唯一索引。

做法是专门建一张表保存已处理请求记录,并把请求 UUID 设为唯一索引。接口处理请求前,先把请求记录插入防重表:

插入成功:请求第一次到达,可以继续处理
唯一索引冲突:请求已经处理过,直接返回

例如:

request_idempotent_table
- request_id 唯一索引
- status
- create_time

请求一来,先插入:

insert into request_idempotent_table(request_id)

插入成功,说明第一次处理,然后继续执行业务逻辑。

唯一索引冲突,说明这个请求之前来过,可以不再重复处理。

它的通用性在于:不管后面的业务逻辑是什么,都可以先用这张防重表挡住重复请求。

幂等接口设计方案三:token

Token 方案的核心是:先向目标服务领一次 token,然后调用接口时带上这个 token,目标服务通过“删除 token 是否成功”判断请求是否第一次执行。

流程:

  1. 上游服务先向目标服务申请一个唯一 token,目标服务把 token 存到 Redis。

  2. 上游服务调用真正接口时携带这个 token。

  3. 目标服务收到请求后,尝试从 Redis 删除该 token:

    • 删除成功:说明 token 还没被用过,这是第一次请求,继续执行业务逻辑。

    • 删除失败:说明 token 不存在,可能已经被前一次请求用掉了,直接返回即可。

注意:获取 token 只执行一次,真正调用接口可以重试。


3.2.2 重试时机

重试时机包含两个维度:

  • 遇到什么错误时重试?
  • 失败后什么时候重试?

RPC 调用错误大致可以分为三类。

错误类型 含义 是否适合重试
业务逻辑错误 参数非法、余额不足等业务自身不成立 不适合
服务质量异常错误 下游限流、拒绝服务、熔断等 可谨慎重试,但要退避
网络错误 超时、丢包、网络抖动、连接断开等 可以在未熔断前提下重试

常见退避策略:

无退避:失败后立即重试
线性退避:每次等待固定时间
随机退避:在一个时间范围内随机等待
指数退避:每次等待时间是上一次的 2 倍
综合退避:指数退避 + 随机退避,消息中间件常用

重试判断的核心原则:

业务逻辑错误:重试没有意义
服务质量异常:不要立即重试,要给下游恢复时间
网络错误:具有随机性,可在未熔断前提下立即重试

3.2.3 重试风险与重试风暴

重试可以提高请求成功率,但也会放大下游服务的请求量。

如果下游服务本来就因为容量不足而负载升高,上游继续重试会进一步增大下游压力,最终可能拖垮整个服务。

因此,每个请求都应该设置最大重试次数。生产环境里一般请求失败后最多重试 3 次,也就是不要无限重试。

即使对请求设置了最大重试次数,也可能会产生重试风暴现象。

假设服务链路是:

Server-1 → Server-2 → Server-3 → 数据库

如果每一层都最多请求 3 次,那么来自 Server-1 的 1 次业务请求,可能被层层放大成:

3 × 3 × 3 = 27 次数据库请求

数据库原本已经高负载,级联重试会让压力更大,这就是重试风暴。


3.2.4 重试控制:不重试的请求

重试的目标是提高服务质量。如果重试不能提高服务质量,就不应该重试。

1. 非关键下游服务

如果某个下游服务不是关键依赖,就应该接受请求失败,而不是执意重试。

例如个人页服务中,用户昵称、头像、已发布内容更关键;IP 地址归属地只是锦上添花。如果地理位置服务调用失败,可以不重试,把容量留给更需要它的业务。

2. 上游服务的重试请求不再重试

为了防止级联重试,可以让重试请求在请求头中携带标记。

如果当前请求已经是上游服务的重试请求,那么本服务调用下游失败时,不再继续重试。

这样可以避免请求量在服务链路上被多层放大。

3. 服务质量异常错误

如果下游已经返回限流错误,或者本服务内部熔断器已经熔断下游服务,说明下游负载或质量存在明显问题。

这时继续重试可能会进一步压垮下游,所以不应该重试。


3.2.5 重试控制:重试请求比

最大重试次数只能限制单个请求的放大倍数,不能控制整体重试量。

如果大量请求同时失败,即使每个请求只重试少数几次,也会形成很大的重试流量。因此还需要控制整体重试请求比。

每个上游服务都应该控制正常请求总数与重试请求总数的比例。

重试请求比 = 重试请求总数 / 正常请求总数

滑动窗口统计方式如图所示:

image-20260526132533317

例如策略是最近 10 秒内重试请求比低于 10% 才允许重试:

SC:正常请求数
RC:重试请求数

取最近 10 个桶:
sum(RC) / sum(SC) < 0.1
→ 允许重试

这样可以把整体请求放大控制在约 1.1 倍。


本节总结

重试可以提高 RPC 请求的最终成功率,但前提是接口可以安全重试。

能重试的接口:必须满足幂等性
不能重试的场景:业务逻辑错误、非关键依赖、下游质量异常、上游已经重试过的请求

幂等接口常见实现:

Redis 分布式锁
数据库防重表
token 方案

重试控制重点:

设置最大重试次数
识别重试请求,避免级联放大
控制重试请求比,避免整体流量膨胀

一句话总结:重试能提升成功率,但必须以幂等、退避和重试量控制为前提。

3.3 熔断与隔离

熔断和隔离都是上游服务可以采取的流量控制策略。

熔断:发现下游质量差时,停止或减少调用,防止被下游拖垮
隔离:不要让所有接口共用同一份资源,避免一个接口出问题时,把其他正常接口也拖死。

关于隔离的补充:

比如一个服务里有两个接口:

接口 A:下单接口
接口 B:查询接口

如果它们共用同一个线程池、连接池、队列:

接口 A 出现问题,请求大量阻塞
→ 占满线程池
→ 接口 B 明明没问题,也拿不到线程处理
→ 接口 B 也被拖慢甚至不可用

3.3.1 服务雪崩

每个微服务都难以保证 100% 可用。某个服务出现质量问题后,它的上游服务调用线程可能会被大量阻塞。

如果阻塞线程越来越多,上游服务自身负载升高,最终也可能宕机。故障沿着依赖链路向上传播,最后导致整个服务链路不可用,这就是服务雪崩

示例:

Server-1 → Server-2 → Server-3

故障传播过程:

Server-3 宕机或响应极慢
→ Server-2 大量线程阻塞在调用 Server-3 上
→ Server-2 被拖垮
→ Server-1 继续调用 Server-2,也被拖垮
→ 用户感知整个链路不可用

熔断的目的就是在风险扩大前,主动断开对故障下游的调用


3.3.2 Hystrix 熔断器

Hystrix 熔断器把下游服务分为 3 种熔断状态。

状态 含义
Closed,熔断关闭 默认状态,下游正常,可以正常调用
Open,熔断开启 下游失败率达到阈值,不再调用下游
Half-Open,熔断半开 经过一段时间后,允许一个请求试探下游是否恢复

状态转换关系:

平时正常调(Closed)→ 故障多了就暂停调(Open)→ 过一会儿少量试探(Half-Open)→ 恢复了就继续正常调(Closed),不行就继续熔断(Open)。

关键变量

变量名 含义
timeInMilliseconds 统计时间窗口,Hystrix 默认 10s
requestVolumeThreshold 请求总数阈值,默认 20,防止样本太小导致误判
sleepWindowInMilliseconds 休眠时间窗口,Open 后多久进入 Half-Open 试探
errorThresholdPercentage 请求失败率阈值,默认 50,即失败率达到 50% 开启熔断

工作流程

调用下游服务前,熔断器先判断请求是否允许执行。

Closed:允许请求执行
Open:如果还没过休眠时间,拒绝请求;如果已过休眠时间,放一个请求试探,并转为 Half-Open
Half-Open:拒绝其他请求,只允许那个试探请求执行

试探请求执行后:

调用成功:Half-Open → Closed,认为下游恢复
调用失败:Half-Open → Open,继续熔断

同时,Hystrix Metrics 会统计时间窗口内的请求成功数和失败数。如果满足下面条件,就开启熔断:

熔断器处于 Closed
统计窗口内请求总数 >= requestVolumeThreshold
请求失败率 >= errorThresholdPercentage

Hystrix 已停止维护,Resilience4j 、Sentinel 才是现在的常用选择。下面简单对比 Resilience4j 和 Sentinel 相比 Hystrix 的改进。


3.3.3 Resilience4j 和 Sentinel 熔断器

不同熔断器的策略对比:

开源项目 熔断器策略
Hystrix 时间窗口请求失败率
Resilience4j 时间窗口请求失败率、慢调用比例
Sentinel 时间窗口请求失败率、慢调用比例、错误计数

慢调用比例

Resilience4j 除了关注失败率,还关注慢调用比例。

变量名 含义
slowCallDurationThreshold 调用耗时阈值,高于该阈值视为慢调用
slowCallRateThreshold 慢调用比例阈值,慢调用比例超过该阈值时开启熔断

慢调用比例关注的是下游性能劣化,而不仅仅是请求失败。

错误计数

Sentinel 还支持错误计数策略:

最近 1 分钟请求失败数超过阈值
→ 开启熔断

这种方式不依赖失败率,可以避免请求样本很大时,需要大量失败请求才能达到失败率阈值的问题。


3.3.4 共享资源与舱壁隔离

业务服务通常会用固定大小的线程池处理下游调用。如果多个接口共享同一个线程池,那么一个下游服务响应变慢,可能会占住大量线程,进而影响其他接口。

举例:服务 A 有 150 个线程,并提供 3 个接口 I1、I2、I3,分别依赖服务 B、C、D。正常情况下,每个接口占 50 个线程。

如果服务 B 响应非常慢:

I1 调用 B 的线程长时间阻塞
→ I1 占用越来越多线程
→ I2、I3 可用线程变少
→ I2、I3 也开始拒绝请求

这说明共享线程池会导致接口之间互相影响。

“舱壁隔离”的思想来自船舶:把船舱分成多个隔离空间,一个船舱漏水不会影响其他船舱。

在服务中,舱壁隔离就是:

把共享资源拆成多个独立资源
一个下游或接口出问题,不影响其他部分

3.3.5 舱壁隔离的实现

Hystrix 提供两种舱壁隔离策略:线程池隔离和信号量隔离。

1. 线程池隔离

线程池隔离的做法是:为每个下游服务创建专用线程池。

例如服务 A 依赖 B、C、D,可以创建 3 个线程池:

调用 B:线程池 B,大小 50
调用 C:线程池 C,大小 50
调用 D:线程池 D,大小 50

如果服务 B 响应变慢,只会耗尽线程池 B,不会占用调用 C、D 的线程池。

缺点是:如果下游服务很多,就要创建很多线程池,会增加内存开销、线程调度开销和上下文切换开销。

2. 信号量隔离

信号量隔离通过信号量的 PV 操作限制并发调用量。

信号量可以理解为一个资源计数器:

S > 0:还有 S 个资源可用
S <= 0:没有可用资源,请求需要等待或被拒绝

PV 操作:

P 操作:获取资源,S = S - 1
V 操作:归还资源,S = S + 1

Hystrix 会为每个下游服务创建一个信号量,并设置初始值。例如给 B、C、D 分别设置值为 50 的信号量,就可以保证每个下游服务的并发调用量不超过 50。

调用下游前:先执行 P 操作
调用完成后:执行 V 操作

这样即使 B 响应很慢,也最多占用 50 个并发调用,不会挤压 C、D。


本节总结

熔断解决的是服务雪崩问题:当下游服务质量下降时,上游服务主动停止调用,避免被拖垮。

Hystrix 三种状态:
Closed:正常调用
Open:停止调用
Half-Open:放一个请求试探恢复情况

隔离解决的是共享资源互相影响问题:

线程池隔离:每个下游服务独立线程池,隔离效果强,但资源开销大
信号量隔离:每个下游服务限制并发数,开销较小

一句话总结:熔断是及时断开故障链路,隔离是把共享资源切开,二者都是为了防止局部故障扩大成整体不可用。

3.4 限流

重试、熔断、隔离更多是上游服务为了提高自身质量、适当保护下游而采取的策略。限流则主要是下游服务保护自己,防止被上游的海量请求打垮。

限流的常见形式:

  • 频控:限制单个用户在一段时间内的操作次数
  • 单机限流 + 固定阈值:每台机器每秒最多处理固定请求数
  • 全局限流 + 固定阈值:整个服务所有实例共享一个限流阈值
  • 单机自适应限流:每台机器根据自身状态动态决定是否限流

3.4.1 频控

频控是一种特殊的限流,通常面向单个用户的操作频率。

例如:

30 秒内只能下载 1 次文件
1 小时内最多发布 5 条动态

可以借助Redis实现频控。

1. N 秒内只能执行 1 次操作

可以使用 Redis SET 命令结合 EXNX 参数。

SET download_{userID} 0 EX 30 NX

含义是:

NX:只有 key 不存在时才写入成功
EX 30:key 30 秒后过期

判断逻辑:

写入成功:最近 30 秒没有下载过,允许下载
写入失败:key 已经存在,最近 30 秒下载过,拒绝下载

2. N 秒内最多执行 M 次操作

如果要限制一段时间内最多执行 M 次,就需要维护计数。

核心命令:

INCR publish_{userID}      # 如果这个 key 不存在,Redis 会先把它当成 0,然后再加 1  
EXPIRE publish_{userID} 3600 # 给 key 设置过期时间

关键点是:只有首次计数时才设置过期时间。为了保证 INCREXPIRE 的并发安全,使用 Lua 脚本实现。

流程可以简化为:

count = INCR key
if count == 1:
EXPIRE key N 秒
if count > M:
触发频控
else:
允许操作

3.4.2 单机限流 1:时间窗口

时间窗口限流是最容易理解的限流算法。

例如限制 1 秒内最多 100 个请求,可以把时间线切成 1 秒一个窗口,每个窗口有 100 个计数。

请求到来
→ 向当前窗口申请 1 个计数
→ 计数大于 0:计数减 1,请求通过
→ 计数等于 0:请求被丢弃

固定时间窗口的问题是:窗口边界附近可能出现 2 倍流量。

例如:

11:30:06 后 500ms 通过 100 个请求
11:30:07 前 500ms 又通过 100 个请求

这样在中间连续 1 秒内,实际上通过了 200 个请求,不符合“1 秒最多 100 个”的预期。因此限流效果比较差。

滑动时间窗口

滑动时间窗口把时间切成更小的槽,例如 50ms 一个槽。

1 秒窗口 = 最近 20 个 50ms 槽的总和
窗口随着时间向后滑动

请求到来时,在最近的多个槽中查找是否还有可用计数:

找到可用槽:扣减计数,请求通过
所有槽都无计数:请求被限流

滑动窗口能提高限流准确性,槽越小越准确。但它需要维护更多槽信息,并在窗口内查找可用槽,内存和 CPU 开销更大。


3.4.3 单机限流 2:漏桶算法

漏桶算法可以类比一个漏水的桶:

请求以任意速率进入漏桶
服务器以固定速率从漏桶取出请求处理
漏桶满了,新请求被拒绝

漏桶通常可以用队列实现,体现的是先进先出。

漏桶算法的优点是请求处理速率稳定,可以保护服务。

缺点也明显:

请求需要排队,响应时间会增加
不支持突增流量,这些突增流量对服务来说可能完全没有处理压力,但请求却在缓慢排队,这是对服务性能的浪费。

3.4.4 单机限流 3:令牌桶算法

令牌桶算法是更常用的限流算法。它的核心思路是:系统按固定速率往桶里放令牌,请求处理前必须先拿到令牌。

基本流程:

1. 每秒向令牌桶放入 R 个令牌
2. 令牌桶容量为 B,最多保存 B 个令牌
3. 请求到来时,尝试获取 1 个令牌
4. 有令牌:扣减令牌,请求执行
5. 没有令牌:请求被限流

令牌桶比漏桶更灵活,因为它允许一定程度的突发流量。

例如限流阈值是每秒 100 个请求:

第 1 秒只有 80 个请求
→ 还剩 20 个令牌
→ 第 2 秒最多可允许 120 个请求通过

从长期看,令牌桶仍然把平均请求并发数限制在固定速率附近;但短期内可以利用桶中积累的令牌,容忍流量波动。


3.4.5 全局限流

单机限流只限制单个服务实例。全局限流要对一个服务的所有实例统一限流,所有实例共享同一个限流配额。

方案一:请求实时访问限流服务器

当任意服务实例收到请求时,先访问限流服务器,查询请求是否允许通过。限流服务器可以用 Redis 实现,细节类似 3.4.1 中的 “在N秒内最多执行M次操作的频控场景” 的频控方案一样,即使用Redis INCR命令,这里不再赘述。

优点:限流精准、实时性强、所有实例共享同一个配额

缺点:每个请求多一次网络调用、限流服务器容易成为单点和瓶颈、不适合请求量巨大的服务

方案二:时间片统计

时间片统计是一种“近似全局限流”方案。

它不是每来一个请求都去中心化服务判断能不能放行,而是:

先让各个服务实例在本地统计一段时间内的流量,再把统计结果汇总到限流服务器,由限流服务器计算一个新的丢弃比例,最后下发给所有服务实例,让它们在本地按概率丢弃请求。

所以它的特点是:不追求每个请求都精确控制,而是周期性根据最近一段时间的整体流量,动态调整各服务实例的限流比例。


为什么需要这种方案

假设一个服务部署了多个实例:

服务实例 1
服务实例 2
服务实例 3
...

如果每个实例都自己限流,那么只能做到单机限流,无法准确知道整个集群的总 QPS。

如果每个请求都去 Redis 或限流服务器扣减计数,虽然全局精确,但会有两个问题:

1. Redis / 限流服务器会成为额外依赖
2. 高并发下中心化限流组件本身可能成为瓶颈

所以时间片统计的思路是:不用每个请求都访问中心化组件,而是让各实例自己先统计,定期上报,再统一计算下一阶段的限流比例。


流程:

服务实例本地统计请求量

时间片结束后上报统计日志

日志收集器汇总所有实例的数据

计算出集群实际 QPS (QPS = 总请求数/时间片长度)

限流服务器根据实际 QPS 计算 dropRate

把 dropRate 下发给各服务实例

服务实例按 dropRate 本地随机丢弃请求

限流比例公式:

dropRate = (实际 QPS - 限流阈值) / 实际 QPS

例如:

实际 QPS = 1200
限流阈值 = 1000

dropRate = (1200 - 1000) / 1200 = 0.167

也就是每个请求有 16.7% 的概率被丢弃。

时间片不宜太长,否则调整不及时;也不宜太短,否则影响性能,10s、30s 是比较合适的选择。


Redis 频控方案和时间片统计方案的区别

可以这样对比:

方案 特点 优点 缺点 适合场景
Redis 频控 每个请求都访问 Redis 进行判断 精确、实时 Redis 成为额外依赖,可能影响稳定性 请求量不太大,要求限流精确
时间片统计 周期性统计,再下发限流比例 性能好,适合高并发 不够精确,有延迟 请求量大,允许近似限流

一句话总结:Redis 方案更精确,但中心化依赖更重;时间片统计方案更高性能,但限流结果是近似的。


什么场景适合全局限流

并不是所有业务都需要全局限流。

如果只是为了防止某台机器被打垮,通常使用单机限流就够了。

例如:每个服务实例最多处理 100 QPS,超过就本地拒绝。

这种情况下,不一定需要全局限流。


但如果要控制的是某个共享资源的总访问量,就适合全局限流。

例如秒杀场景:

商品总库存 = 1000

这时候限制的不是某台机器的流量,而是整个系统对这个商品的总请求量。

所以可以把 “商品总库存” 看作全局限流阈值。

对于抢购请求,就可以做全局限流,避免放进来太多无意义的请求。


本节总结

限流的目标是控制请求进入速度,防止服务被上游流量打垮。

频控:控制单个用户操作频率,常用 Redis 实现
时间窗口:简单,但固定窗口有边界流量问题
滑动窗口:更准确,但资源开销更大
漏桶算法:稳定处理请求,但会增加排队延迟
令牌桶算法:能限制长期平均速率,也能容忍短期突发流量
全局限流:适合共享资源控制,但要注意限流服务本身的瓶颈

一句话总结:限流不是单纯拒绝请求,而是在服务能力边界内控制流量进入,令牌桶通常是兼顾稳定性和突发流量的常用方案。

3.5 自适应限流

前面介绍的时间窗口、漏桶、令牌桶、全局限流,都依赖人工设置限流阈值,这就意味着这些限流策略的实际效果很被动,依赖限流阈值的设置是否足够合理。

人工阈值的问题是:服务能力会不断变化。

服务重构后性能提升:原阈值过低,会浪费资源
服务新增下游调用后性能下降:原阈值过高,保护不了服务

理论上,每次服务迭代后都应该重新压测并调整限流阈值,但这需要大量准备工作和人力成本,难以持续执行。

自适应限流的思路是:

让服务根据自身当前负载情况,自动判断是否继续处理新请求。

3.5.1 服务与等待队列

每个服务都有一定并发度。并发请求在服务能力范围内时,可以被立即处理;当请求量超过服务并发度时,请求就会排队等待。

Netflix 技术团队用等待队列描述服务处理请求的过程,。

请求到达服务
→ 有并发处理能力:立即执行
→ 没有并发处理能力:进入等待队列

如果不限制等待队列,请求会越积越多:

排队请求增加
→ 请求延迟升高
→ 请求大量超时
→ 服务因内存耗尽而崩溃

这就是服务过载。

自适应限流要自动找到一个合适的限流阈值:

在请求延迟开始升高前,服务能允许通过的最大请求并发数。

它应该具备几个特点:

不需要工程师手动设置阈值
能适应服务性能或机器资源变化
限流决策容易计算和执行,能精准防止服务过载

3.5.2 基于请求排队时间

Dagor 是微信团队研发的微服务过载控制系统,提供服务过载检测和准入控制能力。

这里重点关注 Dagor 怎样检测服务过载。

Dagor 使用等待队列中请求的平均等待时间判断服务是否过载:

排队时间 = 请求开始被处理的时间 - 请求到达服务的时间

微信团队设置的平均等待时间阈值是 20ms。如果请求被处理前的平均等待时间超过 20ms,就认为服务已经过载。

Dagor 没有直接使用 CPU 使用率作为过载标准,因为:

CPU 使用率高只是服务高负载的必要不充分条件
只要服务还能及时处理请求,就不应该认为服务过载

也没有直接使用请求响应时间作为过载标准,因为:

响应时间会受到下游服务处理能力影响
下游慢,不一定说明当前服务本身过载

因此,请求排队时间更直接反映当前服务自己的处理压力。


3.5.3 基于延迟比率

Netflix 的 concurrency-limits 组件借鉴 TCP 拥塞控制思想,通过请求最小延迟和采样延迟之间的比值动态调整限流窗口。

第一个核心公式用于计算延迟变化梯度:

gradient = RTT_noload / RTT_actual

变量含义:

RTT_noload:服务无负载时的最佳请求延迟,取最近一段时间内最小请求延迟
RTT_actual:当前采样请求的实际延迟
gradient:延迟比率,也就是梯度

梯度的含义:

gradient = 1:没有请求在等待队列中排队,可以适当放宽限流窗口
gradient < 1:可能已有请求排队,需要逐渐收紧限流窗口

第二个核心公式用于调整限流窗口:

new_limit = current_limit × gradient + queue_size

其中:

current_limit:当前限流窗口大小
queue_size:允许一定程度排队,一般取 current_limit 的平方根

这个公式能产生类似 TCP 拥塞控制的效果:

窗口小:增长更快
窗口大:增长更慢
出现排队:逐步收紧

Netflix 在组件中实现了 gradient、gradient2、vegas 等算法。

它们的共同点是:根据等待队列长度或请求延迟变化,持续上下调整限流窗口,最终收敛到合理范围。


3.5.4 其他方案

Kratos 是 bilibili 开源的 Go 微服务框架,其中提供了一种自适应限流器 BBR limiter,用于在服务压力过高时自动丢弃部分新请求,从而保护系统可用性。

核心思想:

BBR limiter 不是简单设置一个固定 QPS 阈值,而是根据系统当前状态动态判断是否限流。

它主要关注两个指标:

  1. CPU 使用率
    • CPU 低于阈值时,认为系统压力不大,直接放行请求。
    • CPU 超过阈值时,才进入进一步判断。
    • 默认阈值通常是 80%
  2. 当前正在处理的请求数 inFlight
    • 表示系统中还没有处理完成的请求数量。
    • 如果 inFlight 超过系统最近估算出的最大承载能力,就丢弃新请求。

BBR limiter 的本质是:

在 CPU 高负载时,根据服务最近的最大吞吐量和最小响应时间,估算服务当前能承载的最大并发请求数。如果当前正在处理的请求数超过这个估算值,就主动丢弃新请求。

它的优点是:

  • 不需要人为写死固定 QPS。
  • 能根据服务实际处理能力动态调整。
  • 可以在系统过载前主动保护服务。
  • 通过“上次限流时间”避免限流频繁抖动。

可以把它理解为一种 基于 CPU + inFlight + 动态吞吐估算的自适应限流方案


本节总结

自适应限流解决的是固定阈值难以长期适配服务能力变化的问题。

固定阈值:需要人工压测和配置,服务变更后容易失效
自适应限流:服务根据当前负载自动决定是否接收请求

典型思路:

Dagor:看请求平均排队时间,超过 20ms 认为过载
concurrency-limits:根据最小延迟和实际延迟比率调整限流窗口
BBR limiter:结合 CPU 负载、inFlight 和最大吞吐量判断是否丢弃请求

一句话总结:自适应限流的核心是让服务自己感知过载迹象,在延迟明显恶化前主动收紧请求入口。

3.6 降级策略

限流像景区控制游客数量,降级则像景区在高峰期暂时关闭不重要、风险较高或难管理的项目。

服务降级的目的:

保障用户核心体验
保障核心功能可用
在异常或高并发情况下,牺牲非核心场景
把资源释放给核心场景

降级方案通常和具体业务强相关,有几种常见思路。


3.6.1 服务依赖度降级

一个服务通常依赖多个下游服务,但每个下游服务的重要程度不一样。

如果 B 是 A 的下游服务,那么 B 对 A 的重要程度叫作依赖度。依赖度越高,说明 B 对 A 越重要。

常见依赖度划分方式:

二元划分:
强依赖:故障时业务不可接受
弱依赖:故障时业务可以暂时接受

三元划分:
一级依赖:故障会导致服务完全不可用
二级依赖:故障基本不影响服务可用性,但可能有少量用户投诉
三级依赖:故障不影响服务可用性,基本没有用户投诉

当请求量暴增时,就可以优先切断低依赖度的下游调用,把资源留给更核心的下游服务。

盛典活动示例

背景:A 服务在活动期间预计 QPS 高达 100 万,研发团队需要提前做容量评估、扩容、全链路压测,并梳理所有下游依赖。

对各个服务依赖度分级为:

一级依赖:B、D、H、J
二级依赖:C、G
三级依赖:E、F、I、K

活动开始前,可以先关闭三级依赖调用。活动过程中如果 A 服务性能继续劣化,再关闭二级依赖调用。这样 A 服务最终只保留一级依赖调用,把资源留给核心功能。

依赖度也能用于其他治理策略

服务依赖度不只用于降级,也可以增强其他策略:

重试:只对强依赖下游进行重试
熔断:弱依赖可以更早停止调用,强依赖在确认不可用时再熔断
限流:强依赖请求优先执行,弱依赖请求更容易被限流

3.6.2 读请求降级

读请求降级主要依赖两类方案:缓存、兜底数据

一个请求从客户端到最终数据响应,通常会依次经过:

客户端
接入层网关
HTTP 服务
RPC 服务
分布式缓存
数据库

可以在客户端、接入层网关、HTTP/RPC 服务中设置本地缓存,并通过动态配置中心控制各层是否使用缓存,以及缓存过期时间。

image-20260526204116071

兜底数据

兜底数据的思路是:如果访问目标数据失败,就降级为访问另一份基本可用的数据

兜底数据可以是静态数据,也可以来自另一个数据源。

示例一:推荐服务兜底

信息流推荐服务依赖两个核心下游:

推荐算法服务:计算用户可能感兴趣的内容 ID
内容服务:根据内容 ID 打包完整内容

推荐算法服务是计算密集型场景,可用性和性能可能不稳定。可以每天零点离线计算一批用户可能感兴趣的内容或热门内容 ID,由兜底推荐服务维护。

推荐算法服务正常:使用推荐算法结果
推荐算法服务超时或不可用:调用兜底推荐服务获取内容 ID

示例二:礼物列表静态兜底

直播平台的礼物列表服务不可用时,用户可能无法打赏,影响收入。

可以在接入层网关预先配置几个常见礼物数据:

礼物列表服务正常:返回完整礼物列表
礼物列表服务失败:网关返回静态配置的常见礼物

这样用户至少能看到部分礼物,保留打赏可能性。


3.6.3 写请求降级

写请求降级可以使用第 2 章讲过的异步写和写聚合,这里不再展开。

除此之外,某些业务场景也可以直接丢弃写请求。

例如:直播间弹幕自见。

正常弹幕流程:

用户发送弹幕
→ 弹幕服务接收消息
→ 弹幕服务广播给直播间所有用户

如果直播间有 100 万在线用户,每秒 100 个用户发送弹幕,那么弹幕服务每秒可能需要下发 1 亿条消息,网络带宽压力巨大。

弹幕自见的降级方案:

1. 用户 A 发送弹幕后,客户端直接在本地展示这条弹幕
用户 A 认为发送成功

2. 动态配置中心配置客户端消息丢弃比例
客户端按比例决定是否把弹幕发送到服务端

3. 弹幕服务不再广播给全部用户
而是按消息广播比例随机广播给部分用户

4. 研发工程师根据弹幕服务性能
实时调整客户端消息丢弃比例和消息广播比例

这个方案可行,是因为热门直播间弹幕量本来就很大,单个用户的弹幕很容易被快速刷过去;用户通常没有强诉求要求所有人都看到自己的弹幕。


本节总结

降级的目标是保障核心体验和核心功能可用。

服务依赖度降级:
按强依赖/弱依赖或一二三级依赖,优先切断低依赖度调用

读请求降级:
使用多级缓存和兜底数据

写请求降级:
使用异步写、写聚合,必要时按业务特性丢弃部分写请求

降级不是固定技术方案,而是根据业务重要性做取舍。

一句话总结:降级就是在压力或故障下主动牺牲非核心能力,把有限资源留给最核心的用户体验。

3.7 本章小结

微服务架构通过复杂的网络调用把多个服务串联起来。网络健壮性、下游服务质量和请求流量变化都会影响每个服务的可用性。

image-20260526210440734

本章的核心治理手段可以整理为:

重试:提高单次请求的最终成功率
熔断:下游持续异常时暂时停止调用,避免调用方被拖垮,并减少对下游的压力,防止故障扩散
隔离:为不同接口(不同下游调用)划分独立资源,例如线程池、连接池、信号量等,防止接口互相拖累。某个下游服务异常、超时或阻塞时耗尽共享资源,从而影响其他调用。
限流:控制入口流量,保护服务不被打垮
降级:牺牲非核心能力,保障核心功能可用

重点复习

1. 重试

  • 只有幂等接口可以安全重试。读接口天然幂等,写接口需要做幂等设计。

  • 幂等设计包括:Redis 分布式锁、数据库防重表、token 方案

    对于一些特殊业务,如

    一些插入写情况,可以基于数据库业务表唯一约束

    一些累加写情况,可以基于乐观锁

  • 需要根据调用失败的原因决定是否需要重试。如果重试、则需要使用合适的退避策略决定请求重试的时机

  • 为了防止重试风暴,需要控制 最大重试次数和重试请求比


2. 熔断

  • 熔断器可以自动感知下游服务故障,开启熔断后停止调用下游。
  • Hystrix 熔断器基于时间窗口失败率的策略开启熔断,是最基本的熔断器。
  • 在开启熔断一段时间后,Hystrix 熔断器通过半开状态(Half-Open)探测下游服务是否已经恢复。
  • Resilience4j 熔断器还采用了慢调用比例策略开启熔断(慢速请求在总请求量中的比例是否达到阈值)
  • Sentinel 熔断器还可以基于错误计数的策略开启熔断(最近 1 分钟的请求失败数超过阈值)。避免请求样本很大时,需要大量失败请求才能达到失败率阈值的问题。

3. 隔离

为不同接口(不同下游调用)划分独立资源,例如线程池、连接池、信号量等,防止接口互相拖累。某个下游服务异常、超时或阻塞时耗尽共享资源,从而影响其他调用。

线程池隔离:每个下游独立线程池
信号量隔离:每个下游限制并发调用数

4. 限流

限流是下游服务保护自己的手段。

  • 频控是一种特殊的限流,对单个用户限制操作频率,基于 Redis 实现。
  • 单机限流有滑动时间窗口算法、漏桶算法、令牌桶算法,限流阈值依赖人工设置,令牌桶算法表现最优。
  • 全局限流通过引入限流服务,业务服务的各个实例共享限流配额,只有涉及共享资源并发控制时才建议使用此策略。
  • 服务处理请求的流程可以被抽象为请求在等待队列中排队等待执行。
  • 自适应限流不需要人工设置限流阈值,而是根据服务实例的当前服务能力动态限流。业界采用的方案有基于请求排队时间和基于延迟比率的自适应限流,以及 BBR limiter 算法等。

5. 降级

降级用于保障核心功能可用,具体的实施方案比较灵活。

  • 服务依赖度降级:切断低依赖度下游调用,将服务资源倾斜给重要的下游服务
  • 读请求降级:可以使用多级缓存数据或兜底数据做降级策略,兜底数据可以是静态数据,也可以是来自另一个数据源的数据
  • 写请求降级:异步写和写聚合是较为通用的降级策略,在某些业务场景中也可以丢弃写请求,减小写请求量级。