上一篇文章讲到了利用令牌桶(token bucket)和漏桶(leaky bucket)算法进行访问频率限制,这些非常通用,但是也有一些问题,怎么解决(更准确说应该是缓解)呢?
回到最初问题的来源,其实只要把所有的请求都记录下来,每次新来一个请求看最近一段时间有没有超标即可。然而,一般并不能这么粗暴的记录每一条记录的时间戳,然后统计,因为内存可能扛不住,CPU可能也扛不住。
于是,有了固定窗口计数(Fixed Window Counter)这样的算法。算法做的就是每小段时间用一个值记录起始时间,另一个值记录个数即可。比如,限制是每1分钟60次,那么就在每个整分点(11:11:00)这样开始计数,每次来请求就增加记录次数,如果超过60次,就意味着频率超标了。然后每个这样的记录的过期时间是1分钟,这样,如果一个用户访问几次之后,不再访问,内存中对应的记录值也会清掉。事实上Github好多实现就是用了这样的算法。
这样的做法,明显有个不合理的部分。那就是频率峰值可能不均分,例如,虽然设置的是1分钟访问5次,但假设访问都集中在11:00:40
,这样子,11点11分确实只访问了5次。接下来,11:01:00
数据清除,11:01:15
秒有5次访问,也符合规范。然而, 11:00:40
到11:01:15
这段时间(不足1分钟),却能访问10次!
看来这样的方法也不行,问题在于:
- 不能在周期边界暴力清空
- 随着时间推移,确实需要清掉一些旧的不用了的数据
基于这两点,于是有了最终比较符合需求的算法:滑动窗口计数(Sliding window counters)。我突然意识到这个方法,是因为前段时间瞄了一下TCP协议(真是牛逼的协议啊),然后网上一查,发现早就被别人提出来了(见文末参考文档)。
算法思路在于,不是单纯地记录每次请求的时间戳(开头说了,可能太耗空间),而是把请求按照一段一段计数。比如, 1分钟5次这样的要求,可以把一分钟划分成60秒,然后每秒只用一个数字表示这一秒有多少个请求。这样,请求再多,也只需要60个数字记录(因为分成了60份)。同理,1小时100次,可以把时间划分成分钟,进行记录。
然后, 每次有新请求来的时候,删除已经没用的数据(超过限定期限的,比如上面说的1小时),然后算出时间戳,计数增加。如果没有超过限制,那么,就设置整个数据(下面示例中的“user_A")的过期时间(如果以后用户都不访问了, 这些数据会在过期之后被回收掉)。
一个用户的记录示例:
"user_A": {"11:00:00": 5, "11:01:00": 3}
份数划分的意义何在呢?显然首先是为了省内存(同时统计也快),其次是为了控制误差。事实上,这个算法是有误差的。比如假设1小时被划分成60份, 也就是每分钟一份,11:00:40
的访问会被记录在11点整上, 而12:00:10
会被记录在12点整上,这两个整点其实在不同的时间段,也就是说考虑12点整的访问时,已经不用去考虑11点整的访问量了。而实际上,11:00:40
和12:00:10
的相差是不足一个小时的,逻辑上它们的访问量应该算在同一个小时里。
不过,实际中,算法实现的时候,应该能接受这样的误差(如果不能接受,需要在清楚过期数据的时候,注意这样的边界问题,比如额外保留一份小时间段的数据。因为算法本身无法区分11:00:00
和11:00:40
这样的访问)。
这个算法设置好合理的划分(如果用redis,不要超过128,因为效率),自己估算好最大误差值(很显然,极端情况下,依然可以在同一个时间段内允许两倍的访问),应该可以在实际运用中起到比较好的效果了。
说了这么多,我在Github上搜了一下,似乎还没有Golang对应的实现。接下来,我会想办法抽时间实现一下。
更新:
实现过程中,滑动窗口计数的时候,发现太多需要改动一段一段计数的地方。感觉用hash不如序列化然后用string存取快。
实现地址:
restrictor
参考文章:
https://blog.figma.com/an-alternative-approach-to-rate-limiting-f8a06cf7c94c