限流的小傻瓜(限流实现1)

在实际业务中,经常会碰到突发流量的情况。如果公司基础架构做得不好,服务无法自动扩容缩容,在突发高流量情况下,服务会因为压力过大而崩溃。更恐怖的是,服务崩溃如同多米诺骨牌,一个服务出问题,可能影响到整个公司所有组的业务。

为了减少损失,比较常用的方案是限流和熔断。本文会讲述常用的几种方法:

  • 随机拒流
  • 计数器方式
  • 基于滑动时间窗口限流
  • 漏斗算法
  • 令牌桶算法
  • Hystrix

Hystrix主要用来做熔断处理,不过从另一个维度而言,也算实现了限流的功能,就放在一起讨论了。这些方法都会用go写一个实现,鉴于自己时间有限,分三到四期写完。本期先实现随机拒流、计数器方式、基于滑动时间窗口限流。

Go使用的Gin框架,并且引入了swagger,如果大家不熟悉,可以看一下我的这三篇文章

  1. Gin源码剖析https://mp.weixin.qq.com/s/g_MQldFaMmvnhSsCIMJ7xg
  2. Gin简单实现https://mp.weixin.qq.com/s/X9pyPZU63j5FF4SDT4sHew
  3. 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后面隐藏了公司内部的众多服务。

限流的小傻瓜(限流实现1)(1)

随机拒流一般用于客户端和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,执行如下操作:

  1. 修改redis.conf的#requirepass foobared可设置auth,修改后启动时需要加载该文件
  2. 启动redis命令:redis-server /usr/local/Cellar/redis/6.0.1/.bottle/etc/redis.conf
  3. 登录命令: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") }

说明:

  1. 实现计数器算法需要用到reids,这样能够确保整个服务在每秒内访问次数没有超过指定数值
  2. 使用时间戳为key
  3. 这种方案有个缺点,有可能在1s内最多有2倍的请求被处理,举个例子,在当前这一秒的后半秒,处理数量打满,在下一秒的前半秒,处理数量打满,这样在1s的时间内,处理了两倍的请求,某些极端情况下,仍然会导致服务崩溃。

当然,虽然有上面说的这个问题,不过一般而言问题不大,因为流量大多都是比较均匀的。不过有个场景需要大家关注一下,如果有用户刷接口,使用该方法会导致大量正常用户无法正常使用系统,遇到这种情况,可以使用随机方案,另外可以迅速找出用户,将其封禁,这自然也需要健壮的基础设施支持。

基于滑动时间窗口限流

计数器方法有可能导致1s内的流量超过指定阈值,使用基于滑动时间窗口限流方案可以解决这个问题。

限流的小傻瓜(限流实现1)(2)

滑动时间窗口的逻辑很简单,就是将单位时间拆分成更小的块,计算在滑动的单位时间内数值是否超过阈值。举个例子,我们将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") } }

说明:

  1. 本算法使用go的ring实现
  2. 每次head移动,意味当前head已经过期,需要将总计数减去该head的计数,将head的值重新设置为0
  3. 在改变总计数的时候,使用了原子操作保证了总计数的准确性
  4. 在更改ring中计数的时候,需要使用锁,防止数据不一致。性能压测结果表明性能可用。
  5. 本算法使用了定时器,算是比较取巧的一种方案
资料
  1. https://www.cnblogs.com/xiangxiaolin/p/12386775.html
  2. https://jingyan.baidu.com/article/d5a880ebdbed2113f047cc4e.html 安装redis服务
  3. https://www.h3399.cn/201906/702263.html 设置auth
  4. https://blog.csdn.net/Hedon954/article/details/107146301/ Mac 上安装 Redis 和配置密码详细过程
  5. https://blog.csdn.net/gx864102252/article/details/102213616 Go实现滑动窗口限频
  6. http://docscn.studygolang.com/pkg/container/ring/
  7. 限频方案比较
  8. https://blog.csdn.net/micl200110041/article/details/82013032 Golang实现请求限流的几种办法
  9. https://blog.csdn.net/u014691098/article/details/105601511 滑动窗口实现访问频率限制
  10. https://cloud.tencent.com/developer/news/626730
  11. https://zhuanlan.zhihu.com/p/85166364
最后

大家如果喜欢我的文章,可以关注我的公众号(程序员麻辣烫)

往期文章回顾:

  1. Redis实现分布式锁
  2. Golang源码BUG追查
  3. 事务原子性、一致性、持久性的实现原理
  4. 如何锻炼自己的记忆力
  5. CDN请求过程详解
  6. 关于程序员职业发展的思考
  7. 记博客服务被压垮的历程
  8. 常用缓存技巧
  9. 如何高效对接第三方支付
  10. Gin框架简洁版
  11. 关于代码review的思考
  12. InnoDB锁与事务简析
  13. Markdown编辑器推荐-typora
,

免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。文章投诉邮箱:anhduc.ph@yahoo.com

    分享
    投诉
    首页