go api接口限流——(漏桶与令牌桶)

目录

  • 限流
  • 限流策略
    • redis计数器
    • 令牌桶算法 (Token Bucket)
      • 创建令牌桶的方法:
      • 取出令牌的方法如下:
      • 关于令牌数计算的源代码如下:
      • gin框架中使用令牌桶
    • 漏桶算法 (Leaky Bucket)
  • 参考文档

限流

  1. 限流又称为流量控制(流控),通常是指限制到达系统的并发请求数。

  2. 限流虽然会影响部分用户的使用体验,但是却能在一定程度上报障系统的稳定性,不至于崩溃。

  3. 互联网上类似需要限流的业务场景也有很多,比如电商系统的秒杀、微博上突发热点新闻、双十一购物节、12306抢票等等。这些场景下的用户请求量通常会激增,远远超过平时正常的请求量,此时如果不加任何限制很容易就会将后端服务打垮,影响服务的稳定性。

  4. 此外,一些厂商公开的API服务通常也会限制用户的请求次数,比如百度地图开放平台等会根据用户的付费情况来限制用户的请求数等。

限流策略

常见的限流算法有:令牌桶、漏桶、Redis 计数器。

redis计数器

参考drf的实现方法。(djangorestframework全解)

def allow_request(self, request, view):
    """
    Implement the check to see if the request should be throttled.

    On success calls `throttle_success`.
    On failure calls `throttle_failure`.
    """
    if self.rate is None:
        return True
	
	# 从redis中获取缓存的key,
	# 比如:throttle_phone_12345678900(手机号)
    self.key = self.get_cache_key(request, view)
    if self.key is None:
        return True
	
	# 从redis中获取访问记录
    self.history = self.cache.get(self.key, [])
    // 获取当前时间(默认python time模块会返回时间戳)
    self.now = self.timer()

    # 删除历史记录中已超过限制持续时间的所有请求
    # duration = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400}
    # duration = 60
    while self.history and self.history[-1] <= self.now - self.duration:
        self.history.pop()
	
	# 如果历史记录的长度大于配置的该接口允许请求的次数就拒绝
	# self.num_requests去配置文件读取当前接口的配置,比如:1/60s
	# self.num_requests = 1
    if len(self.history) >= self.num_requests:
    	# 返回False
        return self.throttle_failure()
        
    # 否则成功
    # self.throttle_success():
    # 1. 将当前时间放入历史记录列表[time1,time2,time3...]
    # self.history.insert(0, self.now) 
    # 2. 将历史记录存入redis,设置过期时间为配置的时间
    # self.cache.set(self.key, self.history, self.duration)
    # 3. 返回允许
    # return True
    return self.throttle_success()

简陋的实现一个GO版本的:

const (
	sms = "1/60s"
	key = "send_sms_%s"
)

var cache = map[string][]time.Time{}

func ParseThrottle(cfg string) (int, time.Duration) {
	countDuration := strings.Split(cfg, "/")
	count, duration := countDuration[0], countDuration[1]

	c, _ := strconv.Atoi(count)
	tmp, _ := strconv.Atoi(duration[:len(duration)-1])
	flg := duration[len(duration)-1]
	d := time.Duration(tmp)

	switch flg {
	case 's':
		d = d * time.Second
	case 'm':
		d = d * time.Minute
	case 'h':
		d = d * time.Hour
	default:
		panic("invalid throttle config")
	}
	return c, d
}

func Throttle() bool {
	phone := "123456788900"
	k := fmt.Sprintf(key, phone)
	c, d := ParseThrottle(sms)
	// 1 1m0s
	// fmt.Println(c, d)

	history := cache[k]
	now := time.Now()
	newList := make([]time.Time, 0)
	for index := range history {
		// 未过期
		if !history[index].Add(d).Before(now) {
			newList = append(newList, history[index])
		}
	}

	if len(newList) >= c {
		return false
	}

	newList = append(newList, now)
	cache[k] = newList
	return true
}

func main() {
	//RunServer()
	for i := 0; i < 10; i++ {
		fmt.Println(Throttle())
	}
}

go api接口限流——(漏桶与令牌桶)_第1张图片

令牌桶算法 (Token Bucket)

  1. 令牌桶大小固定,系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。

  2. 如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。

  3. 后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。

  4. 对于从桶里取不到令牌的场景,我们可以选择等待也可以直接拒绝并返回。

  5. 对于令牌桶的Go语言实现,可以参照 github.com/juju/ratelimit (2.5k star)库。这个库支持多种令牌桶模式,并且使用起来也比较简单。
    go api接口限流——(漏桶与令牌桶)_第2张图片

创建令牌桶的方法:

// 创建指定填充速率和容量大小的令牌桶
func NewBucket(fillInterval time.Duration, capacity int64) *Bucket
// 创建指定填充速率、容量大小和每次填充的令牌数的令牌桶
func NewBucketWithQuantum(fillInterval time.Duration, capacity, quantum int64) *Bucket
// 创建填充速度为指定速率和容量大小的令牌桶
// NewBucketWithRate(0.1, 200) 表示每秒填充20个令牌
func NewBucketWithRate(rate float64, capacity int64) *Bucket

取出令牌的方法如下:

// 取token(非阻塞)
func (tb *Bucket) Take(count int64) time.Duration
func (tb *Bucket) TakeAvailable(count int64) int64

// 最多等maxWait时间取token
func (tb *Bucket) TakeMaxDuration(count int64, maxWait time.Duration) (time.Duration, bool)

// 取token(阻塞)
func (tb *Bucket) Wait(count int64)
func (tb *Bucket) WaitMaxDuration(count int64, maxWait time.Duration) bool

虽说是令牌桶,但是我们没有必要真的去生成令牌放到桶里,我们只需要每次来取令牌的时候计算一下,当前是否有足够的令牌就可以了,具体的计算方式可以总结为下面的公式:
当前令牌数 = 上一次剩余的令牌数 + (本次取令牌的时刻-上一次取令牌的时刻)/放置令牌的时间间隔 * 每次放置的令牌数

关于令牌数计算的源代码如下:

func (tb *Bucket) currentTick(now time.Time) int64 {
	return int64(now.Sub(tb.startTime) / tb.fillInterval)
}
func (tb *Bucket) adjustavailableTokens(tick int64) {
	if tb.availableTokens >= tb.capacity {
		return
	}
	tb.availableTokens += (tick - tb.latestTick) * tb.quantum
	if tb.availableTokens > tb.capacity {
		tb.availableTokens = tb.capacity
	}
	tb.latestTick = tick
	return
}

获取令牌的TakeAvailable()函数关键部分的源代码如下:

func (tb *Bucket) takeAvailable(now time.Time, count int64) int64 {
	if count <= 0 {
		return 0
	}
	tb.adjustavailableTokens(tb.currentTick(now))
	if tb.availableTokens <= 0 {
		return 0
	}
	if count > tb.availableTokens {
		count = tb.availableTokens
	}
	tb.availableTokens -= count
	return count
}

gin框架中使用令牌桶

对于该限流中间件的注册位置,可以按照不同的限流策略将其注册到不同的位置,例如:

  1. 如果要对全站限流就可以注册成全局的中间件。
  2. 如果是某一组路由需要限流,那么就只需将该限流中间件注册到对应的路由组即可。
func RateLimitMiddleware(fillInterval time.Duration, cap int64) func(c *gin.Context) {
	bucket := ratelimit.NewBucket(fillInterval, cap)
	return func(c *gin.Context) {
		// 如果取不到令牌就中断本次请求返回 rate limit...
		if bucket.TakeAvailable(1) < 1 {
			c.String(http.StatusOK, "rate limit...")
			c.Abort()
			return
		}
		c.Next()
	}
}

漏桶算法 (Leaky Bucket)

  1. 水 (请求) 先进入到漏桶里,漏桶以一定的速度出水 (接口有响应速率), 当水流入速度过大会直接溢出 (访问频率超过接口响应速率), 然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率。
    go api接口限流——(漏桶与令牌桶)_第3张图片

  2. 可见这里有两个变量,一个是桶的大小,支持流量突发增多时可以存多少的水 (burst), 另一个是水桶漏洞的大小 (rate)。

  3. 因为漏桶的漏出速率是固定的参数,所以,即使网络中不存在资源冲突 (没有发生拥塞), 漏桶算法也不能使流突发 (burst) 到端口速率。因此,漏桶算法对于存在突发特性的流量来说缺乏效率.

  4. 关于漏桶的实现,uber团队有一个开源的github.com/uber-go/ratelimit库(3.5k star)。 这个库的使用方法比较简单,Take() 方法会返回漏桶下一次滴水的时间。它的源码实现也比较简单。

import (
	"fmt"
	"time"

	"go.uber.org/ratelimit"
)

func main() {
    rl := ratelimit.New(100) // per second

    prev := time.Now()
    for i := 0; i < 10; i++ {
        now := rl.Take()
        fmt.Println(i, now.Sub(prev))
        prev = now
    }

    // Output:
    // 0 0
    // 1 10ms
    // 2 10ms
    // 3 10ms
    // 4 10ms
    // 5 10ms
    // 6 10ms
    // 7 10ms
    // 8 10ms
    // 9 10ms
}

限制器是一个接口类型,其要求实现一个Take()方法:

type Limiter interface {
	// Take方法应该阻塞已确保满足 RPS
	Take() time.Time
}

实现限制器接口的结构体定义如下

type limiter struct {
	sync.Mutex                // 锁
	last       time.Time      // 上一次的时刻
	sleepFor   time.Duration  // 需要等待的时间
	perRequest time.Duration  // 每次的时间间隔
	maxSlack   time.Duration  // 最大的富余量
	clock      Clock          // 时钟
}

limiter结构体实现Limiter接口的Take()方法内容如下:

// Take 会阻塞确保两次请求之间的时间走完
// Take 调用平均数为 time.Second/rate.
func (t *limiter) Take() time.Time {
	t.Lock()
	defer t.Unlock()

	now := t.clock.Now()

	// 如果是第一次请求就直接放行
	if t.last.IsZero() {
		t.last = now
		return t.last
	}

	// sleepFor 根据 perRequest 和上一次请求的时刻计算应该sleep的时间
	// 由于每次请求间隔的时间可能会超过perRequest, 所以这个数字可能为负数,并在多个请求之间累加
	t.sleepFor += t.perRequest - now.Sub(t.last)

	// 我们不应该让sleepFor负的太多,因为这意味着一个服务在短时间内慢了很多随后会得到更高的RPS。
	if t.sleepFor < t.maxSlack {
		t.sleepFor = t.maxSlack
	}

	// 如果 sleepFor 是正值那么就 sleep
	if t.sleepFor > 0 {
		t.clock.Sleep(t.sleepFor)
		t.last = now.Add(t.sleepFor)
		t.sleepFor = 0
	} else {
		t.last = now
	}

	return t.last
}

上面的代码根据记录每次请求的间隔时间和上一次请求的时刻来计算当次请求需要阻塞的时间——sleepFor,这里需要留意的是sleepFor的值可能为负,在经过间隔时间长的两次访问之后会导致随后大量的请求被放行,所以代码中针对这个场景有专门的优化处理。创建限制器的New()函数中会为maxSlack设置初始值,也可以通过WithoutSlack这个Option取消这个默认值。

func New(rate int, opts ...Option) Limiter {
	l := &limiter{
		perRequest: time.Second / time.Duration(rate),
		maxSlack:   -10 * time.Second / time.Duration(rate),
	}
	for _, opt := range opts {
		opt(l)
	}
	if l.clock == nil {
		l.clock = clock.New()
	}
	return l
}

参考文档

[1]. 编程宝库:漏桶/令牌桶限流算法 Go语言

你可能感兴趣的:(GO,gin,redis,java,数据库)