系统上下游弹性机制全解析
1. 下游弹性机制
1.1 网络调用封装
在进行网络调用时,为了能够调试生产问题,需要监控系统的关键节点。理想情况下,可以将远程调用封装在一个库中,该库能设置超时时间并进行监控,这样每次进行网络调用时就无需手动设置。无论使用哪种编程语言,都可能存在实现了弹性和瞬态故障处理模式的库,可用于封装系统的网络调用。此外,还可以利用位于同一机器上的反向代理来拦截进程发出的所有远程调用,该代理会强制执行超时设置并监控调用,从而让进程无需承担这些责任。
1.2 重试机制
1.2.1 指数退避
当客户端进行网络请求时,应配置超时时间。若请求失败或超时,客户端有两个选择:快速失败或稍后重试。如果失败或超时是由短期连接问题导致的,在退避一段时间后重试很可能会成功。但如果下游服务不堪重负,立即重试只会使情况更糟。因此,需要通过指数退避算法来减缓重试速度,即每次重试的延迟时间逐渐增加,直到达到最大重试次数或自初始请求起经过了一定时间。延迟时间的计算公式为:
delay = 𝑚𝑖𝑛(cap, initial-backoff ⋅2attempt)
例如,若上限(cap)设置为 8 秒,初始退避时间为 2 秒,那么第一次重试延迟为 2 秒,第二次为 4 秒,第三次为 8 秒,之后的延迟将被限制在 8 秒。
不过,指数退避仍存在问题。当下游服务暂时降级时,多个客户端的请求可能会同时失败,导致它们同时重试,给下游服务带来负载高峰,进一步降低其性能。为避免这种“羊群效应”,可以在延迟计算中引入随机抖动,公式如下:
delay = 𝑟𝑎𝑛𝑑𝑜𝑚(0, 𝑚𝑖𝑛(cap, initial-backoff ⋅2attempt))
这样,重试会在时间上分散开来,平滑下游服务的负载。
除了主动等待并重试失败的网络请求,在对实时性要求不高的批处理应用中,还可以将失败的请求放入重试队列,稍后由同一进程或其他进程从队列中读取并重试。
但并非所有网络调用都适合重试。如果错误不是短期的,如进程无权访问远程端点,重试将毫无意义,此时应快速失败并立即取消调用。同时,对于非幂等且副作用会影响应用程序正确性的网络调用,也不应重试。例如,调用支付服务时若请求超时,在不确定操作是否成功的情况下,重试可能会导致账户重复扣费,除非请求是幂等的。
1.2.2 重试放大
当处理客户端请求需要经过一系列依赖服务时,若中间某个服务的请求失败并进行重试,可能会导致上游服务的执行时间变长,增加其超时的可能性,进而引发上游服务的重试,最终导致客户端也重试。这种在依赖链多个层级的重试会放大重试次数,服务在链中的位置越深,由于重试放大效应,其承受的负载就越高。当压力过大时,可能会导致整个系统崩溃。因此,在长依赖链中,应只在单一层级进行重试,其他层级则快速失败。
1.3 断路器
1.3.1 工作原理
如果服务使用超时机制检测与下游依赖的通信故障,并通过重试来缓解瞬态故障,但下游依赖持续无响应,此时就需要断路器机制。断路器的灵感来源于电路中的相同功能,其目标是允许子系统失败而不影响整个系统。当检测到下游依赖的长期降级时,断路器会阻止新请求发送到下游,直到下游恢复正常。
与重试机制不同,断路器会完全阻止网络调用,适用于长期降级的情况。重试机制适用于预期下一次调用会成功的情况,而断路器适用于预期下一次调用会失败的情况。
1.3.2 状态机
断路器通过状态机实现,有三种状态:打开、关闭和半开。
-
关闭状态
:断路器作为网络调用的透明通道,同时跟踪失败次数(如错误和超时)。若在预定义的时间间隔内失败次数超过某个阈值,断路器将跳闸并进入打开状态。
-
打开状态
:网络调用不会被尝试,请求会立即失败。此时需要考虑下游依赖不可用时的业务影响,若下游依赖非关键,服务应优雅降级。例如,飞机在飞行中失去非关键子系统时,不应坠毁,而应优雅降级以确保仍能飞行和降落;亚马逊首页的推荐服务不可用时,页面应在无推荐的情况下正常渲染。
-
半开状态
:经过一段时间后,断路器会给下游依赖一次机会,进入半开状态。此时允许下一个调用通过,如果调用成功,断路器将转换为关闭状态;若调用失败,则返回打开状态。
具体的失败次数阈值和从打开状态到半开状态的等待时间需要根据具体情况,结合过去的失败数据来确定。
以下是断路器状态转换的 mermaid 流程图:
graph LR;
classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px;
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
classDef decision fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;
A([关闭状态]):::startend -->|失败次数超阈值| B([打开状态]):::startend;
B -->|等待一段时间| C([半开状态]):::startend;
C -->|调用成功| A;
C -->|调用失败| B;
1.4 下游弹性机制总结
| 机制 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 网络调用封装 | 所有网络调用场景 | 方便监控和设置超时,减轻进程负担 | 需要引入额外的库或代理 |
| 指数退避重试 | 短期连接问题导致的请求失败 | 增加重试成功的可能性 | 可能导致“羊群效应” |
| 随机抖动指数退避重试 | 避免“羊群效应” | 平滑下游服务负载 | 增加了延迟计算的复杂性 |
| 断路器 | 下游依赖长期无响应 | 保护系统免受级联故障影响 | 需要根据具体情况调整参数 |
2. 上游弹性机制
2.1 负载丢弃
服务器无法完全控制在某一时刻接收到的请求数量,这会严重影响其性能。操作系统每个端口都有一个容量有限的连接队列,当队列满时,新的连接尝试将被立即拒绝。但在极端负载下,服务器通常会在达到该限制之前就因资源(如内存、线程、套接字或文件)耗尽而陷入停滞,导致响应时间增加,最终对外不可用。
当服务器达到容量上限时,继续接受新请求只会进一步降低其性能。此时,服务器应开始拒绝多余的请求,专注于处理已接收的请求。判断服务器是否过载的指标应是可测量且可操作的,例如,可以通过记录当前正在处理的并发请求数量来衡量服务器的负载,新请求到来时增加计数器,处理完请求并返回响应后减少计数器。
当服务器检测到过载时,可以通过快速失败并在响应中返回 503(服务不可用)状态码来拒绝传入请求,这种技术称为负载丢弃。服务器不一定需要随机拒绝请求,如果不同请求具有不同的优先级,服务器可以只拒绝低优先级的请求。
然而,拒绝请求并不能完全减轻服务器处理请求的成本。根据拒绝请求的实现方式,服务器可能仍需承担打开 TLS 连接和读取请求的成本,最终才拒绝请求。因此,负载丢弃的作用有限,如果负载持续增加,拒绝请求的成本最终会超过其带来的好处,导致服务性能下降。
2.2 负载均衡
负载均衡是负载丢弃的一种替代方案,适用于客户端不要求短时间内得到响应的场景。其核心思想是在客户端和服务之间引入一个消息通道,该通道将发往服务的负载与其处理能力解耦,使服务能够按照自己的节奏处理请求,而不是由客户端将请求推送给服务,而是由服务从通道中拉取请求。这种模式称为负载均衡,非常适合应对短期的负载高峰,通道可以平滑这些高峰。
负载丢弃和负载均衡都不能直接解决负载增加的问题,而是保护服务不被过载。为了处理更多的负载,服务需要进行横向扩展。因此,这些保护机制通常与自动扩展相结合,自动扩展可以检测服务是否处于高负载状态,并自动增加其规模以处理额外的负载。
2.3 速率限制
速率限制(或称为限流)是一种在特定配额超出时拒绝请求的机制。服务可以有多个配额,例如在一个时间间隔内接收到的请求数量或字节数。配额通常应用于特定的用户、API 密钥或 IP 地址。
例如,一个服务为每个 API 密钥设置了每秒 10 个请求的配额,如果某个 API 密钥平均每秒发送 12 个请求,那么该服务平均每秒将拒绝 2 个带有该 API 密钥的请求。
当服务对请求进行速率限制时,需要返回一个带有特定错误代码的响应,以便发送方知道请求失败是因为配额已被突破。对于具有 HTTP API 的服务,最常见的做法是返回状态码为 429(请求过多)的响应。响应中应包含关于哪个配额被突破以及超出了多少的详细信息,还可以包含一个 Retry - After 头,指示发送方在多长时间后可以再次发起请求,示例如下:
HTTP/1.1 429 Too Many Requests
Retry - After: 60
如果客户端应用遵守规则,它会在一段时间内停止频繁访问服务,从而保护服务免受非恶意用户因错误而过度占用资源的影响。这也可以防止客户端因各种原因反复无意义地访问下游服务的错误。
速率限制还可用于实施定价层级。如果用户想要使用更多的资源,就需要支付更多的费用。通过设置配额来实施定价层级,可以将服务成本分摊给用户。
虽然速率限制似乎可以有效防止分布式拒绝服务(DDoS)攻击,但实际上只能部分保护服务。被限流的客户端可能会在收到 429 响应后继续频繁访问服务。而且,对请求进行速率限制也并非没有成本,例如,为了按 API 密钥对请求进行速率限制,服务需要承担打开 TLS 连接的成本,并至少下载部分请求以读取密钥。尽管速率限制不能完全抵御 DDoS 攻击,但可以减少其影响。
真正能抵御 DDoS 攻击的是规模经济。如果在一个大型前端服务后面运行多个服务,无论后面的哪个服务受到攻击,前端服务都可以通过拒绝上游流量来承受攻击。这种方法的优点是运行前端服务的成本可以分摊到所有使用它的服务上。
速率限制与负载丢弃有所不同。负载丢弃是基于进程的本地状态(如当前正在处理的请求数量)来拒绝流量,而速率限制是基于系统的全局状态(如所有服务实例中针对特定 API 密钥同时处理的请求总数)来丢弃流量。
2.4 速率限制的实现
2.4.1 单进程实现
下面详细介绍单进程速率限制的实现。假设要为每个 API 密钥设置每分钟 2 个请求的配额。一种简单的方法是为每个 API 密钥使用一个双向链表,链表中存储最后 N 个请求的时间戳。每次新请求到来时,将其时间戳添加到链表末尾,然后定期清理链表中超过一分钟的条目。通过跟踪链表的长度,进程可以将其与配额进行比较,从而对传入请求进行速率限制。但这种方法的问题是,每个 API 密钥都需要一个链表,随着接收到的请求数量增加,内存消耗会迅速增加。
为了减少内存消耗,可以将时间划分为固定时长的桶,例如每分钟一个桶,并记录每个桶内接收到的请求数量。当新请求到来时,根据其时间戳确定所属的桶,并将该桶的计数器加 1。
使用桶机制可以压缩请求数量的信息,使其不会随着请求数量的增加而增加。接下来,可以使用一个实时滑动窗口在桶上移动,跟踪窗口内的请求数量。滑动窗口代表用于决定是否进行速率限制的时间间隔,窗口长度取决于定义配额的时间单位,这里是一分钟。
需要注意的是,滑动窗口可能会与多个桶重叠。为了计算滑动窗口内的请求数量,需要计算桶计数器的加权和,每个桶的权重与其与滑动窗口的重叠比例成正比。虽然这是一种近似方法,但对于我们的目的来说是合理的,并且可以通过减小桶的粒度(例如使用 30 秒的桶而不是 1 分钟的桶)来提高准确性。
我们只需要存储滑动窗口在任何时刻可能重叠的桶的数量。例如,对于一分钟的窗口和一分钟的桶长度,滑动窗口最多会触及 2 个桶。
以下是单进程速率限制实现步骤的列表:
1. 定义时间桶的大小和配额。
2. 根据请求时间戳确定所属的桶。
3. 增加对应桶的计数器。
4. 使用滑动窗口计算窗口内的请求数量。
5. 将窗口内的请求数量与配额比较,决定是否进行速率限制。
2.5 上游弹性机制总结
| 机制 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 负载丢弃 | 服务器达到容量上限时 | 专注处理现有请求,避免性能进一步下降 | 不能完全消除处理请求的成本 |
| 负载均衡 | 客户端对响应时间要求不高时 | 平滑负载高峰,使服务按自身节奏处理请求 | 需要引入消息通道,增加系统复杂度 |
| 速率限制 | 控制特定用户、API 密钥或 IP 地址的请求频率 | 防止资源过度占用,实施定价层级 | 不能完全抵御 DDoS 攻击,有一定成本 |
综上所述,无论是下游弹性机制还是上游弹性机制,都在保障系统的稳定性和可靠性方面发挥着重要作用。在实际应用中,需要根据具体的业务场景和系统需求,合理选择和组合这些机制,以构建具有高弹性的系统。
下面是一个简单的 mermaid 流程图,展示了单进程速率限制的主要流程:
graph LR;
classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px;
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
classDef decision fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;
A([新请求到来]):::startend --> B(确定所属桶):::process;
B --> C(增加桶计数器):::process;
C --> D(计算滑动窗口内请求数量):::process;
D --> E{请求数量是否超配额?}:::decision;
E -->|是| F([拒绝请求]):::startend;
E -->|否| G([处理请求]):::startend;
超级会员免费看
85万+

被折叠的 条评论
为什么被折叠?



