GoFrame + time/rate 限制请求速率

一、背景介绍

1. GoFrame

1698226003361.png

GoFrame是一款模块化、高性能、企业级的Go基础开发框架。GoFrame是一款通用性的基础开发框架,是Golang标准库的一个增强扩展级,包含通用核心的基础开发组件,优点是实战化、模块化、文档全面、模块丰富、易用性高、通用性强、面向团队。GoFrame即可用于开发完整的工程化项目,由于框架基础采用模块化解耦设计,因此也可以作为工具库使用。

​ 官网:https://goframe.org/

​ 开源地址:https://github.com/gogf/gf

特点:

  • 业内领先、工程完备
  • 模块化、松耦合设计
  • 组件丰富、开箱即用
  • 简洁易用、文档详尽
  • 接口化、高扩展性设计
  • 全链路跟踪特性
  • 全错误堆栈特性
  • 接口化的错误码支持
  • 稳健的工程设计规范
  • 更便捷强大的ORM组件
  • 便捷的开发工具、自动化代码生成
  • 支持OpenTelemetry可观测性标准
  • 自动化的接口文档生成,支持OpenAPIV3标准
  • 完善的本地中文化支持
  • 设计为团队及企业使用

过多的不详细介绍,他们官网有详细的文档介绍。

1698226216393.png

2. 限流介绍

2.1 计数器算法

  • 计数器算法

    • 固定窗口算法

      即将整个时间线按固定大小分割成段,每段只允许指定请求数量通过,其是最简单的算法,实现起来也很简单。缺点如下

      • 流量分布不均匀,比如一段为1s,在前0.5秒可能已经用完了所有的请求指标,后0.5秒不允许任何请求通过
      • 跨窗口大量请求通过:本质上还是流量分布不均匀导致,即我们选取跨时间段的一个虚拟时间段,这个时间段上可能允许通过阈值1~2倍的流量
      • 不能处理突发流量:突发大量流量比如短时间内有阈值3倍的流量,那么很多请求将会被直接拒绝(请求没有跨窗口将有最少2倍流量被拒绝,请求跨窗口将最少有1倍流量被拒绝)
    • 滑动窗口算法:相对于固定窗口的优化(优化了跨窗口大量请求通过),缺点也是流量分布不均匀问题(你滑任你滑,我不在短时间内全部请求算我输)、不能处理突发流量

2.2 生产者消费者算法

  • 令牌桶算法:如下面这个带着包浆的图所示,每个请求到来时需要获取令牌,而令牌是均匀生产放入桶中的,请求来到时,桶中有令牌则通过,无令牌则拒绝。

    • 注意点:并不是我们的代码要这样设计,其实各语言的令牌桶算法限流实现基本没有这样设计的,因为按图中所示,我们需要一个定时器不断的产生令牌,我们还需要一个队列存储,这些都是对性能产生影响的因素

    • 缺点:

      • 部分时期请求不均匀:这个在我们设置的令牌桶大小较小时问题不大,当令牌桶容量较大且是较为满状态的情况下,会允许现有令牌数量的请求短时间通过。其它情况下,请求的通过速率不会超过令牌的下发速率,请求会均匀,即对流量进行整形(大量请求被放入队列中,以恒定速率消耗)
      • 请求速率大于令牌产生速率时且令牌桶中没有存量令牌时大量请求被直接拒绝,当然这个不能说是缺点,因为限流就是为了保护自己或者下游服务不会被大量请求冲垮。只是令牌桶没有存储请求的动作,当然我们在代码上可以等待一段时间获取不到才放弃来避免直接拒绝。
1698226927759.png
  • 漏桶算法 :如包浆图所示,与令牌算法不同的是,漏桶算法是将请求放入桶中,以恒定速率流出
    • 优点:不会允许请求突刺的通过,并缓存突发流量,请求始终恒定,即对流量进行整形
1698227041668.png

3. Golang time/rate限流器介绍

​ go time/rate的限流算法是令牌桶算法,与上面介绍令牌桶算法所用图不同的是,其使用懒加载或者说懒计算的方式,且没有存放请求的队列,每一个请求通过调用不同的方法自行决定没有足够令牌是等待还是直接放弃。

​ 限流器结构体:

type Limiter struct {
limit Limit // float64别名, 即QPS, 为Inf即无限含义时代表通过任何请求
burst int // 令牌桶大小, 为0时不允许任何请求通过, 其等于0的优先级小于limit = Inf
mu sync.Mutex // 同步锁
tokens float64 // 桶中的令牌数目 可为负数且 <= burst
last time.Time // 最后一次更新tokens字段时的时间, < 当前时间
lastEvent time.Time // 最迟的请求的通过时间,可能 > 当前时间, 因为tokens可为负, 即请求先更新tokens与lastEvent再等待
// lastEvent即等于当前最迟在等待的请求的Reservation.timeToAct
}

// 请求尝试获取令牌时的返回结构体,核心函数reserveN的返回
type Reservation struct {
ok bool // 是否通过
lim *Limiter
tokens int // 获取的令牌
timeToAct time.Time // 可以通过的时间
limit Limit
}
  • time/rate并没有用队列存储令牌,而只是用tokens字段记录当前桶中个数
  • 再来看两个基本函数:即时间与令牌个数的相互换算
// 计算产生对应数目的令牌需要多长时间
// 浮点数的+-*/都有可能产生误差,根本原因就是由于不管用多少位表示某些浮点数本来就不精确
// 再加上2个浮点数的计算过程(对齐、和尾数的舍去)等也会对精度产生影响
// 这个没有改进精度的原因应该是我们传入的tokens都是整数https://golang.org/cl/200917
func (limit Limit) durationFromTokens(tokens float64) time.Duration {
seconds := tokens / float64(limit)
return time.Nanosecond * time.Duration(1e9*seconds)
}

// 计算传入的时间能生产多少令牌:这里浮点数这样处理的原因可以看注释中的issues链接
// 简单来说就是如果直接用2个float相乘,会有较大的精度损失,而分开整数与小数再与limit相乘
// 精度损失较少(很好理解, 原来64位里面要包含整数信息,那么小数的信息就有可能丢失的就多一点)
// https://golang.org/cl/200900
func (limit Limit) tokensFromDuration(d time.Duration) float64 {
// See golang.org/issues/34861.
sec := float64(d/time.Second) * float64(limit)
nsec := float64(d%time.Second) * float64(limit)
return sec + nsec/1e9
}
  • 上面的token与时间相互转换的方法在最新版本又改回了#34861 issues之前的版本,因为精度的损失很小,但是#34861 issues之前的版本advance方法中有个逻辑是来回转换,这种情况在极端情况下会出现上面#34861 issues所提出的bug。详情见提交1f47c86 (opens new window),原因是已经做了<=桶容量的判断,根本原因还是go允许float溢出后的值进行大小比较(Inf)
1698287228687.png

Em… 后面的已经看不太懂了,原文:https://bioitblog.com/blog/2022/09/20/go-rate/

使用相对比较简单,参考几个示例即可。

首先,我们需要构造一个限流器:

limiter := NewLimiter(10, 1);

这里有两个参数:

  1. 第一个参数是 r:Limit。代表每秒可以向 Token 桶中产生多少 token。Limit 实际上是 float64 的别名。
  2. 第二个参数是 b:int。b 代表 Token 桶的容量大小。

也就是程序会每1秒向桶内放10个令牌,桶内始终只有1个或0个令牌。

除了直接指定每秒产生的 Token 个数外,还可以用 Every 方法来指定向 Token 桶中放置 Token 的间隔,例如:

limit := Every(100 * time.Millisecond);
limiter := NewLimiter(limit, 1);

此处就可以指定放入令牌的间隔时间,以上就表示每 100ms 往桶中放一个 Token。本质上也就是一秒钟产生 10 个。

Limiter 提供了三类方法供用户消费 Token ,用户可以每次消费一个 Token,也可以一次性消费多个 Token。
而每种方法代表了当 Token 不足时,各自不同的对应手段。

func (lim *Limiter) Wait(ctx context.Context) (err error)
func (lim *Limiter) WaitN(ctx context.Context, n int) (err error)
func (lim *Limiter) Allow() bool
func (lim *Limiter) AllowN(now time.Time, n int) bool

当使用 Wait 方法消费 Token 时,如果此时桶内 Token 数组不足 (小于 N),那么 Wait 方法将会阻塞一段时间,直至 Token 满足条件。如果充足则直接返回。我们可以设置 contextDeadline 或者 Timeout,来决定此次 Wait 的最长时间。

Allow 实际上就是 AllowN(time.Now(),1)

AllowN 方法表示,截止到某一时刻,目前桶中数目是否至少为 n 个,满足则返回 true,同时从桶中消费 n 个 token。
反之返回不消费 Token,false。

type Reservation
func (r *Reservation) Cancel()
func (r *Reservation) CancelAt(now time.Time)
func (r *Reservation) Delay() time.Duration
func (r *Reservation) DelayFrom(now time.Time) time.Duration
func (r *Reservation) OK() bool

Reserve 相当于 ReserveN(time.Now(), 1)

ReserveN 的用法就相对来说复杂一些,当调用完成后,无论 Token 是否充足,都会返回一个 Reservation * 对象

可以调用该对象的 Delay() 方法,该方法返回了需要等待的时间。如果等待时间为 0,则说明不用等待。
必须等到等待时间之后,才能进行接下来的工作。

相关代码示例:

r := lim.Reserve()
f !r.OK() {
// Not allowed to act! Did you remember to set lim.burst to be > 0 ?
return
}
time.Sleep(r.Delay())
Act() // 执行相关逻辑

对于 Reserve 函数,返回的结果中,我们可以通过 Reservation.Delay() 函数,得到需要等待时间。
同时调用方可以根据返回条件和现有情况,可以调用 Reservation.Cancel() 函数,取消此次消费。
当调用 Cancel() 函数时,消费的 Token 数将会尽可能归还给 Token 桶。

其次,Limiter 支持可以调整速率和桶大小:

  1. SetLimit(Limit) 改变放入 Token 的速率
  2. SetBurst(int) 改变 Token 桶大小

二、代码改造

​ 讲了这么多,主要是针对应用做一点访问速度限制的解决方案,通过关联IP和限流器,进而对请求速率进行限制,避免爬虫爬的太快(虽然没办法防止)。

​ 速率限制肯定是要放在请求初始状态,首先实现速率限制的相关代码

func (s *sMiddleware) NewLimiter(rLimit rate.Limit, b int) ghttp.HandlerFunc {

limiters := &sync.Map{}
return func(r *ghttp.Request) {

var key = strings.Split(r.Request.RemoteAddr, ":")[0]
l, _ := limiters.LoadOrStore(key, rate.NewLimiter(rLimit, b))
limiter := l.(*rate.Limiter)
if !limiter.Allow() {
r.Response.WriteJson(g.Map{
"code": "429",
"msg": "请求过于频繁,请稍后再试!",
"data": nil,
"success": false,
})
r.ExitAll()
} else {
r.Middleware.Next()
}
}
}

​ 通过创建一个Map,存放一一对应的IP和限流器,传入参数limitb 用于控制令牌每秒放入数量和桶的大小。

​ 将NewLimiter函数注册到 goframe 的中间件上, 并传入参数

s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(
service.Middleware().NewLimiter(6, 10),
service.Middleware().Ctx,
ghttp.MiddlewareCORS,
)
}

​ 这时候,可以测试快速访问下的速率限制情况。在burpsuite的默认配置下,快速请求了20次后即会限制速率,具体限制数值可根据用户量去修改。

1698289150925.png

后续改进: 由于网站用户量较少且很多功能都是放置于登录后的,所以可以对出发请求过于频繁的情况进行一下告警,判断告警情况进行限制参数调整。

参考