Redis和Lua脚本(实现令牌桶限流)

  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的分布式限流器设计与实现

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345