分布式环境下的流量控制,通过redis实现令牌桶算法。记录在设计实现过程中的思维变迁。
流程图(分布式锁实现)
get last refill time:这里需要加入分布式锁,每次只能有1个请求获得lastRefillTime。令牌桶配置为k-v形式,lastRefillTimeLock的key和Token Bucket的key类似(比如通过前缀区分)。释放锁的时间为
refill token bucket
或者nowTime-lastRefillTime>refillInterval
为no,更新lastRefillTime之后释放锁。
现在假设令牌桶的设置key是:tokenBucketKey,对应的分布式锁key是:lockKey。
简单说下此处分布式锁的实现:
set(lockKey, value, "NX", "PX", time)
value为任意值,而time是毫秒数。每次获取lastRefillTime之前都需要通过该命令判断返回值是否为OK,OK表示获得锁。并且设置过期时间,过期表示锁自动释放。
PS:此处还有一种基于redis的订阅/发布(sub/pub)模式的分布式锁。线程安全
线程安全:每个JVM(进程)内的所有线程,对于获得锁应getLock到释放锁releaseLock之间的代码应加关键字
synchronized
,锁住这块代码避免了一个节点内的所有并发都去竞争分布式锁。(可避免分布式锁被过度竞争,将锁竞争的压力分散到各个节点中去,甚至在竞争分布式锁的时候增加竞争间隔来减轻压力)
这有点像加个滤网,先内部竞争,优秀的人才可以去外部竞争。(后来我才知道这叫层次锁Hierarchical Lock,学至深处,万物皆然)
第一次改进(去除分布式锁)
在获取时间锁,到填充令牌的过程使用lua实现,这里利用了redis执行lua的原子性。
lua代码如下:
Redis执行lua注意要点:如上代码会报错:
解析见stackoverflow
Redis tries to protect itself against such cases by blocking any write command (such as DEL) if it is executed after a random command (e.g. SCAN but also TIME, SRANDMEMBER and similar).
像TIME
这种带有随机性的命令,在lua中,得到的结果不能作用于写命令。
第二次改进(将时间由外部传入)
外部传入的时间同时代表nowTime和newRefilledTime,那么newRefilledTime会比实际的newRefilledTime少。
Redis正常的情况下,误差目测为2ms以内。
第三次改进(利用redis的expire)
以上设计都不完美,再次修改lua脚本:
很明显,如此设计去掉了时间参数的限制,老实说,我是受github上一个单机令牌桶算法的影响,跳不出【上次更新令牌时间】这个桎梏,忽略了redis的expire的特性。
大约流程为:
Redis集群的注意要点
对于一个令牌桶,涉及到的配置有:
- 令牌桶令牌数
- 令牌补充间隔
- 上次补充令牌时间[利用了expire可以省掉此配置]。
如果3个配置都需要一个key,那么在redis集群对于一次补充令牌操作,便不能进行批量操作(即利用lua脚本进行令牌补充)了。
此处可设计为一个Hashmap,1个key然后3个field,便可以在集群里面将补充令牌封装成lua。
有个问题是:对于令牌数放Map里面执行decr可以使用HINCRBY -1或者封装成lua操作。
redis执行lua是原子操作,可以避免使用分布式锁。
注意:redis调用lua应该是简单的redis操作,避免阻塞其他的redis操作过久。(因为redis执行lua的原子性)
猜测一下Redis Cluster执行涉及多个key的原子操作的设计原理
- 首先理解slot,slot是redis通过
crc16(key) mod nodeNum
来确定key应该在哪个节点,在redis中slot不存在物理概念,只是用来确定key所属的节点【分片】。 - 为什么跨节点不能执行原子事务?
猜测:redis集群是多个节点都部署了redis,节点间redis是多进程,虽然是redis是单线程,但是每个进程至少有一个线程。如果没有分布式锁,或者类似的机制,已经不能保证跨节点的原子事务。 - 为什么必须同一个slot的key才允许原子操作?
猜测:同一个节点的确可以保证原子性,但是如果2个key落在同一个节点的不同的slot,如果以后扩充或者减少redis集群的节点,都会导致节点拥有的slot的改变,从而导致原来同一个节点的key分片到不同节点。这样一来,坑就大了。集群节点数量改变会导致原来的代码挂掉。
写在最后
强烈建议使用Redis单实例来做,依赖可以避免Redis的事务问题;二来Redis单实例效率比集群高不少(毕竟少了一次Hash定位)。