限流实现-专题一

1,827 阅读7分钟

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

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

  • 随机拒流
  • 计数器方式
  • 基于滑动时间窗口限流
  • 漏斗算法
  • 令牌桶算法
  • 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后面隐藏了公司内部的众多服务。

随机拒流一般用于客户端和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内的流量超过指定阈值,使用基于滑动时间窗口限流方案可以解决这个问题。

滑动时间窗口的逻辑很简单,就是将单位时间拆分成更小的块,计算在滑动的单位时间内数值是否超过阈值。举个例子,我们将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. www.cnblogs.com/xiangxiaoli…
  2. jingyan.baidu.com/article/d5a… 安装redis服务
  3. www.h3399.cn/201906/7022… 设置auth
  4. blog.csdn.net/Hedon954/ar… Mac 上安装 Redis 和配置密码详细过程
  5. blog.csdn.net/gx864102252… Go实现滑动窗口限频
  6. docscn.studygolang.com/pkg/contain…
  7. 限频方案比较
  8. blog.csdn.net/micl2001100… Golang实现请求限流的几种办法
  9. blog.csdn.net/u014691098/… 滑动窗口实现访问频率限制
  10. 限流算法之滑动窗口法
  11. zhuanlan.zhihu.com/p/85166364

最后

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

往期文章回顾:

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