在探讨高可用架构之前,让我们以 O2 系统为例,解释一下何谓可用性。O2 是腾讯内部的一个广告投放系统,专注于提升投放效率、分析广告效果,拥有自动化广告投放、AIGC 自动化素材生产等多种功能。
其整体架构概览如下:
一个完善的架构应该具备3个能力,也就是身体的“三高”:
高性能;
高可用;
易扩展。
理解高可用时,通常参考两个关键指标:
平均故障间隔(Mean Time Between Failure,简称 MTBF):表示两次故障的间隔时间,也就是系统正常运行的平均时间,这个时间越长,说明系统的稳定性越高;
故障恢复时间(Mean Time To Repair,简称 MTTR):表示系统发生故障后恢复的时间,这个时间越短,说明故障对用户的影响越小。
可用性(Availability)的计算公式:Availability= MTBF / (MTBF + MTTR) * 100%
这个公式反映了一个简单的事实:只有当系统故障间隔时间越长,且恢复时间越短,系统的整体可用性才会更高。
因此,在设计高可用系统时,我们的核心目标是延长 MTBF,同时努力缩短 MTTR,以减少任何潜在故障对服务的影响。
在保障系统高可用性的过程中,流量治理扮演着关键角色:它不仅帮助平衡和优化数据流,还提高了系统对不同网络条件和故障情况的适应性,是确保服务高效连续运行的不可或缺的环节.
流量治理的主要目的包括:
微服务系统中,一个服务可能会依赖多个服务,并且有一些服务也依赖于它
当“媒体中心”服务的其中一个依赖服务出现故障(比如用户服务),媒体中心只能被动地等待依赖服务报错或者请求超时;
而依赖“媒体中心”服务的上游服务,也会因为相同的原因出现故障,一系列的级联故障最终会导致整个系统不可用;
合理的解决方案是引入熔断器和优雅降级,通过尽早失败来避免局部不稳定而导致的整体雪崩。
传统熔断器
当请求失败比率达到一定阈值之后,熔断器开启,并休眠一段时间(由配置决定)。这段休眠期过后,熔断器将处于半开状态,在此状态下将试探性地放过一部分流量,如果这部分流量调用成功后,再次将熔断器关闭,否则熔断器继续保持开启并进入下一轮休眠周期。
引入传统熔断器的请求时序图:
传统熔断器实现 关闭、打开、半开 三个状态;
当进入 Open 状态时会拒绝所有请求;进入 Closed 状态时瞬间会有大量请求,这时服务端可能还没有完全恢复,会导致熔断器又切换到 Open 状态;而 Half-Open 状态存在的目的在于实现了服务的自我修复,同时防止正在恢复的服务再次被大量打垮;
所以传统熔断器在实现上过于一刀切,是一种比较刚性的熔断策略。
Google SRE 熔断器
是否可以做到在熔断器 Open 状态下(但是后端未 Shutdown)**仍然可以放行少部分流量呢?**Google SRE 熔断器提供了一种算法:客户端自适应限流(client-side throttling)。
解决的办法就是客户端自行限制请求速度,限制生成请求的数量,超过这个数量的请求直接在本地回复失败,而不会真正发送到服务端。
该算法统计的指标依赖如下两种,每个客户端记录过去两分钟内的以下信息(一般代码中以滑动窗口实现)。
客户端请求被拒绝的概率(Client request rejection probability,以下简称为 p)
p 基于如下公式计算(其中 K 为倍率 - multiplier,常用的值为 2)。
对于后端而言,调整 K 值可以使得自适应限流算法适配不同的服务场景
熔断本质上是一种快速失败策略。旨在通过及时中断失败或超时的操作,防止资源过度消耗和请求堆积,从而避免服务因小问题而引发的雪崩效应。
微服务系统中,隔离策略是流量治理的关键组成部分,其主要目的是避免单个服务的故障引发整个系统的连锁反应。
通过隔离,系统能够局部化问题,确保单个服务的问题不会影响到其他服务,从而维护整体系统的稳定性和可靠性。
常见的隔离策略:
动静隔离通常是指将系统的动态内容和静态内容分开处理
动态内容
指需要实时计算或从数据库中检索的数据,通常由后端服务提供;
可以通过缓存、数据库优化等方法来提高动态内容的处理速度。
静态内容
指可以直接从文件系统中获取的数据,例如图片、音视频、前端的 CSS、JS 文件等静态资源;
可以存储到 OSS 并通过 CDN 进行访问加速。
读写隔离通常是指将读操作和写操作分离到不同的服务或实例中处理
DDD中有一种常用的模式:CQRS(Command Query Responsibility Segregation,命令查询职责分离)来实现读写隔离
写服务
核心隔离通常是指将资源按照 “核心业务”与 “非核心业务”进行划分,优先保障“核心业务”的稳定运行AI助手
热点隔离通常是指一种针对高频访问数据(热点数据)的隔离策略
用户隔离通常是指按照不同的分组形成不同的服务实例。这样某个服务实例宕机了也只会影响对应分组的用户,而不会影响全部用户
基于 O2-SAAS 系统的租户概念,按照隔离级别的从高到低有如下几种隔离方式:
1.每个租户有独立的服务与数据库
网关根据 tenant_id 识别出对应的服务实例进行转发
2.每个租户有共享的服务与独立的数据库
用户服务根据 tenant_id 确定操作哪一个数据库
3.每个租户有共享的服务与数据库
用户服务根据 tenant_id 确定操作数据库的哪一行记录
进程隔离通常是指系统中每一个进程拥有独立的地址空间,提供操作系统级别的保护区。一个进程出现问题不会影响其他进程的正常运行,一个应用出错也不会对其他应用产生副作用
容器化部署便是进程隔离的最佳实践:
线程隔离通常是指线程池的隔离,在应用系统内部,将不同请求分类发送给不同的线程池,当某个服务出现故障时,可以根据预先设定的熔断策略阻断线程的继续执行
集群隔离通常是指将某些服务单独部署成集群,或对于某些服务进行分组集群管理
具体来说就是每个服务都独立成一个系统,继续拆分模块,将功能微服务化:
机房隔离通常是指在不同的机房或数据中心部署和运行服务,实现物理层面的隔离
机房隔离的主要目的有两个:
如何在不可靠的网络服务中实现可靠的网络通信,这是计算机网络系统中避不开的一个问题
微服务架构中,一个大系统被拆分成多个小服务,小服务之间大量的 RPC 调用,过程十分依赖网络的稳定性。
网络是脆弱的,随时都可能会出现抖动,此时正在处理中的请求有可能就会失败。场景:O2 Marketing API 服务调用媒体接口拉取数据。
对于网络抖动这种情况,解决的办法之一就是重试。但重试存在风险,它可能会解决故障,也可能会放大故障。
对于网络通信失败的处理一般分为以下几步:
1.感知错误;
通过不同的错误码来识别不同的错误,在 HTTP 中 status code 可以用来识别不同类型的错误。
2.重试决策;
这一步主要用来减少不必要的重试,比如 HTTP 的 4xx 的错误,通常 4xx 表示的是客户端的错误,这时候客户端不应该进行重试操作,或者在业务中自定义的一些错误也不应该被重试。根据这些规则的判断可以有效的减少不必要的重试次数,提升响应速度。
3.重试策略;
重试策略就包含了重试间隔时间,重试次数等。如果次数不够,可能并不能有效的覆盖这个短时间故障的时间段,如果重试次数过多,或者重试间隔太小,又可能造成大量的资源(CPU、内存、线程、网络)浪费。
4.对冲策略。
对冲是指在不等待响应的情况主动发送单次调用的多个请求,然后取首个返回的回包。
如果重试之后还是不行,说明这个故障不是短时间的故障,而是长时间的故障。那么可以对服务进行熔断降级,后面的请求不再重试,这段时间做降级处理,减少没必要的请求,等服务端恢复了之后再进行请求,这方面的工程实现很多,比如 go-zero 、 sentinel 、hystrix-go。
常见的重试主要有两种方式:同步重试、异步重试
同步重试
程序在调用下游服务失败的时候重新发起一次;
实现简单,能解决大部分网络抖动问题,是比较常用的一种重试方式。
异步重试
如果服务追求数据的强一致性,并且希望在下游服务故障的时候不影响上游服务的正常运行,此时可以考虑使用异步重试。
将请求信息丢到消息队列中,由消费者消费请求信息进行重试;
上游服务可以快速响应请求,由消费者异步完成重试。
无限重试可能会导致系统资源(网络带宽、CPU、内存)的耗尽,甚至引发重试风暴
应评估系统的实际情况和业务需求来设置最大重试次数:
我们知道重试是一个 trade-off 问题:
一方面要考虑到本次请求时长过长而影响到的业务的忍受度;
一方面要考虑到重试对下游服务产生过多请求带来的影响。
退避策略基于重试算法实现。重试算法有多种,思路都是在重试之间加上一个间隔时间
线性间隔(Linear Backoff)
每次重试间隔时间是固定的,比如每 1s 重试一次。
线性间隔+随机时间(Linear Jitter Backoff)
有时候每次重试间隔时间一致可能会导致多个请求在同一时间请求;
加入随机时间可以在线性间隔时间的基础上波动一个百分比的时间。
指数间隔(Exponential Backoff)
间隔时间是指数型递增,例如等待 3s、9s、27s 后重试。
指数间隔+随机时间(Exponential Jitter Backoff)
与 Linear Jitter Backoff 类似,在指数递增的基础上添加一个波动时间。
上面有两种策略都加入了 扰动(jitter),目的是防止 惊群问题 (Thundering Herd Problem) 的发生。
所谓惊群问题当许多进程都在等待被同一事件唤醒的时候,当事件发生后最后只有一个进程能获得处理。其余进程又造成阻塞,这会造成上下文切换的浪费所以加入一个随机时间来避免同一时间同时请求服务端还是很有必要的
gRPC 实现
gRPC 便是使用了 指数间隔+随机时间 的退避策略进行重试:GRPC Connection Backoff Protocol https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md
/* 伪代码 */
ConnectWithBackoff()
current_backoff = INITIAL_BACKOFF
current_deadline = now() + INITIAL_BACKOFF
while (TryConnect(Max(current_deadline, now() + MIN_CONNECT_TIMEOUT))
!= SUCCESS)
SleepUntil(current_deadline)
current_backoff = Min(current_backoff * MULTIPLIER, MAX_BACKOFF)
current_deadline = now() + current_backoff +
UniformRandom(-JITTER * current_backoff, JITTER * current_backoff)
关于伪代码中几个参数的说明:
通过一张图来简单介绍下重试风暴:
此时负载高的 DB 便被卷进了重试风暴中,最终很可能导致服务雪崩。
应该怎么避免重试风暴呢?笔者整理了如下几种方式:
1、限制单点重试
有时候我们接口只是偶然会出问题,并且我们的下游服务并不在乎多请求几次,那么我们可以考虑对冲策略AI助手
对冲是指在不等待响应的情况下主动发送单次调用的多个请求,然后取首个返回的回包
请求流程
普通重试时序图:
对冲重试时序图:
降级是从系统功能角度出发,人为或自动地将某些不重要的功能停掉或者简化,以降低系统负载,这部分释放的资源可以去支撑更核心的功能
以 O2 系统举例,有以下几类降级策略:
虽说故障是不可避免的,要达到绝对高可用一般都是使用冗余+自动故障转移,这个时候其实也不需要降级措施了。
但是这样带来的成本较高,而且可用性、成本、用户体验3者本身之间是需要权衡的,一般来说他们之前会是这样的关系:
降级的策略还是比较丰富的,因此需要从多个角度去化简
超时是一件很容易被忽视的事情
早期架构发展阶段,大家或多或少有过遗漏设置超时或者超时设置太长导致系统被拖慢甚至挂起的经历
随着微服务架构的演进,超时逐渐被标准化到 RPC 中,并可通过微服务治理平台快捷调整超时参数
传统超时会设定一个固定的阈值,响应时间超过阈值就返回失败。在网络短暂抖动的情况下,响应时间增加很容易产生大规模的成功率波动
服务的响应时间并不是恒定的,在某些长尾条件下可能需要更多的计算时间,为了有足够的时间等待这种长尾请求响应,我们需要把超时设置足够长,但超时设置太长又会增加风险,超时的准确设置经常困扰我们
目前业内常用的超时策略有:
超时控制的本质是 fail fast,良好的超时控制可以尽快清空高延迟的请求,尽快释放资源避免请求堆积。
服务间超时传递
一个请求可能由一系列 RPC 调用组成,每个服务在开始处理请求前应检查是否还有足够的剩余时间处理,也就是应该在每个服务间传递超时时间。
如果都使用每个 RPC 服务设置的固定超时时间,这里以上图为例
上图流程如下:
Context 实现超时传递
如果我们的微服务系统对这种短暂的时延上涨具备足够的容忍能力,可以考虑基于 EMA 算法动态调整超时时长。
EMA 算法引入“平均超时”的概念,用平均响应时间代替固定超时时间,只要平均响应时间没有超时即可,而不是要求每次请求都不能超时。
算法实现
算法实现参考:https://github.com/jiamao/ema-timeout
总而言之:
适用条件
1.用于非关键路径
Thwm 设置的相对小,当非关键路径频繁耗时增加甚至超时时,降低超时时间,减少非关键路径异常带来的资源消耗,提升服务吞吐量。
2.用于关键路径
Thwm 设置的相对大,用于长尾请求耗时比较多的场景,提高关键路径成功率。
在 3.5.2 小节有提到,一般超时时间会在链路上传递,避免上游已经超时,下游继续浪费资源请求的情况。
这个传递的超时时间一般是没有考虑网络耗时或不同服务器的时钟不一致的,所以会存在一定的偏差。
超时策略的选择:剩余资源 = 资源容量 - QPS 单次请求消耗资源请求持续时长 – 资源释放所需时长
如何选择合适的超时阈值?超时时间选择需要考虑的几个点:
预期外的突发流量总会出现,对我们系统可承载的容量造成巨大冲击,极端情况下甚至会导致系统雪崩
当系统的处理能力有限时,如何阻止计划外的请求继续对系统施压,这便是限流的作用之处
限流可以帮助我们应对突发流量,通过限制服务的请求率来保护服务不被过载
除了控制流量,限流还有一个应用目的是用于控制用户行为,避免无用请求,比如频繁地下载系统中的数据表格
限流一般来说分为客户端限流和服务端限流两类。
在客户端限流中,由于请求方和被请求方的关系明确,通常采用较为简单的限流策略,如结合分布式限流和固定的限流阈值。
客户端的限流阈值可被视作被调用方对主调方的配额。
合理设定限流阈值的方法包括:
这些算法各有特点,能有效管理客户端的请求流量,保障系统的稳定运行。
这里笔者简单梳理了一张常用的限流算法的思维导图,主要阐述每个算法的局限性,需要根据实际应用场景选择合适的算法:
服务端限流旨在通过主动丢弃或延迟处理部分请求,以应对系统过载的情况。
服务端限流实现的两个关键点:
1、如何判断系统是否过载
常用的判断依据包括:
开源的 Sentinel 采用类似 TCP BBR 的限流方法。它基于利特尔法则,计算时间窗口内的最大成功请求数 (MaxPass) 和最小响应时间(MinRt)。当 CPU 使用率超过 80% 时,根据 MaxPass 和 MinRt 计算窗口内理论上可以通过的最大请求量,进而确定每秒的最大请求数。如果当前处理中的请求数超过此计算值,则进行请求丢弃。
微信后台则使用请求的平均排队时间作为系统过载的判断标准。当平均等待时间超过 20 毫秒时,它会以一定的降速因子来过滤部分请求。相反,如果判断平均等待时间低于 20 毫秒,则会逐渐提高请求的通过率。这种“快速降低,缓慢提升”的策略有助于防止服务的大幅波动。
想要让系统长期“三高”,流量治理只是众多策略的其中一个,其他还有像存储高可用、缓存、负载均衡、故障转移、冗余设计、可回滚设计等等均是确保系统长期稳定运行的关键因素,笔者也期待在后续就这些策略再和大家进行分享。
本文在介绍高可用架构中流量治理部分时,我们详细讨论了从熔断机制到隔离策略、重试逻辑、降级方案,以及超时和限流控制等多种手段,这里简单归纳一下:
最后想说,高可用的本质就是面向失败设计。它基于一个现实且务实的前提:系统中的任何组件都有可能出现故障。
因此,在架构设计时,我们不仅要接受故障的可能性,而且要学会拥抱故障。这意味着从一开始就将容错和恢复能力纳入设计考虑,通过增强系统的弹性、自适应性和恢复机制来应对可能出现的故障和变化。这种方法确保了在面对各种挑战时,系统能够保持持续的运行和服务质量。