简单限流
0.前言
限流算法在分布式领域中是一个经常被提起的话题,当系统处理的能力有限时,如何阻止计划外的请求继续对系统施压,是一个值得研究的问题。除了控制流量,限流还有一个应用,目的是控制用户行为,避免垃圾请求。比如在UGC(User Generated Content,用户原创内容)社区,用户发帖,回复,点赞行为都要严格受控,一般要严格限定某个行为在规定时间内被允许的次数。一旦超过次数,就需要采取一定的措施。比如禁止点击按钮,禁止发送请求,甚至禁号。
1. 使用方法
一个常见的、简单的限流策略,系统要限定用户的某个行为在指定时间内只能允许发生N次,如何使用Redis的数据结构来实现这个限流功能?
# 先定义接口
def is_action_allowed(user_id: str, action_key: str, period: int, max_count: int) -> bool:
"""
指定用户user_id的某个行为action_key在特定时间内period只允许发生的最多次数max_count
"""
# TODO 限流
return True
# 调用接口,一分钟只能只允许回复3个帖子
can_replay = is_action_allowed("leesure", "replay", 60, 3)
if can_replay:
do_replay() # 回复帖子
else:
raise ActionThresholdOverflow() # 异常
解决方案
这个限流需求中存在一个定宽的滑动窗口,可以通过zset结构中的score值,圈出这个时间窗口,窗口之外的数据都可以去掉,zset的value存放的是毫秒级别的时间戳。如图所示,用一个zset结构记录用户的行为历史,每一个行为都会作为zset的item保存下来,同时这个item还包含该行为发生时的时间戳,同一个用户的同一种行为用一个zset记录。

为了节省内存,只需要保存窗口内的行为记录。同时如果用户是冷用户,滑动时间窗口内的行为记录是空记录,那么这个zset就可以从内存中删除,不再占用空间。
通过统计滑动窗口内的行为数量和max_count进行比较就可以得到当前的行为是否被允许。代码实现如下:
import time
import redis
class RedisDemo:
def __init__(self):
self.conn_pool = redis.ConnectionPool(host="localhost", port=6379, max_connections=8)
self.client = redis.Redis(connection_pool=self.conn_pool, socket_timeout=3000, password="admin")
def is_action_allowed(self, user_id, action_key, period, max_count):
key = 'hist:%s:%s' % (user_id, action_key)
now_ts = time.time()
with self.client.pipeline() as pipe:
pipe.zadd(key, {now_ts: now_ts})
pipe.zremrangebyscore(key, 0, now_ts - period * 1000)
pipe.zcard(key)
pipe.expire(key, period + 1)
_, _, current_count, _ = pipe.execute()
return current_count <= max_count
if __name__ == '__main__':
redis_client = RedisDemo()
for i in range(10):
can_replay = redis_client.is_action_allowed('leesure', 'replay', 60, 4)
print(can_replay)
time.sleep(1)
运行效果:

这段代码的整体思路是:每一个行为到来时,都维护一次时间窗口,将时间窗口之外的记录全部清理掉,只保留窗口内的记录,zset中只有score值非常重要,value值没有特别的意义,只要保证它是唯一的就可以。
因为这几个连续的Redis操作都是针对一个Key的,使用pipeline可以显著提升Redis存储效率。
但是这种方案也有缺点,因为他要记录时间窗口内的行为记录,如果这个量很大,比如60s内操作不得超过10万次。那么就不适合做这样的限流。
高级限流-漏斗限流
0. 前言
漏斗限流是最常用的限流方法之一,这个算法的灵感来源于漏斗的结构。漏斗的容量是有限的,如果流出的速率大于流入的速率,那, 这个漏斗永远装不满,如果流出的速率小于流入的速率,那么漏斗一旦满了,就需要暂停流入,等待漏斗腾出一部分空间。
所以漏斗的剩余空间就代表:当前行为可以持续进行的数量,漏嘴的速率代表着系统允许该行为的最大速率。
1. 实现方法
单机漏斗算法:
import time
class Funnel:
def __init__(self, capacity: int, leaking_rate: float):
self.capacity = capacity # 漏斗容量
self.leaking_rate = leaking_rate # 漏嘴流出速率
self.left_quota = capacity # 剩余空间
self.pre_leaking_time = time.time() # 上一次漏水时间
def make_space(self):
now_ts = time.time()
d_ts = now_ts - self.pre_leaking_time
d_quota = d_ts * self.leaking_rate
if d_quota < 1:
return
# 增加剩余空间
self.left_quota += d_quota
# 记录漏水时间
self.pre_leaking_time = now_ts
if self.left_quota > self.capacity:
self.left_quota = self.capacity
def watering(self, quota) -> bool:
self.make_space()
if self.left_quota >= quota:
self.left_quota -= quota
return True
return False
funnels = {}
# leaking_rate : quota / s
def is_action_allowed(user_id: str, action_key: str, capacity: int, leaking_rate: float):
key = '%s:%s' % (user_id, action_key)
funnel = funnels.get(key)
if not funnel:
funnel = Funnel(capacity, leaking_rate)
funnels[key] = funnel
return funnel.watering(1)
if __name__ == '__main__':
for i in range(10):
can_do = is_action_allowed('leesure', 'replay', 3, 0.01)
time.sleep(1)
print(can_do)
Funnel对象的make_space()方法是漏斗算法的核心,在每次灌水前都会调用make_space()触发漏水,腾出空间,能腾出多少空间,取决于当前行为和上一次行为的时间差以及流出速度。而且Funnel对象占据的空间大小和行为频率无关,空间复杂度是O(1)。
执行结果

✋问题来了,分布式的漏斗算法如何实现,能不能使用Redis的基础数据结构实现?
观察Funnel对象的几个字段,发现可以将Funnel对象的内容按照字段存储到hash结构中,当有新的行为发生时,将hash结构字段取出来进行逻辑运算后,再将新的值回填到hash结构中就完成了一次行为频度检测。
但是有个问题,我们无法保证整个过程的原子性。从hash结构中取值,然后在内存中运算,再回填到hash结构中。这三个过程无法原子化。也就是说,需要进行适当的加锁控制,一旦加锁,就有加锁失败的可能,那么就需要选择重试或者放弃。如果重试,就会导致性能下降,如果放弃,就会影响用户体验,同时,代码复杂度也会提许多。
Redis Cell 就是为了解决上述问题的。
3. Redis Cell
Redis4.0提供了一个限流模块,Redis Cell。该模块也使用了漏斗算法,并提供了原子的限流指令。有这个指令,限流问题就变得简单了。
该模块只有一条指令cl.throttle。该指令的参数说明如下图所示.

该指令的意思是:允许用户的“回复”行为的频率为每60s最多30次。漏斗的初始容量为15,也就是说,一开始可以连续回复15个帖子,然后才开始受漏斗速率的影响。这个指令中的 漏水速率变为了2个参数,代替了之前的单个浮点数。用两个参数相除的结果来表达漏水速率。
> cl.throttle user1:replay 15 30 60
1) (integer) 0 # 0表示允许 1表示拒绝
2) (integer) 15 # 漏斗容量
3) (integer) 14 #漏斗剩余空间
4) (integer) -1 #如果被拒绝了,需要多长时间再试,单位:秒
5) (integer) 2 # 多长时间后,漏斗完全空出来(left_quota=capacity) 单位:秒
在执行限流指令时,如果被拒绝了,就需要重试或者放弃,cl.throttle指令的返回值中包含了重试时间,直接获取第四个值,然后sleep(time)就可以了。如果不想阻塞线程,可以使用异步定时任务来重试。
本文介绍了如何使用Redis实现简单的限流策略,通过ZSet数据结构记录用户行为并限制频率。此外,探讨了漏斗限流算法,提出单机实现的Funnel类,并讨论了分布式环境下使用Redis基础数据结构实现漏斗限流的挑战。最后,提到了Redis 4.0的RedisCell模块,它提供了一种原子性的限流指令,简化了分布式限流的实现。

被折叠的 条评论
为什么被折叠?



