Redis小工具系列之「限流器」

什么是限流器

限流器,顾名思义是一种对流量进行控制/限制的工具

尤其是在互联网时代,限流器的使用场景更是多不胜数,例如每年剁手节时,每一个熬夜人的常规操作汇聚成数百万、千万乃至上亿次疯狂刷新和点击,而瑟瑟发抖承受这些流量的系统身后就有它的身影,默默把超过系统承受能力之外的请求阻拦下来,变成让你深恶痛绝的内容~

作为系统的开发人员,我们不可避免会遇到用户请求暴涨的场景,为了不让这些突增的流量冲垮服务,我们的系统需要安装一根「保险丝」,将那些超过系统承受能力之外的请求识别并熔断/降级掉,确保服务可以正常运行,而限流器就是我们需要的那根「保险丝」之一

限流器的种类

计数器

固定窗口

固定窗口限流器(Fixed Window Rate Limiter):在固定的时间窗口内,限制请求或流量的数量。例如,每秒钟只允许处理3个请求

**优点:**算法非常简单,易于实现和理解

**缺点:**存在临界问题,当在时间窗口的临界区例如在[0.5, 1.0]和[1.0,1.5]两个时间窗口分别请求3次,则在[0.5,1.5]这个1秒的时间窗口内则会超出每秒3个请求的限制

滑动窗口

滑动窗口限流器(Sliding Window Rate Limiter):与固定窗口限流器类似,但滑动窗口限流器允许在时间窗口内的不同时间点处理不同数量的请求。例如,每秒钟只允许处理5个请求,但每0.2秒只允许处理1个请求

**优点:**算法简单,精度可控(通过控制时间窗口的大小),解决了固定窗口的临界问题(例如在黄色[0,1.0]和紫色[0.2,1.2]区间分别收到五个请求,则紫色部分收到的请求会因为黄色[0.8,1.0]区域已经达到限流值而被拒绝掉)

**缺点:**无法应对突发流量,一旦在时间窗口内达到限流值所有请求都会被拒绝

漏桶

漏桶限流器(Leaky Bucket Rate Limiter):类似于一个水桶,请求或流量以任意的速率进入漏桶,并通过一个恒定的速率流出,如果桶满了,则超出的请求将被丢弃。

优点:

  • 稳定的限流效果,能够平滑地限制请求的流量

  • 能够应对突发流量,保护系统不会因为短时间内的高流量而崩溃

  • 可以通过调整桶的大小和漏出速率来满足不同的限流需求,可以灵活地适应不同的场景

缺点:

  • 需要对请求进行缓存,会增加服务器的内存消耗

  • 对于流量波动比较大的场景,需要较为灵活的参数配置才能达到较好的效果

  • 面对突发流量的时候,漏桶算法还是按照一定速率处理请求,而我们希望的是系统可以尽量快的处理完,以提升用户的体验

令牌桶

令牌桶限流器(Token Bucket Rate Limiter):类似于一个桶,每个请求或流量都需要一个令牌才能被处理。令牌以固定的速率被放入桶中,如果没有足够的令牌,则请求将被阻塞或丢弃。

优点:

  • 稳定性高:令牌桶算法可以控制请求的处理速度,可以使系统的负载变得稳定

  • 精度高:令牌桶算法可以根据实际情况动态调整生成令牌的速率,可以实现较高精度的限流

  • 弹性好:令牌桶算法可以处理突发流量,可以在短时间内提供更多的处理能力,以处理突发流量

缺点:

  • 实现复杂:相对于固定窗口算法等其他限流算法,令牌桶算法的实现较为复杂。 对短时请求难以处理:在短时间内有大量请求到来时,可能会导致令牌桶中的令牌被快速消耗完,从而限流。这种情况下,可以考虑使用漏桶算法

  • 时间精度要求高:令牌桶算法需要在固定的时间间隔内生成令牌,因此要求时间精度较高,如果系统时间不准确,可能会导致限流效果不理想

漏桶和令牌桶的抉择

  • 漏桶的特点是消费能力固定,当请求量超出消费能力时,提供一定的冗余能力,把请求缓存下来匀速消费。优点是对下游保护更好

  • 令牌桶遇到激增流量会更从容,只要存在令牌,则可以一并消费掉,适合有突发特征的流量,如秒杀场景

基于令牌桶的一种实现:RRateLimiter

前言

作为一个Java转Go的开发者来说,除了Spring之外,Redisson无疑是最让人怀念的功能库之一了,大量开箱即用的Api几乎覆盖了我们对于Redis使用的一切场景,分布式限流器就是其中之一,而在开发以下Go版本的限流器时也大量参考了Redisson中的lua脚本,毕竟是经过无数验证的脚本,总比自己从头手撸要有保障的多

在正式看代码之前,有一个点需要先搞明白,虽然我们在示意图和描述中都提到了"令牌以固定的速率被放入桶中",但在具体实现时,我们其实并不会真的去单独实现这个动作,而是根据历史有效请求的时间戳在每次请求令牌时动态计算的来实现

完整代码

Go 复制代码
package redis

import (
    "code.byted.org/gopkg/lang/maths"
    "code.byted.org/gopkg/logs"
    "code.byted.org/gopkg/pkg/errors"
    "code.byted.org/kv/goredis"
    "code.byted.org/kv/redis-v6"
    "context"
    "fmt"
    "math/rand"
    "strings"
    "time"
)

func NewRRateLimiter(client *goredis.Client, name string, defaultWaitingTime time.Duration) *RRateLimiter {
    limiter := &RRateLimiter{
       redisClient:        client,
       name:               name,
       defaultWaitingTime: defaultWaitingTime,
    }
    return limiter
}

type RRateLimiter struct {
    redisClient        *goredis.Client
    name               string
    defaultWaitingTime time.Duration
}

func (rl *RRateLimiter) Name() string {
    return rl.name
}

func (rl *RRateLimiter) getValueName() string {
    return rl.WrapKey("value")
}

func (rl *RRateLimiter) getPermitsName() string {
    return rl.WrapKey("permits")
}

func (rl *RRateLimiter) WrapKey(key string) string {
    // use HashTag to ensure wrapped key is distribute in one redis slot
    return fmt.Sprintf("%v:{%v}", key, rl.Name())
}

func (rl *RRateLimiter) IsRequestTimeoutError(err error) bool {
    if err != nil && strings.Contains(err.Error(), "timeout") {
       return true
    }
    return false
}

func (rl *RRateLimiter) TrySetRate(ctx context.Context, rate int64, rateInterval time.Duration) (bool, error) {
    var success = int64(1)
    command := `
       local r1 = redis.call('hsetnx', KEYS[1], 'rate', ARGV[1]); 
       local r2 = redis.call('hsetnx', KEYS[1], 'interval', ARGV[2]);
        if r1 > 0 or r2 > 0 then
            redis.call('del', KEYS[2], KEYS[3])
            return 1
        end;
       return 0
    `
    result, err := rl.redisClient.WithContext(ctx).Eval(command, []string{rl.Name(), rl.getValueName(), rl.getPermitsName()}, rate, rateInterval.Milliseconds()).Result()
    if err != nil {
       logs.CtxError(ctx, "RRateLimiter TrySetRate error, key:[%v], err:%+v", rl.Name(), err)
       return false, errors.Wrap(err, fmt.Sprintf("RRateLimiter TrySetRate error, key:[%v], err:%+v", rl.Name(), err))
    }
    resultNum, ok := result.(int64)
    if !ok {
       logs.CtxError(ctx, "RRateLimiter TrySetRate convert result to int64 failed, key:[%v], result:%+v", rl.Name(), result)
       return false, fmt.Errorf("RRateLimiter TrySetRate convert result to int64 failed, key:[%v], result:%+v", rl.Name(), result)
    }
    if resultNum == success {
       logs.CtxInfo(ctx, "RRateLimiter TrySetRate success key:[%v]", rl.Name())
       return true, nil
    }
    logs.CtxError(ctx, "RRateLimiter TrySetRate failed, key:[%v] error, err:%+v", rl.Name(), err)
    return false, nil
}

func (rl *RRateLimiter) SetRate(ctx context.Context, rate int64, rateInterval time.Duration) error {
    command := `
       redis.call('hset', KEYS[1], 'rate', ARGV[1]); 
        redis.call('hset', KEYS[1], 'interval', ARGV[2]);
       return redis.call('del', KEYS[2], KEYS[3])
    `
    _, err := rl.redisClient.WithContext(ctx).Eval(command, []string{rl.Name(), rl.getValueName(), rl.getPermitsName()}, rate, rateInterval.Milliseconds()).Result()
    if err != nil {
       logs.CtxError(ctx, "RRateLimiter SetRate error, key:[%v], err:%+v", rl.Name(), err)
       return errors.Wrap(err, fmt.Sprintf("RRateLimiter SetRate error, key:[%v], err:%+v", rl.Name(), err))
    }
    logs.CtxInfo(ctx, "RRateLimiter SetRate success key:[%v]", rl.Name())
    return nil
}

func (rl *RRateLimiter) Acquire(ctx context.Context, timeout time.Duration) (bool, error) {
    return rl.doAcquire(ctx, 1, timeout)
}

func (rl *RRateLimiter) AcquireWithValue(ctx context.Context, value int64, timeout time.Duration) (bool, error) {
    if value <= 0 {
       return false, fmt.Errorf("RRateLimiter AcquireWithValue failed, value must gte zero, key[%v]", rl.Name())
    }
    return rl.doAcquire(ctx, value, timeout)
}

func (rl *RRateLimiter) doAcquire(ctx context.Context, value int64, timeout time.Duration) (bool, error) {
    expectedWaitTime, err := rl.TryAcquire(ctx, value)
    if err == nil && expectedWaitTime == nil {
       return true, nil
    }
    if timeout <= 0 {
       timeout = rl.defaultWaitingTime
       logs.CtxInfo(ctx, "RRateLimiter doAcquire will use default timeout, key:[%v], timeout:[%v]ms", rl.Name(), rl.defaultWaitingTime.Milliseconds())
    }
    now := time.Now()
    endTime := now.Add(timeout)
    tryCount := 0
    for time.Now().UnixMilli() < endTime.UnixMilli() {
       logs.CtxInfo(ctx, "RRateLimiter doAcquire try acquire, key:[%v], try count:[%v]", rl.Name(), tryCount)
       tryCount += 1
       expectedWaitTime, err := rl.TryAcquire(ctx, value)
       if err == nil && expectedWaitTime == nil {
          return true, nil
       }
       if err != nil {
          logs.CtxError(ctx, "RRateLimiter doAcquire error, key:[%v], try count:[%v], err:%v", rl.Name(), tryCount, err.Error())
          return false, err
       }
       if expectedWaitTime != nil && *expectedWaitTime > 0 {
          // choose sleep min(timeout remain, expectedWaitTime)
          sleepTime := maths.MinInt64(endTime.UnixMilli()-time.Now().UnixMilli(), *expectedWaitTime)
          logs.CtxInfo(ctx, "RRateLimiter doAcquire will sleep and acquire again, key:[%v], try count:[%v], sleep time:[%v]ms", rl.Name(), tryCount, sleepTime)
          time.Sleep(time.Duration(sleepTime) * time.Millisecond)
       }
    }
    // will try last time if timeout
    expectedWaitTime, err = rl.TryAcquire(ctx, value)
    if err == nil && expectedWaitTime == nil {
       return true, nil
    }
    logs.CtxInfo(ctx, "RRateLimiter doAcquire timeout, key:[%v], try count:[%v], timeout:[%v]ms", rl.Name(), tryCount, timeout.Milliseconds())
    return false, nil
}

func (rl *RRateLimiter) TryAcquire(ctx context.Context, value int64) (*int64, error) {
    random := make([]byte, 16)
    rand.Read(random)
    command := `
       local rate = redis.call('hget', KEYS[1], 'rate');
       local interval = redis.call('hget', KEYS[1], 'interval');
       assert(rate ~= false and interval ~= false, 'RateLimiter is not initialized')
       
       local valueName = KEYS[2];
       local permitsName = KEYS[3];
       
       assert(tonumber(rate) >= tonumber(ARGV[1]), 'Requested permits amount could not exceed defined rate');
       
       local currentValue = redis.call('get', valueName);
       local res;
       if currentValue ~= false then
             local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
             local released = 0;
             for i, v in ipairs(expiredValues) do
                local random, permits = struct.unpack('Bc0I', v);
                released = released + permits;
             end;
       
             if released > 0 then
                redis.call('zremrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
                if tonumber(currentValue) + released > tonumber(rate) then
                    currentValue = tonumber(rate) - redis.call('zcard', permitsName);
                else
                    currentValue = tonumber(currentValue) + released;
                end;
                redis.call('set', valueName, currentValue);
             end;
       
             if tonumber(currentValue) < tonumber(ARGV[1]) then
                local firstValue = redis.call('zrange', permitsName, 0, 0, 'withscores');
                res = 3 + interval - (tonumber(ARGV[2]) - tonumber(firstValue[2]));
             else
                redis.call('zadd', permitsName, ARGV[2], struct.pack('Bc0I', string.len(ARGV[3]), ARGV[3], ARGV[1]));
                redis.call('decrby', valueName, ARGV[1]);
                res = nil;
             end;
       else
             redis.call('set', valueName, rate);
             redis.call('zadd', permitsName, ARGV[2], struct.pack('Bc0I', string.len(ARGV[3]), ARGV[3], ARGV[1]));
             redis.call('decrby', valueName, ARGV[1]);
             res = nil;
       end;
       
       local ttl = redis.call('pttl', KEYS[1]);
       if ttl > 0 then
          redis.call('pexpire', valueName, ttl);
          redis.call('pexpire', permitsName, ttl);
       end;
       return res;
    `
    result, err := rl.redisClient.WithContext(ctx).Eval(command, []string{rl.Name(), rl.getValueName(), rl.getPermitsName()}, value, time.Now().UnixMilli(), random).Result()
    if err != nil && err != redis.Nil {
       logs.CtxError(ctx, "RRateLimiter TryAcquire error, key:[%v], err:%+v", rl.Name(), err)
       return nil, errors.Wrap(err, fmt.Sprintf("RRateLimiter TryAcquire error, key:[%v], err:%+v", rl.Name(), err))
    }
    if err == redis.Nil {
       logs.CtxInfo(ctx, "RRateLimiter TryAcquire success key:[%v] ", rl.Name())
       return nil, nil
    }

    resultNum, ok := result.(int64)
    if !ok {
       logs.CtxError(ctx, "RRateLimiter TryAcquire convert result to int64 failed, key:[%v], result:%+v", rl.Name(), result)
       return nil, fmt.Errorf("RRateLimiter TryAcquire convert result to int64 failed, key:[%v], result:%+v", rl.Name(), result)
    }
    return &resultNum, nil
}

func (rl *RRateLimiter) Delete(ctx context.Context) error {
    command := `
       return redis.call('del', KEYS[1], KEYS[2], KEYS[3]);
    `
    _, err := rl.redisClient.WithContext(ctx).Eval(command, []string{rl.Name(), rl.getValueName(), rl.getPermitsName()}).Result()
    if err != nil {
       logs.CtxError(ctx, "RRateLimiter Delete error, key:[%v], err:%+v", rl.Name(), err)
       return errors.Wrap(err, fmt.Sprintf("RRateLimiter Delete error, key:[%v], err:%+v", rl.Name(), err))
    }
    logs.CtxInfo(ctx, "RRateLimiter Delete success key:[%v]", rl.Name())
    return nil
}

实现讲解

TryAcquire中省略的 lua 脚本

lua脚本实现了根据历史请求记录和当前请求来动态计算令牌的限流器算法实现,内容跟Redisson中RedissonRateLimiter.tryAcquireAsync的基本一致,只是删除了其中单机限流的类型判断逻辑

Key介绍:

其中过程key里包含了{}是使用了redis支持的hash tags机制,使用了{}后,redis在计算slot时会只考虑{}内的内容,用来保证主key和两个过程key都在同一个slot上,hash tags机制可以查询 参考「3」中的内容

key分类 key值 key类型 字段 说明 示例
主key 用户自定义 dict(hset,hget) rate:数值,令牌产生个数interval:数值,令牌时间周期,单位毫秒 用户维护限流器的配置信息,供acquire时动态计算使用 Key: test_rate_limiter_key1hgetall Value: ["rate","3","interval","5000"](此处应该是使用了压缩链表)
过程key value:{主key} 普通类型(get,set) 用于保存当前可用令牌数量 **Key: **value:{test_rate_limiter_key1}get Value: 2
过程key permits:{主key} zset 1、用于保存历史请求记录2、zset中每个对象的内容是一个经过struct.pack 拼接的二进制值,拼接内容为:128位随机字节+本次申请请求令牌数struct.pack函数可以查阅参考「1」 **Key: **permits:{test_rate_limiter_key1}zrange Value: ["\u0010���/>G�\u0013~\u001fI�BL�\u0001\u0000\u0000\u0000"]
Lua 复制代码
// 获取SetRate防范中设置的令牌生成速率信息
local rate = redis.call('hget', KEYS[1], 'rate');
local interval = redis.call('hget', KEYS[1], 'interval');
// 确保两个值不为空,否则认为限流器没有初始化,直接报错返回
assert(rate ~= false and interval ~= false, 'RateLimiter is not initialized')

// 限流器的两个内部key值的名称
// valueName用于保存当前可用的令牌数
// permitsName是一个zset, 用于保存历史的请求和请求的令牌数,score为时间戳
local valueName = KEYS[2];
local permitsName = KEYS[3];

// 确保令牌产生速率大于本次请求的令牌数,否则直接报错,因为单次请求超过令牌产生速率时永远不会成功的
assert(tonumber(rate) >= tonumber(ARGV[1]), 'Requested permits amount could not exceed defined rate');

// 获取当前可用令牌数量
local currentValue = redis.call('get', valueName);
// 定义返回结果
local res;

// 判断是否为限流器初始化后首次获取令牌,首次请求时走else分支,因为valueName还没有设置,因此取出来的currentValue为不存在
if currentValue ~= false then
     // 从permitsName中获取已经过期的请求
     // 当zset的score小于"当前时间戳 - 1个时间周期",说明这些请求已经过期了,可以释放掉令牌了
     local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
     local released = 0;
     for i, v in ipairs(expiredValues) do
        // 此处的 struct.unpack 用于解包操作,将拼接的值进行拆分
        // 因为在下方放入时做了pack操作,因此此处做反操作,取出请求获取的令牌数,并累加到released上
        // struct.unpack 看下方 参考 小节中「1」的链接
        local random, permits = struct.unpack('Bc0I', v);
        released = released + permits;
     end;

     // 当released>0时说明,存在过期的请求,因此需要将这些令牌释放掉
     if released > 0 then
        // 将zset中score小于"当前时间戳 - 1个时间周期"的数据移除掉
        redis.call('zremrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
        if tonumber(currentValue) + released > tonumber(rate) then
            // 此处的if判断根据其commit是为了解决在某种情况下速率突破了限流器限制的issue
            // 具体原因可以看下方 参考 小节中「2」的链接
            currentValue = tonumber(rate) - redis.call('zcard', permitsName);
        else
            // 将currentValue加上刚刚释放的值
            currentValue = tonumber(currentValue) + released;
        end;
        // 更新redis中当前可用令牌数量
        redis.call('set', valueName, currentValue);
     end;
     // 当前可用令牌数小于请求的令牌数时,获取最早释放的一个请求,并计算它和此次请求时间戳的差值
     // 其中加了一个3,应该是希望下次请求稍微延后一点点吧
     if tonumber(currentValue) < tonumber(ARGV[1]) then
        local firstValue = redis.call('zrange', permitsName, 0, 0, 'withscores');
        res = 3 + interval - (tonumber(ARGV[2]) - tonumber(firstValue[2]));
     else
        // 如果当前令牌数足够,则将本次请求通过struct.pack打包后放入zset中
        redis.call('zadd', permitsName, ARGV[2], struct.pack('Bc0I', string.len(ARGV[3]), ARGV[3], ARGV[1]));
        // 将当前可用令牌数减去本次请求需要的令牌数
        redis.call('decrby', valueName, ARGV[1]);
        // 设置返回为nil, 认为获取令牌成功
        res = nil;
     end;
else
     // 此处为限流器初始化后首次获取令牌,因此需要先先初始化当前可用令牌为rate
     redis.call('set', valueName, rate);
     // 将本次请求初始化到zset中
     redis.call('zadd', permitsName, ARGV[2], struct.pack('Bc0I', string.len(ARGV[3]), ARGV[3], ARGV[1]));
     // 将当前可用令牌数减去本次请求需要的令牌数
     redis.call('decrby', valueName, ARGV[1]);
     // 设置返回为nil, 认为获取令牌成功
     res = nil;
end;
// 此处作用主要是保持限流器的过程key和主key的过期时间保持一致
local ttl = redis.call('pttl', KEYS[1]);
if ttl > 0 then
  redis.call('pexpire', valueName, ttl);
  redis.call('pexpire', permitsName, ttl);
end;
return res;

使用介绍

参考

「1」redis.io/docs/intera...

「2」github.com/redisson/re...

「3」redis.io/docs/refere...

相关推荐
孤雪心殇1 天前
简单易懂,解析Go语言中的Map
开发语言·数据结构·后端·golang·go
zhuyasen1 天前
告别低效!Go 开发框架 Sponge 与 AI 助手深度联动,打造极速开发新体验
低代码·go·deepseek
NPE~2 天前
Bug:Goland debug失效详细解决步骤【合集】
go·bug·goland·dlv失效
喵个咪3 天前
开箱即用的GO后台管理系统 Kratos Admin - 交互式API文档 Swagger UI
后端·go·swagger
小石潭记丶3 天前
goland无法debug项目
go
千舟3 天前
自己动手编写tcp/ip协议栈4:tcp数据传输和四次挥手
网络协议·go
CHSnake3 天前
gRPC和gRPC-gateway
go
喵个咪5 天前
开箱即用的GO后台管理系统 Kratos Admin - 后端项目结构说明
后端·微服务·go
烛阴5 天前
Go语言内置包:提升开发效率的必备神器!
后端·go
天葬6 天前
Ollama 模型迁移备份工具 ollamab
go·ollamab