前言
首先思考一个问题:为什么要限流器?
这个问题很简单,就是防止系统在面对大流量,高并发的请求下崩溃。牺牲部分请求来保证系统的可用性。常见的限流算法有固定窗口,滑动窗口,令牌桶与漏斗桶。下面将一一介绍并给予基于go的实现。
固定窗口
原理
固定窗口算法又叫计数器算法,是一种简单方便的限流算法。主要通过一个支持原子操作的计数器来累计一个限流窗口时间内请求次数,当 限流窗口内计数达到限流阈值时触发拒绝策略。每过一个限流窗口时间,计数器重置为 0 开始重新计数。
代码实现
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)
}
结果:
可以看到在1秒内10个请求只有2个通过了,符合限流器预期
优缺点
优点:
简单易实现
缺点::
-
一段时间内(不超过时间窗口)系统服务不可用,如上述测试结果在0.0000几秒的时候就已经达到限流了,后面基本上1秒的请求都是被拦截住的。
-
最坏情况在临界值时会达到限流器的两倍qps,例如限制QPS为2,前一秒的只在后500ms请求了2次,后一秒的前500ms请求了2次,此时中间的1s QPS就是4了。
可以将上面代码注释打开再执行可以得到以下结果:
滑动窗口
原理
滑动窗口算法是计数器算法的一种改进,将原来的一个时间窗口划分成多个时间窗口,并且不断向右滑动该窗口。流量经过滑动时间窗口算法整形之后,可以保证任意时间窗口内,都不会超过最大允许的限流值,从流量曲线上来看会更加平滑,可以部分解决上面提到的临界突发流量问题。对比固定时间窗口限流算法,滑动时间窗口限流算法的时间窗口是持续滑动的,并且除了需要一个计数器来记录时间窗口内接口请求次数之外,还需要记录在时间窗口内每个接口请求到达的时间点,对内存的占用会比较多。 在临界位置的突发请求都会被算到时间窗口内,因此可以解决计数器算法的临界问题。
代码实现
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)
}
结果:
可以看到在1秒内10个请求只有2个通过了,符合限流器预期,且在固定窗口同样的临界条件时不会突破qps。
优缺点
优点:
- 解决了固定窗口窗口切换时达到的2倍qps的情况
缺点:
- 无法解决固定窗口一段时间内(不超过时间窗口)系统服务不可用的情景
- 内存消耗过多,划分的时间窗口粒度越细越精准但消耗的内存就越多
令牌桶
原理
令牌桶有一个固定的大小的“桶”,系统会以固定的速率往令牌桶里放令牌,请求到来的时候会先从桶里获取令牌,有令牌的请求才会被系统处理,当没有令牌时请求会被丢弃。这个往桶里放令牌的速率为允许通过的qps,由于有桶的缓存,令牌桶允许突发的流量,极端最大的qps为令牌桶大小+额定qps。因为有初始容量所以能一定程度缓解固定窗口跟滑动窗口一段时间内系统服务不可用的情景。
代码实现
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)
}
结果:
优缺点
优点:
相对于其他算法能够在限制调用的平均速率的同时还允许一定程度的流量突发。
漏斗桶
原理
漏斗桶是将到达的请求先存到一个固定大小的桶里,再以固定速率(qps)让请求通行,当桶满了的时候,后续到达的请求将被丢弃。
代码实现
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)
}
结果:
优缺点
优点:
- 漏桶的漏出速率是固定的,可以起到整流的作用,不管流量怎么异动漏斗桶流出的速率都是一样的
缺点:
- 不能解决突发流量问题,虽然有桶存储请求,但流出的速率不会加快,存住的请求很有可能会超时
总结
在日常开发中使用最多的是令牌桶跟漏斗桶,令牌桶更多是用来保护自己系统不被打崩并接受一定的突发流量,漏斗桶一般是来控制自己调用第三方系统的速率,避免超频失败。