在实际业务中,经常会碰到突发流量的情况。如果公司基础架构做的不好,服务无法自动扩容缩容,在突发高流量情况下,服务会因为压力过大而崩溃。更恐怖的是,服务崩溃如同多米诺骨牌,一个服务出问题,可能影响到整个公司所有组的业务。
为了减少损失,比较常用的方案是限流和熔断。本文会讲述常用的几种方法:
- 随机拒流
- 计数器方式
- 基于滑动时间窗口限流
- 漏斗算法
- 令牌桶算法
- Hystrix
Hystrix主要用来做熔断处理,不过从另一个维度而言,也算实现了限流的功能,就放在一起讨论了。这些方法都会用go写一个实现,鉴于自己时间有限,分三到四期写完。本期先实现随机拒流、计数器方式、基于滑动时间窗口限流。
Go使用的Gin框架,并且引入了swagger,如果大家不熟悉,可以看一下我的这三篇文章
- Gin源码剖析https://mp.weixin.qq.com/s/g_MQldFaMmvnhSsCIMJ7xg
- Gin简单实现https://mp.weixin.qq.com/s/X9pyPZU63j5FF4SDT4sHew
- Gin框架集成swagger过程)
具体代码可以查看https://github.com/shidawuhen/asap/tree/master
代码里做了简单的单元测试和性能测试,大家可以执行如下命令查看效果
go test controller/limit/randomReject_test.go
go test -v -test.bench controller/limit/slidewindowsReject*
限流有分布式限流和单机限流两种大类,分布式限流是将集群看做整体,一般需要用到其他分布式工具,如Redis等,单机限流是将每个机器单做独立物理单元。两种方式各有利弊,分布式限流复杂度会更高一些,而且一般需要保证机器时间一致,单机限流实现相对简单,但是在扩缩容后需要更改限流的阈值。
随机拒流和基于滑动窗口的拒流,使用的是单机限流方案,技术限流使用的是分布式方案。
如下是三个方案的实现:
随机拒流
随机拒流代码最简单,也往往是最有效的。
一般的服务架构如下图所示,有一个客户端,有一个BFF层直接和客户端交互,BFF后面隐藏了公司内部的众多服务。
随机拒流一般用于客户端和BFF层之间。负责BFF层的业务组,在发现流量不可控情况下,按比例开启随机拒流,在部分影响业务的情况下,保证生产的正常运行,另外也保护了后端服务。
代码实现如下:
package limit
import (
"github.com/gin-gonic/gin"
"math/rand"
"net/http"
)
// @Tags limit
// @Summary 随机拒流
// @Produce json
// @Success 200 {string} string "成功会返回ok"
// @Failure 502 "失败返回reject"
// @Router /limit/randomreject [get]
func RandomReject(c *gin.Context) {
refuseRate := 200
if refuseRate != 0 {
temp := rand.Intn(1000)
if temp <= refuseRate {
c.String(http.StatusBadGateway, "reject")
return
}
}
c.String(http.StatusOK, "ok")
}
说明:refuseRate通常读取的是配置数据,通过refuseRate的值,可以控制拒流的比例。线上多次出现过大流量情况,开始随机拒流开关后,效果显著。
计数器
计数器使用分布式方案,此处用到了Redis,在本机调试的时候需要安装Redis,执行如下操作:
- 修改redis.conf的\#requirepass foobared可设置auth,修改后启动时需要加载该文件
- 启动redis命令:redis-server /usr/local/Cellar/redis/6.0.1/.bottle/etc/redis.conf
- 登录命令:redis-cli -h 127.0.0.1 -a 111111
代码实现如下:
package limit
import (
"asap/aredis"
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"time"
)
// @Tags limit
// @Summary 计数拒流,每秒超过指定次数会拒流掉
// @Produce json
// @Success 200 {string} string "成功会返回ok"
// @Failure 502 "失败返回reject"
// @Router /limit/countreject [get]
func CountReject(c *gin.Context) {
currentTime := time.Now().Unix()
key := fmt.Sprintf("count:%d", currentTime)
limitCount := 1
fmt.Println(key)
trafficCount, _ := aredis.GetRedis(aredis.BASEREDIS).Incr(key)
if trafficCount == 1 {
aredis.GetRedis(aredis.BASEREDIS).Expire(key, 86400)
}
if int(trafficCount) > limitCount {
c.String(http.StatusOK, "reject")
return
}
c.String(http.StatusOK, "ok")
}
说明:
- 实现计数器算法需要用到reids,这样能够确保整个服务在每秒内访问次数没有超过指定数值
- 使用时间戳为key
- 这种方案有个缺点,有可能在1s内最多有2倍的请求被处理,举个例子,在当前这一秒的后半秒,处理数量打满,在下一秒的前半秒,处理数量打满,这样在1s的时间内,处理了两倍的请求,某些极端情况下,仍然会导致服务崩溃。
当然,虽然有上面说的这个问题,不过一般而言问题不大,因为流量大多都是比较均匀的。不过有个场景需要大家关注一下,如果有用户刷接口,使用该方法会导致大量正常用户无法正常使用系统,遇到这种情况,可以使用随机方案,另外可以迅速找出用户,将其封禁,这自然也需要健壮的基础设施支持。
基于滑动时间窗口限流
计数器方法有可能导致1s内的流量超过指定阈值,使用基于滑动时间窗口限流方案可以解决这个问题。
滑动时间窗口的逻辑很简单,就是将单位时间拆分成更小的块,计算在滑动的单位时间内数值是否超过阈值。举个例子,我们将1s拆分为10小块,则每小块时间长度为100ms,假设我们设置的阈值是500/s,极端情况下,第一秒的前九个小块都没有流量,第十个小块有500流量,时间往后移动,在第二秒的第一个小块,因为和第一秒的第十个小块在一个滑动窗口内,所以会将所有流量拒掉,只有时间移动到第二秒第十个小块时,系统才能继续处理请求。
代码实现如下:
package limit
import (
"container/ring"
"github.com/gin-gonic/gin"
"net/http"
"sync"
"sync/atomic"
"time"
"fmt"
)
var (
limitCount int = 5 // 1s限频
limitBucket int = 10 // 滑动窗口个数
curCount int32 = 0 // 记录限频数量
head *ring.Ring // 环形队列(链表)
printRes = 0
)
func init(){
// 初始化滑动窗口
head = ring.New(limitBucket)
for i := 0; i < limitBucket; i++ {
head.Value = 0
head = head.Next()
}
// 启动执行器
go func() {
//ms级别,limitBucket int = 10意味将每秒分为10份,每份100ms
timer := time.NewTicker(time.Millisecond * time.Duration(1000/limitBucket))
for range timer.C { // 定时每隔指定时间刷新一次滑动窗口数据
//subCount的作用,是因为当移动到head的时候,意味着该head要被废弃了。所以总count的值需要减去
//head的值,并将head的值重新赋值为0
subCount := int32(0 - head.Value.(int))
newCount := atomic.AddInt32(&curCount, subCount)
arr := make([]int,limitBucket)
for i := 0; i < limitBucket; i++ { //打印出当前每个窗口的请求数量
arr[i] = head.Value.(int)
head = head.Next()
}
if printRes == 1 {
fmt.Println("move subCount,newCount,arr", subCount, newCount,arr)
}
head.Value = 0
head = head.Next()
}
}()
}
// @Tags limit
// @Summary 滑动窗口计数拒流,每秒超过指定次数会拒流掉
// @Produce json
// @Success 200 {string} string "成功会返回ok"
// @Failure 502 "失败返回reject"
// @Router /limit/slidewindowsreject [get]
func SlideWindowsReject(c *gin.Context){
n := atomic.AddInt32(&curCount, 1)
if n > int32(limitCount) { // 超出限频
atomic.AddInt32(&curCount, -1) //将多增加的数据减少
c.String(http.StatusBadGateway, "reject")
} else {
mu := sync.Mutex{}
mu.Lock()
pos := head.Prev()
val := pos.Value.(int)
val++
pos.Value = val
mu.Unlock()
c.String(http.StatusOK, "ok")
}
}
说明:
- 本算法使用go的ring实现
- 每次head移动,意味当前head已经过期,需要将总计数减去该head的计数,将head的值重新设置为0
- 在改变总计数的时候,使用了原子操作保证了了计数的准确性
- 在更改ring中计数的时候,需要使用锁,防止数据不一致。性能压测结果表明性能可用。
- 本算法使用了定时器,算是比较取巧的一种方案
资料
- https://www.cnblogs.com/xiangxiaolin/p/12386775.html
- https://jingyan.baidu.com/article/d5a880ebdbed2113f047cc4e.html 安装redis服务
- https://www.h3399.cn/201906/702263.html 设置auth
- https://blog.csdn.net/Hedon954/article/details/107146301/ Mac 上安装 Redis 和配置密码详细过程
- https://blog.csdn.net/gx864102252/article/details/102213616 Go实现滑动窗口限频
- http://docscn.studygolang.com/pkg/container/ring/
- 限频方案比较
- https://blog.csdn.net/micl200110041/article/details/82013032 Golang实现请求限流的几种办法
- https://blog.csdn.net/u014691098/article/details/105601511 滑动窗口实现访问频率限制
- 限流算法之滑动窗口法
- https://zhuanlan.zhihu.com/p/85166364
最后
大家如果喜欢我的文章,可以关注我的公众号(程序员麻辣烫)
往期文章回顾: