thor不是雷神
2023/02/09阅读:41主题:萌绿
公司go限流现状
1. 现状
目前公司整个 Go 团队都没有一套完整且稳定的熔断降级方案,沟通了几个团队 leader 得到的答复是已目前公司的业务量和架构方案,暂时不需要考虑熔断降级这类解决方案。
2. 思考
作为年金团队,以其特殊性,如果一旦爆发大面积的线上年金活动,可能对我们年金系统会照成不小的冲击, 尤其是数据库与 Redis, 目前我们存储中间件是十分薄弱的, 甚至还在使用单机进行支撑, 一旦哪天突然的流量,势必照成大面积的瘫痪甚至不可用。故我们急需建立起一套限流熔断方案,以便更好且稳定的支撑后续年金业务的开展。
3. 限流原理
3.1 漏桶
漏桶算法的原理比较简单,水(请求)先进入到漏桶里,人为设置一个最大出水速率,漏桶以<=出水速率的速度出水,当水流入速度过大会直接溢出(拒绝服务):

- 因此,这个算法的核心为:
- 存下请求
- 匀速处理
- 多于丢弃
- 因此这是一种强行限制请求速率的方式,但是缺点非常明显,主要有两点: 无法面对突发的大流量----比如请求处理速率为 1000,容量为 5000,来了一波 2000/s 的请求持续 10s,那么后 5s 的请求将全部直接被丢弃,服务器拒绝服务,但是实际上网络中突发一波大流量尤其是短时间的大流量是非常正常的,超过容量就拒绝,非常简单粗暴 无法有效利用网络资源----比如虽然服务器的处理能力是 1000/s,但这不是绝对的,这个 1000 只是一个宏观服务器处理能力的数字,实际上一共 5 秒,每秒请求量分别为 1200、1300、1200、500、800,平均下来 qps 也是 1000/s,但是这个量对服务器来说完全是可以接受的,但是因为限制了速率是 1000/s,因此前面的三秒,每秒只能处理掉 1000 个请求而一共打回了 700 个请求,白白浪费了服务器资源 所以,通常来说利用漏桶算法来限流,实际场景下用得不多。
3.2 令牌桶
令牌桶算法是网络流量整形(Traffic Shaping)和限流(Rate Limiting)中最常使用的一种算法,它可用于控制发送到网络上数据的数量并允许突发数据的发送。

从某种意义上来说,令牌桶算法是对漏桶算法的一种改进,主要在于令牌桶算法能够在限制调用的平均速率的同时还允许一定程度的突发调用,来看下令牌桶算法的实现原理:

整个的过程是这样的: 系统以恒定的速率产生令牌,然后将令牌放入令牌桶中 令牌桶有一个容量,当令牌桶满了的时候,再向其中放入的令牌就会被丢弃 每次一个请求过来,需要从令牌桶中获取一个令牌,假设有令牌,那么提供服务;假设没有令牌,那么拒绝服务 那么,我们再看一下,为什么令牌桶算法可以防止一定程度的突发流量呢?可以这么理解,假设我们想要的速率是 1000QPS,那么往桶中放令牌的速度就是 1000 个/s,假设第 1 秒只有 800 个请求,那意味着第 2 秒可以容许 1200 个请求,这就是一定程度突发流量的意思,反之我们看漏桶算法,第一秒只有 800 个请求,那么全部放过,第二秒这 1200 个请求将会被打回 200 个。 注意上面多次提到一定程度这四个字,这也是我认为令牌桶算法最需要注意的一个点。假设还是 1000QPS 的速率,那么 5 秒钟放 1000 个令牌,第 1 秒钟 800 个请求过来,第 2~4 秒没有请求,那么按照令牌桶算法,第 5 秒钟可以接受 4200 个请求,但是实际上这已经远远超出了系统的承载能力,因此使用令牌桶算法特别注意设置桶中令牌的上限即可。 根据描述,似乎令牌桶的实现为:
准备一个定时器和一个队列,每隔 X 时间固定往队列中放入令牌
请求到来时,从队列中获取令牌,如果能获取,就执行业务逻辑,否则说明被限流了。要么等待一段时间,要么丢弃该次请求
但这么实现,需要占用一些 CPU 和内存资源,且桶的容量越大,占用内存资源越多
实际上常见的令牌桶算法的实现都没有用定时器和一个桶容量大小的队列,而只用 4 个变量:
rate:每秒生成多少令牌
capacity:桶的容量
size:当前桶中剩余令牌的数量
lastTime:上次请求令牌桶的时间
那当请求到来时,怎么根据这 4 个变量判断是否应该放行呢? 计算从 lastTime 到当前时间,经历了多久 计算这段时间内桶中产生了多少令牌 再加上在 lastTime 时,桶中原本就有的令牌 如果结果大于桶的容量,需要修正为桶的容量,因为不能超过桶的最大限制 判断此时桶中的容量满不满足此次请求需要的令牌数 如果满足就放行,并使桶中的令牌数减去消耗的令牌数 否则不放行,此次请求被限流! 通过每次请求到来时再实时计算当前桶中的令牌,取代真正往桶中放令牌的过程,能有效降低对 CPU 和内存资源的占用 总而言之,作为对漏桶算法的改进,令牌桶算法在限流场景下被使用更加广泛。
4. 业界方案
4.1 Go-Sentinel
https://github.com/alibaba/sentinel-golang
需要通过 go-mod 引入 组件支持 require github.com/alibaba/sentinel-golang v1.0.4
4.1.1 定义资源
资源 (resource) 是 Sentinel 中的最核心概念之一,Sentinel 中所有的限流熔断机制都是基于资源生效的,不同资源的限流熔断规则互相隔离互不影响。 在 Sentinel 中,用户可以灵活的定义资源埋点。资源可以是应用、接口、函数、甚至是一段代码。我们的流量治理机制都是为了保护这段资源运行如预期一样。 用户通过 Sentinel api 包里面的接口可以把资源访问包起来,这一步称为“埋点”。每个埋点都有一个资源名称(resource),代表触发了这个资源的调用或访问。有了资源埋点之后,我们就可以针对资源埋点配置流量治理规则。即使没有配置任何规则,资源埋点仍然会产生 metric 统计。 下面是一个示例代码,将 fmt.Println("hello world");
作为资源(被保护的逻辑),用 API 包装起来。参考代码如下:
// We should initialize Sentinel first.
err := sentinel.InitDefault()
if err != nil {
log.Fatalf("Unexpected error: %+v", err)
}
// initialize sentinel rules
initRules()
e, b := sentinel.Entry("some-test", sentinel.WithTrafficType(base.Inbound))
if b != nil {
// Blocked. We could get the block reason from the BlockError.
} else {
// the resource was guarded.
fmt.Println("hello world")
// Be sure the entry is exited finally.
e.Exit()
}
4.1.2 规则配置
针对埋点资源配置相应的规则,来达到流量治理的效果。目前 Sentinel Go 支持以下几种规则: 流控规则 流量隔离规则(并发控制) 熔断规则 自适应过载保护规则 热点参数流控规则 基于 QPS 限流的完整的示例 import ( sentinel "github.com/alibaba/sentinel-golang/api" )
func main() {
// 务必先进行初始化
err := sentinel.InitDefault()
if err != nil {
log.Fatal(err)
}
// 配置一条限流规则
_, err = flow.LoadRules([]*flow.Rule{
{
Resource: "some-test",
Threshold: 10,
TokenCalculateStrategy: flow.Direct,
ControlBehavior: flow.Reject,
},
})
if err != nil {
fmt.Println(err)
return
}
ch := make(chan struct{})
for i := 0; i < 10; i++ {
go func() {
for {
// 埋点逻辑,埋点资源名为 some-test
e, b := sentinel.Entry("some-test")
if b != nil {
// 请求被拒绝,在此处进行处理
time.Sleep(time.Duration(rand.Uint64() % 10) * time.Millisecond)
} else {
// 请求允许通过,此处编写业务逻辑
fmt.Println(util.CurrentTimeMillis(), "Passed")
time.Sleep(time.Duration(rand.Uint64() % 10) * time.Millisecond)
// 务必保证业务结束后调用 Exit
e.Exit()
}
}
}()
}
<-ch
}
Demo 运行后,可以看到控制台每秒稳定输出 "Passed" 10 次,和规则中预先设定的阈值是一样的。我们可以在 metric 日志里看到类似下面的输出:
1581516234000|2020-02-12 22:03:54|some-test|10|2068|10|0|5|0|0|0
1581516235000|2020-02-12 22:03:55|some-test|10|2073|10|0|3|0|0|0
1581516236000|2020-02-12 22:03:56|some-test|10|2058|10|0|5|0|0|0
1581516237000|2020-02-12 22:03:57|some-test|10|2023|10|0|5|0|0|0
1581516238000|2020-02-12 22:03:58|some-test|10|2046|10|0|5|0|0|0
其中 some-test 这一列代表埋点资源名,后面的数字依次代表该一秒内的通过数(pass)、拒绝数(block)、完成数(complete)、错误数目(error)、平均响应时长(rt)。
4.1.3 基于 kubenates 的 CRD 进行规则的动态配置
因为线上流量存在不确定性, 如果不能很好的支持动态配置,那么对于限流能力,也是会大打折扣 云原生 yaml 配置就可以了 https://github.com/sentinel-group/sentinel-go-datasource-k8s-crd
4.2 Uber-Go
4.2.1 引入
https://github.com/uber-go/ratelimit
需要引入 uber-go 的 go modules, 原理是基于漏桶的改进, 做了一些性能上的处理。
4.2.2 使用方式
rl := ratelimit.New(100) // per second
prev := time.Now()
for i := 0; i < 10; i++ {
now := rl.Take()
fmt.Println(i, now.Sub(prev))
prev = now
}
要实现以上每秒固定速率的目的,其实还是比较简单的。 在 ratelimit 的 New 函数中,传入的参数是每秒允许请求量 (RPS)。 我们可以很轻易的换算出每个请求之间的间隔:
limiter.perRequest = time.Second / time.Duration(rate)
以上 limiter.perRequest 指的就是每个请求之间的间隔时间。 如下图,当请求 1 处理结束后, 我们记录下请求 1 的处理完成的时刻, 记为 limiter.last。 稍后请求 2 到来, 如果此刻的时间与 limiter.last 相比并没有达到 perRequest 的间隔大小,那么 sleep 一段时间即可。

4.2.3 最大松弛量
传统的漏桶每个请求的间隔是固定的,然而在实际上的互联网应用中,流量经常是突发性的。对于这种情况,uber-go 对漏桶做了一些改良,引入了最大松弛量 (maxSlack) 的概念。 我们先理解下整体背景: 假如我们要求每秒限定 100 个请求,平均每个请求间隔 10ms。但是实际情况下,有些请求间隔比较长,有些请求间隔比较短。如下图所示:

请求 1 完成后,15ms 后,请求 2 才到来,可以对请求 2 立即处理。请求 2 完成后,5ms 后,请求 3 到来,这个时候距离上次请求还不足 10ms,因此还需要等待 5ms。 但是,对于这种情况,实际上三个请求一共消耗了 25ms 才完成,并不是预期的 20ms。在 uber-go 实现的 ratelimit 中,可以把之前间隔比较长的请求的时间,匀给后面的使用,保证每秒请求数 (RPS) 即可。 对于以上 case,因为请求 2 相当于多等了 5ms,我们可以把这 5ms 移给请求 3 使用。加上请求 3 本身就是 5ms 之后过来的,一共刚好 10ms,所以请求 3 无需等待,直接可以处理。此时三个请求也恰好一共是 20ms。 如下图所示:

4.3 Go-RateLimiter
4.3.1 go 官方推出
引入 go.org/x/time/rate/rate.go
, go 扩展包自带
4.3.2 基于令牌桶实现
令牌桶其实非常适合互联网突发式请求的场景,其请求 Token 时并不是严格的限制为固定的速率,而是中间有一个桶作为缓冲。 只要桶中还有 Token,请求就还可以一直进行。当突发量激增到一定程度,则才会按照预定速率进行消费。
4.3.3 使用
构造一个限流器 我们可以使用以下方法构造一个限流器对象:
limiter := NewLimiter(10, 1);
这里有两个参数:
第一个参数是
r Limit。代表每秒可以向 Token 桶中产生多少 token。Limit 实际上是 float64 的别名。
第二个参数是
b int。b 代表 Token 桶的容量大小。
那么,对于以上例子来说,其构造出的限流器含义为,其令牌桶大小为 1, 以每秒 10 个 Token 的速率向桶中放置 Token。 除了直接指定每秒产生的 Token 个数外,还可以用 Every 方法来指定向 Token 桶中放置 Token 的间隔,例如:
limit := Every(100 \* time.Millisecond);
limiter := NewLimiter(limit, 1);
以上就表示每 100ms 往桶中放一个 Token。本质上也就是一秒钟产生 10 个。 Limiter 提供了三类方法供用户消费 Token,用户可以每次消费一个 Token,也可以一次性消费多个 Token。 而每种方法代表了当 Token 不足时,各自不同的对应手段。 Wait/WaitN
func (lim *Limiter) Wait(ctx context.Context) (err error)
func (lim *Limiter) WaitN(ctx context.Context, n int) (err error)
Wait 实际上就是 WaitN(ctx,1)。 当使用 Wait 方法消费 Token 时,如果此时桶内 Token 数组不足 (小于 N),那么 Wait 方法将会阻塞一段时间,直至 Token 满足条件。如果充足则直接返回。 这里可以看到,Wait 方法有一个 context 参数。 我们可以设置 context 的 Deadline 或者 Timeout,来决定此次 Wait 的最长时间。
5. 团队选择
起初我倾向于选择 alibaba 开源的 go-sentinel 来作为我们的限流框架, 理由是 go-sentinel 的出现,主要就是为了解决云原生环境的问题,与现在主流的云原生大环境构成了强大的互补作用。由于有 Java-sentinel 璞玉在前, 这一套限流熔断降级是非常成熟,而且可配置化灵活,这样对于团队后面可能出现的流量问题,都可以在改动极小的情况下,快速响应和适配。 但是通过实际和 C 端以及架构组相关负责人沟通, 目前我司的 go 框架还是使用的十分古老的 go path 管理, 这种管理方式导致一旦需要引入一个全新的中间件,会十分困难, 需要把该中间件所有的依赖包下载到 path 维护, 里面可能还牵涉到兼容问题。 由于现在的 go module 大面积普及, 它类似于 Java 的 maven 管理, 只需要在 go mod 中配置 需要依赖的项目, 它就自动去寻找一系列子依赖, 即便有兼容问题,也可以通过 exclude 来排除一些不兼容的组件。 我司现在全面使用的还是 path 方式 与 go module 是无法兼容的, 只能二选一 , 故而没办法在我们独立项目组内实现第三方包引入。 所以最终放弃了 go-sentinel 与 uber-go 这 2 个解决方案。 从而选择了 go 官方提供的 ratelimiter。
6. 落地使用
6.1 包装一个 limiter 结构体
package conf
import (
"wesure.com/modifysrc/time/rate"
)
type RateLimiter struct {
Limiter \*rate.Limiter
}
type RateLimiterConfig struct {
Limit rate.Limit
Burst int
}
const defaultLimiter rate.Limit = 50
// SetLimiter 动态设置令牌数
func (r \*RateLimiter) SetLimiter(cLimiter rate.Limit) {
if cLimiter == 0 {
cLimiter = defaultLimiter
}
// 不做重复设置, 如果并发穿透, 交给源码处理
if r.Limiter.Limit() == cLimiter {
return
}
r.Limiter.SetLimit(cLimiter)
}
// NewLimiter 初始化
func (r \*RateLimiter) NewLimiter(limit rate.Limit, burst int) {
r.Limiter = rate.NewLimiter(limit, burst)
}
// NewLimiterWithConfig 配置化
func (r *RateLimiter) NewLimiterWithConfig(config *RateLimiterConfig) {
r.Limiter = rate.NewLimiter(config.Limit, config.Burst)
}
6.2 在需要使用的 service 中注入这个结构体
RateLimiter *conf.RateLimiter `inject:"private"
注意: 这里使用的注入为 private , 用法类似于 Java 的 prototype, 每个注入都会是一个新的对象,不会使用单例来完成,这样做的目的是适配不同的 service 可能其负载也是不一样的, 配置化的时候就能更加的独立, 不会相互影响。
6.3 在 service start 方法中
func (s _BenefitService) Start() {
config := &conf.RateLimiterConfig{}
if msf.IsPRD() {
config.Limit = rate.Limit(200)
config.Burst = 300
} else {
config.Limit = rate.Every(50 _ time.Millisecond)
config.Burst = 3
}
s.RateLimiter.NewLimiterWithConfig(config)
}
这里做的目的是在项目启动的时候, 就可以进行规则初始化加载。
6.4 埋点需要限流的方法
timeoutCtx, _ := context.WithTimeout(ctx, time.Second\*2)
if err = s.RateLimiter.Limiter.Wait(timeoutCtx); err != nil {
log.With(timeoutCtx).Errorf("触发限流拦截 policyID : %s", req.PolicyID)
return nil, errors.NewFmtMessage(common.ToFast)
}
当无令牌时, 超过 2 秒还未获取令牌, 就会返回限流。 这样可以应对短暂流量波动。
6.5 使用公司的 config 对限流动态配置
const defaultLimiter rate.Limit = 50
// SetLimiter 动态设置令牌数
func (r *RateLimiter) SetLimiter(cLimiter rate.Limit) {
if cLimiter == 0 {
cLimiter = defaultLimiter
}
// 不做重复设置, 如果并发穿透, 交给源码处理
if r.Limiter.Limit() == cLimiter {
return
}
r.Limiter.SetLimit(cLimiter)
}
我们封装了一下设置令牌数的方法, 然后定义一个 config 配置, 对 limiter 进行动态配置,当 config 中 limiter 不是默认的 50 , 那么就会触发令牌数动态设置, 从而达到在极端情况下的动态缩/扩操作。
7. 问题
使用自带 rate 能在一定程度上解决限流问题, 但是由于是基于本机处理, 流量感知很难触达到集群, 这样有个问题就是 单机限流如果设置过大, 会导致集群流量的突增, 其实还是不能很好的起到保护数据层不被打垮的问题。 如果需要对集群流量进行控制,我还是倾向于引入 sentinel, 不然就得自己写一个流量 monitor 来管控整个服务集群的流量情况,这个其实是很没有必要的, 理论上来说我们的公共网关就可以做到, 他是最先流量的感知入口, 倘若能抽出一个业务网关来统一处理,并提供配置化, 那真是极好的。
作者介绍