为什么要分布式限流

其实大多数场景下你并不需要使用集群限流,单机限流就足够了。仔细思考其实只有几种情况下可能需要使用到集群限流:

  1. 当想要配置单机QPS限制 <1 时单机模式是无法满足的,只能使用集群限流模式来限制集群的总QPS。比如有个性能极差的接口单机最多只能扛住0.5QPS,部署了10台机器那么需要将集群最大容量是5 QPS,当然这个例子有点极端。再比如我们希望某个客户调用某个接口总的最大QPS为10,但是实际我们部署了20台机器,这种情况是真实存在的;

  2. 如果单机限流阈值是10 QPS,部署了3个节点,理论上集群的总QPS可以达到30,但是实际上由于流量不均匀导致集群总QPS还没有达到30就已经触发限流了。很多人会说这不合理,但我认为需要按真实情况来分析。如果这个 “10QPS”是根据容量规划的系统承载能力推算出来的阈值(或者说该接口请求如果超过10 QPS就可能会导致系统崩溃),那这个限流的结果就是让人满意的。如果这个“10QPS”只是业务层面的限制,即便某个节点的QPS超过10了也不会导致什么问题,其实我们本质上是想限制整个集群总的QPS,那么这个限流的结果就不是合理的,并没有达到最佳效果;

所以,实际取决于你的限流是为了实现“过载保护”,还是实现业务层的限制。

还有一点需要说明的是:集群限流并无法解决流量不均匀的问题,限流组件并不能帮助你重新分配或者调度流量。集群限流只是能让流量不均匀场景下整体限流的效果更好。

实际使用建议是:集群限流(实现业务层限制)+ 单机限流(系统兜底,防止被打爆)

限流方案

服务端限流 vs 客户端限流 vs 网关限流

绝大多数情况下限流都是发生在服务端的,因为很多情况下客户端的数量是不确定的。但有时候为了防止单个客户端过度使用服务,那么此处可以在客户端来完成,当然在服务端也可以同时进行。

一般都推荐在服务端做限流

服务端限流

服务在处理请求前,应该对请求进行限流计算,防止系统过载。 同时也要考虑到为不同的业务的客户端提供不同的限流策略,不能因为某个业务的问题达到达到限流阈值而造成其他业务无法请求服务。

优点

  • 更好控制整个服务的负载情况:服务端的限流阈值不会因为客户端数量增加或减少而改变
  • 方便对不同上游服务进行不同阈值的限流策略:可以对不同的调用者进行不同的限流配额,也可以给不同业务打上不同的tag再根据tag来限流。

缺点

  • 如果服务端只针对QPS限流,而不考虑连接数:服务在建连过程中也会产生一些资源消耗,而这些压力往往可能会成为瓶颈。特别是短连接,不断的建链过程会产生大量的资源消耗
  • 如果服务端也针对连接数进行限制:则不好对不同链路或服务进行配额区分。容易造成某个业务或服务的连接过多而导致其他服务也被限制

客户端限流

客户端调用下游服务时,以每个服务集群的限流配额对下游服务进行过载保护。 优点

  • 达到阈值不会请求服务端,避免服务端产生额外的资源消耗,如建立连接

缺点

  • 客户端的数量的增加或减少需要重新计算每个客户端的限流阈值
  • 客户端限流可能出现bug,或者客户端负载均衡产生倾斜导致限流失效
  • 服务不同API不同限流阈值:下游服务较多,而每个服务的不同API有不同限流配额,则客户端的限流较为复杂

网关限流

请求通过网关来请求服务端,在网关中对不同服务及不同的API进行限流。 优点

  • 能很好的保护整个集群的负载压力,服务端数量增加或减少,则网关进行相应的阈值调整即可
  • 对不同的上游业务的服务设置不同的限流配额和不同的限流策略

缺点

  • 需要网关资源
  • 网关本身高可用性

服务端限流

我们有没有一个解决方案,将限流下沉到业务层来,让开发团队可以自行控制?我们来思考一下如何在分布式环境中引入服务层限流。

对于分布式环境来说,无非是需要一个类似中心节点的地方存储限流数据。打个比方,如果我希望控制接口的访问速率为每秒100个请求,那么我就需要将当前1s内已经接收到的请求的数量保存在某个地方,并且可以让集群环境中所有节点都能访问。那我们可以用什么技术来存储这个临时数据呢?这个场景天然适合我们的中间件大显神威!而且还得需要支持超高并发的中间件,谁能堪此重任?

Redis简直就是为服务端限流量身打造的利器。利用Redis过期时间特性,我们可以轻松设置限流的时间跨度(比如每秒10个请求,或者每10秒10个请求)。同时Redis还有一个特殊技能–脚本编程,我们可以将限流逻辑编写成一段脚本植入到Redis中,这样就将限流的重任从服务层完全剥离出来,同时Redis强大的并发量特性以及高可用集群架构也可以很好的支持庞大集群的限流访问。

限流组件

除了上面介绍的几种方式以外,目前也有一些开源组件提供了类似的功能,比如Sentinel就是一个不错的选择。Sentinel是阿里出品的开源组件,并且包含在了Spring Cloud Alibaba组件库中,可以为Cloud服务在下一个“微服务架构设计与落地”的大章节中,我们将详细介绍Sentinel在分布式限流中的应用。

从架构维度考虑限流设计

在真实的大型项目里,不会只使用一种限流手段,往往是几种方式互相搭配使用,让限流策略有一种层次感,达到资源的最大使用率。在这个过程中,限流策略的设计也可以参考前面提到的漏斗模型,上宽下紧,漏斗不同部位的限流方案设计要尽量关注当前组件的高可用。

限流粒度

限流的粒度可以分为:

  • 服务:对服务所有API进行统一的限流策略
  • API:每个API会有不同的请求链路,则相应会有不同的限流策略(阈值等)
  • API参数:很多时候我们希望能够对某个热点数据中访问频次最高的 Top K 数据进行限制。例如:秒杀,大促等场景,不要因为某个商品的频繁访问引起的限流导致其他商品无法访问。

服务粒度

服务粒度:

一个服务提供一个统一的限流的策略。

优点是非常简单,但很容易造成限流失效,无法保护服务本身及下游。

如:服务提供两种API,都是访问数据,两种API的查询语句并不一致,API1 查询非常复杂,数据库安全水位只能提供10/s的TPS,而对于API2,数据库可以提供1000/s的TPS,这种情况下,如果按照服务粒度进行限流,则只能提供10/s QPS的限流阈值。所以是非常不合理的。

API粒度:

不同的API进行不同的限流策略,这种方式相对复杂些,但是更为合理,也能很好的保护服务。

要考虑几种情况:

  • 增加或减少API,则限流策略要做相应的调整
  • API实现的改变:请求处理实现变化则可能需要重新对限流阈值进行调整,避免因为增加一些业务逻辑而导致服务本身或者下游服务过载。

大多数情况下,都应该进行API粒度的限流,这样才能更好的保护服务本身及服务的下游服务和中间件,达到更好的限流效果。

服务端限流方案

QPS统一分配

这种方案的思想是将集群限流最大程度的本地化。

举个例子,我们有两台服务器实例,对应的是同一个应用程序(Application.name相同),程序中设置的QPS为100,将应用程序与同一个控制台程序进行连接,控制台端依据应用的实例数量将QPS进行均分,动态设置每个实例的QPS为50,若是遇到两个服务器的配置并不相同,在负载均衡层的就已经根据服务器的优劣对流量进行分配,例如一台分配70%流量,另一台分配30%的流量。面对这种情况,控制台也可以对其实行加权分配QPS的策略。

客观来说,这是一种集群限流的实现方案,但依旧存在不小的问题。该模式的分配比例是建立在大数据流量下的趋势进行分配,实际情况中可能并不是严格的五五分或三七分,误差不可控,极容易出现用户连续访问某一台服务器遇到请求驳回而另一台服务器此刻空闲流量充足的尴尬情况。

Redis令牌桶

令牌桶算法概念如下:

  • 令牌以固定速率生成;
  • 生成的令牌放入令牌桶中存放,如果令牌桶满了则多余的令牌会直接丢弃,当请求到达时,会尝试从令牌桶中取令牌,取到了令牌的请求可以执行;
  • 如果桶空了,那么尝试取令牌的请求会被直接丢弃。

令牌桶算法既能够将所有的请求平均分布到时间区间内,又能接受服务器能够承受范围内的突发请求,因此是目前使用较为广泛的一种限流算法。

这种方案存在的问题:

  • 要和redis进行交互:时延较差
  • 热点资源redis容易成为瓶颈
  • redis进行主从切换会导致限流失效
  • 服务的时钟会有误差:由于lua中有写操作就不能使用带随机性质的读操作所以不能通过redis lua获取

发票服务器

这种方案的思想是建立在Redis令牌桶方案的基础之上的。如何解决每次取令牌都伴随一次网络开销,该方案的解决方法是建立一层控制端,利用该控制端与Redis令牌桶进行交互,只有当客户端的剩余令牌数不足时,客户端才向该发票服务器取令牌并且每次取一批。如阿里开源的sentinel

发票服务器一般由一些服务进程组成一个或多个发票集群。而服务通过RPC向发票服务器领票,成功则可以执行,否则则进入限流机制。为了减少RPC通信带来的延迟,一般可以批量获取。

发票规则(限流算法)可以存储到一致性存储或者数据库等,发票服务器定期更新或者监听通知来获取规则的变化。也可以通过其他服务来动态调整算法和阈值,然后通知发票服务器,也可以发票服务器自己根据负载情况来计算。

集群流控中共有两种身份:

  • Token Client:集群流控客户端,用于向所属 Token Server 通信请求 token。集群限流服务端会返回给客户端结果,决定是否限流。
  • Token Server:即集群流控服务端,处理来自 Token Client 的请求,根据配置的集群规则判断是否应该发放 token(是否允许通过)。

基于redis的分布式限流算法有以下缺点:

  • 单个大流量的接口,使用 redis 容易产生热点。
  • pre-request 模式对性能有一定影响,高频的网络往返。

改进:

  • 从获取单个 quota 升级成批量 quota。quota: 表示速率,获取后使用令牌桶算法来限制。
  • 每次心跳后,异步批量获取 quota,可以大大减少请求 redis 的频次,获取完以后本地消费,基于令牌桶拦截。

分配规则(最大最小公平分享)

每次申请的配额需要手动设定静态值略欠灵活,比如每次要20,还是50。如何基于单个节点按需申请,并且避免出现不公平的现象?

初次使用默认值,一旦有过去历史窗口的数据,可以基于历史窗口数据进行 quota 请求。

我们经常面临给一组用户划分稀有资源的问题,他们都享有等价的权利来获取资源,但是其中一些用户实际上只需要比其他用户少的资源。

那么我们如何来分配资源呢?一种在实际中广泛使用的分享技术称作“最大最小公平分享”(Max-Min Fairness)。

直观上,公平分享分配给每个用户想要的可以满足的最小需求,然后将没有使用的资源均匀的分配给需要‘大资源’的用户。

最大最小公平分配算法的形式化定义如下:

  • 资源按照需求递增的顺序进行分配。
  • 不存在用户得到的资源超过自己的需求。
  • 未得到满足的用户等价的分享资源。

特点

  • 发票服务器可用性高:通过集群模式,且可以持久化到数据库。
  • 发票服务器负载均衡:服务从发票服务集群领票要注意发票服务器负载均衡,避免造成有的发票服务器发票领完有的却有大量剩余发票
  • 发票服务器高性能:因为发票服务器的计算和存储都基于内存,所以性能不容易成为瓶颈
  • 发票服务器一致性:类似于ID生成器,对于极高要求的场景,可以定期将发票服务器发票的信息等进行持久化存储,故障时再从中进行恢复

Redis令牌桶实现

原理

从整体上令牌桶生产token逻辑如下:

  • 用户配置的平均发送速率为r,则每隔1/r秒一个令牌被加入到桶中;
  • 假设桶中最多可以存放b个令牌。如果令牌到达时令牌桶已经满了,那么这个令牌会被丢弃;
  • 当流量以速率v进入,从桶中以速率v取令牌,拿到令牌的流量通过,拿不到令牌流量不通过,执行熔断逻辑;

下面来看看 lua script 控制的几个关键属性:

argument mean
ARGV[1] rate 「每秒生成几个令牌」
ARGV[2] burst 「令牌桶最大值」
ARGV[3] now_time「当前时间戳」
ARGV[4] get token nums 「开发者需要获取的token数」
KEYS[1] 表示资源的tokenkey
KEYS[2] 表示刷新时间的key
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
-- 返回是否可以获得预期的token

local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

-- fill_time:需要填满 token_bucket 需要多久
local fill_time = capacity/rate
-- 将填充时间向下取整
local ttl = math.floor(fill_time*2)

-- 获取目前 token_bucket 中剩余 token 数
-- 如果是第一次进入,则设置 token_bucket 数量为 令牌桶最大值
local last_tokens = tonumber(redis.call("get", KEYS[1]))
if last_tokens == nil then
    last_tokens = capacity
end

-- 上一次更新 token_bucket 的时间
local last_refreshed = tonumber(redis.call("get", KEYS[2]))
if last_refreshed == nil then
    last_refreshed = 0
end

local delta = math.max(0, now-last_refreshed)
-- 通过当前时间与上一次更新时间的跨度,以及生产token的速率,计算出新的token数
-- 如果超过 max_burst,多余生产的token会被丢弃
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
if allowed then
    new_tokens = filled_tokens - requested
end

-- 更新新的token数,以及更新时间
redis.call("setex", KEYS[1], ttl, new_tokens)
redis.call("setex", KEYS[2], ttl, now)

return allowed

上述可以看出 lua script :只涉及对 token 操作,保证 token 生产合理和读取合理。

流程中包含:

  • 有多重保障机制,保证限流一定会完成。
  • 如果redis limiter失效,至少在进程内rate limiter兜底。
  • 重试 redis limiter 机制保证尽可能地正常运行。

代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
package limit

import (
	"fmt"
	"log"
	"strconv"
	"sync"
	"sync/atomic"
	"time"

	"github.com/go-redis/redis"
	xrate "golang.org/x/time/rate"
)

const (
	// to be compatible with aliyun redis, we cannot use `local key = KEYS[1]` to reuse the key
	// KEYS[1] as tokens_key
	// KEYS[2] as timestamp_key
	script = `local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local fill_time = capacity/rate
local ttl = math.floor(fill_time*2)
local last_tokens = tonumber(redis.call("get", KEYS[1]))
if last_tokens == nil then
    last_tokens = capacity
end
local last_refreshed = tonumber(redis.call("get", KEYS[2]))
if last_refreshed == nil then
    last_refreshed = 0
end
local delta = math.max(0, now-last_refreshed)
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
if allowed then
    new_tokens = filled_tokens - requested
end
redis.call("setex", KEYS[1], ttl, new_tokens)
redis.call("setex", KEYS[2], ttl, now)
return allowed`
	tokenFormat     = "{%s}.tokens"
	timestampFormat = "{%s}.ts"
	pingInterval    = time.Millisecond * 100
)

// A TokenLimiter controls how frequently events are allowed to happen with in one second.
type TokenLimiter struct {
	rate           int
	burst          int
	store          *redis.Client
	tokenKey       string
	timestampKey   string
	rescueLock     sync.Mutex
	redisAlive     uint32
	rescueLimiter  *xrate.Limiter
	monitorStarted bool
}

// NewTokenLimiter returns a new TokenLimiter that allows events up to rate and permits
// bursts of at most burst tokens.
func NewTokenLimiter(rate, burst int, store *redis.Client, key string) *TokenLimiter {
	tokenKey := fmt.Sprintf(tokenFormat, key)
	timestampKey := fmt.Sprintf(timestampFormat, key)

	return &TokenLimiter{
		rate:          rate,
		burst:         burst,
		store:         store,
		tokenKey:      tokenKey,
		timestampKey:  timestampKey,
		redisAlive:    1,
		rescueLimiter: xrate.NewLimiter(xrate.Every(time.Second/time.Duration(rate)), burst),
	}
}

// Allow is shorthand for AllowN(time.Now(), 1).
func (lim *TokenLimiter) Allow() bool {
	return lim.AllowN(time.Now(), 1)
}

// AllowN reports whether n events may happen at time now.
// Use this method if you intend to drop / skip events that exceed the rate rate.
// Otherwise use Reserve or Wait.
func (lim *TokenLimiter) AllowN(now time.Time, n int) bool {
	return lim.reserveN(now, n)
}

func (lim *TokenLimiter) reserveN(now time.Time, n int) bool {
	if atomic.LoadUint32(&lim.redisAlive) == 0 {
		return lim.rescueLimiter.AllowN(now, n)
	}

	resp, err := lim.store.Eval(
		script,
		[]string{
			lim.tokenKey,
			lim.timestampKey,
		},
		[]string{
			strconv.Itoa(lim.rate),
			strconv.Itoa(lim.burst),
			strconv.FormatInt(now.Unix(), 10),
			strconv.Itoa(n),
		}).Result()
	// redis allowed == false
	// Lua boolean false -> r Nil bulk reply
	if err == redis.Nil {
		return false
	} else if err != nil {
		log.Printf("fail to use rate limiter: %s, use in-process limiter for rescue", err)
		lim.startMonitor()
		return lim.rescueLimiter.AllowN(now, n)
	}

	code, ok := resp.(int64)
	if !ok {
		log.Printf("fail to eval redis script: %v, use in-process limiter for rescue", resp)
		lim.startMonitor()
		return lim.rescueLimiter.AllowN(now, n)
	}

	// redis allowed == true
	// Lua boolean true -> r integer reply with value of 1
	return code == 1
}

func (lim *TokenLimiter) startMonitor() {
	lim.rescueLock.Lock()
	defer lim.rescueLock.Unlock()

	if lim.monitorStarted {
		return
	}

	lim.monitorStarted = true
	atomic.StoreUint32(&lim.redisAlive, 0)

	go lim.waitForRedis()
}

func (lim *TokenLimiter) waitForRedis() {
	ticker := time.NewTicker(pingInterval)
	defer func() {
		ticker.Stop()
		lim.rescueLock.Lock()
		lim.monitorStarted = false
		lim.rescueLock.Unlock()
	}()

	for range ticker.C {
		if err := lim.store.Ping().Err(); err != nil {
			atomic.StoreUint32(&lim.redisAlive, 1)
			return
		}
	}
}

限流策略

限流是指在一段时间内,定义某个客户或应用可以接收或处理多少个请求的技术。例如,通过限流,你可以过滤掉产生流量峰值的客户和微服务,或者可以确保你的应用程序在自动扩展(Auto Scaling)失效前都不会出现过载的情况。

  • 令牌桶、漏桶 针对单个节点,无法分布式限流。
  • QPS 限流
    • 不同的请求可能需要数量迥异的资源来处理。
    • 某种静态 QPS 限流不是特别准。
  • 给每个用户设置限制
    • 全局过载发生时候,针对某些“异常”进行控制。
    • 一定程度的“超卖”配额。
  • 按照优先级丢弃。
  • 拒绝请求也需要成本。返回5XX也需要网络消耗.

限流 - 重要性

每个接口配置阈值,运营工作繁重,最简单的我们配置服务级别 quota,更细粒度的,我们可以根据不同重要性设定 quota,我们引入了重要性(criticality):

  • 最重要 CRITICAL_PLUS,为最终的要求预留的类型,拒绝这些请求会造成非常严重的用户可见的问题。
  • 重要 CRITICAL,生产任务发出的默认请求类型。拒绝这些请求也会造成用户可见的问题。但是可能没那么严重。
  • 可丢弃的 SHEDDABLE_PLUS 这些流量可以容忍某种程度的不可用性。这是批量任务发出的请求的默认值。这些请求通常可以过几分钟、几小时后重试。
  • 可丢弃的 SHEDDABLE 这些流量可能会经常遇到部分不可用情况,偶尔会完全不可用。

gRPC 系统之间,需要自动传递重要性信息。如果后端接受到请求 A,在处理过程中发出了请求 B 和 C 给其他后端,请求 B 和 C 会使用与 A 相同的重要性属性。

  • 全局配额不足时,优先拒绝低优先级的。
  • 全局配额,可以按照重要性分别设置。
  • 过载保护时,低优先级的请求先被拒绝。

限流 - Case Study

  • 二层缓存穿透、大量回源导致的核心服务故障。
  • 异常客户端引起的服务故障(query of death)
    • 请求放大。
    • 资源数放大。
  • 用户重试导致的大面积故障。

参考

微服务架构下的分布式限流方案思考

分布式服务限流实战,已经为你排好坑了

限流的概念,算法,分布式限流以及微服务架构下限流的难点

go-zero 如何扛住流量冲击(二)