流控是一种控制网络流量流入和流出的技术,常用于管理 API 的请求流。根据要求,流控可以基于用户 ID、IP 地址、调用的 API 类型进行设置,这使得流控不仅可以防止系统过载,还可以使资源在用户之间的分配更加均衡,保证用户的使用体验。
流控逻辑可以在客户端实现,也可以在服务端实现。通常客户端的流控更易于实现,但并不十分高效,因为恶意用户可以通过修改客户端代码而绕过流控逻辑。相比之下,服务端的流控更加高效,并且不会被轻易绕过,同时还可以保护服务端免受恶意用户的侵害。
⒈ 令牌桶算法(Token Bucket)
令牌桶算法会定期的向桶中添加一定数量的令牌,每个请求都会消耗一个令牌。当请求到达时,如果当前桶中还有令牌,则请求会被响应,同时会消耗一个桶中的令牌;如果桶中没有令牌,则会拒绝响应当前的请求。
令牌桶算法简单,易于理解和实现。令牌桶算法可以灵活调整桶的容量以及令牌生成的速率来适应多种应用场景,但需要对不同场景下的桶容量和令牌生成速率进行合理预估,否则无法进行有效的应对。令牌桶算法可以允许一定量的瞬时尖峰流量,但对于长时间出现的尖峰流量则会因为令牌耗尽而无法有效应对。
python
import threading
import time
class TokenBucket:
def __init__(self, capacity: int, token_add_rate: int) -> None:
"""
初始化令牌桶
:param capacity: 令牌桶的容量
:param token_add_rate: 令牌的生成速率,个/秒
"""
self.capacity = capacity
self.tokens = capacity
self.token_add_rate = token_add_rate
self.last_time = int(time.time())
self.lock = threading.Lock()
def add_tokens(self) -> None:
"""
向令牌桶中添加令牌
"""
current_time = int(time.time())
# 计算距离上次添加令牌所经过的时间
elapsed_time = current_time - self.last_time
self.last_time = current_time
# 计算需要添加的令牌数
added_tokens = elapsed_time * self.token_add_rate
# 令牌桶中的令牌数不能超过容量上限
self.tokens = min(self.capacity, self.tokens + added_tokens)
def process(self) -> bool:
"""
处理请求,同时消耗令牌
:return: bool
"""
with self.lock:
self.add_token()
if self.tokens > 0:
self.tokens -= 1
return True
else:
return False
⒉ 漏桶算法(Leaky Bucket)
漏桶算法中流量以固定的速度从桶中流出,新到达的请求会占用桶中的空间。当请求到达时,如果当前桶中还有空间,则请求将被响应;如果桶已满,则请求被拒绝。
在漏桶算法中,请求以固定的速率被响应,流量也以固定的速率流出,这可能会导致响应延时。另外,漏桶算法无法应对流量尖峰的场景,无论是瞬时尖峰还是长时间的尖峰。
python
import threading
import time
class LeadyBucket:
def __init__(self, capacity: int, leak_rate: int) -> None:
"""
漏桶初始化
:param capacity: 桶的容量
:param leaky_rate: 桶中令牌的流出速率,个/秒
"""
self.capacity = capacity
self.tokens = 0
self.leak_rate = leak_rate
self.last_time = int(time.time())
self.lock = threading.Lock()
def leak_tokens(self) -> None:
"""
从桶中流出令牌
"""
current_time = int(time.time())
# 计算距离上次流出令牌所经过的时间
elapsed_time = current_time - self.last_time
self.last_time = current_time
# 计算需要从桶中流出的令牌数
leaked_tokens = elapsed_time * self.leak_rate
self.tokens = max(0, self.tokens - leaked_tokens)
def process(self) -> bool:
"""
处理请求,同时增加桶中的令牌
:return: bool
"""
with self.lock:
self.leak_tokens()
if self.tokens < self.capacity:
self.tokens += 1
return True
else:
return False
⒊ 固定窗口算法(Fixed Window)
固定窗口算法将时间分割成固定的间隔或窗口,同时为每个窗口设置允许的最大请求数。一旦当前时间窗口处理的请求数量达到设置的上限,则在当前时间窗口内不再响应请求。
滑动窗口算法简单直观,易于理解,同时可以做到精确控制各个时间窗口中处理的请求数量。但这种算法无法有效处理尖峰流量,会出现由于当前时间窗口已经达到允许的最大请求数从而拒绝后续的请求,所以这种算法比较适合请求比较稀疏的场景。另外,这种算法在时间窗口的边界处容易出现流量尖峰的情形。
python
import time
class FixedWindow:
def __init__(self, capacity: int, window_size: int) -> None:
"""
固定窗口算法初始化
:param capacity: 每个时间窗口允许处理的请求的上限
:param window_size: 时间窗口:秒
"""
self.capacity = capacity
self.window_size = window_size
self.windows = {}
def clear_expired_windows(self) -> int:
"""
清理过期的时间窗口信息
:return: int
"""
# 计算当前所处的时间窗口
current_window = int(time.time()) // self.window_size
# 为当前窗口设置默认值(如果当前窗口在字典中不存在的话)
self.windows.setdefault(current_window, 0)
# 删除过期的窗口信息
expired_window_boundary = current_window - 2
for window_index in self.windows:
if window_index <= expired_window_boundary:
del self.windows[window_index]
return current_window
def process(self) -> bool:
"""
处理请求
:return: bool
"""
current_window = self.clear_expired_windows()
if self.windows[current_window] < self.capacity:
self.windows[current_window] += 1
return True
return False
⒋ 滑动窗口日志算法(Sliding Window Log)
滑动窗口日志算法的实现较为复杂,该方法会记录每个请求达到的时间点,当有新的请求到达时,算法首先会检查当前滑动窗口中已经处理的请求数,如果已经处理的请求数达到了窗口允许的最大请求数,则拒绝响应当前请求(也可以将请求入队,等待后续处理)。
所谓滑动窗口,指的是从当前时间点算起往前推一个时间窗口的时长。假设一个时间窗口的时长为 1 分钟,当前时间点为 16:04:15,则 16:03:16 ~ 16:04:15 为一个滑动窗口。
滑动窗口日志可以通过动态的调整窗口的大小来适应不同的应用场景。滑动窗口日志实时的记录和计算请求的处理情况,适合对实时性要求较高的场景;但实时记录和计算也增加了算法实现的复杂度以及系统的开销。
python
from collections import deque
import time
class SlidingLog:
def __init__(self, capacity: int, window_size: int) -> None:
"""
滑动窗口日志算法初始化
:param capacity: 每个时间窗口允许处理的请求的上限
:param window_size: 时间窗口:秒
"""
self.capacity = capacity
self.window_size = window_size
self.logs = deque()
def process(self) -> bool:
"""
处理请求
:return: bool
"""
current_time = time.time()
expired_log_boundary = current_time - self.window_size
# 日志中的时间戳小于过期时间的全部移除
while len(self.logs) > 0 and self.logs[0] < expired_log_boundary:
self.logs.popleft()
if len(self.logs) < self.capacity:
self.logs.append(current_time)
return True
return False
⒌ 滑动窗口计数算法(Sliding Window Counter)
滑动窗口计数算法结合了固定窗口算法和滑动窗口日志算法的特点,该算法跟踪滚动时间范围内的请求数量,在对当前时间窗口中的请求进行计数的同时还会考虑上一个时间窗口中已经处理的部分请求数量,实现了在时间窗口之间更加平滑的过度。
上图中,一个时间窗口为 1 分钟,每个时间窗口允许最多处理 60 个请求。00:00 ~ 01:00 的时间窗口已经处理了 54 个请求,在 01:10 时有新的请求到达,此时当前的时间窗口已经处理了 10 个请求。
按照滑动窗口计数算法的逻辑,判断当前新到达的请求是否应该被处理需要考虑上一个时间窗口的请求处理的情况:54 ×((60 - 10)÷ 60)+ 10 = 55。而一个时间窗口允许处理的请求数量最大为 60,所以当前到达的请求���以被处理。
滑动窗口计数算法提供了比较好的处理请求的平滑性,同时还可以实时跟踪和记录每个时间窗口中请求的处理情况,复杂度介于固定窗口和滑动窗口日志之间,但由于要记录至少两个时间窗口中的请求处理情况,所以需要消耗比较大的内存。
python
import math
import time
class SlidingWindow:
def __init__(self, capacity: int, window_size: int) -> None:
"""
滑动窗口计数算法初始化
:param capacity: 每个时间窗口允许处理的请求的上限
:param window_size: 时间窗口:秒
"""
self.capacity = capacity
self.window_size = window_size
self.windows = {}
def clear_expired_windows(self) -> int:
"""
清理过期的时间窗口信息
:return: int
"""
# 计算当前所处的时间窗口
current_window = int(time.time()) // self.window_size
# 为当前窗口设置默认值(如果当前窗口在字典中不存在的话)
self.windows.setdefault(current_window, 0)
# 删除过期的窗口信息
expired_window_boundary = current_window - 2
for window_index in self.windows:
if window_index <= expired_window_boundary:
del self.windows[window_index]
return current_window
def process(self) -> bool:
"""
处理请求
:return: bool
"""
# 当前时间窗口
current_window = self.clear_expired_windows()
# 上一个时间窗口
previous_window = current_window - 1
# 计算当前时间窗口已经经过的秒数
current_elapsed_seconds = int(time.time()) - current_window * self.window_size
# 按比例计算当前滑动窗口中已经处理的请求数
consumed_requests = self.windows.get(previous_window, 0) * (self.window_size - current_elapsed_seconds) \
/ self.window_size + self.windows[current_window]
if math.floor(consumed_requests) < self.capacity:
self.windows[current_window] += 1
return True
return False