限流技术方案

171 阅读9分钟

文章首发于公众号——北国故事

image.png

引言

系统中大量的场景是流量承载能力是有限的,极端场景下,突发流量会导致整体服务的稳定性,为了避免服务整体不可用,系统层面西药保证部分可用而不是所有的流量瘫痪,所以就涉及限流——即,必要的情况下,舍弃一部分流量保证有限的流量。

流量突发问题的另一个解决方案是使用MQ,削峰填谷。即将流量异步化,进而保证流量的整体平缓进而保证服务的整体可用。本文后续仅对限流做深入探讨。

单机限流

使用channel

核心思路:利用channel的缓冲设定,channel满了即阻塞

优缺点

  1. 优点:简单易实现,适合一次性限流
  2. 缺点:阻塞无时间限制,无法自动解除限流
package limiter

type ChannelLimiter struct {
	bufferCount chan int
}

func NewChannelLimiter(limit int) *ChannelLimiter {
	return &ChannelLimiter{bufferCount: make(chan int, limit)}
}

func (c *ChannelLimiter) Wait() bool {
	select {
	case c.bufferCount <- 1:
		return true
	default:
		return false
	}
}

func (c *ChannelLimiter) Release() {
	<-c.bufferCount
}

代码测试

package limiter

import (
	"fmt"
	"testing"
)

func TestChannelLimiter(t *testing.T) {
	limiter := NewChannelLimiter(2)
	wait1 := limiter.Wait()
	wait2 := limiter.Wait()
	wait3 := limiter.Wait()
	fmt.Println("wait1 wait2 wait3", wait1, wait2, wait3)
}

固定时间计数器

实现思想:在单位时间内进行计数,如果请求数大于设置的最大值,则进行拒绝;如果过了单位时间,则重新进行计数

优缺点

  1. 优点:简单易实现
  2. 缺点:突发流量会出现尖刺现象,限流不准确——两个相邻的时间窗口之间构成的单位时间窗口内大于目标限流计数
package limiter

import (
	"sync/atomic"
	"time"
)

type CountLimiter struct {
	// 当前时间窗口的数量
	counter int64
	limit   int64
	// 间隔纳秒 1S=1 000 000 000 ns
	intervalNano int64
	// 最近的一次起始时间纳秒
	lastNano int64
}

func NewCountLimiter(interval time.Duration, limit int64) *CountLimiter {
	return &CountLimiter{
		counter:      0,
		limit:        limit,
		intervalNano: int64(interval),
		lastNano:     time.Now().UnixNano(),
	}
}

func (c *CountLimiter) Wait() bool {
	now := time.Now().UnixNano()
	if now-c.lastNano > c.intervalNano {
		atomic.StoreInt64(&c.counter, 0)
		atomic.StoreInt64(&c.lastNano, now)
		return true
	}
	atomic.AddInt64(&c.counter, 1)
	return c.counter <= c.limit
}

代码测试

package limiter

import (
	"fmt"
	"testing"
	"time"
)

func TestCountLimiter(t *testing.T) {
	limiter := NewCountLimiter(time.Second*2, 2)
	// 2S  2000ms   2000 000ns       intervalNano 2000 000 000
	wait1 := limiter.Wait()
	wait2 := limiter.Wait()
	time.Sleep(time.Second * 2)
	wait3 := limiter.Wait()
	fmt.Println("wait1 wait2 wait3", wait1, wait2, wait3)
}

滑动窗口计数器

实现思想:参考TCP的滑动窗口协议

实现参考:github.com/ulovecode/r…

优缺点

​ 优点:将固定时间段分块,时间比“计数器”复杂,适用于稍微精准的场景

​ 缺点:时间区间的精度越高,算法所需的空间容量就越大;实现稍微复杂,还是不能彻底解决“计数器”存在的边界问题

package limiter

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

var once sync.Once

type SlidingWindowLimiter struct {
	currentSlidingTimeReqNum int32         // 当前滑动时间单元内请求数量
	durationRequestsNum      chan int32    // 请求队列:存储的是每个时间片内的请求数量
	slidingSnippet           time.Duration // 时间窗口最小滑动单元
	timeSpan                 time.Duration // 时间窗口跨度
	timeSpanReqSum           int32         // 窗口内请求数之和
	allowRequests            int32         // 最大允许数量
}

func NewSlidingWindowLimiter(slidingSnippet, timeSpan time.Duration, allowRequests int32) *SlidingWindowLimiter {
	return &SlidingWindowLimiter{
		durationRequestsNum: make(chan int32, timeSpan/slidingSnippet),
		slidingSnippet:      slidingSnippet,
		timeSpan:            timeSpan,
		allowRequests:       allowRequests,
	}
}

func (s *SlidingWindowLimiter) Wait() error {
	once.Do(func() {
		// 往前划动一个最小时间窗口
		go sliding(s)
		go calculate(s)
	})
	for {
		requestSum := atomic.LoadInt32(&s.timeSpanReqSum)
		if requestSum >= s.allowRequests {
			return fmt.Errorf("ErrExceededLimit")
		}
		if atomic.CompareAndSwapInt32(&s.timeSpanReqSum, requestSum, requestSum+1) {
			break
		}
	}
	atomic.AddInt32(&s.currentSlidingTimeReqNum, 1)
	return nil
}

func sliding(s *SlidingWindowLimiter) {
	for {
		select {
		case <-time.After(s.slidingSnippet):
			s.durationRequestsNum <- atomic.SwapInt32(&s.currentSlidingTimeReqNum, 0)
		}
	}
}

func calculate(s *SlidingWindowLimiter) {
	for {
		<-time.After(s.slidingSnippet)
		// channel满了
		if len(s.durationRequestsNum) == cap(s.durationRequestsNum) {
			break
		}
	}
	// 超过一个时间窗口后运行的是下面的循环
	for {
		<-time.After(s.slidingSnippet)
		t := <-s.durationRequestsNum
		if t != 0 {
			// 全量请求数量减去最早的时间片请求数量
			atomic.AddInt32(&s.timeSpanReqSum, -t)
		}
	}
}

代码测试

func TestSlidingWindowLimiter(t *testing.T) {
	limiter := NewSlidingWindowLimiter(time.Millisecond*200, time.Second, 2)
	err := limiter.Wait()
	if err != nil {
		fmt.Println("err", err)
	}
	err1 := limiter.Wait()
	if err1 != nil {
		fmt.Println("err1", err1)
	}
	time.Sleep(time.Millisecond * 1300)
	err2 := limiter.Wait()
	if err2 != nil {
		fmt.Println("err2", err2)
	}
}

漏桶限流

核心思想:

  1. 将每个请求视作 " 水滴 " 放入 " 漏桶 " 进行存储
  2. “漏桶 " 以固定速率向外 " 漏 " 出请求来执行如果 " 漏桶 " 空了则停止 " 漏水”
  3. 如果 " 漏桶 " 满了则多余的 " 水滴 " 会被直接丢弃
// Note: This file is inspired by:
// https://github.com/prashantv/go-bench/blob/master/ratelimit

// Limiter is used to rate-limit some process, possibly across goroutines.
// The process is expected to call Take() before every iteration, which
// may block to throttle the goroutine.
type Limiter interface {
	// Take should block to make sure that the RPS is met.
	Take() time.Time
}


type state struct {
	last     time.Time
	sleepFor time.Duration
}

type atomicLimiter struct {
	state unsafe.Pointer
	//lint:ignore U1000 Padding is unused but it is crucial to maintain performance
	// of this rate limiter in case of collocation with other frequently accessed memory.
	padding [56]byte // cache line size - state pointer size = 64 - 8; created to avoid false sharing.

	perRequest time.Duration
	maxSlack   time.Duration
	clock      Clock
}

// newAtomicBased returns a new atomic based limiter.
func newAtomicBased(rate int, opts ...Option) *atomicLimiter {
	// TODO consider moving config building to the implementation
	// independent code.
	config := buildConfig(opts)
	perRequest := config.per / time.Duration(rate)
	l := &atomicLimiter{
		perRequest: perRequest,
		maxSlack:   -1 * time.Duration(config.slack) * perRequest,
		clock:      config.clock,
	}

	initialState := state{
		last:     time.Time{},
		sleepFor: 0,
	}
	atomic.StorePointer(&l.state, unsafe.Pointer(&initialState))
	return l
}

// Take blocks to ensure that the time spent between multiple
// Take calls is on average per/rate.
func (t *atomicLimiter) Take() time.Time {
	var (
		newState state
		taken    bool
		interval time.Duration
	)
	for !taken {
		now := t.clock.Now()

		previousStatePointer := atomic.LoadPointer(&t.state)
		oldState := (*state)(previousStatePointer)

		newState = state{
			last:     now,
			sleepFor: oldState.sleepFor,
		}

		// If this is our first request, then we allow it.
		if oldState.last.IsZero() {
			taken = atomic.CompareAndSwapPointer(&t.state, previousStatePointer, unsafe.Pointer(&newState))
			continue
		}

		// sleepFor calculates how much time we should sleep based on
		// the perRequest budget and how long the last request took.
		// Since the request may take longer than the budget, this number
		// can get negative, and is summed across requests.
		newState.sleepFor += t.perRequest - now.Sub(oldState.last)
		// We shouldn't allow sleepFor to get too negative, since it would mean that
		// a service that slowed down a lot for a short period of time would get
		// a much higher RPS following that.
		if newState.sleepFor < t.maxSlack {
			newState.sleepFor = t.maxSlack
		}
		if newState.sleepFor > 0 {
			newState.last = newState.last.Add(newState.sleepFor)
			interval, newState.sleepFor = newState.sleepFor, 0
		}
		taken = atomic.CompareAndSwapPointer(&t.state, previousStatePointer, unsafe.Pointer(&newState))
	}
	t.clock.Sleep(interval)
	return newState.last
}

优缺点

优点:限流稳定

缺点:当短时间内有大量的突发请求时,即便此时服务器没有任何负载,每个请求也都得在队列中等待一段时间才能被响应,可以理解为弹性不够好

令牌桶限流

实现思想:

  1. 令牌以固定速率生成
  2. 生成的令牌放入令牌桶中存放,如果令牌桶满了则多余的令牌会直接丢弃,当请求到达时,会尝试从令牌桶中取令牌,取到了令牌的请求可以执行
  3. 如果桶空了,那么尝试取令牌的请求会被直接丢弃

代码参考:pkg.go.dev/golang.org/…

type Limiter struct {
   mu     sync.Mutex
   limit  Limit
   burst  int
   tokens float64
   last time.Time
   lastEvent time.Time
}

func NewLimiter(r Limit, b int) *Limiter {
   return &Limiter{
      limit: r,
      burst: b,
   }
}

func (lim *Limiter) Allow() bool {
   return lim.AllowN(time.Now(), 1)
}

func (lim *Limiter) AllowN(now time.Time, n int) bool {
   return lim.reserveN(now, n, 0).ok
}

func (lim *Limiter) Reserve() *Reservation    
func (lim *Limiter) ReserveN(now time.Time, n int) *Reservation    
func (lim *Limiter) Wait(ctx context.Context) (err error)    
func (lim *Limiter) WaitN(ctx context.Context, n int) (err error)

type Reservation struct {
   ok        bool
   lim       *Limiter
   tokens    int
   timeToAct time.Time
   limit Limit
}

func (lim *Limiter) SetBurst(newBurst int)    
func (lim *Limiter) SetBurstAt(now time.Time, newBurst int)    
func (lim *Limiter) SetLimit(newLimit Limit)    
func (lim *Limiter) SetLimitAt(now time.Time, newLimit Limit)

优缺点

  1. 优点:令牌桶算法既能够将所有的请求平均分布到时间区间内,又能接受服务器能够承受范围内的突发请求,因此是目前使用较为广泛的一种限流算法
  2. 缺点:需要提前设置阈值(实际也算不上缺点)

自适应限流

实现较为复杂

  1. 平滑突发限流算法(Smooth Bursty):平滑突发限流算法是在令牌桶算法的基础上进行改进的一种自适应限流算法。它通过引入令牌生成速率的平滑衰减,使得突发流量可以得到更好的处理。该算法可以根据系统的负载情况动态调整令牌生成速率,以平衡流量的突发性和平滑性。
  2. 基于反馈控制的自适应限流:基于反馈控制的自适应限流算法利用系统的反馈信息来动态调整限流策略。通过不断收集和分析系统的负载指标(如请求延迟、系统资源利用率等),算法可以自动调整限流参数,以达到系统性能和资源利用的最优化。

分布式限流

Redis实现分布式限流

  1. 使用Redis的有序集合(Sorted Set)数据结构。有序集合中的成员是请求标识符(如用户ID、IP地址等),分数(score)是请求的时间戳或一个递增的序列号。
  2. 在每个请求到达时,使用Lua脚本执行以下操作:
    • 获取当前时间戳或递增序列号作为请求的分数。
    • 将请求标识符作为成员插入有序集合中,分数为请求的分数。
    • 移除有序集合中分数小于当前时间戳减去限流时间窗口的成员。
  3. 通过比较有序集合的长度与限流规定的阈值来判断是否允许通过请求。如果有序集合的长度超过了阈值,则表示请求超过了限流阈值,需要进行限流处理。
  4. 可以使用定时任务或定时器定期清理过期的成员,以确保有序集合中只包含当前时间窗口内的请求。

这种基于Redis的分布式限流方案具有以下优点:

  • 使用有序集合的自动排序功能,方便获取特定时间范围内的请求数量。
  • Redis的单线程模型和高性能读写操作,使得限流操作具有较低的延迟和高并发能力。
  • 可以通过设置合适的过期时间和阈值来控制限流窗口的大小和请求通过的数量。

需要注意的是,由于Redis是一个内存数据库,限流数据存储在内存中,因此需要根据实际情况评估可用内存的大小和Redis的配置参数。此外,为了保证分布式限流的一致性,可以使用Redis的集群模式或者使用分布式锁来保护限流操作的原子性。

基于流量分片的限流

将请求按照某种规则(如哈希算法)分片到不同的限流节点上进行处理,每个节点独立进行限流操作。这种方案可以将流量分散到多个节点上,减轻单个节点的压力,并且可以在节点之间共享限流配置信息。

基于令牌桶算法的分布式限

使用分布式的令牌桶算法来进行限流。每个节点维护自己的令牌桶,并根据请求到达的速率和节点负载情况动态调整令牌生成速率或令牌桶的容量。节点之间可以通过消息队列或分布式协调服务来共享限流信息

基于漏桶算法的分布式限流

类似于令牌桶算法,每个节点维护自己的漏桶,并根据请求到达的速率和节点负载情况动态调整漏桶的容量或漏水速率。节点之间可以通过消息队列或分布式协调服务来共享限流信息。

后记

真实的场景中一般会有单机限流分布式限流两种场景。具体限流方案需要考虑具体的应用场景、负载情况和性能需求。同时,还需考虑方案的实现复杂度、可扩展性和可靠性。根据具体的需求,可以选择使用现有的开源库或者自行设计和实现分布式限流方案。