令牌桶限流怎么实现?

wen python案例 1

令牌桶限流怎么实现?从原理到实战,一篇讲透高并发保护机制

目录导读

  1. 为什么需要令牌桶限流?
  2. 令牌桶算法的核心工作原理
  3. 手写一个令牌桶实现(Java版)
  4. 经典框架中的令牌桶实现:Guava RateLimiter 解析
  5. 分布式场景下的令牌桶挑战与解决方案
  6. 常见QA:你的疑惑我来答

为什么需要令牌桶限流?

在互联网高并发场景中,系统往往无法承受瞬间的流量洪峰,例如一个电商秒杀活动,如果每秒请求量从1000突增至10万,后端服务很可能直接崩溃。令牌桶算法就是在这种背景下诞生的“流量调节器”。

令牌桶限流怎么实现?

与简单的计数器法(固定窗口)相比,令牌桶能允许一定程度的突发流量,同时保证长期的平均速率不超过阈值,这使其成为业界最主流的限流算法之一,广泛应用于 API 网关、微服务、数据库连接池等场景。

核心优势:既能平滑流量,又不会完全拒绝突发请求(只要桶里还有令牌)。


令牌桶算法的核心工作原理

1 三个核心要素

要素 说明
令牌桶容量(capacity) 桶最多能存放的令牌数,控制最大突发量
令牌生成速率(rate) 每秒向桶中放入的令牌数
当前令牌数(tokens) 桶中实时剩余的令牌数

2 执行流程

  1. 初始化:桶中填满令牌(通常等于容量)
  2. 周期性放入:每隔 1/rate 秒放入一个令牌,但不超过容量上限
  3. 请求处理:每个请求到达时,从桶中取出一个令牌:
    • 若桶中有令牌 → 取走并放行请求
    • 若桶中无令牌 → 拒绝请求或等待

3 为什么允许突发流量?

假设桶容量为100,速率为10/s,当系统闲置10秒后,桶会积累100个令牌,此时若突然涌入100个请求,它们可以全部被放行(桶中令牌耗尽),相当于在1秒内处理了100个请求——这就是突发能力,之后请求只能以10/s的速率被处理,直到桶中令牌重新积累。

对比漏桶算法:漏桶以恒定速率出水,不允许突增,而令牌桶则恰好相反。


手写一个令牌桶实现(Java版)

public class TokenBucket {
    private final long capacity;   // 桶容量
    private final double refillRate; // 每秒填充令牌数
    private double tokens;         // 当前令牌数
    private long lastRefillTime;   // 上次填充时间戳
    public TokenBucket(long capacity, double refillRate) {
        this.capacity = capacity;
        this.refillRate = refillRate;
        this.tokens = capacity;
        this.lastRefillTime = System.currentTimeMillis();
    }
    public synchronized boolean tryAcquire() {
        refill(); // 先补充令牌
        if (tokens >= 1) {
            tokens -= 1;
            return true;
        }
        return false;
    }
    private void refill() {
        long now = System.currentTimeMillis();
        double elapsed = (now - lastRefillTime) / 1000.0; // 转为秒
        double newTokens = elapsed * refillRate;
        if (newTokens > 0) {
            tokens = Math.min(capacity, tokens + newTokens);
            lastRefillTime = now;
        }
    }
}

使用示例

TokenBucket bucket = new TokenBucket(10, 1); // 桶容量10,每秒补充1个
for (int i = 0; i < 20; i++) {
    boolean allowed = bucket.tryAcquire();
    System.out.println("请求 " + i + (allowed ? " 放行" : " 限流"));
    Thread.sleep(50);
}

前10个请求会被立即放行(吃完了初始的10个令牌),后续请求每1秒只能放行1个。


经典框架中的令牌桶实现:Guava RateLimiter 解析

Google Guava 库中的 RateLimiter 是令牌桶的标志性实现,但它不是严格的时间戳预计算,而是采用平滑突发限流(SmoothBursty) 模式,其核心设计更巧妙:

1 核心设计思想

  • 不维护“当前令牌数”,而是维护 下次可用令牌的时间点storedPermitsstoredPermits 的最大存储时间)
  • 采用 预支付(credit)机制:即使令牌不足,也可以允许请求通过,但下一次请求必须等待“还债”

2 关键方法对比

方法 行为 适用场景
tryAcquire() 立即返回是否能通过 非阻塞判断
acquire() 阻塞直到获取到令牌 必须执行的请求
tryAcquire(timeout, unit) 在指定时间内等待令牌 带超时的重试

3 平滑预热限流(SmoothWarmingUp)

Guava 还提供了 WarmUp 模式:

  • 刚启动时,限流速率较慢(如 2/s),然后逐渐升到目标速率(10/s)
  • 避免了冷启动时令牌瞬间被消耗完,导致后端雪崩
RateLimiter limiter = RateLimiter.create(10, 1, TimeUnit.SECONDS);
for (int i = 0; i < 10; i++) {
    limiter.acquire(); // 阻塞等待直到获取令牌
    System.out.println("执行第 " + i + " 个请求");
}

分布式场景下的令牌桶挑战与解决方案

单机令牌桶很容易实现,但在微服务、分布式集群中,需要全局共享令牌状态,如何确保多个节点之间令牌的一致性?

常见的方案有以下三种:

Redis + Lua 脚本

利用 Redis 的单线程特性,通过 Lua 脚本原子操作一个 key 中的令牌数:

-- 令牌桶 Lua 脚本
local key = KEYS[1]
local capacity = ARGV[1]           -- 桶容量
local rate = ARGV[2]               -- 每秒补充令牌数
local now = tonumber(ARGV[3])      -- 当前时间戳(毫秒)
local bucket = redis.call('hgetall', key)
local tokens, lastTime
if #bucket == 0 then
    tokens = capacity
    lastTime = now
else
    tokens = tonumber(bucket[2])
    lastTime = tonumber(bucket[4])
end
-- 补充令牌
local elapsed = (now - lastTime) / 1000
local newTokens = elapsed * rate
tokens = math.min(capacity, tokens + newTokens)
lastTime = now
-- 判断是否允许请求
if tokens >= 1 then
    tokens = tokens - 1
    redis.call('hmset', key, 'tokens', tokens, 'lastTime', lastTime)
    return 1  -- 放行
else
    redis.call('hmset', key, 'tokens', tokens, 'lastTime', lastTime)
    return 0  -- 限流
end
  • 优点:原子性、跨进程共享
  • 缺点:一次限流多一次 Redis 调用,性能有一定损耗

集中式限流中间件

  • Sentinel(阿里):支持令牌桶热点限流,提供控制台管理规则
  • Nginx + lua-resty-limit-traffic:在高性能代理层做限流
  • Kong / APISIX:API 网关内置令牌桶插件

客户端令牌预分配

每个节点预先从中心拉取一批令牌到本地内存,使用完后再申请,减少了中心依赖,但存在令牌浪费(节点宕机时预分配令牌丢失)。


常见QA:你的疑惑我来答

Q1:令牌桶和漏桶的区别是什么?

算法 流量类型 核心特点
令牌桶 允许突发 短期可超过限制,长期平均
漏桶 强制平滑 泄漏速率恒定,不允许抖动

一句话总结:令牌桶“给令牌,能排队”;漏桶“匀速泄洪,不允许积压”。

Q2:桶容量应该设置多大?

取决于系统能容忍的瞬时最高 QPS,假设系统能安全处理 200 QPS,但接口希望平滑到 50 QPS,

  • 如果桶容量 = 40:可以承受连续 40 个突发请求
  • 一般推荐容量 = 容忍最大突发时间 × 速率 ,例如容忍 2 秒突发,速率 50/s → 容量 100

Q3:为什么不能直接用 Java 的 synchronized 做分布式限流?

synchronized 只能保证单 JVM 内的线程安全,如果服务部署了 5 个节点,每个节点各自维护令牌桶,那么总 QPS 最高可达 5 × 单机阈值,失去了限流意义,必须使用分布式协调组件。

Q4:令牌桶能用于“按用户限流”吗?

可以,只需在键中加上用户 ID,bucket:user:12345,每个用户独立一个桶,但注意 Redis 内存占用,建议设置 TTL 自动清理长期未使用的用户桶。

Q5:如果请求需要等待令牌,用什么实现?

  • 本地场景:使用 Semaphore + 定时补充线程,或 Guava 的 acquire() 方法(会阻塞当前线程)
  • 分布式场景:用 Redis 实现“令牌等待队列”比较复杂,建议直接返回 429,让客户端重试,如果想等待,可以结合 Redis 的 BLPOP 和发布订阅机制来实现通知。

写在最后:令牌桶算法不仅仅是一个公式,它是高并发系统设计的艺术——既不让系统被突增流量冲垮,也不要错过真正的商业机会,从单机手写到 Redis Lua 脚本,从 Guava 到 Sentinel,理解其核心思想后,你就能在各种框架中游刃有余地选择最适合的方案。

如果你在实战中遇到其他限流问题,欢迎留言交流。

抱歉,评论功能暂时关闭!