之前写的一篇限流算法的go语言实现的blog里提到了几种常见的限流算法的适用场景,这篇文章聊一下在实际开发中应该如何去对单个用户的访问频率进行限制。令牌桶比较适合用于这个场景,因为只需要为每个用户维护上次获取令牌的时间戳和剩余令牌数量两个数据即可,在用户量比较大的时候存储的开销并不大。同时令牌桶的用户体验比较好,支持一定的突发流量。在生产环境中使用令牌桶还需要考虑下面几个问题:
操作的原子性
先来看一下基于令牌桶如何判断用户的某次请求是否超限的过程:
- 获取用户上一次访问的时间戳t1和剩余令牌数量
- 计算当前时间到t1这段时间生成的令牌数量n1
- 上次剩余的令牌数量加上n1得到当前可用的令牌数量n2
- 判断n2是否大于等于本次请求要消耗的令牌数量
这个过程并不是原子性的,在高并发场景下存在数据竞争的问题。
记录存储
在分布式系统中,用户每次访问的机器可能不同,如何保证每次都能取到已有的访问记录?
对于记录的存储,如果使用每台机器的local cache,就要在负载均衡器对uid或ip进行hash,让同一个uid或ip始终访问同一台机器,考虑到普通的hash算法在增减节点时会导致大量的key失效,最好要使用一致性hash算法以及考虑在节点数量变化时自动对数据进行迁移,实现起来比较的麻烦。一个比较简单的方式是让频率控制服务无状态,把令牌桶数据保存到第三方存储比如redis,利用像redis cluster等比较成熟的分布式分片存储工具去应对高并发的场景。至于如何实现操作的原子性,可以使用lua脚本把上面的令牌桶操作封装成一个原子性的操作,而今天要介绍的redis-cell是一个redis的扩展模块,提供了一个实现令牌桶算法的命令并且操作是原子性的,省去了自己开发lua脚本的麻烦。
安装方式
参考项目的git仓库
使用说明
该扩展模块只提供了一个命令:
CL.THROTTLE <key> <max_burst> <count per period> <period> [<quantity>]
参数说明
key: redis key,对单个用户进行请求频率控制时,可以用uid或者ip地址
max_burst: 令牌桶的容量,由于令牌桶算法中可以在请求频率低的时候积攒一定的令牌,所以令牌桶的容量也就反应了最大的突发流量
count per period: 指定时间段内生成的令牌数量,跟参数period一同决定了生成令牌的速度
quantity: 请求消耗的令牌数量,为可选参数,默认是1
示例
如果定义最大容量是200,每分钟生成500个令牌,每次成功访问消耗2个令牌,相应的命令如下:
cl.throttle user_1 200 500 60 2
1) (integer) 0
2) (integer) 201
3) (integer) 199
4) (integer) -1
5) (integer) 0
命令响应说明:
1) 0表示允许访问,1表示访问被拒绝
2) 最大令牌数,初始把桶填满,其实现的默认值为max_burst+1,这里需要注意最大令牌数并不是max_burst参数,而是+1后的值
3) 剩余令牌数,由于上面的命令中指定每次请求消耗2个令牌,所以剩余199
4) 如果访问被拒绝,多少秒后可以重试,如果允许访问这个值为-1
5) 多少秒之后令牌桶会被填满,由于每分钟产生500个令牌,不到1秒令牌桶就会被重新填满,所以返回0
redis-cell除了提供对令牌桶的原子性操作之外,命令的响应携带的信息也比较丰富,比如在用户获取验证码频率超限的时候,我们就可以利用响应中的第4个字段,给用户返回一个请xx秒之后再试的友好提示。