跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客我的书AI学习GitHub 精选镜像AI 生图工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
Javajava算法

限流算法详解:令牌桶、漏桶与计数器算法及 Java 实现

三种常见的限流算法:令牌桶、漏桶和计数器算法。令牌桶通过固定速率生成令牌并允许突发流量,适合保护后端服务;漏桶强制平滑输出速率,适合控制带宽;计数器算法基于时间窗口统计请求数,实现简单但存在临界区问题。文章提供了每种算法的 Java 实现代码,分析了各自的关键概念、优缺点及适用场景,并提及了利用 Redis+Lua 优化计数器算法的方案。

灵魂摆渡发布于 2026/3/24更新于 2026/10/1990 浏览
限流算法详解:令牌桶、漏桶与计数器算法及 Java 实现

令牌桶算法

**令牌桶(Token Bucket)**算法以一个设定的速率产生令牌(Token)并放入令牌桶,每次用户请求都得申请令牌,如果令牌不足,则拒绝请求。

令牌的数量也是有上限的。令牌的数量与时间和发放速率强相关,时间流逝的时间越长,会不断往桶里加入越多的令牌,如果令牌发放的速度比申请速度快,令牌桶会放满令牌,直到令牌占满整个令牌桶。

  • 想象一个固定容量的'令牌桶',系统按固定速率向桶中添加令牌(比如每秒放 10 个)。
  • 当有请求到达时,需要从桶中获取一个令牌才能被处理;如果桶中没有令牌,请求则被丢弃或排队。
  • 令牌桶的容量决定了允许的最大突发流量(比如桶容量 100,意味着最多允许 100 个请求同时突发)。

关键概念:

  • 令牌生成速率(r):每秒生成的令牌数量(控制长期平均速率)。
  • 令牌桶容量(b):桶最多能存放的令牌数(控制最大突发流量)。
  • 令牌消耗:每个请求消耗 1 个令牌(可调整为按请求大小消耗)。

文章配图

特点:

  • 平滑流量:通过固定速率生成令牌,限制长期平均请求速率。消费速度不固定。
  • 允许突发:桶中累积的令牌可应对短时间的突发流量(只要不超过桶容量)。
  • 灵活性:可调整令牌生成速率和桶容量,适配不同场景。

Java 代码实现

/**
 * 令牌桶限流算法
 */
public class TokenBucketLimiter {
    // 桶的容量
    private final int capacity;
    // 令牌生成速度 (个/秒)
    private final int rate;
    // 当前令牌数量
    private final AtomicInteger tokens;

    /**
     * 构造函数
     * @param capacity 桶的容量
     * @param rate 令牌生成速度 (个/秒)
     */
    public TokenBucketLimiter(int capacity, int rate) {
        this.capacity = capacity;
        this.rate = rate;
        this.tokens = new AtomicInteger(capacity); // 初始化时令牌桶是满的
        // 启动令牌生成器线程
        startTokenProducer();
    }

    /**
     * 启动一个定时任务,以固定速率向桶中添加令牌
     */
    private void startTokenProducer() {
        ScheduledExecutorService scheduledThreadPool = Executors.newSingleThreadScheduledExecutor();
        // 延迟 0 秒后开始执行,每隔 1 秒执行一次
        scheduledThreadPool.scheduleAtFixedRate(() -> {
            // 尝试增加令牌,确保不超过容量
            // getAndUpdate 是一个原子操作,它会获取当前值,根据函数计算新值,并更新它
            tokens.getAndUpdate(current -> Math.min(capacity, current + rate));
            // System.out.println("令牌已补充,当前令牌数:" + tokens.get());
            // 可以注释掉,用于调试
        }, 0, 1, TimeUnit.SECONDS);
    }

    /**
     * 尝试获取一个令牌
     * @return 如果获取成功则返回 true,否则返回 false
     */
    public boolean tryAcquire() {
        return tryAcquire(1);
    }

    /**
     * 尝试获取指定数量的令牌
     * @param numberOfTokens 需要获取的令牌数量
     * @return 如果获取成功则返回 true,否则返回 false
     */
    public boolean tryAcquire(int numberOfTokens) {
        if (numberOfTokens <= 0) {
            throw new IllegalArgumentException("令牌数量必须大于 0");
        }
        while (true) {
            int current = tokens.get();
            // 如果当前令牌数不足以获取所需数量,则直接返回 false
            if (current < numberOfTokens) {
                return false;
            }
            // 尝试原子地减少令牌数
            if (tokens.compareAndSet(current, current - numberOfTokens)) {
                // 减少成功,返回 true
                return true;
            }
            // 如果 compareAndSet 失败,说明在获取和设置之间有其他线程修改了令牌数,
            // 循环会重试,直到成功或确定无法获取为止。
        }
    }

    /**
     * 获取当前桶中的令牌数 (主要用于调试和监控)
     * @return 当前令牌数
     */
    public int getCurrentTokens() {
        return tokens.get();
    }
}

漏桶算法

想象一个底部有漏洞的'漏桶',水(请求 / 数据包)从顶部流入桶中,桶底以固定速率漏水(处理请求)。无论流入速度多快(突发流量),漏水速度始终保持不变,从而限制了输出速率。如果流入速度超过漏水速度,桶会逐渐装满,当桶满后,多余的水会溢出(丢弃请求)。

文章配图

关键概念:

  • 漏桶容量(b):桶最多能容纳的请求数(或数据量),决定了允许的最大突发流量缓冲。
  • 漏水速率(r):每秒从桶中处理的请求数(固定速率,控制长期输出速率)。
  • 流入速率:请求到达的速率(可变,可能突发)。

令牌桶算法是'漏桶算法'的改进版:漏桶算法仅限制输出速率,不允许突发流量;令牌桶允许突发,但长期速率受控。

特点:

  • 平滑输出:无论输入流量如何突发,输出速率始终保持固定(由漏水速率决定)。
  • 限制突发:桶容量限制了最大缓冲请求数,超过容量的突发流量会被丢弃。
  • 无累积令牌:与令牌桶不同,漏桶不会累积'处理能力',仅被动缓冲和匀速处理。
/**
 * 漏桶限流算法
 */
public class LeakyBucketLimiter {
    // 漏桶的容量
    private final int capacity;
    // 漏水速率 (个/秒)
    private final int rate;
    // 当前桶中的请求数量
    private final AtomicInteger water = new AtomicInteger(0);

    /**
     * 构造函数
     * @param capacity 漏桶的容量
     * @param rate 漏水速率 (个/秒)
     */
    public LeakyBucketLimiter(int capacity, int rate) {
        this.capacity = capacity;
        this.rate = rate;
        // 启动漏水线程
        startLeaker();
    }

    /**
     * 启动一个定时任务,以固定速率从桶中'漏水'(处理请求)
     */
    private void startLeaker() {
        ScheduledExecutorService scheduledThreadPool = Executors.newSingleThreadScheduledExecutor();
        // 延迟 0 秒后开始执行,每隔 1 秒执行一次
        scheduledThreadPool.scheduleAtFixedRate(() -> {
            // 每次漏水,尝试将当前水量减少 rate 个
            // getAndUpdate 是原子操作,确保线程安全
            water.getAndUpdate(current -> {
                int newWaterLevel = current - rate;
                // 不能让水量小于 0
                return Math.max(0, newWaterLevel);
            });
            // System.out.println("漏水后,当前水量:" + water.get());
            // 用于调试
        }, 0, 1, TimeUnit.SECONDS);
    }

    /**
     * 尝试添加一个请求到漏桶中
     * @return 如果添加成功(桶未满)则返回 true,否则返回 false(桶满,请求被丢弃)
     */
    public boolean tryAddRequest() {
        return tryAddRequest(1);
    }

    /**
     * 尝试添加指定数量的请求到漏桶中
     * @param numberOfRequests 需要添加的请求数量
     * @return 如果添加成功(桶未满)则返回 true,否则返回 false(桶满,请求被丢弃)
     */
    public boolean tryAddRequest(int numberOfRequests) {
        if (numberOfRequests <= 0) {
            throw new IllegalArgumentException("请求数量必须大于 0");
        }
        while (true) {
            int currentWaterLevel = water.get();
            // 检查添加后是否会溢出
            if (currentWaterLevel + numberOfRequests > capacity) {
                return false; // 桶满,拒绝请求
            }
            // 尝试原子地增加水量
            if (water.compareAndSet(currentWaterLevel, currentWaterLevel + numberOfRequests)) {
                return true; // 添加成功
            }
            // 如果 compareAndSet 失败,则循环重试
        }
    }

    /**
     * 获取当前桶中的请求数量 (主要用于调试和监控)
     * @return 当前水量
     */
    public int getCurrentWaterLevel() {
        return water.get();
    }
}

计数器算法

计数器算法的核心思想非常简单:在一个固定的时间窗口内,统计请求的数量,如果请求数超过了预设的阈值,就拒绝后续的请求。

可以想象成一个收费站,在一个小时内(时间窗口)最多允许 100 辆车通过(阈值)。当第 101 辆车到达时,收费站就会关闭栏杆,拒绝其通过。等到下一个小时开始,计数器清零,栏杆重新打开。

优点:

  • 实现简单:逻辑非常清晰,代码容易编写和理解。
  • 高效:只涉及简单的时间比较和计数操作,性能开销极小。

缺点(临界问题):

最严重的问题是'临界区'或'突刺流量'问题。假设时间窗口是 1 秒,阈值是 100。如果在第 0.9 秒时来了 100 个请求,全部被允许。然后在第 1.1 秒时(进入了新的窗口),又来了 100 个请求,也全部被允许。那么在 0.9 秒到 1.1 秒这短短 0.2 秒内,系统实际上处理了 200 个请求,这可能会瞬间压垮下游服务。

这个问题的根源在于时间窗口的切换是瞬间完成的,没有平滑过渡。

/**
 * 计数器限流算法
 */
public class CounterLimiter {
    // 一个时间窗口内的最大请求数
    private final int maxRequests;
    // 时间窗口大小(毫秒)
    private final long windowSize;
    // 当前窗口的请求计数器
    private final AtomicInteger currentCount = new AtomicInteger(0);
    // 当前窗口的起始时间戳(毫秒)
    private final AtomicLong windowStartTime = new AtomicLong(System.currentTimeMillis());

    /**
     * 构造函数
     * @param maxRequests 一个时间窗口内的最大请求数
     * @param windowSize 时间窗口大小(秒)
     */
    public CounterLimiter(int maxRequests, int windowSize) {
        this.maxRequests = maxRequests;
        this.windowSize = windowSize * 1000L; // 转换为毫秒
        // 启动一个定时任务,定期检查并重置窗口,避免长时间不请求导致的窗口不更新问题
        startWindowResetTask();
    }

    /**
     * 尝试获取一个请求许可
     * @return 如果获取成功则返回 true,否则返回 false
     */
    public boolean tryAcquire() {
        long now = System.currentTimeMillis();
        // 1. 检查是否进入了新的时间窗口,如果是则重置计数器和窗口起始时间
        // 使用 compareAndSet 确保重置操作的原子性
        if (now - windowStartTime.get() > windowSize) {
            // 尝试原子地将窗口起始时间更新为当前时间
            // 只有当当前窗口起始时间仍然是 oldStartTime 时才会更新成功
            // 这可以防止多个线程同时重置窗口
            long oldStartTime = windowStartTime.get();
            if (windowStartTime.compareAndSet(oldStartTime, now)) {
                // 重置计数器
                currentCount.set(0);
                System.out.println("时间窗口已重置,新窗口起始时间:" + now);
            }
        }
        // 2. 检查当前窗口内的请求数是否已超限,并原子地增加计数
        int current = currentCount.getAndIncrement();
        return current < maxRequests;
    }

    /**
     * 启动一个定时任务,定期检查窗口是否过期。
     * 这是一个守护线程,主要用于在长时间没有请求的情况下,也能保证窗口能被重置。
     */
    private void startWindowResetTask() {
        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
        // 每隔 windowSize/2 的时间检查一次,确保窗口能及时重置
        scheduler.scheduleAtFixedRate(() -> {
            long now = System.currentTimeMillis();
            if (now - windowStartTime.get() > windowSize) {
                windowStartTime.set(now);
                currentCount.set(0);
                // System.out.println("定时任务触发窗口重置");
                // 用于调试
            }
        }, this.windowSize / 2, this.windowSize / 2, TimeUnit.MILLISECONDS);
    }
}

可以通过 Redis + Lua 来实现计数器算法的功能。典型的场景就是用户登录场景,可以用计数器 + 验证码的功能来实现削峰,降低并发量。可以设定时间窗口为 1s,请求根据具体业务设置请求数阈值,每当发出一个用户登录请求就通过 Lua 脚本在 Redis 中更新计数器。如果达到了阈值就使当前请求进入验证码流程,然后重置计数器。

目录

  1. 令牌桶算法
  2. Java 代码实现
  3. 漏桶算法
  4. 计数器算法

更多推荐文章

查看全部
  • AI 重构产品工作流:当“人人都是产品经理”不再只是口号
  • OpenClaw Self-Improving 技能解析:从 SKILL.md 看 AI 自我进化逻辑
  • HexStrike AI 全自动渗透测试助手部署与配置指南
  • 宇树机器人 G1 二次开发:FAST_LIO 建图与 RViz 配置教程
  • 本地离线部署 Whisper 模型实现语音转写实战
  • 双足机器人 2-RSS-1U 并联踝关节设计与运动学分析
  • spdlog 日志库嵌入式 Linux C++使用指南
  • 嵌入式 C/C++ 核心知识点:变量作用域、虚函数与系统调用
  • 从 Alpaca 到 ShareGPT:Llama Factory 数据格式全解析
  • Conda 虚拟环境与安装包路径修改:释放磁盘空间配置指南
  • 低空经济下无人机光伏巡检技术与应用
  • Copilot Cowork 核心逻辑解析:使用 Kotlin 构建 AI Agent
  • 前端拖拽排序实现详解:从原理到实践
  • WebODM 无人机地图制作核心优势与应用解析
  • 大模型量化详解:基于 llama.cpp 的 GGUF 转换与压缩
  • Barrier 跨平台 KVM 软件使用教程
  • llama.cpp 本地部署性能调优指南:从启动瓶颈到推理效率优化
  • Python 中文分词库 Jieba 核心用法详解:模式、词典与关键词提取
  • RAG 检索增强生成技术:现状、挑战与优化方案
  • USAD 算法深度解析与工业级时序异常检测实战

相关免费在线工具

  • Keycode 信息

    查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online

  • Escape 与 Native 编解码

    JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online

  • JavaScript / HTML 格式化

    使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online

  • JavaScript 压缩与混淆

    Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online

  • 加密/解密文本

    使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online

  • Gemini 图片去水印

    基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online