一文搞懂常见限流器

196 阅读3分钟

前言

首先思考一个问题:为什么要限流器?

这个问题很简单,就是防止系统在面对大流量,高并发的请求下崩溃。牺牲部分请求来保证系统的可用性。常见的限流算法有固定窗口,滑动窗口,令牌桶与漏斗桶。下面将一一介绍并给予基于go的实现。

固定窗口

原理

固定窗口算法又叫计数器算法,是一种简单方便的限流算法。主要通过一个支持原子操作的计数器来累计一个限流窗口时间内请求次数,当 限流窗口内计数达到限流阈值时触发拒绝策略。每过一个限流窗口时间,计数器重置为 0 开始重新计数。

无标题流程图 (2).png

代码实现

package simple_window

import (
	"errors"
	"runtime"
	"sync"
	"time"
)

// Limit 固定窗口限流器
type Limit struct {
	*limit
}

// NewLimit 新建限流器
func NewLimit(qps int, cleanupInterval time.Duration) *Limit {
	l := newLimit(qps, cleanupInterval)
	L := &Limit{
		l,
	}
	if cleanupInterval > 0 {
		runJanitor(l, cleanupInterval)
		runtime.SetFinalizer(L, stopJanitor)
	}
	return L
}

type limit struct {
	// QPS
	qps int
	// 读写锁
	mu sync.RWMutex
	// 计数器
	count int
	// 定时器
	janitor *janitor
}

func newLimit(qps int, duration time.Duration) *limit {
	return &limit{
		qps: qps,
	}
}

type janitor struct {
	Interval time.Duration
	stop     chan bool
}

// Reset 新窗口重置计数器
func (l *limit) Reset() {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.count = 0
}

// TryAcquire 尝试通过
func (l *Limit) TryAcquire() error {
	if l.count >= l.qps {
		return errors.New("rate limited ")
	}
	l.mu.Lock()
	defer l.mu.Unlock()
	l.count++
	return nil
}

func stopJanitor(l *Limit) {
	l.janitor.stop <- true
}

func runJanitor(l *limit, ci time.Duration) {
	j := &janitor{
		Interval: ci,
		stop:     make(chan bool),
	}
	l.janitor = j
	go j.Run(l)
}

// Run 启动定时重置限流窗口
func (j *janitor) Run(l *limit) {
	ticker := time.NewTicker(j.Interval)
	for {
		select {
		case <-ticker.C:
			l.Reset()
		case <-j.stop:
			ticker.Stop()
			return
		}
	}
}

func main() {
   limit := simple_window.NewLimit(2, time.Second)

   // 先睡500ms,模拟临界条件
   // time.Sleep(time.Second / 2)
   now := time.Now()
   count := 0
   for i := 0; i < 10; i++ {
      if err := limit.TryAcquire(); err != nil {
         fmt.Println()
         continue
      }
      // 模拟领届
      // time.Sleep(time.Millisecond * 250)
      count += 1
   }
   fmt.Printf("在%fs时间内,通过%d个请求", time.Now().Sub(now).Seconds(), count)
}

结果:

image-20230227154433377-7483874.png 可以看到在1秒内10个请求只有2个通过了,符合限流器预期

优缺点

优点:

简单易实现

缺点:

  • 一段时间内(不超过时间窗口)系统服务不可用,如上述测试结果在0.0000几秒的时候就已经达到限流了,后面基本上1秒的请求都是被拦截住的。

  • 最坏情况在临界值时会达到限流器的两倍qps,例如限制QPS为2,前一秒的只在后500ms请求了2次,后一秒的前500ms请求了2次,此时中间的1s QPS就是4了。

未命名文件-7484450.png

可以将上面代码注释打开再执行可以得到以下结果:

image-20230227154936800-7484178.png

滑动窗口

原理

滑动窗口算法是计数器算法的一种改进,将原来的一个时间窗口划分成多个时间窗口,并且不断向右滑动该窗口。流量经过滑动时间窗口算法整形之后,可以保证任意时间窗口内,都不会超过最大允许的限流值,从流量曲线上来看会更加平滑,可以部分解决上面提到的临界突发流量问题。对比固定时间窗口限流算法,滑动时间窗口限流算法的时间窗口是持续滑动的,并且除了需要一个计数器来记录时间窗口内接口请求次数之外,还需要记录在时间窗口内每个接口请求到达的时间点,对内存的占用会比较多。 在临界位置的突发请求都会被算到时间窗口内,因此可以解决计数器算法的临界问题。

无标题流程图 (3).png

代码实现

package rolling_window

import (
   "errors"
   "runtime"
   "sync"
   "time"
)

type Limit struct {
   win []*limit
   // 计数器
   count int
   qps   int
   // 定时器
   janitor *janitor
}

type limit struct {
   // 读写锁
   mu sync.RWMutex
   // 计数器
   count     int
   startTime time.Time
}

func NewLimit(qps int, cleanupInterval time.Duration) *Limit {
   L := &Limit{
      win:   []*limit{{count: 0}},
      count: 0,
      qps:   qps,
   }
   if cleanupInterval > 0 {
      // 默认将窗口分成10份
      runJanitor(L, cleanupInterval/10)
      runtime.SetFinalizer(L, stopJanitor)
   }
   return L
}

func (l *Limit) TryAcquire() error {
   // 判断滑动窗口qps
   if l.count >= l.qps {
      return errors.New("rate limited ")
   }
   // 最新窗口请求数加1
   l.win[len(l.win)-1].mu.Lock()
   defer l.win[len(l.win)-1].mu.Unlock()
   l.count++
   l.win[len(l.win)-1].count++
   return nil
}

func (l *Limit) Reset() {
   now := time.Now()
   // 存在滑动窗口且第一个滑动窗口的时间大于一个限流窗口时间,则将第一个滑动窗口去除
   if len(l.win) > 1 && now.Sub(l.win[0].startTime) >= l.janitor.Interval*10 {
      l.count -= l.win[0].count
      l.win = l.win[1:]
   }
   // 滑动一个窗口
   l.win = append(l.win, &limit{
      count: 0,
   })
}

type janitor struct {
   Interval time.Duration
   stop     chan bool
}

func stopJanitor(l *Limit) {
   l.janitor.stop <- true
}

func runJanitor(l *Limit, ci time.Duration) {
   j := &janitor{
      Interval: ci,
      stop:     make(chan bool),
   }
   l.janitor = j
   go j.Run(l)
}

func (j *janitor) Run(l *Limit) {
   ticker := time.NewTicker(j.Interval)
   for {
      select {
      case <-ticker.C:
         l.Reset()
      case <-j.stop:
         ticker.Stop()
         return
      }
   }
}
func main() {
	//limit := simple_window.NewLimit(2, time.Second)
	limit := rolling_window.NewLimit(2, time.Second)
	// 先睡500ms,模拟临界条件
	time.Sleep(time.Millisecond * 500)
	now := time.Now()
	count := 0
	for i := 0; i < 10; i++ {
		if err := limit.TryAcquire(); err != nil {
			fmt.Println(err)
			continue
		}
		fmt.Println("succ")
		count += 1
		time.Sleep(time.Millisecond * 250)
	}
	fmt.Printf("在%fs时间内,通过%d个请求", time.Now().Sub(now).Seconds(), count)
}

结果:

image-20230227171048418-7489049.png 可以看到在1秒内10个请求只有2个通过了,符合限流器预期,且在固定窗口同样的临界条件时不会突破qps。

优缺点

优点

  • 解决了固定窗口窗口切换时达到的2倍qps的情况

缺点:

  • 无法解决固定窗口一段时间内(不超过时间窗口)系统服务不可用的情景
  • 内存消耗过多,划分的时间窗口粒度越细越精准但消耗的内存就越多

令牌桶

原理

令牌桶有一个固定的大小的“桶”,系统会以固定的速率往令牌桶里放令牌,请求到来的时候会先从桶里获取令牌,有令牌的请求才会被系统处理,当没有令牌时请求会被丢弃。这个往桶里放令牌的速率为允许通过的qps,由于有桶的缓存,令牌桶允许突发的流量,极端最大的qps为令牌桶大小+额定qps。因为有初始容量所以能一定程度缓解固定窗口跟滑动窗口一段时间内系统服务不可用的情景。

无标题流程图 (4).png

代码实现

package token_bucket

import (
	"errors"
	"runtime"
	"sync/atomic"
	"time"
)

type Limit struct {
	qps        int32
	bucketSize int32
	bucket     atomic.Int32
	janitor    *janitor
}

func NewLimit(qps int32, cleanupInterval time.Duration, bucketSize int32) *Limit {
	L := &Limit{
		qps:        qps,
		bucketSize: bucketSize,
	}
	// 默认有一个
	L.bucket.Add(1)
	if cleanupInterval > 0 {
		runJanitor(L, cleanupInterval)
		runtime.SetFinalizer(L, stopJanitor)
	}
	return L
}

func (l *Limit) TryAcquire() error {
	if ans := l.bucket.Load(); ans <= 0 {
		return errors.New("rate limited ")
	}
	l.bucket.Add(-1)
	return nil
}

func (l *Limit) Add() {
	if ans := l.bucket.Load(); ans >= l.bucketSize {
		return
	}
	l.bucket.Add(l.qps / 2)
}

type janitor struct {
	Interval time.Duration
	stop     chan bool
}

func stopJanitor(l *Limit) {
	l.janitor.stop <- true
}

func runJanitor(l *Limit, ci time.Duration) {
	j := &janitor{
		Interval: ci,
		stop:     make(chan bool),
	}
	l.janitor = j
	go j.Run(l)
}

func (j *janitor) Run(l *Limit) {
	ticker := time.NewTicker(j.Interval / 2)
	for {
		select {
		case <-ticker.C:
			l.Add()
		case <-j.stop:
			ticker.Stop()
			return
		}
	}
}

package main

import (
   "fmt"
   "rate_limit/token_bucket"
   "time"
)

func main() {
   //limit := simple_window.NewLimit(2, time.Second)
   //limit := rolling_window.NewLimit(2, time.Second)
   limit := token_bucket.NewLimit(2, time.Second, 4)
   // 先睡500ms,模拟临界条件
   time.Sleep(time.Millisecond * 500)
   now := time.Now()
   count := 0
   for i := 0; i < 10; i++ {
      if err := limit.TryAcquire(); err != nil {
         fmt.Println(err)
         continue
      }
      fmt.Println("succ")
      count += 1
      time.Sleep(time.Millisecond * 250)
   }
   fmt.Printf("在%fs时间内,通过%d个请求", time.Now().Sub(now).Seconds(), count)
}

结果:

image-20230227173222381-7490343.png

优缺点

优点:

相对于其他算法能够在限制调用的平均速率的同时还允许一定程度的流量突发。

漏斗桶

原理

漏斗桶是将到达的请求先存到一个固定大小的桶里,再以固定速率(qps)让请求通行,当桶满了的时候,后续到达的请求将被丢弃。

无标题流程图 (5).png

代码实现

package leaky_bucket

import (
   "errors"
   "runtime"
   "sync/atomic"
   "time"
)

type Limit struct {
   qps        int32
   bucketSize int32
   bucket     atomic.Int32
   janitor    *janitor
}

func NewLimit(qps int32, cleanupInterval time.Duration, bucketSize int32) *Limit {
   L := &Limit{
      qps:        qps,
      bucketSize: bucketSize,
   }
   if cleanupInterval > 0 {
      runJanitor(L, cleanupInterval)
      runtime.SetFinalizer(L, stopJanitor)
   }
   return L
}

func (l *Limit) Reduce() {
   if l.bucket.Load() > 0 {
      if size := l.bucket.Add(-l.qps); size < 0 {
         l.bucket.Add(-size)
      }
   }
}

func (l *Limit) TryAcquire() error {
   if ans := l.bucket.Load(); ans >= l.bucketSize {
      return errors.New("rate limited ")
   }
   l.bucket.Add(1)
   size := l.bucket.Load() - 1
   // 模拟等待时间
   time.Sleep(time.Second * time.Duration(size/l.qps))
   return nil
}

type janitor struct {
   Interval time.Duration
   stop     chan bool
}

func stopJanitor(l *Limit) {
   l.janitor.stop <- true
}

func runJanitor(l *Limit, ci time.Duration) {
   j := &janitor{
      Interval: ci,
      stop:     make(chan bool),
   }
   l.janitor = j
   go j.Run(l)
}

func (j *janitor) Run(l *Limit) {
   ticker := time.NewTicker(j.Interval)
   for {
      select {
      case <-ticker.C:
         l.Reduce()
      case <-j.stop:
         ticker.Stop()
         return
      }
   }
}
package main

import (
   "fmt"
   "rate_limit/leaky_bucket"
   "time"

   "golang.org/x/sync/errgroup"
)

func main() {
   limit := leaky_bucket.NewLimit(2, time.Second, 4)
   now := time.Now()
   count := 0
   g := errgroup.Group{}
   for i := 0; i < 10; i++ {
      time.Sleep(time.Millisecond * 250)
      g.Go(func() error {
         if err := limit.TryAcquire(); err != nil {
            fmt.Println(err)
            return err
         }
         fmt.Printf("succ time is %f", time.Now().Sub(now).Seconds())
         fmt.Println()
         count += 1
         return nil
      })
   }
   g.Wait()
   fmt.Printf("在%fs时间内,通过%d个请求", time.Now().Sub(now).Seconds(), count)
}

结果:

image-20230227194757947-7498479.png

优缺点

优点:

  • 漏桶的漏出速率是固定的,可以起到整流的作用,不管流量怎么异动漏斗桶流出的速率都是一样的

缺点:

  • 不能解决突发流量问题,虽然有桶存储请求,但流出的速率不会加快,存住的请求很有可能会超时

总结

在日常开发中使用最多的是令牌桶跟漏斗桶,令牌桶更多是用来保护自己系统不被打崩并接受一定的突发流量,漏斗桶一般是来控制自己调用第三方系统的速率,避免超频失败。