分布式限流算法对比:令牌桶、滑动窗口与漏桶的生产级实现
2026/6/11 11:33:04 网站建设 项目流程

分布式限流算法对比:令牌桶、滑动窗口与漏桶的生产级实现

一、流量突刺与雪崩效应:分布式限流的工程必要性

在微服务架构中,一个服务的过载可能引发级联故障——上游超时导致重试,重试加剧下游压力,最终形成雪崩。限流是防止雪崩的第一道防线:当请求量超过服务承载能力时,主动拒绝多余请求,保护核心链路。

单机限流(如 Guava RateLimiter)只能保护单个实例,在分布式环境下,多个实例的限流阈值之和才是全局限流容量。但各实例的负载不均,简单的"每实例限流 = 全局限流 / 实例数"无法保证精确的全局控制。分布式限流的核心挑战是:在多实例环境下实现精确的全局限流,同时保证低延迟和高可用。

二、三种限流算法的底层机制对比

2.1 令牌桶(Token Bucket)

令牌桶以固定速率向桶中放入令牌,请求需要消耗令牌才能通过。桶有容量上限,满时新令牌被丢弃。令牌桶允许突发流量——当桶中有积攒的令牌时,可以瞬间处理一批请求。

2.2 漏桶(Leaky Bucket)

漏桶以固定速率处理请求,多余的请求在桶中排队等待。桶满时新请求被拒绝。漏桶的输出速率恒定,无法应对突发流量。

2.3 滑动窗口(Sliding Window)

滑动窗口统计最近一个时间窗口内的请求量,超过阈值则拒绝。相比固定窗口,滑动窗口解决了窗口边界处的"两倍突发"问题。

flowchart TD A[请求到达] --> B{限流算法选择} B -->|令牌桶| C[桶中有令牌?] C -->|是| D[消耗令牌, 放行] C -->|否| E[拒绝/排队] B -->|漏桶| F[桶未满?] F -->|是| G[入桶排队, 固定速率处理] F -->|否| H[拒绝] B -->|滑动窗口| I[窗口内请求数 < 阈值?] I -->|是| J[记录请求, 放行] I -->|否| K[拒绝]

三、生产级分布式限流的代码实现

3.1 基于 Redis + Lua 的令牌桶限流器

/** * 基于 Redis + Lua 脚本的分布式令牌桶限流器 * Lua 脚本保证令牌获取和扣减的原子性 */ @Component public class RedisTokenBucketLimiter { private final StringRedisTemplate redisTemplate; private final DefaultRedisScript<Long> tokenBucketScript; public RedisTokenBucketLimiter(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; this.tokenBucketScript = new DefaultRedisScript<>(); this.tokenBucketScript.setScriptText(buildLuaScript()); this.tokenBucketScript.setResultType(Long.class); } /** * 尝试获取令牌 * @return 剩余令牌数,-1 表示获取失败(被限流) */ public long tryAcquire(String key, long capacity, long refillRate, long refillIntervalMs, long requestedTokens) { long now = System.currentTimeMillis(); Long remaining = redisTemplate.execute( tokenBucketScript, List.of(key), String.valueOf(capacity), String.valueOf(refillRate), String.valueOf(refillIntervalMs), String.valueOf(requestedTokens), String.valueOf(now) ); return remaining != null ? remaining : -1; } /** * Lua 脚本:原子性地计算令牌补充和消耗 * 避免分布式环境下的竞态条件 */ private String buildLuaScript() { return """ local key = KEYS[1] local capacity = tonumber(ARGV[1]) local refill_rate = tonumber(ARGV[2]) local refill_interval = tonumber(ARGV[3]) local requested = tonumber(ARGV[4]) local now = tonumber(ARGV[5]) local bucket = redis.call('HMGET', key, 'tokens', 'last_refill') local tokens = tonumber(bucket[1]) local last_refill = tonumber(bucket[2]) -- 首次请求:初始化桶为满 if tokens == nil then tokens = capacity last_refill = now end -- 计算自上次补充以来应添加的令牌数 local elapsed = now - last_refill local refill_count = math.floor(elapsed / refill_interval) * refill_rate if refill_count > 0 then tokens = math.min(capacity, tokens + refill_count) last_refill = last_refill + math.floor(elapsed / refill_interval) * refill_interval end -- 尝试消耗令牌 if tokens >= requested then tokens = tokens - requested redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill) redis.call('PEXPIRE', key, refill_interval * 2) return tokens else -- 令牌不足,更新补充时间但不扣减 redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill) redis.call('PEXPIRE', key, refill_interval * 2) return -1 end """; } }

3.2 滑动窗口限流器

/** * 基于 Redis Sorted Set 的滑动窗口限流器 * 每个请求以时间戳为 score 插入 ZSet,统计窗口内请求数 */ @Component public class RedisSlidingWindowLimiter { private final StringRedisTemplate redisTemplate; /** * 滑动窗口限流判断 * @param key 限流 Key(如 "rate_limit:api:/orders") * @param windowMs 窗口大小(毫秒) * @param maxRequests 窗口内最大请求数 * @return true 表示放行,false 表示限流 */ public boolean tryAcquire(String key, long windowMs, long maxRequests) { long now = System.currentTimeMillis(); long windowStart = now - windowMs; // Lua 脚本保证原子性:清理过期记录 + 计数 + 插入新记录 DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText(""" local key = KEYS[1] local window_start = tonumber(ARGV[1]) local now = tonumber(ARGV[2]) local max_requests = tonumber(ARGV[3]) -- 清理窗口外的过期记录 redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start) -- 统计当前窗口内的请求数 local count = redis.call('ZCARD', key) if count < max_requests then -- 未超限:插入新请求记录 redis.call('ZADD', key, now, now .. ':' .. math.random(1000000)) redis.call('PEXPIRE', key, %d) return 1 else return 0 end """.formatted(windowMs)); script.setResultType(Long.class); Long result = redisTemplate.execute(script, List.of(key), String.valueOf(windowStart), String.valueOf(now), String.valueOf(maxRequests)); return result != null && result == 1; } }

3.3 限流策略组合与降级

/** * 限流策略组合器:本地限流 + 分布式限流 * 本地限流作为第一道防线,Redis 不可用时降级为纯本地限流 */ @Service public class HybridRateLimiter { private final RedisTokenBucketLimiter redisLimiter; private final ConcurrentHashMap<String, RateLimiter> localLimiters; /** * 混合限流:先本地快速判断,再分布式精确控制 * Redis 不可用时降级为本地限流,保证服务可用性 */ public boolean tryAcquire(String key, RateLimitConfig config) { // 1. 本地限流:快速失败,避免所有请求都打到 Redis RateLimiter localLimiter = localLimiters.computeIfAbsent(key, k -> RateLimiter.create(config.getLocalRate())); if (!localLimiter.tryAcquire()) { return false; } // 2. 分布式限流:精确的全局控制 try { long remaining = redisLimiter.tryAcquire( "rate_limit:" + key, config.getCapacity(), config.getRefillRate(), config.getRefillIntervalMs(), 1 ); return remaining >= 0; } catch (RedisConnectionFailureException e) { // Redis 不可用:降级为本地限流,记录告警 log.warn("Redis 不可用, 降级为本地限流: key={}", key); return true; // 本地已通过,放行 } } }

四、分布式限流的边界分析与架构权衡

Redis 单点瓶颈。所有限流请求都经过 Redis,当 QPS 超过 Redis 单节点承载能力(约 10 万 QPS)时,Redis 本身成为瓶颈。解决方案:按 Key 分片到多个 Redis 实例,或使用本地限流 + 定期同步的近似方案。

时钟偏移问题。滑动窗口依赖时间戳,分布式环境下不同节点的时钟可能有毫秒级偏移。对于秒级窗口影响不大,但对于毫秒级窗口可能导致限流不精确。建议使用 Redis 的TIME命令获取服务端时间。

令牌桶的突发容忍度。令牌桶允许突发流量,这在某些场景下是优势(如批处理任务启动),但在另一些场景下是风险(如数据库连接池保护)。如果需要严格限制请求速率,应使用漏桶而非令牌桶。

适用边界:令牌桶适合 API 网关限流(允许突发),漏桶适合下游资源保护(恒定速率),滑动窗口适合精确计数场景(如"每分钟不超过 100 次")。生产中建议组合使用:网关层令牌桶 + 服务层滑动窗口。

五、总结

分布式限流是微服务稳定性保障的核心手段。令牌桶允许突发但控制平均速率,漏桶输出恒定但无法应对突发,滑动窗口精确计数但内存开销较大。基于 Redis + Lua 的实现保证了原子性,本地限流 + 分布式限流的混合方案在 Redis 不可用时仍能提供基本保护。选型时应根据业务场景的突发容忍度和精确度要求选择合适的算法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询