1. 限流

1.1 什么场景下需要限流

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

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

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

作为服务提供者,我们无法限制调用者如何去调用我们接口,我们曾经就遇到过调用方多线程并发跑job来请求我们的接口,或者调用方bug或者业务上突发流量,导致某个接口请求数量突增,过度争用服务线程资源,而来自其他调用方的接口请求因此只能排队等待。使得我们服务整体请求响应时间变长,我们需要对每个调用者进行细粒度的访问限流。

因为我们系统中存在一些“慢”接口,因为处理逻辑复杂,处理时间比较长。如果不对“慢”接口进行限流,过多的“慢”接口请求会一直占用服务的线程资源不释放,也会影响其他业务接口请求。可能会引起大量接口超时。

核心接口,若是大量访问也会对业务影响比较大,也是进行限流控制。

1.2 限流的标准

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

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

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

lua源码 支持 debug库 安装_lua源码 支持 debug库 安装

压测的聚合报告.png

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

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

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

lua源码 支持 debug库 安装_Lua_02

jmeter配置.png

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

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

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

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

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

对整个系统流量进行限流(根据服务器性能);

对调用者进行限流;限制每秒访问频率(根据调用者数据进行限流);

对“慢”接口进行限流,防止影响其他接口(根据历史数据和压测结果进行限流);

2. 如何限流

Lua脚本的数据类型

Lua是动态语言,变量不需要定义类型,只需要为变量赋值。

Lua中有8个基本类型分别为nil、boolean、number、string、userdata、function、thread和table。

详见:Lua的数据类型

lua源码 支持 debug库 安装_lua 令牌桶 源码_03

Lua的数据类型.png

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

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

将Lua脚本持久化到AOF文件中,保证Redis重启时可以回放执行过的Lua脚本;

把这段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脚本,而是一个确定的值。

注意事项:

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

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

大流量写入时不建议使用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

lua源码 支持 debug库 安装_redis_04

请求令牌桶.png

lua源码 支持 debug库 安装_Lua_05

获取令牌的算法.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的分布式限流器设计与实现