分布式系统保证服务可用的三大银弹:限流, 降级, 缓存,今天复习一下限流器的原理
为什么需要限流
在高并发、大流量请求的场景下,如秒杀等场景,激增的请求很容易把系统打挂,导致系统无法提供稳定的服务。
而限流器的作用顾名思义,就是限制流量,从而保证系统的可用性
服务端的流量控制我们称为限流,客户端的流量控制我们称为限速; 常用的限流算法主要有 计算器,漏桶,令牌桶,后面两种在实际生产中用的更普遍一点;
漏桶算法的特点:流量稳定,不能处理突发流量,保护下游(突发流量来了也不会提高访问速度,避免下游被打垮)
令牌桶算法特点:可以处理突发流量,保护自己(突发流量来了,如果令牌够,会提高请求速度,防止自己被打垮)
令牌桶算法原理
令牌桶,顾名思义,我们有一个bucket用于存放令牌,bucket的容量固定;
每秒钟会往bucket里放 k 个 token(增速固定),token满了则丢弃
请求来的时候会现到bucket里获取token,如果没有token,则丢弃请求或者等待
从定义里可以看出,实现一个限流器必要的元素就是
- 令牌投放速度
limit: 每秒投放多少个令牌 - 令牌桶的大小
burst: 这个参数其实决定了令牌桶最多支持多少并发 - 剩余令牌数量
token: 当前系统能支持多少并发 - 上次取token的时间
last: 根据这个时间可以算出下一次请求时又新增了多少token
type Limiter struct {
limit float64
burst int64
tokens float64
last time.Time
}
golang 官方提供的 time/rate 包提供了令牌桶的限流算法
初始化一个限流器试试
除了Allow,AllowN允许你一次性获取N个token;获取失败直接拒绝
Wait,WaitN 允许你获取N个token,获取失败则阻塞等待
分布式限流
上述这种限流方式属于本地限流,限流器的信息保存在本地;但当系统扩展起来后,有多个服务需要统一个限流器,则需要升级到分布式限流。
分布式限流可以使用redis + lua脚本实现,保证命令单线程串行执行
先介绍一个 redis使用lua脚本的特性,redis执行脚本有两种模式
- 脚本传播模式 : 脚本传播模式是 Redis 复制脚本时默认使用的模式,Redis会将被执行的脚本及其参数复制到 AOF 文件以及从服务器里面。调用
eval执行脚本时,主服务器将向从服务器发送完全相同的 eval 命令;因此,为了执行相同的脚本以及参数必须产生相同的效果,在这一模式下执行的脚本不能有时间、内部状态、随机函数(spop)等。 - 命令传播模式:命令传播模式的主服务器会将执行脚本产生的所有写命令用事务包裹起来,然后将事务复制到 AOF文件以及从服务器里面。因为命令传播模式复制的是写命令而不是脚本本身,所以即使脚本本身包含时间、内部状态、随机函数等,主服务器给所有从服务器复制的写命令仍然是相同的。
开启命令传播模式的lua脚本需要先在脚本里面调用以下函数
redis.replicate_commands()
实现限流器的lua脚本 主要分布三个部分 首先是获取调用脚本时传入的参数,以及一些变量定义
redis.replicate_commands()
-- 获取调用脚本时传入key(限流名称)
local key = KEYS[1]
-- 获取调用脚本时传入的第一个参数值(token增速)
local rate = tonumber(ARGV[1])
-- 获取调用脚本时传入的第二个参数值(bucket大小)
local capacity = tonumber(ARGV[2])
-- 获取调用脚本时传入的第三个参数值(请求获取的token数量)
local needTokenNum = tonumber(ARGV[3])
-- 获取调用脚本时传入的第四个参数值(允许提前获取的token最大数量)
local futureReserveMaxN = tonumber(ARGV[4])
-- 第一个返回值,是否成功获取到token, 0表示获取token成功,1表示获取token失败
local result = 1
-- 第二个返回值,需要等待的时间
local sleepFor = -1
-- 1s = 1000000000 ns
local nanoRate = 1000000000
-- 计算产生1个Token的时长,单位 ns
local perTokenIntv = nanoRate/rate
其次是时间的处理
-- 获取当前时间
local time = redis.call('time')
-- time获取到一个数组,第一个值是单位s的时间戳,第二个值是这一秒过去的微秒数,精度是纳秒需要*1000
local now_s = time[1]
local now_nano = time[2]*1000
-- 最大等待时间= 容量*一个token需要的时间
local maxIntv = perTokenIntv * capacity
-- 从redis 获取获取上一次执行时间
local res = redis.call('hmget', key, 'last_s', 'last_nano')
local last_s = tonumber(res[1])
local last_nano = tonumber(res[2])
-- 距上次获取Token的时长,单位ns
local intv = 0
if last_s ~= nil and last_nano ~= nil then
intv = (now_s - last_s) * nanoRate + (now_nano - last_nano)
end
-- 如果上次获取时间间隔已经超过最大等待时间 或者没有上次执行时间数据->token量为满的
-- 修改上次执行时间为当前时间 - 最大等待时间
if intv > maxIntv or last_s == nil or last_nano == nil then
intv = maxIntv
-- 上次和这次的执行时间在同一秒内
if now_nano >= intv then
last_s = now_s
last_nano = now_nano - intv
else
-- 上次和这次不在同一秒内
local intvNano = intv % nanoRate
if now_nano >= intvNano then
last_s = now_s - math.floor(intv / nanoRate)
last_nano = now_nano - intvNano
else
last_s = now_s - math.floor(intv / nanoRate) - 1
last_nano = nanoRate + now_nano - intvNano
end
end
end
然后就可以计算 限流了
-- 计算要等待的时间
-- 产生一个token的时间* 需要的token的数量 - 已经等待了的时间
sleepFor = perTokenIntv * needTokenNum - intv
-- 如果超过 允许等待的时间,返回失败
if sleepFor - perTokenIntv * futureReserveMaxN > 0 then
result = 1
else
result = 0
--保存这次需要的token生产完成时间,
-- 新的 lasts不是执行请求的时间,如果桶里还有token,其实lasts会往前推一定时间(把剩余token的产生时间加上),相当于记录了 剩余token数量
local nextNano = last_nano + perTokenIntv * needTokenNum
last_nano = nextNano % nanoRate
last_s = last_s + math.floor(nextNano / nanoRate)
redis.call('hmset', key, 'last_s', tostring(last_s), 'last_nano', tostring(last_nano))
end
return {result, sleepFor}
上述redis 限流器没测过,只是个大致思路
漏桶算法原理
漏桶的思想和令牌桶其实差不多,但是漏桶的请求速度是恒定的
请求来的时候,判断桶里还有没有容量,有则放入,无则丢弃
uber提供了一个漏桶算法的实现go.uber.org/ratelimit,相比于 令牌桶 中,只要桶内还有剩余令牌,调用方就可以一直消费的策略。Leaky Bucket 相对来说更加严格,调用方只能严格按照预定的间隔顺序进行消费调用。
func main() {
rl := ratelimit.New(100) // per second
prev := time.Now()
for i := 0; i < 10; i++ {
now := rl.Take()
fmt.Println(i, now.Sub(prev))
prev = now
}
}
初始化漏桶的时候,我们定义了每秒钟可以允许几次请求;在请求调用的时候,会计算当前时间和可执行执行直接的差距,如果大于0,会sleep一会再执行,保证匀速调用