- 限流
1.1 什么场景下需要限流
1.2 令牌桶和漏桶限流
1.3 限流的标准 - Lua+Redis如何实现限流
2.1 Lua脚本的数据类型
2.2 Lua脚本为什么必须是纯函数形式
2.3 Lua脚本如何实现随机写入
2.4 Lua脚本实现分布式令牌桶限流
1. 限流
1.1 什么场景下需要限流
在应对秒杀等高性能压力的场景下,为了保证系统平稳运行,限流已经成为了标配技术解决方案。限流作用就是针对超过预期流量,通过预先设定的限流规则选择性的针对某些请求进行限流“熔断”。
上面的前提是高并发,但是很多项目流量并不是很大,可能不存在高并发的情况,那么是否有必要对接口进行限流?
答案是有的,对于一个系统,若对外暴露API接口。可能在下面场景下也发挥着巨大的作用。
- 作为服务提供者,我们无法限制调用者如何去调用我们接口,我们曾经就遇到过调用方多线程并发跑job来请求我们的接口,或者调用方bug或者业务上突发流量,导致某个接口请求数量突增,过度争用服务线程资源,而来自其他调用方的接口请求因此只能排队等待。使得我们服务整体请求响应时间变长,我们需要对每个调用者进行细粒度的访问限流。
- 因为我们系统中存在一些“慢”接口,因为处理逻辑复杂,处理时间比较长。如果不对“慢”接口进行限流,过多的“慢”接口请求会一直占用服务的线程资源不释放,也会影响其他业务接口请求。可能会引起大量接口超时。
- 核心接口,若是大量访问也会对业务影响比较大,也是进行限流控制。
1.2 令牌桶和漏桶限流
常见的限流算法有两种:漏桶和令牌桶算法。
漏桶算法思路很简单:请求先进入漏桶中,漏桶以一定的速度出水,当水流速度过大会直接溢出。
但是限流处理限制数据的平均传输速率外,还要求允许某种程度的突发传输。而漏桶算法可能就不合适。
此时就使用到了令牌桶进行限流:
令牌桶是系统以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则先需要从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
1.3 限流的标准
一般通过TPS或者QPS指标作为限流依据,QPS和TPS的指标。
对一个接口(单场景)压测,且这个接口内部不会再去请求其他接口,那么tps=qps。
(该场景下调用的内部接口不会再去请求其他接口)本服务器的TPS在630左右,随着并发线程数的增加,响应时间也会变长。
在响应时间300ms左右的情况下,服务器每秒会处理630笔请求。当然随着并发数的增加,在TPS不变的情况下,响应时间也会随着增加。
限流就是允许1s内可以通过的线程数。
若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脚本为什么必须是纯函数形式
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()
的情况下,只会给备库复制这段脚本,但是调用之后主从就会进行大量的写命令复制,增加主从复制的流量。
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
--- @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 目录