欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

Redis 和 Lua 脚本(实施令牌桶流量限制)

最编程 2024-03-07 10:30:25
...
  1. 限流
    1.1 什么场景下需要限流
    1.2 令牌桶和漏桶限流
    1.3 限流的标准
  2. Lua+Redis如何实现限流
    2.1 Lua脚本的数据类型
    2.2 Lua脚本为什么必须是纯函数形式
    2.3 Lua脚本如何实现随机写入
    2.4 Lua脚本实现分布式令牌桶限流

1. 限流

1.1 什么场景下需要限流

在应对秒杀等高性能压力的场景下,为了保证系统平稳运行,限流已经成为了标配技术解决方案。限流作用就是针对超过预期流量,通过预先设定的限流规则选择性的针对某些请求进行限流“熔断”。

上面的前提是高并发,但是很多项目流量并不是很大,可能不存在高并发的情况,那么是否有必要对接口进行限流?

答案是有的,对于一个系统,若对外暴露API接口。可能在下面场景下也发挥着巨大的作用。

  1. 作为服务提供者,我们无法限制调用者如何去调用我们接口,我们曾经就遇到过调用方多线程并发跑job来请求我们的接口,或者调用方bug或者业务上突发流量,导致某个接口请求数量突增,过度争用服务线程资源,而来自其他调用方的接口请求因此只能排队等待。使得我们服务整体请求响应时间变长,我们需要对每个调用者进行细粒度的访问限流。
  2. 因为我们系统中存在一些“慢”接口,因为处理逻辑复杂,处理时间比较长。如果不对“慢”接口进行限流,过多的“慢”接口请求会一直占用服务的线程资源不释放,也会影响其他业务接口请求。可能会引起大量接口超时。
  3. 核心接口,若是大量访问也会对业务影响比较大,也是进行限流控制。

1.2 令牌桶和漏桶限流

常见的限流算法有两种:漏桶和令牌桶算法。

漏桶算法思路很简单:请求先进入漏桶中,漏桶以一定的速度出水,当水流速度过大会直接溢出。

但是限流处理限制数据的平均传输速率外,还要求允许某种程度的突发传输。而漏桶算法可能就不合适。

此时就使用到了令牌桶进行限流:

image.png

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

1.3 限流的标准

压测工具——Apache JMeter(解压版)安装与使用

一般通过TPS或者QPS指标作为限流依据,QPS和TPS的指标

对一个接口(单场景)压测,且这个接口内部不会再去请求其他接口,那么tps=qps。

压测的聚合报告.png

(该场景下调用的内部接口不会再去请求其他接口)本服务器的TPS在630左右,随着并发线程数的增加,响应时间也会变长。

在响应时间300ms左右的情况下,服务器每秒会处理630笔请求。当然随着并发数的增加,在TPS不变的情况下,响应时间也会随着增加。

限流就是允许1s内可以通过的线程数。

jmeter配置.png

若1s内有1000个线程并发某个接口,那么这个接口的平均响应时间为1.6s(TPS为600左右)。

若是1s内有2000个线程访问某个接口,那么接口的平均响应时间为3.2s(TPS为570左右)。

且会影响到项目中其他接口的访问。

如果1s内有1万个线程线程访问接口,那么平均响应时间会更长。

(1000线程情况下)这还是单接口的情况下,若是内部调用其他服务的接口,会导致内部调用接口的响应时间也会变长。最终调用一个接口的响应时间会在4-5s左右。

  • 对整个系统流量进行限流(根据服务器性能);
  • 对调用者进行限流;限制每秒访问频率(根据调用者数据进行限流);
  • 对“慢”接口进行限流,防止影响其他接口(根据历史数据和压测结果进行限流);

2. 如何限流

Lua脚本的数据类型

Lua是动态语言,变量不需要定义类型,只需要为变量赋值。
Lua中有8个基本类型分别为nilbooleannumberstringuserdatafunctionthreadtable
详见:Lua的数据类型

Lua的数据类型.png

Lua脚本为什么必须是纯函数形式

Redis允许Lua脚本中调用redis.call()或者redis.pcall()来执行Redis命令,如果Lua脚本对Redis的数据做了更改,那么除了执行执行脚本本身外还需要数据的持久化操作。

  1. 将Lua脚本持久化到AOF文件中,保证Redis重启时可以回放执行过的Lua脚本;
  2. 把这段Lua脚本复制给备库,保证主备库的数据一致性;

由于上述两个原因,就可以理解为什么Redis要求Lua脚本必须是纯函数的形式了,想象一下给定一段Lua脚本和输入参数但是却得到了不同的结果,会造成重启前后主备之间数据不一致。

Lua脚本如何实现随机写入

Redis必须是纯函数的原因是受到了持久化和主从复制的约束,而制约的根本原因是持久化和复制的粒度是整个Lua脚本,如果能够只把发生更改的数据做持久化和主从复制,那么就可以化随机为确定。

replicate [ˈreplɪkeɪt] 复制 乱普利kei特

Redis提供了redis.replicate_commands()函数来实现这一功能。把发生数据变更的命令以事务的方式来做持久化和主从复制,从而运行Lua脚本内的随机写入。

127.0.0.1:6379> eval "redis.replicate_commands(); local now = redis.call('time')[1]; redis.call('set','now',now); return redis.call('get','now')" 0
"1504460040"

在脚本开头插入redis.replicate_commands()就可以成功把时间写入;这是因为执行了redis.replicate_commands()之后,Redis就可以使用multi/exec来包围Lua脚本中调用命令。持久化和复制的不再是整个Lua脚本,而是一个确定的值。

注意事项:

  1. 在写命令之前调用redis.replicate_commands()

调用redis.replicate_commands()之后Redis开始用事务来代替整个Lua脚本做持久化和主从复制,但是Redis并没有缓存redis.replicate_commands()之前的命令。如果在此之前调用了写命令会破坏数据的一致性。此时redis.replicate_commands()并不会生效;

  1. 大流量写入时不建议使用redis.replicate_commands

若不使用redis.replicate_commands()的情况下,只会给备库复制这段脚本,但是调用之后主从就会进行大量的写命令复制,增加主从复制的流量。

文章节选——redis4.0之Lua脚本新姿势

Lua脚本实现分布式令牌桶限流

限流器在每次请求令牌放入令牌的操作中,存在一个协同的问题,即获取令牌操作要尽可能保证原子性。在RateLimiter的实现中使用了mutex作为互斥锁来保证了操作的原子性。而在redis中也需要一个机制来保证操作的原子性。

  • 将获取令牌的操作封装在Lua脚本中。由于Lua脚本在redis中天然的原子性,可以实现我们的需求;
  • 若太过依赖redis的话,我们可以每次请求redis时,预支一些令牌放在本地,通过本地的进程锁来分配这些令牌,消耗完毕在此请求redis。
--- key,即redis中的key。
local key = KEYS[1]
--- args第一个参数即要调用的方法名。
local method = ARGV[1]
--- 请求令牌
if method == 'acquire' then  
    return acquire(key, ARGV[2], ARGV[3])
--- 请求时间
elseif method == 'currentTimeMillis' then
    return currentTimeMillis()
--- 初始化令牌桶
elseif method == 'initTokenBucket' then
    return initTokenBucket(key, ARGV[2], ARGV[3])
end
请求令牌桶.png
获取令牌的算法.png
--- @param key 令牌的唯一标识
--- @param permits  请求令牌数量
--- @param curr_mill_second 当前时间
--- 0 没有令牌桶配置;-1 表示取令牌失败,也就是桶里没有令牌;1 表示取令牌成功
local function acquire(key,  permits, curr_mill_second)
    local local_key =  key --- 令牌桶key ,使用 .. 进行字符串连接
    if tonumber(redis.pcall("EXISTS", local_key)) < 1 then --- 未配置令牌桶
        return 0
    end

    --- 令牌桶内数据:
    ---             last_mill_second  最后一次放入令牌时间
    ---             curr_permits  当前桶内令牌
    ---             max_permits   桶内令牌最大数量
    ---             rate  令牌放置速度
    local rate_limit_info = redis.pcall("HMGET", local_key, "last_mill_second", "curr_permits", "max_permits", "rate")
    local last_mill_second = rate_limit_info[1]
    local curr_permits = tonumber(rate_limit_info[2])
    local max_permits = tonumber(rate_limit_info[3])
    local rate = rate_limit_info[4]

    --- 标识没有配置令牌桶
    if type(max_permits) == 'boolean' or max_permits == nil then
        return 0
    end
   --- 若令牌桶参数没有配置,则返回0
    if type(rate) == 'boolean' or rate == nil then
        return 0
    end

    local local_curr_permits = max_permits;

    --- 令牌桶刚刚创建,上一次获取令牌的毫秒数为空
    --- 根据和上一次向桶里添加令牌的时间和当前时间差,触发式往桶里添加令牌,并且更新上一次向桶里添加令牌的时间
    --- 如果向桶里添加的令牌数不足一个,则不更新上一次向桶里添加令牌的时间
    --- ~=号在Lua脚本的含义就是不等于!=
    if (type(last_mill_second) ~= 'boolean'  and last_mill_second ~= nil) then
        if(curr_mill_second - last_mill_second < 0) then
            return -1
        end
      --- 生成令牌操作
        local reverse_permits = math.floor(((curr_mill_second - last_mill_second) / 1000) * rate) --- 最关键代码:根据时间差计算令牌数量并匀速的放入令牌
        local expect_curr_permits = reverse_permits + curr_permits;
        local_curr_permits = math.min(expect_curr_permits, max_permits);  --- 如果期望令牌数大于桶容量,则设为桶容量
        --- 大于0表示这段时间产生令牌,则更新最新令牌放入时间
        if (reverse_permits > 0) then
            redis.pcall("HSET", local_key, "last_mill_second", curr_mill_second)
        end
    else
        redis.pcall("HSET", local_key, "last_mill_second", curr_mill_second)
    end
  --- 取出令牌操作
    local result = -1
    if (local_curr_permits - permits >= 0) then
        result = 1
        redis.pcall("HSET", local_key, "curr_permits", local_curr_permits - permits)
    else
        redis.pcall("HSET", local_key, "curr_permits", local_curr_permits)
    end
    return result
end
--- 初始化令牌桶
local function initTokenBucket(key, max_permits, rate)
    if(key == nil or string.len(key) < 1) then
        return 0
    end
    local local_max_permits = 100
    if(tonumber(max_permits) > 0) then
        local_max_permits = max_permits
    end

    local local_rate = 100
    if(tonumber(rate) > 0) then
        local_rate = rate
    end
    redis.pcall("HMSET", key, "max_permits", local_max_permits, "rate", local_rate)
    return 1;
end
--- 获取当前时间,单节点获取,避免集群模式下(无论业务系统集群,还是redis集群)获取的时间不同,导致桶不匀速
local function currentTimeMillis()
    local times = redis.pcall("TIME")
    return tonumber(times[1]) * 1000 + tonumber(times[2]) / 1000
end

最关键的一点在于为了重启前后主备之间数据的一致性。Lua脚本值只允许纯函数的情况,在redis4.0之后,提供了redis.replicate_commands命令来确保可以使用随机数。但是在大流量下主从会进行大量主从写命名的复制,会增加主从复制的流量。所以在需要应用程序中获取时间,并传入给Lua脚本。
因为要计算当前时间最后一次生成令牌时间产生的令牌数,所以一定要确保不同节点时钟的稳定性,并且要使用分布式锁保证获取时间获取锁的原子性。

历史文章

mybatis&&数据库优化&&缓存目录
JAVA && Spring && SpringBoot2.x 目录

推荐阅读

Lua的数据类型

redis4.0之Lua脚本新姿势

基于redis和lua的分布式限流器设计与实现