请求量限制方法-使用本地Cache记录当前请求量[坑]

  有个需求:需要限制每个账户请求服务器的次数(该次数可以配置在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         }
View Code

至此,完毕。

 

------备注----------------

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()方法无论有没有原值,一律能覆盖原来的项,不返回任何。

 

posted on 2015-04-26 00:08  nlh774  阅读(652)  评论(0编辑  收藏  举报