稳定性平台的设计与实现


概念:熔断与限流

微服务架构中,服务数量大大增加,调用关系变得复杂。用户的一个请求,会放大为内部服务间的若干次调用,依赖实际上变多了。而一个服务的故障,沿着调用链传播,也可能造成难以预料的影响。更糟糕的是,在服务数量很多的时候,故障是无可避免的。不论单个服务可用性达到几个 9,在服务数量 N 很大时,它的乘方一定会离 0 越来越近。在这种现状下,增强整体容错性就成为一项重要的工作。

一方面当下游服务挂掉时,上游服务作为调用方,需要有一定容错能力,设置一些兜底逻辑,尽量避免直接随之也挂掉。同时,也应避免无脑多次重试,降低下游服务的负载,使其有恢复的机会。

另一方面,作为服务本身,其资源是有限的,服务能力也是有上限的。对于超出上限的流量,只能忍痛丢弃。毕竟只服务部分请求,总比接收所有请求然后拖死整个系统要好得多。

这两方面的考量,正是我们稳定性平台的主题:熔断与限流。网络上流传着一句话,熔断、限流、降级是分布式架构的三板斧,可见其重要性。

熔断(Circuit breaker)也叫断路器。这是借用自电路的说法,其实就是保险丝的升级版。保险丝烧断后只能更换,而断路器断开后不用换,可以手动复位。熔断器用于软件系统,最早可能是在 Release It!: Design and Deploy Production-Ready Software 这本书中提出的。「重构」的作者 Martin Fowler 写文章介绍过这个概念,见 CircuitBreaker,并被 Netflix 的 Hystrix 项目发扬光大。

熔断器核心逻辑,可表示为一个简单的状态机:
1.png

简单地说,当达到失败阈值后,熔断器将从 closed 状态进入 open 状态。open 即断开,所有请求都不允许通过,直接返回错误(这正是兜底逻辑的接入点)。进入 open 状态一段时间后,自动进入 half_open 状态,此时会允许少量请求通过,如果返回成功的数量超过一定阈值则进入 closed 状态,否则返回 open 状态。

熔断器是由调用方使用的。从调用者角度看,可通过熔断器插入兜底逻辑,以减轻下游服务故障的影响。从被调用者角度看,被调用服务故障时,熔断断开,调用暂停,这对于过载恢复意义重大。而从整体上看,调用链上处处使用熔断器,可以阻断故障沿着调用链向上传播(此即级联失败,cascading failure),保证了系统整体的稳定性。另外,熔断器自动在 open - half_open - closed 的状态迁移,也可减少故障过程中的人工介入。

限流的概念相对简单,计算 QPS 并据此决策即可。这里其实存在着两类场景,根据限流器使用的位置,是流量的「发起方」还是「接收方」,处理逻辑有所不同。对于微服务场景来说,是将限流器用在 server 端,以对调用方限速,这是所谓「流量的接收方」,超出阈值后通常直接丢弃即可,我们称之为「否决式限流」。而像消费 MQ 消息时,或者发送 Push 时,为避免打挂所依赖的下游服务,而对自身消费/发送 Push 的行为进行限速,这就是所谓「流量的发起方」,此时如果超出阈值我们一般选择等待,即阻塞在对限流器的调用上,只有从调用中返回时,我们才会继续执行动作,我们称之为「阻塞式限流」。而实现上,常见限流算法有滑动窗口、令牌桶、漏桶等供我们选择。

稳定性平台:需求与设计

首先,我们希望与服务框架深度整合。熔断方面,要支持对调用的每一个接口设置熔断阈值。限流方面,要支持按接口对不同调用方设置不同的限流阈值。而对于非接口的熔断限流也要加以支持,特别地,对于非接口的限流需要同时支持否决式限流和阻塞式限流。我们的现状是,服务治理平台已经解析了所有服务的 IDL 并提供了接口,因此很方便地就能获取服务的接口信息。而对于按调用方限流,现实就没那么美好了,被调用方暂时无法拿到调用方的服务标识。经过调研发现,可以通过 OpenTracing 的 baggage 机制,来支持这一特性。但这涉及我们服务框架和基础库的一些改造工作,因此「按调用方限流」的功能,只好放到二期再支持了。

其次,未来熔断限流功能可能会整合到其他中间件中,因此除了管理后台之外,还需要提供单独的 SDK。

第三,微服务一般是集群部署的,谈论服务能力时,我们也常默认其为集群的服务能力。如果我们提供集群级别的限流能力,则与此视角保持一致,而且使用者可无视服务扩容缩容的影响,体验将会更好。但是考虑到集群级别限流有额外的实现复杂度及开销,比如,需要外部存储保存状态并同步多个节点对状态的读写以保持数据一致性,又比如读写外部存储的网络开销,可能导致限流器本身的延迟将达到 ms 级别(而访问内存的开销可忽略不计),我们最终决定暂时只提供单节点级别的限流。另外,我们也调研过 Sentinel 的集群限流方案,其 token server 与一个服务无甚区别。而我们认为,SDK 应是一个 library,是「无我」的。


熔断本就是单节点视角的,无此问题。
系统设计方面,我们决定:
  • 管理后台与 SDK 完全独立。二者只约定好熔断、限流配置的存储方式即可(我们将配置保存在 etcd 中)。
  • SDK 划分为核心 API 与外围 API。核心 API 包括熔断器、限流器的核心逻辑,而外围 API 则是对核心 API 封装,比如添加了从 etcd 中加载配置及监听配置更新等功能。外围 API 使得 SDK 与服务框架的整合更加简单,只需短短十来行代码即可。同时还保证了稳定性平台的独立性。而核心 API 则可用于其他特定场景,比如其中的 SlidingWindow 就被其他项目用于实现资源限额功能。


具体实现上,核心 API 的行为基本上都有良好的定义,重点注意并发读写问题及性能即可。另外,我们支持监听配置更新,这就需要注意,配置的更新与当前内部状态的相互影响。

熔断器

熔断器持有一个滑动窗口,及其他一些内部状态。滑动窗口拥有 10 个 cell,每个 cell 负责 1s 时长,熔断器以这 10s 的统计数据,以及每个请求的成败,驱动其状态迁移。

由于访问下游服务的时间是不确定,请求完成时,熔断器可能已经发生了状态迁移,需要特别关照一下。比如,对下游的某次请求非常慢,发起时熔断器状态为 closed,而请求完成时熔断器已经变为 open 甚至 half_open 状态了。对于 open 状态,直接忽略即可。而对于 half_open 状态,这个请求是不可用于探测下游服务是否恢复(称为 probe 请求)的。为了应对这种情况,就需要给每个请求标记是否为 probe。

阻塞式限流器

对于阻塞式限流器,我们选择通过漏桶算法来实现。需要注意的是,一不小心的话,实现的漏桶算法可能等价于令牌桶算法,维基百科 Leaky bucket 中也有提到。这样的话,漏桶能够避免突发流量的优点就没有了。

我们的实现逻辑是这样的。每个 LeakyBucketRateLimiter 持有一个 channel,并有一个 leak() 方法完成「漏」的动作。leak() 运行在单独的 go routine 中,它根据 LeakyBucketRateLimiter 的 QPS 阈值,计算 tick,在每个 tick 的时间间隔里,漏掉“一滴水”。所谓漏掉“一滴水”,实际上是 channel 中读出一个元素而已。而读出的元素的类型也是 channel,leak() 将其读出之后,会往其中写入一个值,以唤醒可能处于阻塞中的调用方。

而调用方调用的方法为 Limit(),它创建一个 channel,并将之写入到 LeakyBucketRateLimiter 的 channel 中。写入后,调用方会读取其创建的 channel。这里调用方可能阻塞在两个地方。一个是往 LeakyBucketRateLimiter 的 channel 写入时,如果已满,将会阻塞在写入操作上,只有当 leak() 从 LeakyBucketRateLimiter 的 channel 读出数据从而使之不满时,才会解除阻塞。第二个阻塞的地方是,调用方读取其创建的 channel 时,如果写入 LeakyBucketRateLimiter 的 channel 已成功,但 leak() 尚未处理写入的 channel 时,调用方将阻塞,直接 leak() 处理完毕。

通过以上逻辑,我们确保调用 Limit() ,不会超过 QPS 阈值,若有可能超出则将之阻塞直到不超。

为了应对配置变更的情况,需要一些额外处理。当配置的 QPS 阈值变化后,leak() 的 tick 会发生变化。我们的做法是增加一个 change channel,当 QPS 阈值发生变化之后,立即向 change channel 写入数据。而 leake() 通过 select..case 同时读取多个 channel,一旦发现 change channel 有数据,便重新计算 tick。

另外,leak() 运行在单独 go routine 之中。我们希望限流器使用完毕之后,能够释放所有资源,包括这个 go routine。因此增加了一个 close channel,调用 Close() 方法时即向这个 channel 写入一个值,leak() 读取后立即退出,从而释放掉其所在的 go routine。

最后,leak() 会在每个 tick 时「漏掉“一滴水”」,当 QPS 阈值很大时,tick 会很小。此时 timer 的精度和性能开销可能会变得突出(我们使用的是 time.Tick())。tick 非常小的时候,timer 的精度可能不足,并且限流器本身的性能开销变得不可忽略,从而影响限流的效果。当然,限流的场景下,定时器精度不是关注的重点,此时需要注意的是性能问题。要优化掉这个问题,也不麻烦。只需要将「每个 tick 漏掉一滴水」改为「每个 tick 漏掉 N 滴水」即可。我们可以选择 timer 支持的 tick(越小越好),并计算这个 tick 时间段内应漏出的水滴数量。这本质上是精度与性能的权衡。

核心代码如下:
func (lbrl *LeakyBucketRateLimiter) leak() {
tickCh := time.Tick(lbrl.getTick())

OUTER:
for {
    select {
    case <-lbrl.stopCh: // stopped
        break OUTER
    case <-lbrl.changeCh: // rate limiter modified
        newTick := lbrl.getTick()
        tickCh = time.Tick(newTick)
    case <-tickCh:
        select {
        case waiterCh := <-lbrl.ch:
            waiterCh <- struct{}{}
        default:
            // pass
        }
    }
}
}

func (lbrl *LeakyBucketRateLimiter) Limit() error {
ch := make(chan struct{}, 1)
lbrl.ch <- ch
<-ch
return nil
}

func (lbrl *LeakyBucketRateLimiter) Close() {
lbrl.stopCh <- struct{}{}
}

func (lbrl *LeakyBucketRateLimiter) ChangeQpsThreshold(newQpsThreshold int64) {
atomic.StoreInt64(&lbrl.qpsThreshold, newQpsThreshold)
lbrl.changeCh <- struct{}{}


否决式限流器

对于否决式限流器,我们选择通过滑动窗口来实现,并与熔断器复用同一个滑动窗口实现。

类似地,限流器持有一个滑动窗口。滑动窗口拥有 10 个 cell,每个 cell 负责 0.1s 时长,限流器统计这 1s 的请求数量,若未超出 QPS 阈值,则通过,若超出则返回 error。

否决式限流器的实现是非常简单的,无庸赘述。

熔断器与否决式限流器都用到了滑动窗口,这里简单说一下滑动窗口的实现。

一个滑动窗口 SlidingWindow 由若干个「格子」(Cell)组成,Cell 的数量及 Cell 的时长,在创建 SlidingWindow 时指定。大体逻辑是,自 epoch 以来,依据 Cell 的时长,将时间轴切成一个个的段,对应到 Cell。每个 Cell 记录了其开始时间并持有一个用于计数的 map。

SlidingWindow 是 lazy 的,只有当访问某个 Cell 时,才会根据当前时间,Cell 的开始时间及 Cell 的时长,来确定 Cell 是否过期。若过期则先进行重置。lazy 的 SlidingWindow,使得我们不需要使用单独的 go routine 不时地更新 SlidingWindow 的内容,进而使得 SlidingWindow 内部不需要使用锁。另外,SlidingWindow 中需要使用「当前时间」时也全由参数传入,尽量减少了 SlidingWindow 内部状态,使得其更易于测试。

外围 API

外围 API 只是对核心 API 的一层封装,目的是方便使用。

以熔断器为例,我们定义了一个 Registry interface,其有一个实现 EtcdRegistry。模块中提供了 Init() 函数来创建一个 EtcdRegistry 实例,并赋值给私有全局 Registry 实例。Init() 要求传入服务的标识,这样就能加载管理后台中各服务的配置了。模块的另一公开 API 是 func Do(ctx context.Context, name string, run runFunc, fallback fallbackFunc) error,name 指定调用的服务及接口名,runFunc 为正常逻辑,fallbackFunc 为兜底逻辑。这与 hystrix-go 对外暴露的接口是一致的。Do() 内部,会根据 name 查到相应的熔断器,并调用熔断器的逻辑类似的 Do() 方法。EtcdRegistry 持有多个 CircuitBreaker,其在创建时,会开启一个 go routine,监听 etcd 中配置的变化,如有变化,则调用相应 CircuitBreaker 的方法以修改其配置。

这里使用全局变量,也是为了方便使用。使用者只须调用 Init() 和 Do() 函数即可,无须自己创建 EtcdRegistry 并监听 etcd。暴露的 API 尽量少,其他都作为实现细节隐藏。这样的话,与服务框架整合也更加方便,需要考虑的细节也少了很多。

核心代码:
type Registry interface {
// should be concurrency-safe
Get(name string) *CircuitBreaker
}

func Init(servGroup, servName string) error {
etcdRegistry, err := NewEtcdRegistry(servGroup, servName)
if err != nil {
    return err
}
go func() {
    etcdRegistry.Watch()
}()

return InitWithRegistry(etcdRegistry)
}


func InitWithRegistry(newRegistry Registry) error {
registry = newRegistry
return nil
}

var (
registry Registry = nil
)

func Do(ctx context.Context, name string, run runFunc, fallback fallbackFunc) error {
if registry == nil {
    return ErrCircuitBreakerRegistryNotInited
}

cb := registry.Get(name)
if cb == nil {
    return run(ctx)
}
return cb.Do(ctx, run, fallback)


限流器的外围 API 与之类似,无庸赘述。

上线后的效果

上线之后,查看相应服务的监控指标发现,Go 协程数和堆对象数都有相当幅度的下降,甚至有些服务各方面的指标都提升了,CPU 使用率、内存、响应时间都下降了,并且响应时间更加平稳,毛刺明显减少。如图:

上线当天(上线时间为 2020/08/19 11:40 左右):
2.png

时间拉长到上线前一天及上线后两天:
3.png

可以看到,即使运行几天之后,效果仍然明显。

我们的服务框架,之前已集成了 hystrix-go 的熔断器,但没有限流器。升级之后,替换成了我们自己实现的熔断器与限流器。

上线前后的不同之处在于,用我们的 SDK 取代了 hystrix-go。另外则是加上了限流器。当然限流器只可能增加各方面的开销,而不是减少(被观察的那个服务没有触发限流,不会因限流而减少服务的负载)。最终的结论是,我们的 SDK 性能方面优于 hystrix-go。至少从 go routine 方面看,hystrix-go 每次调用至少开启两个 go routine,而且使用了不少 channel 之类的同步设施。而我们的熔断器实现,只有全局一个 go routine。因此 go routine 及相关的开销是大小减少了的。另外,我们的熔断器只使用了锁这一种同步设施,没有使用 channel,可能也能省掉一些开销。

后续工作与思考

前面已经提到,想要实现「按调用方限流」功能的话,需要对我们的服务框架和基础库做一些改造,这是我们下一期迭代首先要解决的问题。

另外一方面,就是集群限流功能。微服务一向是集群部署的,我们谈论的服务,默认就是其作为集群对外提供服务的能力,而限流设置时切换到单实例视角,总是有些别扭之处的。前面已提到了集群限流的一些挑战,尝试解决这些问题的过程,将会是非常有意思的。Sentinel 给出了 token server 解决方案,我们或许也能提出些不同的思路。比如,使用常见的基础设施,如 Redis + Lua 或者 Nginx + Lua,问题的复杂度似乎也能下降不少。

最后一点思考,是关于「限速」与「限额」的,它们都被称作 rate limit,但是实为不同的概念。稳定性平台是限速的,限制的是速度,而速度与时间无关。限速时,滑动窗口取多长属于实现细节,对外界是隐藏的,不同的滑动窗口长度,影响的是测量精度,但「速度」本身才是我们关注的概念。而限额不同,限额关注的是资源的消耗「量」。限额常常也会给出一个时间因素,加重了混淆。「一分钟请求量不超过 60」,并不等价于「QPS 不超过 1」,前者是限额,后者是限速。差不多所有以「XX 时间段内不得超过 YY」的表述,都可视为限额,而「QPS 不得超过 XX」才是限速。它们的区别,与高中物理中提到的「速度」vs「平均速度」的区别,本质上类似。而且,「限额」常常由于多条规则来表述,比如「一分钟请求不超过 60」且「十分钟请求不超过 500」,且「一小时请求不超过 2000」。另外,「限额」还涉及「限额主体」,比如对 IP/user 发起限制,不同主体的状态需要保持独立。

总结一下,涉及以下这些点的,一般为限额:
  • 对时间段的描述,涉及比「秒」更大的单位,如「一分钟 xxx」
  • 涉及多条规则,如一分钟怎样,且一小时怎样
  • 涉及主体,如对 IP/user 发起限制


当然前文提到的各种算法,如令牌桶、漏桶、滑动窗口等算法都可用来实现限额。但由于它们实属不同概念,因此稳定性平台是不适用于这些场景的,我们需要不同的锤子去锤不同的钉子。而且,由于限额主体一般来说数量巨大,比如 user 可能达到千万甚至亿级别,限额的内部状态,常常不适合于放在内存中,需要借助于外部存储。

作者:yfaming,伴鱼技术中台架构师,半路出家的程序员,先后从事 Web 后端开发,基础架构开发,拥有丰富的挖坑踩坑经验。

原文链接:https://tech.ipalfish.com/blog/2020/08/23/dolphin/

0 个评论

要回复文章请先登录注册