高可用的含义是尽量减少服务的不可用(日常维护或者突发系统故障)时长,提升服务的可用时长。如何衡量一个服务的可用性呢?或许你也听说过,通常企业可能会要求服务的可用性能能够达到三个 9(也就是 99.9%)或者 4个 9 (也就是 99.99%),可是你知道这是如何计算的吗?需要重点强调的是,可用性是每一个 Go 开发者都必须关注的事情。
1. 可用性定义与高可用性三板斧
我们的目标是构建高可用的 Go 服务,那如何定义服务的可用性呢?我们又该如何提升服务的可用性呢?其实这些都是有固定套路的。
1.1 可用性定义
我们总说提升服务的可用性,可是如何衡量服务的可用性呢?如果没有一个量化的指标,你又怎么知道服务的可用性是提升了还是降低了。我们可以按照下面的方式定义服务的可用性:
其中 MTTF (Mean Time To Failure) 指的是服务的平均无故障时间,即服务从正常运行到出现故障的平均时间:MTTR ( Mean Time To Repair)指的是服务的平均修复时间(故障时间),即服务从出现故障到修复成功的平均时间。根据这个定义,我们可以计算出在不同的可用性目标下,全年服务可以接受的最长故障时间,如下表所示:
可用性级别 | 可用性目标 | 全年故障时间 | 每天故障时间 |
1 | 90% | 36.5天 | 2.4h |
2 | 99% | 3.65 天 | 14min |
3 | 99.9% | 8.76h | 86s |
4 | 99.99% | 53min | 8.6s |
当我们的可用性目标是 4 个 9(也就是 99.99%)时,全年故障时间为 53min,每天故障时间为 8.6s。也就是说,如果全年的故障时间超过 53min,那么当年的可用性指标肯定就无法达到 99.99% 了。
根据上述可用性的定义,提升服务可用性最直观的方式就是减少故障时长。如何减少呢?我们可以从两方面入手:
1)预防:尽可能避免服务发生故障。
2)故障处理:当服务发生故障时,尽可能快速地恢复服务的正常运行。当然,这样的描述还是过于泛化,具体的可用性提升方案还需要进一步细化,可以参考下表:
预防 | 故障处理 |
质量提升 | 1. 研发质量提升:比如技术方案评审,代码评审,单元测试等 2. 测试质量提升:比如白盒测试,自动化测试,仿真环境建设等 |
变更管控 | 1. 变更窗口:比如业务高峰期禁止线上变更 2. 任何变更都需要双重检查,都需要有回滚方案,都需要有检查清单 3. 灰度发布 ,小流量发布 |
容错设计 | 1. 资源隔离:比如服务部署隔离、网关侧隔离、数据库隔离等 2. 错误隔离:比如请求级的错误隔离、进程级的错误隔离、服务级的错误隔离等 3. 限流、熔断与降级:非核心功能可降级,非核心依赖可熔断 4. 故障演练 |
冗余设计 | 1. 避免单点:主备/集群化部署,服务可快速扩容 2. 容量冗余:性能评估与性能压测 3. 异地多活 |
发现 | 1.核心业务监控与报警:比如订单服务、支付服务 2. 服务可用性监控与报警:比如服务错误日志、网关侧异常状态码等 3. 基本指标的监控与报警:比如 CPU、内存、网络等 |
定位 | 1. 日志与全链路追踪:业务日志、网关侧访问日志、全链路日志 2. 监控:多维度多特征监控、容量监控、基本指标监控、端到端监控 |
止损 | 1. 流量调度:主要针对集群故障、机房故障 2.限流、熔断与降级:紧急限流、非核心功能降级、非核心依赖熔断 3. 变更回滚:针对变更引起的异常 4. 快速扩容:针对容量不足的情况 5. 服务重启 |
恢复 | 1. 复原:问题修复,数据修复,执行过的止损操作还原 2. 复盘:分析根本原因,制订改进计划 |
1.2 高可用三板斧
高可用三板斧指的是限流、熔断与降级。限流是通过对并发请求进行限速来保护自身服务;熔断是为了避免依赖的第三方服务影响自身服务;降级是通过牺牲非核心功能来保障核心功能。为什么要单独介绍限流、熔断与降级呢?因为这是构建高可用服务不可缺少的三种手段。下面将分别介绍限流、熔断与降级的基本原理。
1.2.1 限流
首先来说限流,限流是通过对并发请求进行限速来保护自身服务。当请求速率超过限制速率时,服务端可以直接拒绝请求(返回固定的错误,或者定向到错误页面),或者将请求排队等待后续处理。常见的限流方式有:限制瞬时并发数,限制单位时间窗口内的平均速率。当然,也可以根据网络连接数、网络流量、CPU 和 内存负载等进行限流。
以限制单位时间窗口内的平均速率为例,常用的限流算法有计数器算法、漏桶算法、令牌桶算法。下面我们详细介绍这几种限流算法。
(1)计数器算法
计数器算法是限流算法中最简单且最容易实现的一种算法。例如,假设我们规定某个接口的平均每秒访问速率不能超过 1000 次,为了实现这个限制,我们可以为该接口维护一个计数器,其有效时间为 1s。每当有请求到达时,计数器就会加 1,如果计数器的值超过了 1000,就表示请求速率超过了限制值。
需要说明的是,尽管这个算法很简单,但准确度却很差。我们将 1s 的时间段划分为 1000ms。假设在第 999ms 时,有 1000 个请求到达,显然不会触发限流(0~1000ms 是一个计数周期)。而在第 1001 ms 时,又有 1000 个请求到达,同样不会出触发限流(1000~2000ms是一个计数周期)。但是你会发现,瞬时的请求速率非常高。在从 999ms 到 1001ms 这 2ms 内,就需要处理 2000 个请求,这可能已经超过了服务的处理能力。
(2)漏桶算法
漏桶算法的基本原理可以参考下图,
有一个固定容量的漏桶,该漏桶可以按照固定速率流出水滴,如果漏桶是空的,则不会流出水滴。流入漏桶的水流速度(请求速率)是随意的,如果流入的水(请求)超出了漏桶的容量,则会溢出(请求被丢失)。可以看到,漏桶算法的最大请求速率是恒定的。
(3)令牌桶算法
令牌桶算法示意图如下所示:
思考一下,如果令牌桶的容量是 0,并且排队等待的请求队列长度是有限制的,这时候令牌桶算法是不是和漏桶算法非常类似呢?
1.2.2. 熔断与降级
熔断是为了避免依赖的第三方服务影响自身服务,如何避免呢?当然是不再依赖异常的第三方服务了,比如当需要请求第三方服务时,直接返回默认的数据即可。为什么需要熔断呢?假设有这么一个业务场景:A 服务依赖了 B 服务,B 服务的平均响应时间是 100ms,此时 A 服务的 QPS 可以达到 1 万;某一个业务高峰, B 服务突然变慢了,平均响应时间变成了 500ms, 此时 A 服务的平均响应时间肯定也会变长,那么其 QPS 还能达到 1 万吗?通常情况下远远达不到的。
这还不是最糟糕的,要知道这可是处于业务高峰时期,A 服务的性能变差之后,大量的业务请求将会失败,而通常请求失败时还会有一些重试(用户重试或者自动重试),这就导致 A 服务需要承担更大的请求压力,极端情况下甚至可能会压垮 A 服务,进而拖垮 B 服务。
当然,如果 B 服务是 A 服务的弱依赖(也就是说,即使没有 B 服务,A 服务也可以正常运行),当 B 服务出现异常(比如变慢)时,A 服务可以采取熔断策略,不再依赖 B 服务,以此保证自身服务的正常运行。
最后说一下降级,降级的目的是降低系统压力,保障核心服务的正常运行。比如每年的双 11、618等购物节,你会发现当天这些电商平台的部分功能是不可用的(被降级了),而这些功能基本上是不会影响用户的购物体验的。通常什么场景下需要降级呢?比如业务高峰期,机器资源不足时,可以将非核心服务降级,以保障核心服务的资源充足;比如系统出现异常并且无法快速定位时,同样可以将非核心服务降级,以避免非核心服务影响核心服务。
2. 流量治理组件 Sentinel
Sentinel 是阿里技术团队开源的流量治理组件,其主要以流量为切入点,从流量控制、流量整形、熔断降级、系统自适应过载保护等多个维度来帮助开发者保障服务的稳定性。2012 年 Sentinel 就诞生了,只是这时候只有 java 版本,直到 2020 年阿里团队才推出了 Go 版本。
2.1 Sentinel 快速入门
在讲解Sentinel 的使用之前,先介绍一个基本概念:资源(resource),这是 Sentinel 中的最核心概念之一。Sentinel 中所有的限流熔断机制都是基于资源生效的,不同资源的限流熔断规则互相隔离互不影响。在 Sentinel 中,用户可以灵活地定义资源,比如可以将应用、接口、函数,甚至是一段代码等定义为一种资源,而流量治理的目的就是保护资源如预期一样运行。
用户可以通过 Sentinel 提供的接口将资源访问包装起来,这一步称为 “埋点”。每个埋点都有一个资源名称(resource),代表触发了这个资源的调用或访问,有了资源埋点之后,我们就可以针对资源埋点配置流量治理规则。Sentinel 支持多种类型的流量治理规则,如流量控制规则、流量隔离规则、熔断降级规则、自适应过载保护规则以及热点参数流量控制规则。
那么如何使用 Sentinel 呢?可以参考 Sentinel 官方给出的基于 QPS 限流示例,代码如下所示:
func main(){
//务必先进行初始化
err := sentinel.InitDefault()
//配置一条限流规则
_,err = flow.LoadRules([]*flow.Rule){{
Resource: "some-test",
Threshold: 10,
.....},})
//模拟并发访问
for i :=0; i <10;i++{
go func(){
for {
//埋点逻辑,埋点资源名称为 some-test
e,b := sentinel.Entry("some-test")
if b != nil {
//请求被拒绝,在此进行处理
}else {
//请求允许通过,此直编写业务逻辑
fmt.Println(util.CurrentTimeMillis(),"Passed")
e.Exit() //务比何证业务结束后调用 Exit
}
}
}()
}
......
}
2.2 流量控制
流量控制的目的是避免服务被瞬时的流量高峰冲垮,其原理是根据令牌计算策略来计算可用令牌的资源,并根据流量控制策略对请求进行控制(拒绝或者排队等待)。Sentinel 的流量控制规则定义如下所示:
type Rule struct {
ID string
Resource string //规则ID
TokenCalculateStrategy TokenCalculateStrategy //资源名称
ControlBehavior ControlBehavior //令牌计算策略
Threshold float64 //流量控制行为
MaxQueueingTimeMS uint32 //限流阈值
WarmUpPeriodSec uint32 //请求排队的最长等待时间
WarmUpColdFactor unit32 //预热的时间长度
StatIntervalInMs uint32 //预热因子,该值会影响预热速度
LowMemUsageThreshold int64 //流量控制器的统计周期,单位是ms
HighMemUsageThreshold int64 //内存使用小于低水位时的限流阈值
MemLowWaterMarkBytes int64 //内存低水位
MemHighWaterMarkBytes int64 //内存高水位
}
流量控制规则的字段还是比较多的,这里就不一一介绍了。我们主要介绍一下令牌计算策略、流量控制行为以及内存自适应流量控制。
Sentinel 支持两种类型的令牌计算策略。
2.3 系统自适应流量控制
Sentinel 支持系统自适应流量控制,什么意思呢?就是结合系统的负载、CPU 利用率、服务的入口 QPS、服务的平均响应时间、并发数等几个维度的监控指标,通过自适应的方式进行流量控制。系统自适应流量控制的目的是平衡入口流量与系统负载,在保障系统稳定性的前提下使得系统尽可能地对外提供服务。需要注意的是,系统自适应流量控制是面向整个服务的,而不是单个接口的,并且仅对入口流量生效。
Sentinel 系统自适应流量控制目前支持 5 种类型的流量控制方式,定义如下:
1)负载:负载是对 CPU 工作量的度量,指的是单位时间内系统中的平均活跃进程数;负载可以用 3 个指标衡量,load1、load5 与 load15,分别表示过去 1min、5min 与 15min 的平均负载,Sentinel 使用的是 load1 来进行系统自适应流量控制。
2)CPU 利用率:CPU 利用率用于衡量 CPU 的繁忙程度,其定义是除了空闲时间外的其他时间占总 CPU 时间的百分比。
3)平均响应时间:Sentinel 会统计所有入口流量总的响应时间,总响应时间除以总的请求数就是平均响应时间。
4)并发数:当请求通过时并发数加1,当请求执行结束时并发数减1。
5)入口 QPS:入口 QPS 的统计同样是基于滑动窗口实现的。
Sentinel 系统自适应流量控制的规则定义如下所示:
type Rule struct {
ID string //规则ID
MetricType MetricType //流量控制指标类型
TriggerCount float64 //流量控制触发阈值
Strategy AdaptiveStrategy //自适应流量控制策略
}
在上面的代码中,字段 MetricType 就是我们介绍的流量控制指标类型,字段 Strategy 表示自适应流量控制策略。Sentinel 提供了两种类型的自适应流量控制策略:NoAdaptive 与 BBR。NoAdaptive 类型的自适应流量控制策略非常简单,只需要对应的指标,如负载大于阈值就拒绝请求。那什么是 BBR 呢?思考一下,仅仅根据系统负载或者 CPU 利用率进行流量控制合适吗?换一个思路,系统负载或者 CPU 利用率其实是结果,是系统容量无法支持高并发请求的结果。如果我们仅仅根据系统负载或者 CPU 利用率进行流量控制,就始终存在一定的延迟。
为了解决上面的问题,Sentinel 参考了 TCP 拥塞控制算法(BBR)。TCP 拥塞控制算法的初衷同样是在保证通信质量的前提下尽可能提升链路带宽利用率。该算法认为当同时满足最大带宽和最小延迟时,整个网络处于最优工作状态,此时网络中的数据包总量等于最大带宽乘最小时延。参考 TCP 拥塞控制算法,Sentinel 实现的 BBR 自适应流量控制策略如下所示:
func checkBbrSimple() bool {
concurrency := stat.InboundNode().CurrentConcurrency()
minRt := stat.InboundNode().MinRT()
maxComplete := stat.InboundNode().GetMaxAvg(base.MetricEventComplete)
if concurrency > 1 && float64(concurrency) > maxComplete*minRt/1000.0 {
return false
}
return true
}
在上面的代码中,变量 concurrency 表示当前系统处理的并发请求数,minRt表示最小时延,maxComplete 表示每秒最大处理的请求数。这一逻辑与 TCP 拥塞控制算法是一致的。
系统自适应流量控制的使用还是比较简单的,只需要定义好流量控制规则就可以了,只是别忘了在请求入口将所有资源的访问包起来。
2.4 熔断降级
熔断与降级其实是非常类似的,本质上都是切断不稳定的弱依赖服务调用(只是熔断是从服务消费者视角出发的,降级是从服务提供者视角出发的),所以 Sentinel 将熔断降级合成了一种规则。
Sentinel 的熔断降级基于熔断器模式实现,内部维护了一个熔断器的状态机,状态机的转换关系如下图所示:
熔断器有 3 种状态。3 种状态含义如下所示:
1)CLOSED:关闭状态,这也是初始状态,当熔断器处于这一状态时,所有请求都会通过。
2) OPEN: 断开状态,当熔断器统计的指标数据,比如失败比例、平均响应时间(RT,response time)、异常请求数等超过阈值时,熔断器将 CLOSED 状态转移到 OPEN 状态。当熔断器处于 OPEN 状态时,所有请求都会被拒绝。
3) HALF-OPEN:半断开状态,处于 OPEN 状态的熔断器有一个熔断超时时间,超过熔断时长之后熔断器将转移到 HALF-OPEN 状态。这时候熔断器会周期性地允许一些探测请求通过,如果探测请求能够成功返回,则熔断器转移到 CLOSED 状态,如果探测失败,则熔断器转移到 OPEN 状态。
2.5 Sentinel 原理浅析
Sentinel 为我们提供了多种流量治理策略,如流量控制、系统自适应流量控制、熔断降级等。但是,如果 Sentinel 原生的流量治理策略无法满足业务需求,该怎么办呢?这时候有可能就需要我们自定义流量治理策略了,如何自定义呢?这就需要我们对 Sentinel 的原理有一定了解。
Sentinel 的主框架是基于责任链模式实现的,每一个请求都需要经过多个请求处理器,理论上每一个请求处理器都会根据自身的流量治理策略以及流量治理规则判断是否允许请求通过。当然,Sentinel 其实提供了三种类型的请求处理器:第一种请求处理器主要用于执行一些初始化操作;第二种请求处理器就是 Sentinel 提供的各种类型的流量治理策略;第三种请求处理器主要用于统计指标。这三种类型的请求处理器接口定义如下:
//执行一些初始化工作
type StatPrepareSlot interface {
Prepare(ctx *EntryContext)
}
//流量治理策略,方法 Check 返回是否允许请求通过
type RuleCheckSlot interface {
Check(ctx *EntryContext) *TokenResult
}
//统计指标
type StatSlot interface {
OnEntryPassed(ctx *EntryContext)
OnEntryBlocked(ctx *EntryContext,blockError *BlockError)
OnCompleted(ctx *EntryContext)
}
在上面的代码中,如果你想自定义流量治理策略,其实只需要实现接口 RuleCheckSlot 就可以了,当然如果你的流量治理策略还依赖于一些特殊的指标,那么你还需要实现接口 StatSlot。Sentinel 默认初始化的请求处理链如下所示:
func BuildDefaultSlotChain() *base.SlotChain {
sc := base.NewSlotChain()
sc.AddStatPrepareSlot(stat.DefaultResourceNodePrepareSlot)
//流量治理策略
sc.AddRuleCheckSlot(system.DefaultAdaptiveSlot)
sc.AddRuleCheckSlot(flow.DefaultSlot)
sc.AddRuleCheckSlot(isolation.DefaultSlot)
sc.AddRuleCheckSlot(hotspot.DefaultSlot)
sc.AddRuleCheckSlot(circuitbreaker.DefaultSlot)
//统计指标
sc.AddStatSlot(stat.DefaultSlot)
sc.AddStatSlot(log.DefaultSlot)
sc.AddStatSlot(flow.DefaultStandaloneStatSlot)
sc.AddStatSlot(hotspot.DefaultConcurrencyStatSlot)
sc.AddStatSlot(circuitbreaker.DefaultMetricStatSlot)
return sc
}
在上面的代码中,我们可以看到前面所介绍的几种流量治理策略,其中 system.DefaultAdaptiveSlot 表示系统自适应流量控制策略,flow.DefaultSlot 表示流量控制策略,circuitbreaker.DefaultSlot 表示熔断降级策略等。
总的来说,Sentinel 的原理其实就是遍历上述请求处理链,执行每一个请求处理器罢了。自定义流量治理策略也非常简单,一来只需要实现自定义的请求处理器(也就是实现接口 RuleCheckSlot 与 StatSlot),二来将自定义的请求处理器添加到全局请求处理链就可以了。
3. Go 服务监控
在故障处理时,完善的监控与报警体系可以帮助我们快速地发现问题与定位问题。对 Go 服务而言,如何监控 Go 服务的核心指标呢?比如协程数、内存使用量、线程数等。
3.1 运行时监控
如何监控 Go 服务的运行时指标呢?
第一步,当然是采集 Go 服务的运行时指标了,常用的运行时指标包括线程数、协程数、内存使用量、GC耗时等。如何采集呢?幸运的是,Go 语言为我们提供了 SDK,通过这些 SDK 我们可以很方便地获取到这些运行时指标
第二步,如何导出与查看这些运行时指标呢?我们可以借助 Prometheus,这是一款开源的监控与报警系统,并且提供了多种语言的客户端库,其中就包括 Go 语言。
3.2 自定义监控
3.1 小节讲解了如何监控 Go 服务的运行时指标,那如果我们想自定义一些监控指标该如何实现呢?比如服务或者接口的访问 QPS、响应时间等。这就需要我们对 Prometheus 的几种指标类型以及 Prometheus 客户端库的使用有一些了解。
4. 其他
在 Go 项目开发过程中,有两个细节特别容易忽视:超时控制与错误处理。不合理的超时时间错误处理可能导致服务因为一些轻微的异常而崩溃。因此,在依赖第三方资源时一定要注意设置合理的的超时时间,并且在项目开发过程中要有完善的错误处理机制。
4.1 超时控制
大部分Web 服务通常都会依赖 HTTP 服务、数据库以及 Redis等。下面将分别介绍在依赖这三种类型的资源时,如何设置合理的超时时间。
4.1.1 HTTP服务
当我们使用 Go 语言原生的 HTTP 客户端访问第三方服务时,可以通过两种方式设置超时时间。第一种方式是基于 HTTP 客户端的 Timeout 字段实现的,第二种方式是基于上下文 context 实现的。
4.1.2 数据库
Go 服务操作数据库通常都是基于长连接,因此数据库的超时时间可以分为建立连接的超时时间与处理请求的超时时间。
4.1.3 Redis
Go 服务操作 Redis 通常是基于长连接,因此 Redis 的超时时间也分为建立连接的超时时间与处理请求的超时时间。
4.2 错误处理
Go 语言将错误分为两种类型:一种是普通错误,也就是我们常用的类型 error; 一种是严重错误,通常我们用关键字 panic 声明发生了严重错误。下面分别介绍这两种类型的错误。
4.2.1 error
在实际项目开发过程中,你会发现很多函数都会有多个返回值,通常第一个返回值用于返回真正的结果,第二个返回值是类型 error,表示是否发生了错误。代码如下所示:
resp,err := client.Do(req)
if err != nil {
fmt.Println(fmt.Sprintf("client error:%v",err))
return
}
在上面的代码中,我们在调用一些函数之后通常都会判断返回值 error 是否为空:如果不为空,则说明发生了错误,此时需要执行一些错误处理操作,比如记录错误日志等;如果为空,说明函数调用成功,则继续执行后续流程。
4.2.2 panic
关键字 panic 通常用于声明发生了严重错误,需要特别注意的是,panic 会导致程序异常退出。以下面程序为例:
package main
import "fmt"
func main() {
fmt.Println("test1 start")
panic("this is a panic")
fmt.Println("panic 1")
}
执行上面的程序之后,控制台会输出如下信息:
test1 start
panic: this is a panic
//协程栈桢
goroutine 1 [running]:
main.main()
//程序异常退出
错误: 进程退出代码 2.
参考上面的输出结果,当我们通过 panic 声明错误之后,程序直接异常退出了,panic 语句之后的输出语句并没有执行。幸运的是,当程序因为 panic 异常退出时,会输出异常信息以及协程栈帧,通过这些信息我们基本上就能排查出问题所在了。当然,在实际项目开发过程中,我们可能需要避免程序因为 panic 异常退出,毕竟不能因为一个请求异常影响整个服务,这时候可以使用延迟调用defer 捕获异常,代码如下所示:
//该语句在调用函数 test 之前
defer func(){
fmt.Println("defer 1")
if rec := recover(); rec != nil {
fmt.Println(rec)
}
fmt.Println("defer 2")
}()
程序输出如下:
test1 start
defer 1
this ia a panic
defer 1
//程序正常退出
Process finished with the exit code 0
在上面的代码中,当我们使用延迟调用 defer 捕获异常之后,程序就能够从 defer 语句开始恢复执行,最终程序也会正常退出。
最后补充一下,在一些业务场景中,当发生错误时,可能会进行重试(重试可以在一定程度上解决部分问题),
思考一下,为什么需要采用指数退避算法进行重试呢?因为当第三方服务返回错误时,如果我们立即重试,很有可能还会得到一个错误的响应,并且频繁地重试对第三方服务的压力也比较大,所以可以稍微等一段时间再重试。
另外需要注意的是,重试需谨慎,不合理的重试可能会导致服务的雪崩。为什么呢?这里举两个具体的例子。
第一个例子,假设 A 服务依赖了 B 服务,在某个业务高峰期间,B 服务出现了异常(大量返回错误或者大量超时),此时 A 服务在调用 B 服务时,发现返回了错误,于是又进行重试。这会导致什么呢?A 服务的重试会进一步增加 B 服务的负载,甚至导致 B 服务的崩溃。
第二个例子,整个请求的链路其实是非常复杂的,一个客户端请求到达 A 服务可能需要经过全站加速,接入层网关,容器 Ingress 等,A 服务可能依赖 B 服务,B 服务还有可能依赖 C 服务等。注意,每一条链路都有可能配置重试,那么客户端的一次重试请求, C 服务可能会收到多个请求(甚至数十个)。可以看到,这存在明显的请求放大情况,极端情况下,C 服务需要承载可能被放大了数十倍。
看到了吧,不合理的重试在某些情况下可能会导致非常严重的影响,一定要考虑哪些情况可以重试,哪些情况不能重试;另外在制定重试策略时,应该从全局去分析考虑,不能局限于局部的调用链路。