Redis实现限流的几种方式
参考文章:https://zhuanlan.zhihu.com/p/439093222
https://mp.weixin.qq.com/s/zf9uqfJfRYvmSVXUQofF2A
https://www.cnhackhy.com/156593.htm
互联网应用往往是高并发的场景,互联网的特性就是瞬时、激增,比如鹿晗官宣了,此时,如果没有流量管控,很容易导致系统雪崩。
而限流是用来保证系统稳定性的常用手段,当系统遭遇瞬时流量激增,很可能会因系统资源耗尽导致宕机,限流可以把一超出系统承受能力外的流量直接拒绝掉,保证大部分流量可以正常访问,从而保证系统只接收承受范围以内的请求。
我们常用的限流算法有:漏桶算法、令牌桶算法。
漏桶算法
漏桶算法很形象,我们可以想像有一个大桶,大桶底部有一个固定大小的洞,Web请求就像水一样,先进入大桶,然后以固定的速率从底部漏出来,无论进入桶中的水多么迅猛,漏桶算法始终以固定的速度来漏水。
对应到Web请求就是:
- 当桶中无水时表示当前无请求等待,可以直接处理当前的请求;
- 当桶中有水时表示当前有请求正在等待处理,此时新来的请求也是需要进行等待处理;
- 当桶中水已经装满,并且进入的速率大于漏水的速率,水就会溢出来,此时系统就会拒绝新来的请求;
令牌桶算法
令牌桶跟漏桶算法有点不一样,令牌桶算法也有一个大桶,桶中装的都是令牌,有一个固定的“人”在不停的往桶中放令牌,每个请求来的时候都要从桶中拿到令牌,要不然就无法进行请求操作。
- 当没有请求来时,桶中的令牌会越来越多,一直到桶被令牌装满为止,多余的令牌会被丢弃
- 当请求的速率大于令牌放入桶的速率,桶中的令牌会越来越少,直止桶变空为止,此时的请求会等待新令牌的产生
漏桶算法 VS 令牌桶算法
- 漏桶算法是桶中有水就需要等待,桶满就拒绝请求。而令牌桶是桶变空了需要等待令牌产生;
- 漏桶算法漏水的速率固定,令牌桶算法往桶中放令牌的速率固定;
- 令牌桶可以接收的瞬时流量比漏桶大,比如桶的容量为100,令牌桶会装满100个令牌,当有瞬时80个并发过来时可以从桶中迅速拿到令牌进行处理,而漏桶的消费速率固定,当瞬时80个并发过来时,可能需要进行排队等待;
介绍了算法,接下来我们介绍下Redis实现限流的几种方式。
第一种:基于Redis的setNX的操作(固定时间算法)
我们在使用Redis的分布式锁的时候,大家都知道是依靠了setNX的指令,在CAS(Compare and swap)的操作的时候,同时给指定的key设置了过期实践(expire),我们在限流的主要目的就是为了在单位时间内,有且仅有N数量的请求能够访问我的代码程序。所以依靠setnx可以很轻松的做到这方面的功能。
比如我们需要在10秒内限定20个请求,那么我们在setnx的时候可以设置过期时间10,当请求的setnx数量达到20时候即达到了限流效果。代码比较简单就不做展示了。
当然这种做法的弊端是很多的,比如当统计1-10秒的时候,无法统计2-11秒之内,如果需要统计N秒内的M个请求,那么我们的Redis中需要保持N个key等等问题。
第二种:基于Redis的数据结构zset(滑动时间算法)
其实限流涉及的最主要的就是滑动窗口,上面也提到1-10怎么变成2-11。其实也就是起始值和末端值都各+1即可。
而我们如果用Redis的list数据结构可以轻而易举的实现该功能
我们可以将请求打造成一个zset数组,当每一次请求进来的时候,value保持唯一,可以用UUID生成,而score可以用当前时间戳表示,因为score我们可以用来计算当前时间戳之内有多少的请求数量。而zset数据结构也提供了range方法让我们可以很轻易的获取到2个时间戳内有多少请求(解决了第一种方案中无法统计2-11秒的问题)。
示例代码:
using System; using System.Threading; using ServiceStack.Redis; namespace IPCounter { class Program { static void Main(string[] args) { LimitRequest1(); Console.WriteLine("Hello World!"); } public static long ToUnixTimestampBySeconds(DateTime dt) { DateTimeOffset dto = new DateTimeOffset(dt); return dto.ToUnixTimeSeconds(); } /// <summary> /// 基于redis的zset实现 /// </summary> static void LimitRequest1() { RedisClient client = new RedisClient("1633com@192.168.1.128:6379"); string key = "aa"; for (int i = 0; i < 100; i++) { long currentTime = ToUnixTimestampBySeconds(DateTime.Now); if (client.ContainsKey(key)) { var count = client.GetRangeFromSortedSetByHighestScore(key, currentTime - 10, currentTime).Count; //10秒内10次,平均1次/秒 if (count > 10) { Console.WriteLine("您的请求频率太高了"); Console.ReadLine(); } count = client.GetRangeFromSortedSetByHighestScore(key, currentTime - 60, currentTime).Count; //1分钟30次 if (count > 30) { Console.WriteLine("您的请求频率太高了"); Console.ReadLine(); } count = client.GetRangeFromSortedSetByHighestScore(key, currentTime - 120, currentTime).Count; //2分钟50次 if (count > 50) { Console.WriteLine("您的请求频率太高了"); Console.ReadLine(); } } string value = Guid.NewGuid().ToString(); long score = currentTime; client.AddItemToSortedSet(key, value, score); //清除2分钟之前的记录 var list = client.GetRangeFromSortedSetByHighestScore(key, 0, currentTime - 65); client.RemoveItemsFromSortedSet(key, list); Thread.Sleep(500); } } static void LimitRequest() { RedisClient client = new RedisClient("1633com@192.168.1.128:6379"); string key = "limitRate"; var result = client.PopItemFromList(key); if (result == null) { Console.WriteLine("系统繁忙,请稍后再试"); } else { Console.WriteLine("访问成功"); } } /// <summary> /// 比如我们速率限制是1分钟100个,那么就处理为1分钟内,桶中就只有100个令牌。 /// </summary> static void AddTokenToBucket() { string key = "limitRate"; RedisClient client = new RedisClient("1633com@192.168.1.128:6379"); var count = client.GetListCount(key); for(var i=0;i<100-count;i++) //需要判断原来是否还有剩余,有则相应扣减,确保桶中只有100个令牌 { client.AddItemToList(key, Guid.NewGuid().ToString()); } } } }
通过上述代码可以做到滑动窗口的效果,并且能保证每N秒内至多M个请求,缺点就是zset的数据结构会越来越大。实现方式相对也是比较简单的。
第三种:基于Redis的令牌桶算法
令牌桶算法提及到输入速率和输出速率,当输出速率大于输入速率,那么就是超出流量限制了。
也就是说我们每访问一次请求的时候,可以从Redis中获取一个令牌,如果拿到令牌了,那就说明没超出限制,而如果拿不到,则结果相反。
依靠上述的思想,我们可以结合Redis的List数据结构很轻易的做到这样的代码,只是简单实现依靠List的leftPop方法来获取令牌。
示例代码:
// 输出令牌
static void LimitRequest() { RedisClient client = new RedisClient("1633com@192.168.1.128:6379"); string key = "limitRate"; var result= client.PopItemFromList(key); if(result==null) { Console.WriteLine("系统繁忙,请稍后再试"); } else { Console.WriteLine("访问成功"); } }
再依靠定时任务,定时往令牌桶List中加入新的令牌(使用List的rightPush方法),当然令牌也需要唯一性,这里还是用UUID生成令牌:
// 10S的速率往令牌桶中添加UUID,保证唯一性
/// <summary> /// 比如我们速率限制是1分钟100个,那么就处理为1分钟内,桶中就只有100个令牌。 /// </summary> static void AddTokenToBucket() { string key = "limitRate"; RedisClient client = new RedisClient("1633com@192.168.1.128:6379"); var count = client.GetListCount(key); for(var i=0;i<100-count;i++) //需要判断原来是否还有剩余,有则相应扣减,确保桶中只有100个令牌 { client.AddItemToList(key, Guid.NewGuid().ToString()); } }
综上,代码实现起始都不是很难,针对这些限流方式我们可以在AOP或者filter中加入以上代码,用来做到接口的限流,最终保护系统的稳定。
更多分享,请大家关注我的个人公众号: