GoFrame + time/rate 限制请求速率
一、背景介绍
1. GoFrame
GoFrame
是一款模块化、高性能、企业级的Go
基础开发框架。GoFrame
是一款通用性的基础开发框架,是Golang
标准库的一个增强扩展级,包含通用核心的基础开发组件,优点是实战化、模块化、文档全面、模块丰富、易用性高、通用性强、面向团队。GoFrame
即可用于开发完整的工程化项目,由于框架基础采用模块化解耦设计,因此也可以作为工具库使用。
官网:https://goframe.org/
开源地址:https://github.com/gogf/gf
特点:
- 业内领先、工程完备
- 模块化、松耦合设计
- 组件丰富、开箱即用
- 简洁易用、文档详尽
- 接口化、高扩展性设计
- 全链路跟踪特性
- 全错误堆栈特性
- 接口化的错误码支持
- 稳健的工程设计规范
- 更便捷强大的ORM组件
- 便捷的开发工具、自动化代码生成
- 支持
OpenTelemetry
可观测性标准 - 自动化的接口文档生成,支持
OpenAPIV3
标准 - 完善的本地中文化支持
- 设计为团队及企业使用
过多的不详细介绍,他们官网有详细的文档介绍。
2. 限流介绍
2.1 计数器算法
计数器算法
固定窗口算法
即将整个时间线按固定大小分割成段,每段只允许指定请求数量通过,其是最简单的算法,实现起来也很简单。缺点如下
- 流量分布不均匀,比如一段为1s,在前0.5秒可能已经用完了所有的请求指标,后0.5秒不允许任何请求通过
- 跨窗口大量请求通过:本质上还是流量分布不均匀导致,即我们选取跨时间段的一个虚拟时间段,这个时间段上可能允许通过阈值1~2倍的流量
- 不能处理突发流量:突发大量流量比如短时间内有阈值3倍的流量,那么很多请求将会被直接拒绝(请求没有跨窗口将有最少2倍流量被拒绝,请求跨窗口将最少有1倍流量被拒绝)
滑动窗口算法:相对于固定窗口的优化(优化了跨窗口大量请求通过),缺点也是流量分布不均匀问题(你滑任你滑,我不在短时间内全部请求算我输)、不能处理突发流量
2.2 生产者消费者算法
令牌桶算法:如下面这个带着包浆的图所示,每个请求到来时需要获取令牌,而令牌是均匀生产放入桶中的,请求来到时,桶中有令牌则通过,无令牌则拒绝。
注意点:并不是我们的代码要这样设计,其实各语言的令牌桶算法限流实现基本没有这样设计的,因为按图中所示,我们需要一个定时器不断的产生令牌,我们还需要一个队列存储,这些都是对性能产生影响的因素
缺点:
- 部分时期请求不均匀:这个在我们设置的令牌桶大小较小时问题不大,当令牌桶容量较大且是较为满状态的情况下,会允许现有令牌数量的请求短时间通过。其它情况下,请求的通过速率不会超过令牌的下发速率,请求会均匀,即对流量进行整形(大量请求被放入队列中,以恒定速率消耗)
- 请求速率大于令牌产生速率时且令牌桶中没有存量令牌时大量请求被直接拒绝,当然这个不能说是缺点,因为限流就是为了保护自己或者下游服务不会被大量请求冲垮。只是令牌桶没有存储请求的动作,当然我们在代码上可以等待一段时间获取不到才放弃来避免直接拒绝。
- 漏桶算法 :如包浆图所示,与令牌算法不同的是,漏桶算法是将请求放入桶中,以恒定速率流出
- 优点:不会允许请求突刺的通过,并缓存突发流量,请求始终恒定,即对流量进行整形
3. Golang time/rate限流器介绍
go time/rate
的限流算法是令牌桶算法,与上面介绍令牌桶算法所用图不同的是,其使用懒加载或者说懒计算的方式,且没有存放请求的队列,每一个请求通过调用不同的方法自行决定没有足够令牌是等待还是直接放弃。
限流器结构体:
type Limiter struct { |
time/rate
并没有用队列存储令牌,而只是用tokens
字段记录当前桶中个数- 再来看两个基本函数:即时间与令牌个数的相互换算
// 计算产生对应数目的令牌需要多长时间 |
- 上面的token与时间相互转换的方法在最新版本又改回了#34861 issues之前的版本,因为精度的损失很小,但是#34861 issues之前的版本advance方法中有个逻辑是来回转换,这种情况在极端情况下会出现上面#34861 issues所提出的bug。详情见提交1f47c86 (opens new window),原因是已经做了<=桶容量的判断,根本原因还是go允许float溢出后的值进行大小比较(Inf)
Em… 后面的已经看不太懂了,原文:https://bioitblog.com/blog/2022/09/20/go-rate/
使用相对比较简单,参考几个示例即可。
首先,我们需要构造一个限流器:
limiter := NewLimiter(10, 1); |
这里有两个参数:
- 第一个参数是
r:Limit
。代表每秒可以向 Token 桶中产生多少 token。Limit 实际上是 float64 的别名。 - 第二个参数是
b:int
。b 代表 Token 桶的容量大小。
也就是程序会每1秒向桶内放10个令牌,桶内始终只有1个或0个令牌。
除了直接指定每秒产生的 Token 个数外,还可以用 Every 方法来指定向 Token 桶中放置 Token 的间隔,例如:
limit := Every(100 * time.Millisecond); |
此处就可以指定放入令牌的间隔时间,以上就表示每 100ms 往桶中放一个 Token。本质上也就是一秒钟产生 10 个。
Limiter 提供了三类方法供用户消费 Token
,用户可以每次消费一个 Token,也可以一次性消费多个 Token。
而每种方法代表了当 Token 不足时,各自不同的对应手段。
func (lim *Limiter) Wait(ctx context.Context) (err error) |
当使用 Wait
方法消费 Token 时,如果此时桶内 Token
数组不足 (小于 N
),那么 Wait 方法将会阻塞一段时间,直至 Token
满足条件。如果充足则直接返回。我们可以设置 context
的 Deadline
或者 Timeout
,来决定此次 Wait
的最长时间。
Allow
实际上就是 AllowN(time.Now(),1)
。
AllowN
方法表示,截止到某一时刻,目前桶中数目是否至少为 n
个,满足则返回 true
,同时从桶中消费 n
个 token。
反之返回不消费 Token,false。
type Reservation |
Reserve 相当于 ReserveN(time.Now(), 1)
。
ReserveN 的用法就相对来说复杂一些,当调用完成后,无论 Token
是否充足,都会返回一个 Reservation *
对象
可以调用该对象的 Delay()
方法,该方法返回了需要等待的时间。如果等待时间为 0,则说明不用等待。
必须等到等待时间之后,才能进行接下来的工作。
相关代码示例:
r := lim.Reserve() |
对于 Reserve 函数,返回的结果中,我们可以通过 Reservation.Delay()
函数,得到需要等待时间。
同时调用方可以根据返回条件和现有情况,可以调用 Reservation.Cancel()
函数,取消此次消费。
当调用 Cancel()
函数时,消费的 Token 数将会尽可能归还给 Token 桶。
其次,Limiter 支持可以调整速率和桶大小:
SetLimit(Limit)
改变放入 Token 的速率SetBurst(int)
改变 Token 桶大小
二、代码改造
讲了这么多,主要是针对应用做一点访问速度限制的解决方案,通过关联IP和限流器,进而对请求速率进行限制,避免爬虫爬的太快(虽然没办法防止)。
速率限制肯定是要放在请求初始状态,首先实现速率限制的相关代码
func (s *sMiddleware) NewLimiter(rLimit rate.Limit, b int) ghttp.HandlerFunc { |
通过创建一个Map,存放一一对应的IP和限流器,传入参数limit
和 b
用于控制令牌每秒放入数量和桶的大小。
将NewLimiter
函数注册到 goframe 的中间件上, 并传入参数
s.Group("/", func(group *ghttp.RouterGroup) { |
这时候,可以测试快速访问下的速率限制情况。在burpsuite的默认配置下,快速请求了20次后即会限制速率,具体限制数值可根据用户量去修改。
后续改进: 由于网站用户量较少且很多功能都是放置于登录后的,所以可以对出发请求过于频繁的情况进行一下告警,判断告警情况进行限制参数调整。