限流技术与分布式实现

在高并发系统中,服务稳定性是至关重要的。面对突发流量、恶意请求或系统资源瓶颈,如果不加以限制,轻则导致响应延迟升高,重则引发雪崩效应,使整个系统瘫痪。因此,限流(Rate Limiting)成为保障系统可用性的重要手段之一。本文将深入探讨几种主流的限流算法——滑动窗口、漏桶、令牌桶,并进一步分析如何在分布式环境下实现高效、一致的限流机制。

一、为什么需要限流?

限流的核心目标是控制系统单位时间内的请求处理量,防止系统过载。它通常用于以下场景:

  • 保护后端服务:避免数据库、缓存等底层组件因过载而崩溃;
  • 防止恶意攻击:如高频刷接口、DDoS攻击等;
  • 资源公平分配:对不同用户或租户实施配额管理;
  • 保障SLA(服务等级协议):确保关键业务路径的资源优先级。

限流策略可以作用于多个层级:客户端、API网关、微服务内部等。无论在哪一层,其核心逻辑都依赖于某种限流算法。

二、主流限流算法详解

1. 固定窗口(Fixed Window)

固定窗口是最简单的限流方式。它将时间划分为固定长度的窗口(如 1秒),在每个窗口内统计请求数,若超过阈值则拒绝后续请求。

例如:限制每秒最多100次请求。从 00:00:00 到 00:00:01为第一个窗口,若在此期间收到 100 个请求,则第 101 个请求被拒绝;下一窗口从 00:00:01 开始重新计数。

优点:实现简单,性能高。

缺点:存在“边界问题”。假设在 00:00:00.9接收了 100 个请求,又在 00:00:01.1 接收了100 个请求,虽然总时间仅0.2秒,但两个窗口各自未超限,实际却在短时间内承受了 200 QPS 的压力,可能压垮系统。

因此,固定窗口适用于对精度要求不高的场景,但在生产环境中往往不够稳健。

2.滑动窗口(Sliding Window)

为解决固定窗口的边界问题,滑动窗口通过更细粒度的时间划分来平滑限流效果。

滑动窗口有两种常见实现方式:

  • 基于时间戳的滑动窗口:记录每个请求的时间戳,每次判断时剔除超出时间窗口的旧请求,再统计剩余数量。
  • 分片滑动窗口(Sliding Log或 Sliding WindowCounter):将大窗口划分为多个小格子(buckets),每个格子记录该时间段内的请求数。例如,1秒窗口划分为 10 个100ms的格子。每次只保留最近 10 个格子的数据,动态滑动。

以分片滑动窗口为例:设窗口大小为 1秒,划分为 10 个100ms 的桶。当前时间为t,我们只考虑[t-1s, t]范围内的请求。每个新请求到来时,更新对应时间桶的计数,并丢弃过期桶。最终限流判断基于所有有效桶的总和。

优点:比固定窗口更精确,能有效避免边界突刺。
缺点:实现复杂度较高,需维护多个时间桶;内存占用随窗口粒度增加而上升。

3.漏桶算法(Leaky Bucket)

漏桶算法将请求想象成水流,进入一个固定容量的“桶”中,桶以恒定速率“漏水”(即处理请求)。如果桶满了,新来的请求就会被丢弃。

关键参数:

  • 桶容量(burst capacity):允许突发的最大请求数;
  • 漏水速率(rate):单位时间处理的请求数。

漏桶的特点是输出速率恒定,即使有突发流量,也会被平滑处理。例如,设置速率为 100 QPS,桶容量为 50。那么即使瞬间涌入 200 个请求,前50 个可暂存,后续150个被拒绝;之后系统以 100 QPS 的速度匀速处理。

优点:输出平滑,适合对处理速率有严格要求的场景(如支付系统)。
缺点:无法应对合理突发流量。比如用户正常操作产生短时高峰,也可能被限流。

4. 令牌桶算法(Token Bucket)

令牌桶是漏桶的“反向”模型。系统以固定速率向桶中添加令牌,每个请求需要消耗一个令牌才能被处理。若桶中无令牌,则请求被拒绝。

关键参数:

  • 令牌生成速率(rate);
  • 桶容量(burstsize):最大可积攒的令牌数。

例如:速率100 QPS,桶容量 50。若系统空闲 1 秒,桶中会积攒 100 个令牌(但最多 50),此时可一次性处理 50 个突发请求。

优点:

  • 允许一定程度的突发流量;
  • 实现灵活,广泛应用于网络流量控制(如 TCP)、API 网关(如 Nginx、SpringCloud Gateway)。

缺点:若桶容量设置过大,可能在突发时仍造成后端压力。

三、算法对比总结

算法 是否允许突发 输出是否平滑 实现复杂度 适用场景
固定窗口 简单限流,容忍边界问题
滑动窗口 较平滑 高精度限流,如 API 调用频率控制
漏桶 需严格平滑输出的场景
令牌桶 否(输入可突发) 通用限流,兼顾突发与稳态

在实际工程中,令牌桶因其灵活性和良好平衡性,成为最常用的限流算法。

四、单机限流的Java 实现

下面我们用 Java 实现几种限流器,并结合 LeetCode 风格的问题进行演示。

LeetCode 风格题目:设计一个限流器,支持每秒最多处理 N 个请求。若请求超过限制,则返回 false;否则返回 true。

解法一:固定窗口(简单但有缺陷)

public class FixedWindowRateLimiter {
   private final int maxRequests;
   private final long windowSizeMs;
   private int currentRequests;
   private long windowStart;

   public FixedWindowRateLimiter(int maxRequests, long windowSizeMs) {
       this.maxRequests= maxRequests;
       this.windowSizeMs = windowSizeMs;
       this.currentRequests= 0;
       this.windowStart = System.currentTimeMillis();
   }

   public synchronized boolean allow() {
       long now = System.currentTimeMillis();
      if (now - windowStart > windowSizeMs) {
           //进入新窗口
           currentRequests =0;
           windowStart = now;
       }
      if (currentRequests < maxRequests) {
           currentRequests++;
           return true;
      }
       return false;
  }
}

该实现线程安全,但存在边界问题。

解法二:滑动窗口(基于时间戳)

import java.util.LinkedList;
import java.util.Queue;

public class SlidingWindowRateLimiter {
   private final int maxRequests;
   private final long windowSizeMs;
   private final Queue<Long> timestamps;

   public SlidingWindowRateLimiter(int maxRequests, long windowSizeMs) {
       this.maxRequests = maxRequests;
       this.windowSizeMs = windowSizeMs;
       this.timestamps = new LinkedList<>();
  }

   public synchronized boolean allow() {
       long now = System.currentTimeMillis();
       //清理过期请求
       while (!timestamps.isEmpty() && now - timestamps.peek() > windowSizeMs){
           timestamps.poll();
       }
       if (timestamps.size() < maxRequests) {
           timestamps.offer(now);
           return true;
       }
       return false;
   }
}

此方法精度高,但队列可能积累大量数据(尤其在高并发下),影响性能。

解法三:令牌桶(推荐)

我们可以借助 Guava 的 RateLimiter,但为了展示原理,手写一个简化版:

public class TokenBucketRateLimiter {
   private final double rate; // tokens per second
   private final int capacity;
   private double tokens;
   private long lastRefillTimestamp;

   public TokenBucketRateLimiter(double rate, intcapacity) {
       this.rate = rate;
       this.capacity = capacity;
       this.tokens = capacity;
       this.lastRefillTimestamp = System.currentTimeMillis();
   }

   public synchronized boolean allow() {
       refill();
       if(tokens >= 1) {
           tokens -=1;
           return true;
       }
       return false;
   }

   private void refill() {
       long now = System.currentTimeMillis();
       long elapsedMs = now - lastRefillTimestamp;
       if (elapsedMs > 0) {
           double newTokens = elapsedMs /1000.0 * rate;
           tokens = Math.min(capacity, tokens + newTokens);
           lastRefillTimestamp = now;
       }
   }
}

该实现支持突发,且性能较好。注意:实际生产中建议使用原子变量或更高效的并发结构(如 LongAdder)替代synchronized,以提升吞吐量。

五、分布式限流的挑战与方案

单机限流在单体架构中足够使用,但在微服务或集群环境下,各节点独立限流会导致整体流量失控。例如,10 个实例,每个限流 100 QPS,理论上系统可承受 1000 QPS,但如果所有流量打到其中一个实例,仍可能过载。

因此,分布式限流需要全局统一的计数器。核心挑战包括:

  • 一致性:多个节点需共享同一限流状态;
  • 性能:频繁读写中心化存储可能成为瓶颈;
  • 容错性:中心节点故障不应导致全局限流失效。

常见解决方案如下:

  1. 基于 Redis 的分布式令牌桶

Redis提供了原子操作(如 INCR、EXPIRE、Lua 脚本),非常适合实现分布式限流。

以滑动窗口为例,可使用 Redis 的 ZSET(有序集合)存储请求时间戳:

-- Lua 脚本:滑动窗口限流
local key = KEYS[1]
local window = tonumber(ARGV[1]) --窗口大小(毫秒)
local limit = tonumber(ARGV[2]) -- 最大请求数
local now = tonumber(ARGV[3])

--移除过期元素
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 获取当前请求数
local count = redis.call('ZCARD', key)

if count < limit then
   redis.call('ZADD', key, now, now, math.random())
   redis.call('EXPIRE',key, math.ceil(window /1000))
   return 1
else
   return 0
end

Java调用示例(使用 Jedis):

public boolean tryAcquire(String key, int limit, long windowMs) {
   long now = System.currentTimeMillis();
   String script =" ... "; // 上述Lua 脚本
   Object result = jedis.eval(script, Collections.singletonList(key), 
      Arrays.asList(String.valueOf(windowMs), String.valueOf(limit), String.valueOf(now)));
   return ((Long) result) == 1;
}

优点:精度高,支持滑动窗口; 缺点:ZSET 内存占用较大,高并发下可能影响 Redis性能。

2.Redis + 固定窗口(高性能方案)

若对精度要求不高,可使用 INCR + EXPIRE 实现固定窗口:

public boolean tryAcquire(String key, int limit, int windowSec) {
   String redisKey = "rate_limit:" + key;
   Long count = jedis.incr(redisKey);
   if (count == 1) {
       jedis.expire(redisKey, windowSec);
   }
   return count <= limit;
}

该方法简单高效,但存在边界问题。可通过“双窗口”技巧缓解:同时维护当前窗口和上一窗口的计数,按比例估算真实流量。

3. 本地缓存 +异步同步(混合限流)

为减少对 Redis 的依赖,可采用“本地令牌桶 + 中央协调”的混合模式:

  • 每个节点维护本地令牌桶;
  • 定期从Redis 获取全局配额,并按节点数均分;
  • 使用“预分配”或“租约”机制,避免频繁交互。

例如:总限流 1000 QPS,10 个节点,则每个节点初始获得100 QPS配额。当本地配额耗尽,向 Redis 申请新配额(如一次申请 10个)。这种方式在保证一定精度的同时,大幅降低 Redis压力。

4.使用 Sentinel 或 Resilience4j

在微服务生态中,可直接集成成熟框架:

  • Sentinel(阿里开源):支持集群流控,通过 TokenServer 统一管理限流规则;
  • Resilience4j:轻量级容错库,支持基于环形缓冲区的滑动窗口限流;
  • Spring Cloud Gateway:内置 Redis+ Lua 的分布式限流过滤器。

六、实际工程建议

  1. 选择合适的算法:一般优先选用令牌桶,兼顾突发与稳态;对输出平滑性要求极高时选漏桶。
  2. 避免过度限流:限流应作为兜底手段,配合降级、熔断、排队等策略。
  3. 监控与告警:记录限流日志,监控限流触发频率,及时调整阈值。
  4. 测试边界场景:模拟突发流量、时钟漂移、节点扩容等异常情况。
  5. 考虑多维度限流:不仅限于 QPS,还可按用户 ID、IP、API路径等维度组合限流。

七、结语

限流是系统稳定性建设的基石之一。从单机到分布式,从固定窗口到令牌桶,每种算法都有其适用边界。理解其原理,结合业务场景选择合适方案,才能在高并发浪潮中稳如磐石。随着云原生和Service Mesh 的发展,限流能力正逐步下沉至基础设施层(如 Istio、Envoy),但开发者仍需掌握底层逻辑,方能在复杂系统中做出正确决策。

限流不是目的,而是手段。真正的目标,是在保障用户体验的同时,让系统在极限压力下依然可靠运行。

本站简介

聚焦于全栈技术和量化技术的技术博客,分享软件架构、前后端技术、量化技术、人工智能、大模型等相关文章总结。