线上系统一旦遭遇突发流量,最先崩溃的往往是数据库和下游依赖服务,而入口层却毫无感知。限流就是解决这个问题的第一道防线。令牌桶算法因为实现简单、支持突发流量,成为绝大多数限流组件的底层选择。本文将系统讲解令牌桶的原理与实现,并延伸讨论被限流请求的排队处理方案,帮助你建立完整的流量防护思路。

一、为什么需要限流:从一次线上故障说起
假设一个订单服务每秒最多能稳定处理1000个请求,某天运营做活动,瞬时QPS飙到5000。如果没有限流措施,大量请求会堆积在应用线程池和数据库连接池中,响应时间从50毫秒恶化到数秒,最终线程池被打满,服务彻底不可用,甚至通过依赖调用传导影响到整个集群。这就是典型的雪崩效应。
限流的本质是在流量超出系统承载能力时主动丢弃或延迟一部分请求,用局部的牺牲换取整体的稳定。与熔断、降级不同,限流作用于流量入口,是一种事前的防护手段。业界常见的限流算法有四种:固定窗口计数器、滑动窗口计数器、漏桶算法和令牌桶算法。固定窗口存在临界突刺问题,滑动窗口实现相对复杂,而漏桶和令牌桶各有侧重,其中令牌桶因兼顾平滑与突发能力被广泛使用,比如Guava的RateLimiter就是基于令牌桶实现的。
二、令牌桶算法的核心原理与实现
令牌桶的工作机制可以概括为:系统以固定速率向一个桶中放入令牌,桶有容量上限,满了则不再放入;每个请求到达时先从桶中取一个令牌,取到则放行,取不到则被限流。这个设计有两个关键参数:一是令牌生成速率,决定了系统的平均处理能力;二是桶容量,决定了允许的突发流量上限。
举个具体例子:速率为100个令牌每秒,桶容量为200。如果系统长时间空闲,桶里会积累200个令牌,此时突然来了300个请求,前200个可以立即通过(突发流量),之后请求只能按每秒100个的速率被处理。这是令牌桶相对漏桶最大的优势——漏桶强制以恒定速率放行请求,完全不允许突发,而令牌桶在桶中有存量时可以瞬间放行一批请求,更符合真实业务流量的波动特征。
下面是一个基于惰性计算的Java单机实现。惰性计算指不依赖定时线程生成令牌,而是在每次请求到达时根据时间差补算应生成的令牌数,这样实现更轻量也避免了线程开销。
public class TokenBucket {
private final double capacity; // 桶容量
private final double rate; // 令牌生成速率(个/秒)
private double tokens; // 当前令牌数
private long lastRefillTime; // 上次补充令牌的时间戳
public TokenBucket(double capacity, double rate) {
this.capacity = capacity;
this.rate = rate;
this.tokens = capacity;
this.lastRefillTime = System.nanoTime();
}
public synchronized boolean tryAcquire(int permits) {
long now = System.nanoTime();
// 根据时间差补充令牌
double elapsed = (now - lastRefillTime) / 1e9;
tokens = Math.min(capacity, tokens + elapsed * rate);
lastRefillTime = now;
if (tokens >= permits) {
tokens -= permits;
return true;
}
return false;
}
}
使用方式很简单,创建一个容量为200、速率为100的桶,在接口入口处调用tryAcquire(1),返回false时执行限流逻辑。单机场景下也可以直接使用Guava的RateLimiter,它还提供了tryAcquire带超时参数的重载,允许请求短暂等待令牌。需要注意的是,上面的实现使用synchronized保证线程安全,高并发下会成为竞争点,可以改用原子变量或LongAdder优化。
分布式场景下,单机的令牌桶各自为政,无法控制集群总流量。常见做法是基于Redis实现:用Lua脚本把读取剩余令牌、按时间差补充、扣减令牌三个操作封装成原子操作,避免并发下的超发。Redisson的RRateLimiter已经封装好了这套逻辑,开箱即用:
RRateLimiter limiter = redissonClient.getRateLimiter("order:api:limiter");
// 初始化:总速率100 QPS,每个节点共享
limiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.SECONDS);
if (limiter.tryAcquire()) {
// 正常处理业务
} else {
// 触发限流逻辑
}
三、被限流的请求怎么办:快速失败与请求排队
限流只是判断,真正影响用户体验的是被拒绝的请求如何处理。最简单的策略是快速失败:立即返回特定状态码(如429)和友好提示,让客户端稍后重试。这种方式实现成本最低,系统压力也最小,适合对一致性要求不高的读接口。但写入类操作直接丢弃可能造成数据丢失,需要谨慎评估。
更友好的方案是请求排队。当令牌不足时不立即拒绝,而是让请求进入等待队列,排在队头的请求优先获得令牌。排队把瞬时压力转换为时间上的平滑消耗,本质上是削峰填谷。Guava的RateLimiter.acquire()就是阻塞式排队实现,它会返回本次等待的秒数:
RateLimiter limiter = RateLimiter.create(100); // 100 QPS
// 阻塞直到获取到令牌,适合后台任务
double waitSeconds = limiter.acquire();
log.info("本次排队等待了 {} 秒", waitSeconds);
processRequest();
使用阻塞式排队时必须配合超时控制,否则流量持续超限时队列会无限增长,最终内存溢出。实践中通常会设置最大等待时长(比如2秒),超时后转为快速失败。此外,队列要有容量上限并区分优先级:核心业务请求(如支付)可以走高优先级队列,非核心请求(如日志上报)超限直接丢弃。
在架构层面,更彻底的排队方案是引入消息队列。请求到达后先写入Kafka或RocketMQ立即返回受理结果,消费端按照下游可承受的速率消费消息。这种异步化改造把用户线程的同步等待转化为后台异步处理,适合秒杀、批量任务提交等高并发写入场景。代价是请求结果的实时性下降,客户端需要通过查询接口或回调获取最终处理结果。
四、在网关层落地限流的实践要点
限流应该尽量前置,在流量到达业务服务之前完成拦截,网关是最佳位置。以Nginx为例,可以使用limit_req_zone配置基于漏桶的请求速率限制,配合burst参数允许突发,nodelay参数控制突发请求是否立即处理:
http {
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s;
server {
location /api/ {
# 突发容量200,超出的请求直接返回503
limit_req zone=api_limit burst=200 nodelay;
}
}
}
如果使用Spring Cloud Gateway,可以自定义过滤器集成Redis令牌桶,官方甚至提供了RequestRateLimiter过滤器,只需配置Redis和KeyResolver即可按用户、IP或接口维度限流。多维度限流是实践中的常见需求:全局维度保护整体容量,用户维度防止单个账号滥用,接口维度保护昂贵操作,多层规则叠加形成纵深防御。
最后一个容易被忽视的点是限流阈值的选择。阈值不是拍脑袋定的,应基于压测得出的系统容量并留出20%到30%的余量,同时结合下游依赖的承受能力综合评估。上线后要持续监控限流触发率和队列长度,触发率长期为零说明阈值过松形同虚设,频繁触发则说明需要扩容或优化。限流参数应该是可动态调整的,借助配置中心可以在流量变化时快速生效,避免重新发布。