后端服务限流架构:从单机令牌桶到分布式全局限流
后端服务限流架构从单机令牌桶到分布式全局限流一、限流不是设个 QPS 上限就完事了在实习期间第一次接触限流我写了一个简单的计数器用 Redis 记录每分钟的请求数超了就拒绝。这个方案运行了三天在第四天的一次流量峰值中彻底翻车——因为计数器边界问题第 59 秒和第 1 秒之间只有两秒的间隔但计数器在两分钟后都会重置这两秒内可以涌入双倍的请求。这就是限流中最经典的一个陷阱固定窗口计数器的边界问题。它提醒我们限流不是一个简单的数字比大小而是一个需要精确时间窗口的工程问题。在这篇文章中我会从单机令牌桶讲到分布式滑动窗口完整覆盖限流架构的三个演进阶段让你理解每种方案的数学原理和工程边界。flowchart TB A[限流需求] -- B{流量规模} B --|单机 1000 QPS| C[单机限流] B --|分布式 10000 QPS| D[分布式限流] C -- C1[计数器] C -- C2[滑动窗口] C -- C3[令牌桶] C -- C4[漏桶] D -- D1[Redis Lua 滑动窗口] D -- D2[Redis 令牌桶] D -- D3[Sentinel 集中式] C1 -- E{边界问题?} C2 -- E C3 -- E{支持突发?} C4 -- E E --|有缺陷| F[升级到滑动窗口/令牌桶] E --|满足需求| G[部署上线]二、从计数器到令牌桶的算法演进固定窗口计数器最简单的限流方案。维护一个时间窗口如 1 分钟内的计数器超了就拒绝。问题在于两个窗口的边界存在双倍流量漏洞在第 0:59 和第 1:00 之间两个窗口的计数器同时有效。滑动窗口固定窗口的自然改进。不按固定的时间边界重置而是维护一个环形数组记录每个小时间片如 1 秒内的请求数。当新请求到来时移除超出窗口范围的旧数据。时间复杂度 O(1)没有边界问题。令牌桶最灵活的限流方案。系统以恒定速率如每秒 100 个往桶里放令牌桶有容量上限如 200。每个请求需要消耗一个令牌才能通过。桶满则丢弃多余的令牌。令牌桶的核心优势是允许突发流量如果一段时间没人请求桶里积攒了 200 个令牌瞬间来 200 个请求都能通过消耗所有积攒的令牌。之后恢复到每秒 100 个的稳态速率。这种允许突发但限制长期均值的特性非常符合实际流量的波动规律。漏桶令牌桶的对称方案。请求进入一个队列漏桶以恒定速率漏出处理。漏桶强行平滑流量不允许任何突发——即使系统有空闲能力也必须按固定速率处理。三、单机与分布式限流的完整实现/** * 单机限流Guava RateLimiter 封装 * 基于令牌桶算法 */ Service public class LocalRateLimiter { // 平滑突发限流器允许短时间内的突发流量 private final RateLimiter burstLimiter; // 预热限流器启动后逐渐增加速率适合冷启动场景 private final RateLimiter warmupLimiter; public LocalRateLimiter() { // 每秒生成 100 个令牌 // 为什么用 create 而非 create(double, long, TimeUnit) // 不需要预热时create 更简洁 this.burstLimiter RateLimiter.create(100.0); // 预热模式3 秒预热期逐步增加到 100 QPS // 为什么需要预热服务刚启动时缓存未命中、 // 连接池未建立此时全额放量可能击垮下游 this.warmupLimiter RateLimiter.create(100.0, 3, TimeUnit.SECONDS); } public boolean tryAcquire() { // 非阻塞获取无令牌时立即返回 false return burstLimiter.tryAcquire(); } public boolean tryAcquireWithTimeout() { // 带超时的获取等待最多 500ms // 为什么需要等待对于非关键路径短暂等待比直接拒绝 // 能提升成功率同时避免无限期阻塞 return burstLimiter.tryAcquire(500, TimeUnit.MILLISECONDS); } } /** * 分布式限流基于 Redis Lua 的滑动窗口 * * 为什么用 Lua 脚本 * Redis 的 Lua 脚本是原子执行的 * 避免了读取计数 → 判断 → 写入三步操作的竞态条件。 */ Component public class DistributedRateLimiter { Autowired private StringRedisTemplate redisTemplate; /** * 滑动窗口限流的 Lua 脚本 * * 核心逻辑 * 1. 移除窗口外的旧记录 * 2. 统计当前窗口内的请求数 * 3. 判断是否超过限制 * * 为什么窗口大小是 1 秒、精度是毫秒 * 用 sorted setscore 存毫秒时间戳 * zremrangeByScore 可以精确删除过期数据。 */ private static final String SLIDING_WINDOW_SCRIPT local key KEYS[1]\n local window_ms tonumber(ARGV[1])\n // 窗口大小毫秒 local limit tonumber(ARGV[2])\n // 限制数 local now tonumber(ARGV[3])\n // 当前时间毫秒 local window_start now - window_ms\n // 窗口起始时间 // 清理窗口外的过期记录 // 为什么每次请求都清理避免过期记录无限堆积 redis.call(ZREMRANGEBYSCORE, key, 0, window_start)\n // 统计窗口内的请求数 local current redis.call(ZCARD, key)\n // 判断是否超过限制 if current limit then\n redis.call(ZADD, key, now, now .. - .. math.random())\n // 为什么 value 加随机数时间戳相同时 zadd 会覆盖 // 加随机后缀保证同一毫秒内的多个请求都有唯一记录 redis.call(PEXPIRE, key, window_ms)\n // 为什么用 PEXPIRE 而非 EXPIRE毫秒级精度 return {1, current 1}\n // 通过返回当前计数 else\n return {0, current}\n // 拒绝 end; /** * 检查是否允许请求通过 * * param key 限流键如 rate_limit:api:user:123 * param windowSeconds 时间窗口秒 * param limit 窗口内最大请求数 * return 是否允许 */ public boolean isAllowed(String key, int windowSeconds, int limit) { long now System.currentTimeMillis(); long windowMs windowSeconds * 1000L; DefaultRedisScriptList script new DefaultRedisScript(); script.setScriptText(SLIDING_WINDOW_SCRIPT); script.setResultType(List.class); SuppressWarnings(unchecked) ListLong result (ListLong) redisTemplate.execute( script, Collections.singletonList(key), String.valueOf(windowMs), String.valueOf(limit), String.valueOf(now) ); return result ! null result.size() 0 result.get(0) 1L; } /** * 获取剩余配额 * 用于在前端展示本小时还可查询 N 次 */ public long remainingQuota(String key, int windowSeconds, int limit) { long now System.currentTimeMillis(); long windowMs windowSeconds * 1000L; long windowStart now - windowMs; // 清理过期 统计 redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart); Long current redisTemplate.opsForZSet().zCard(key); return Math.max(0, limit - (current ! null ? current : 0)); } } /** * Redis 令牌桶的 Lua 实现 * 支持多实例共享令牌 */ Component public class RedisTokenBucket { Autowired private StringRedisTemplate redisTemplate; private static final String TOKEN_BUCKET_SCRIPT local key KEYS[1]\n local rate tonumber(ARGV[1])\n // 令牌生成速率个/秒 local capacity tonumber(ARGV[2])\n // 桶容量 local now tonumber(ARGV[3])\n // 当前时间秒 local requested tonumber(ARGV[4])\n // 请求令牌数 // 获取 bucket 信息tokens当前令牌数last_time上次更新时间 local bucket redis.call(HMGET, key, tokens, last_time)\n local tokens tonumber(bucket[1]) or capacity\n // 为什么默认给满桶首次请求时从满桶开始 // 避免冷启动时直接拒绝所有请求 local last_time tonumber(bucket[2]) or now\n // 计算应该补充的令牌数过去这段时间生成的令牌 local elapsed math.max(0, now - last_time)\n local new_tokens math.min(capacity, tokens elapsed * rate)\n // 判断令牌是否充足 local allowed new_tokens requested\n if allowed then\n new_tokens new_tokens - requested\n end\n // 更新桶状态 redis.call(HMSET, key, tokens, new_tokens, last_time, now)\n redis.call(EXPIRE, key, 60)\n // 1 分钟无请求则自动清理 return {allowed and 1 or 0, new_tokens}\n; public boolean tryAcquire(String key, double rate, int capacity) { long now System.currentTimeMillis() / 1000; DefaultRedisScriptList script new DefaultRedisScript(); script.setScriptText(TOKEN_BUCKET_SCRIPT); script.setResultType(List.class); SuppressWarnings(unchecked) ListLong result (ListLong) redisTemplate.execute( script, Collections.singletonList(key), String.valueOf(rate), String.valueOf(capacity), String.valueOf(now), 1 // 每次请求 1 个令牌 ); return result ! null result.size() 0 result.get(0) 1L; } }四、限流的工程边界与多级联动单机 vs 分布式的精度差异。单机限流如 Guava RateLimiter是完全精确的因为所有状态都在内存中。分布式限流如 Redis 滑动窗口是最终一致的——网络延迟可能导致瞬时误差几毫秒到几十毫秒但总体请求数在窗口内是准确的。多级限流的必要性。生产系统通常需要多层限流应用层Nginx 全局限流→ 网关层Sentinel/Gateway 接口限流→ 服务层Guava 单机限流→ 资源层数据库连接池限流。每一层兜住上一层的漏网之鱼。限流和熔断降级的配合。限流是拒绝超额请求熔断是检测下游异常后主动停止调用。两者需要联动当下游熔断时上游应该收紧限流阈值避免请求在熔断状态下大量堆积。热点参数限流。并不是所有请求是等价的。一个热门的商品 ID 被刷时全局限流会让所有商品都受影响。热点参数限流针对特定参数值如商品 ID做精确限流保护正常请求不受干扰。五、总结限流从固定窗口到滑动窗口再到令牌桶的演进本质上是对流量应该如何被限制这个问题的不断深入理解。固定窗口只看总次数滑动窗口看时间段内的次数令牌桶看速率和突发。在工程选型上单机场景用 Guava RateLimiter简单可靠分布式场景用 Redis Lua 滑动窗口原子操作无需额外锁如果需要全局精确的速率控制Redis 令牌桶提供了突发流量的兼容性。记住限流的 KPI 不是拒绝了多少请求而是在系统承载范围内通过了多少合理的请求。好的限流方案让正常用户无感知、恶意流量被干净截断。