为什么不用 INCR 做固定窗口在高并发场景下我们经常需要实现“限流”机制比如控制某个接口在 1 秒内最多只能被调用 100 次。一种常见的想法是用 Redis 的INCR命令配合过期时间做一个“固定窗口”计数器。但如果你真的在生产环境这么用可能会踩到一个大坑——窗口边界问题。今天我们就来拆解这个经典问题看看为什么INCR不适合做固定窗口以及正确的替代方案是什么。## 什么是固定窗口算法固定窗口算法Fixed Window是最简单的限流模型。它的核心思想是把时间切成固定长度的“窗口”比如 1 秒、1 分钟每个窗口内只允许一定数量的请求通过。窗口结束时计数器重置。举个栗子我们设定“1 秒内最多 5 个请求”。那么时间窗口就是 [0,1), [1,2), [2,3)…… 每个窗口内请求数达到 5 就拒绝后续请求。### 用 INCR 实现固定窗口的“直觉”很多新手开发者会这样写伪代码python# 错误的示例用 INCR 做固定窗口import redisr redis.Redis()def is_allowed(user_id): # 用当前秒数作为 key窗口长度 1 秒 key frate_limit:{int(time.time())}:{user_id} count r.incr(key) # 原子自增 if count 1: r.expire(key, 1) # 设置 1 秒过期 return count 5 # 限流阈值 5这段代码看起来“对”吗INCR原子递增EXPIRE确保窗口到期自动消失。那问题出在哪我们来看一个典型场景。## INCR 固定窗口的致命缺陷窗口边界流量尖峰假设两个请求刚好落在窗口边界上- 请求 A时间 0.99 秒窗口 [0,1) 计数为 5被拒绝- 请求 B时间 1.01 秒窗口 [1,2) 计数为 0被允许这看起来没问题。但真正的灾难发生在大量请求集中在窗口边界时假设限流阈值是 5 次/秒。如果攻击者在 0.99 秒发送 5 个请求然后在 1.01 秒又发送 5 个请求那么实际上在 0.2 秒内0.99 到 1.01系统就承受了 10 个请求这完全绕过了限流限制。为什么因为固定窗口的“窗口”是硬边界流量可以卡在边界处“叠罗汉”。这就像一个门卫只看整点时间却不管你在 11:59 和 12:01 连续闯入。### 更直观的比喻想象一个停车场规则是“每小时内只能停 10 辆车”。固定窗口相当于你在 8:59 停了 10 辆然后 9:01 又停了 10 辆——实际上 2 分钟里停了 20 辆。这显然违背了“每小时 10 辆”的初衷。## 代码验证复现边界问题让我们用 Python 模拟这个场景。我们将创建两个线程分别冲击两个相邻窗口的边界pythonimport threadingimport timeimport redisr redis.Redis()def simulate_burst(): 模拟边界流量突增 # 线程1在窗口A的末尾第0.9秒发送5个请求 def thread1(): time.sleep(0.9) for _ in range(5): key fwindow:{int(time.time())} count r.incr(key) if count 1: r.expire(key, 1) print(f线程1时间 {time.time():.2f}窗口 {int(time.time())}计数 {count}) # 线程2在窗口B的开始第1.1秒发送5个请求 def thread2(): time.sleep(1.1) for _ in range(5): key fwindow:{int(time.time())} count r.incr(key) if count 1: r.expire(key, 1) print(f线程2时间 {time.time():.2f}窗口 {int(time.time())}计数 {count}) t1 threading.Thread(targetthread1) t2 threading.Thread(targetthread2) t1.start() t2.start() t1.join() t2.join()if __name__ __main__: print(模拟固定窗口边界突增...) simulate_burst() # 输出示例 # 线程1时间 0.91窗口 1700000000计数 1~5被允许 # 线程2时间 1.12窗口 1700000001计数 1~5被允许 # 但实际在0.9~1.12秒内总共10个请求通过运行这段代码你会发现两个线程总共通过了 10 个请求但时间跨度只有 0.22 秒——远小于 1 秒窗口。这就是固定窗口的边界漏洞。## 为什么 INCR 不是好方案深层原因除了边界问题INCR还有以下技术缺陷### 1. 原子性不等于一致性INCR本身是原子的但结合EXPIRE时存在竞态条件。如果两个请求同时执行INCR然后都判断count 1就会设置两次过期时间但第二次会覆盖第一次。虽然 Redis 的单线程模型避免了并发冲突但INCR和EXPIRE之间不是原子操作理论上存在窗口永不失效的风险虽然概率极低。### 2. 无法处理分布式场景如果限流逻辑在多个应用实例上执行每个实例都生成key int(time.time())由于时间同步问题时钟漂移不同机器可能对“当前窗口”有不同理解。比如机器A认为当前是1700000000机器B认为是1700000001导致限流失效。### 3. 滑动窗口 vs 固定窗口固定窗口本质上是一种粗糙的算法。更好的方案是滑动窗口比如用 Redis 的ZSET存储每个请求的时间戳通过ZREMRANGEBYSCORE删除过期请求再用ZCARD统计当前窗口内的请求数。这样可以精确控制任意 1 秒内的请求数而不是每个整秒。### 4. INCR 的“非自然”过期行为INCR本身不设过期时间你需要手动EXPIRE。如果 Redis 宕机或 key 未正确设置过期计数器会永久存在导致限流“卡死”。而像RedisRateLimiter等成熟方案会使用MULTI/EXEC事务保证原子性。## 正确方案用 ZSET 实现滑动窗口下面是一个基于 Redis 有序集合Sorted Set的滑动窗口限流实现pythonimport timeimport redisr redis.Redis()def sliding_window_rate_limit(user_id, max_requests5, window_seconds1): 滑动窗口限流器 :param user_id: 用户标识 :param max_requests: 窗口内最大请求数 :param window_seconds: 窗口长度秒 :return: True 表示允许False 表示拒绝 key fsliding:{user_id} now time.time() window_start now - window_seconds # 使用事务保证原子性 with r.pipeline() as pipe: while True: try: # 监视 key防止并发修改 pipe.watch(key) # 删除窗口外的旧记录 pipe.zremrangebyscore(key, 0, window_start) # 统计当前窗口内请求数 current_count pipe.zcard(key) if current_count max_requests: # 添加当前请求时间戳作为 score 和 member pipe.zadd(key, {str(now): now}) # 设置 key 的过期时间防止内存泄漏 pipe.expire(key, window_seconds * 2) pipe.execute() return True else: pipe.unwatch() return False except redis.exceptions.WatchError: # 如果被其他客户端修改重试 continue# 测试if __name__ __main__: for i in range(10): allowed sliding_window_rate_limit(user123, max_requests3) print(f请求 {i1}: {允许 if allowed else 拒绝}) time.sleep(0.2) # 模拟 0.2 秒间隔这个方案的关键点- 使用ZREMRANGEBYSCORE清理过期数据- 用ZADD记录每个请求的时间戳- 用ZCARD统计窗口内请求数- 通过WATCH和事务保证并发安全相比INCR滑动窗口虽然实现更复杂但能精确控制任意时间窗口内的流量不会出现边界突增问题。## 总结INCR做固定窗口看似简单实则隐患重重窗口边界流量突增会导致实际限流效果远低于预期再加上原子性、分布式时钟漂移等问题它只适合对精度要求极低的场景比如统计点赞数而不是限流。在实际生产环境中建议优先选择1.Redis 官方推荐的滑动窗口方案基于 ZSET 或 Lua 脚本2.成熟限流组件如Guava RateLimiter令牌桶、nginx limit_req漏桶3.中间件方案如 Sentinel、Resilience4j记住限流算法没有银弹但至少我们知道了为什么不能拿INCR当锤子。下次有人问“为什么不用 INCR 做固定窗口”你可以微笑着告诉他因为我们要的是“平滑限流”而不是“边界狂欢”。