有个需求:需要限制每个账户请求服务器的次数(该次数可以配置在DB,xml文件或其他)。单位:X次/分钟。若1分钟内次数<=X 则允许访问,1分钟内次数>X则不再允许访问。 这类需求很常见的,像请求Baidu Map Api服务接口等,都有这类限制,只是单位不同。
一般来说,接口的请求在服务器端都会有记录的,比如记在DB中,记录 账户、请求时间、请求信息、请求操作、服务器响应信息 等。所以逻辑上完全可以获取请求当前时间段的请求量(执行记录 的count()操作,在DB中为Sql : SELECT COUNT(*) FROM RequestRecordTable where Account = .. And ReqTime .. )。但这样不现实:为这么一个小功能竟然还要耗时去计算,完全浪费计算资源、内存资源,一旦请求量、记录数大增,那么性能肯定很差。
于是就想到了缓存:1.把该账户的最大请求量放在缓存里,永不过期。2.把该账户的1分钟以来的请求量放在缓存中,1分钟过期自动释放。 3.每来一次请求,则当前请求量+1,后与该账户的最大请求量比对,<=则允许,>则超过次数。为了简单起见,使用.Net 本地缓存。详见代码(该账户的最大请求量已获取到,若获取失败则读配置:
1 /// <summary> 2 /// 验证该账户当前请求量是否超过限制 3 /// </summary> 4 /// <param name="account">账户</param> 5 /// <param name="maxNumLimit">最大请求量,已获取到</param> 6 /// <returns>true-超过,false-未超过</returns> 7 public bool VerifyMaxNumLimit(string account, int maxNumLimit) 8 { 9 string currentSendNumCacheKey = string.Format("SysCode_{0}_CurrentSendNum", account); 10 object currentSendNumObj = HttpContext.Current.Cache[currentSendNumCacheKey]; 11 int currentSendNum = 0; 12 if (currentSendNumObj != null) //缓存存在 13 { 14 currentSendNum = (int)currentSendNumObj; 15 currentSendNum++; 16 HttpContext.Current.Cache[currentSendNumCacheKey] = currentSendNum; 17 } 18 else //缓存失效,已到期或者缓存问题。重新写入:目前请求次数 1次,过期时间 1分钟 19 { 20 currentSendNum = 1; 21 HttpContext.Current.Cache.Insert(currentSendNumCacheKey, 1,null, DateTime.Now.AddMinutes(1),Cache.NoSlidingExpiration); 22 } 23 24 return currentSendNum > maxNumLimit; 25 }
乍一看,完全没有问题,大功告成。可是,当你本地自己测试时,却发现 :前几次不超过限制量次数的调用接口都是成功的,但是一旦超过该请求量以后,哪怕是5分钟后,也无法在此请求。究其原因:VerifyMaxNumLimit 此后一直返回true=> 1分钟后缓存没有清除,仍然存在。
最终原因是什么??
1.是15行: HttpContext.Current.Cache[currentSendNumCacheKey] = currentSendNum;
这句代码看似只是修改缓存值。可实际上,这句代码等效于:HttpContext.Current.Cache.Insert(currentSendNumCacheKey, currentSendNum)!即覆盖缓存,并将缓存设置为永不过期(除非IIS应用程序池回收,或内存不足等外部情况)=》缓存不失效=》当前请求量不断++,所以 return currentSendNum > maxNumLimit 一直是true.
2.即使把1解决了,还有一个不易发现的BUG,不容易看出来,是多个线程使用并修改统一资源=》诱发并发问题:当同账号的请求1和请求2同时来临,可能请求1的line 16 与请求2的line21 是先后执行的。就出现了脏读写。
所以,需要解决:1.找到一个可修改缓存值又不修改缓存失效时间的方法(本地缓存HttpContext.Current.Cache 似乎无法实现,放弃,2.加锁或做成单例。
我公司正好有Redis组件,满足上述要去并且本身封装时即为线程安全的。所以最终我用了Redis来记录账户当前请求量。具体代码其实类似 上述代码段,只是把和HttpContext.Current.Cache 有关的地方全部改为Redis 相关语句。
至此结束,实现请求量限制。
经验:关于HttpContext.Current.Cache,有些代码看似平淡但是内部有说不清楚的作用。
以后还是要多写写代码,多积累经验,遇到的坑多了,才会吃一堑长一智。当然,若能得到高人指点或者自己之前在其他地方见过这个坑的相关介绍,那么能避免再好不过,少走弯路节省时间。 至于,多个请求同时请求、并发、共享资源的事情要小心,最好加锁(加锁会导致效率变差),这也是设计时要考虑好的,不然出了BUG难以调试重现出来。
------2015/10/08续集--------------------------
最近因为我这边因为Redis环境问题,导致我的程序-发送量限制有问题、不稳定,所以又研究了下不用Redis来实现的方法。
1.直接写sql查库,读出当前分钟内的发送量。
1 const string dateTimeFormat = "yyyy-MM-dd HH:mm"; 2 string sql = "Select Count(*) From Message With(Nolock) Where SystemId = " + systemId + " CreatedOn >= '" + 3 DateTime.Now.ToString(dateTimeFormat) + "' And CreatedOn <'" + DateTime.Now.AddMinutes(1).ToString(dateTimeFormat) + "'";
如果请求并发量不大、消息Message表不大或者定期归档,那么上述方法是可用的。当然,还需请DBA对照次sql增加数据库表索引(SystemId,CreatedOn),最好还要优化该sql。
但是我一直感觉这种方法太不好、小题大做、浪费性能。想用本地缓存做。
2.本地缓存做。
本地缓存实现的最大痛点是:难以实现缓存值自增修改与过期失效两个功能的兼得。所以,必须得解决或者绕过这个问题。
参考同事的程序、代码,找到了解决方法:将缓存值设置为 时间_计数 的形式,把时间直接写在缓存值中,代码自己判断是否是当前分钟的缓存。若当前时间-分钟在缓存中,则加1并判断是否过量;不是则直接修改缓存值为当前时间_数量1。若缓存不存在,则一定是当前分钟的第一次请求-一定不过量、新建缓存。
1 /// <summary> 2 /// 发送量缓存值:日期格式 3 /// </summary> 4 private const string DateTimeFormat = "yyyy-MM-dd HH:mm"; 5 6 /// <summary> 7 /// 发送量缓存值:日期和发送量的分隔符 8 /// </summary> 9 private const string CacheValueSplit = "_"; 10 11 /// <summary> 12 /// 账号是否过量请求 13 /// </summary> 14 /// <param name="accountCode">账号代码</param> 15 /// <param name="accountMaxSendNumLimit">账号每分钟的最大请求量</param> 16 /// <returns></returns> 17 public bool VerifySendNumLimit(string accountCode, int accountMaxSendNumLimit) 18 { 19 int currentSendNum = GetCurrentSendNum(accountCode); 20 if (currentSendNum != -1) //缓存未失效,请求次数+1并更新缓存 21 { 22 currentSendNum++; 23 SetCurrentSendNum(accountCode, currentSendNum); 24 } 25 else //缓存已过期释放,重新写入:请求次数 1次,默认过期时间 1分钟 26 { 27 currentSendNum = 1; 28 SetCurrentSendNum(accountCode, 1); 29 } 30 31 return currentSendNum <= accountMaxSendNumLimit; 32 } 33 34 /// <summary> 35 /// 获取当前一分钟内的请求量 36 /// </summary> 37 /// <param name="currentSendNumCacheKey">账号发送量缓存键</param> 38 private static int GetCurrentSendNum(string currentSendNumCacheKey) 39 { 40 //缓存键:AccountCode,示例:testSystem 41 //缓存值:yyyy-MM-dd HH:mm_Count,示例:2015-10-08 15:53_3 42 43 object cacheObj = Helper.GetCache(currentSendNumCacheKey); 44 if (cacheObj == null) 45 return -1; 46 47 string[] cacheValue = cacheObj.ToString().StringSplit(CacheValueSplit); //自己写的扩展方法,按CacheValueSplit分割成字符串数组 48 string minute = cacheValue[0]; 49 int requesNum = int.Parse(cacheValue[1]); 50 if (minute == DateTime.Now.ToString(DateTimeFormat)) 51 return requesNum; 52 else 53 return -1; 54 } 55 56 /// <summary> 57 /// 设置当前发送量,过期时间为一分钟 58 /// </summary> 59 /// <param name="currentSendNumCacheKey">账号发送量缓存键</param> 60 /// <param name="currentSendNum">要设置的当前发送量</param> 61 private static void SetCurrentSendNum(string currentSendNumCacheKey, int currentSendNum) 62 { 63 //缓存键:AccountCode,示例:testSystem 64 //缓存值:yyyy-MM-dd HH:mm_Count,示例:2015-10-08 15:53_3 65 66 Helper.SetCache(currentSendNumCacheKey, DateTime.Now.ToString(DateTimeFormat) + CacheValueSplit + currentSendNum, 1); 67 } 68 69 /// <summary> 70 /// 设置本地缓存 71 /// </summary> 72 /// <param name="key">缓存键</param> 73 /// <param name="obj">缓存值</param> 74 /// <param name="minutes">过期时间(分钟),可为空</param> 75 public static void SetCache(string key, object obj, int? minutes) 76 { 77 try 78 { 79 if(minutes.HasValue) 80 CacheHelper.Save(key, obj, null, DateTime.Now.AddMinutes(minutes.Value), Cache.NoSlidingExpiration); 81 else 82 CacheHelper.Save(key, obj); 83 } 84 catch (Exception ex) 85 { 86 new Response("本地缓存写入异常:"+ ex.Message); 87 return; 88 } 89 } 90 91 /// <summary> 92 /// 获取缓存值 93 /// </summary> 94 /// <param name="key">缓存键</param> 95 /// <returns>缓存值</returns> 96 public static object GetCache(string key) 97 { 98 try 99 { 100 return CacheHelper.Get(key); 101 } 102 catch (Exception ex) 103 { 104 new Response("本地缓存读取异常:" + ex.Message); 105 return null; 106 } 107 }
至此,完毕。
------备注----------------
1.使用本地缓存后,就不便使用负载、集群了。因为本地缓存只存在于单个应用程序域内,多机器、多网站应用程序域不能共享。若要支持负载,还是redis,mc 吧
2.即使把1解决了,还有一个不易发现的BUG,不容易看出来,是多个线程使用并修改统一资源=》诱发并发问题:当同账号的请求1和请求2同时来临,可能请求1的line 16 与请求2的line21 是先后执行的。就出现了脏读写。
此话错误。.Net 的HttpRuntime.Cache读写 内部封装了锁机制来处理并发、是线程安全的,用户无需再写。而Application貌似不是。 当然,若请求量并发量不大、作用不是很重要(比如只是计数限制量)的业务场景下,不考虑也罢。
3.分析一下这三种ASP.NET缓存过期策略。
◆永不过期。直接赋值缓存的Key和Value即可
◆绝对时间过期。DateTime.Now.AddSeconds(10)表示缓存在10秒后过期,TimeSpan.Zero表示不使用平滑过期策略。
◆变化时间过期(平滑过期)。DateTime.MaxValue表示不使用绝对时间过期策略,TimeSpan.FromSeconds(10)表示缓存连续10秒没有访问就过期。
Cache.Insert("Data1", "Data1"); //只要服务器内存够、没重启回收,缓存一直在
Cache.Insert("Data2", "Data2", null, DateTime.Now.AddSeconds(10), TimeSpan.Zero); //加入缓存10秒后过期,不管有没有访问
Cache.Insert("Data3", "Data3", null, DateTime.MaxValue, TimeSpan.FromSeconds(5)); //最后一次访问5秒后过期,若访问一直存在则一直不过期
4.Cache的Insert()Add()方法,两者都能向缓存中添加项。不同之处在于: Add()方法只能添加缓存中没有的项并返回项值,如果添加缓存中已有的项将失败(但不会抛出异常);而Insert()方法无论有没有原值,一律能覆盖原来的项,不返回任何。