Redis限流器如何用令牌桶算法实现?

来源:JS脚本作者:云朵头衔:草根站长
导读:本期聚焦于云朵创作的《Redis限流器如何用令牌桶算法实现?》,敬请观看详情。当并发流量突然增大时,仅靠固定窗口计数容易在窗口切换瞬间放过两倍请求,用漏桶又难以保留合理的突发能力。令牌桶算法在平均速率限制和短时突发之间取得了平衡:令牌按固定速率生成,桶满则丢弃,请求需要先取令牌才能继续。本文围绕 Redis 实现一个可复用的令牌桶限流器,重点说明为什么用 Hash 保存剩余令牌和最后补充时间,以及如何通过 Lua 脚本把计算补充、判断扣减和写回三步合并为单次原子操作。随后给出 Lua 脚本和 Java 客户端调用示例,并讨论桶容量、速率精度、Redis 单线程特性对限流结果的影响,帮助你在分布式网关或接口防刷场景中快速落地。

限流器需要回答两个问题:允许多少请求通过,以及以什么节奏通过。令牌桶给出的答案是:平均速率由令牌生成速度决定,突发规模由桶容量决定。把桶放进 Redis,是因为分布式部署下多个实例共享同一个限流状态,必须有一个集中式的存储来统一计数。这里采用 Hash 保存两个字段:当前剩余令牌数和上次补充令牌的时间戳。每次请求到来时,先在内存中计算应当补充多少令牌,再判断是否足够扣除,最后把新状态写回 Redis。这三个动作如果在客户端分开执行,就存在并发覆盖和超发风险,而 Redis 的 Lua 脚本可以在服务端一次性完成,天然规避这个问题。

Redis限流器如何用令牌桶算法实现?

令牌桶算法解决什么问题

固定窗口计数是最容易想到的限流方案,例如限制接口每分钟最多 600 次。但如果上一分钟的最后 10 秒和下一分钟的前 10 秒分别用完 600 次,实际在 20 秒内放过了 1200 次,远超出系统设计的平均速率。滑动窗口可以缓解边界问题,但实现成本和存储开销更高。漏桶算法则严格平滑流量,所有请求像从桶底小孔流出,不管上游来得多猛,下游速率始终一致,代价是突发流量被强行削平,延迟敏感的业务可能因此超时。

令牌桶处于两者之间。系统以固定速率生成令牌,桶的容量有限,放满后新令牌直接丢弃。请求到达时必须从桶中取走一个或多个令牌,取不到就拒绝或等待。因为令牌可以积攒,所以允许一定程度的突发;因为生成速率恒定,所以长时间来看平均通过量不会超过预设值。这一特性非常适合 API 网关、消息发送、第三方接口调用等既要保护后端又要容忍瞬时高峰的场景。

在分布式环境中,单机内存实现的令牌桶无法保证全局统一。多个实例各自维护桶状态,最终限制值会被放大。Redis 作为集中式存储,天然适合保存桶的剩余令牌和时间戳。配合 Lua 脚本,还能把计算、比较和写回做成一个原子步骤,避免分布式锁带来的额外复杂度和性能损失。

基于 Redis Hash 和 Lua 的原子实现

最简单的数据结构是一个 Redis Hash,键为业务限流标识,例如 rate:user:123:api:login,字段 tokens 保存当前剩余令牌数,字段 ts 保存上次补充令牌的时间戳。之所以不用两个独立的 String 键,是因为如果分开读写,仍然存在读到一半状态的问题;Hash 可以一次性取出多个字段,减少往返次数。

下面这段 Lua 脚本会先读取当前桶数据,如果是首次访问则初始化桶为满容量。然后根据当前时间与上次补充时间的差值,乘以每秒生成速率,得到这段间隔内应该新增的令牌数。新增令牌与剩余令牌相加后不能超过容量,这是令牌桶最关键的封顶逻辑。最后判断剩余令牌是否足够扣除本次请求所需的令牌数,足够则扣减并返回 1,不足则保持原状返回 0。

-- KEYS[1] 限流键
-- ARGV[1] 桶容量
-- ARGV[2] 每秒生成令牌数
-- ARGV[3] 本次请求需要的令牌数
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])

local time = redis.call('TIME')
local now_ms = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)

local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local last_refill = tonumber(data[2])

if tokens == nil then
  tokens = capacity
  last_refill = now_ms
end

local delta = math.max(0, now_ms - last_refill)
local refill = delta * (rate / 1000.0)
if refill > 0 then
  tokens = math.min(capacity, tokens + refill)
  last_refill = now_ms
end

local allowed = false
if tokens >= requested then
  tokens = tokens - requested
  allowed = true
end

redis.call('HSET', key, 'tokens', tokens, 'ts', last_refill)
redis.call('PEXPIRE', key, 60000)
return {allowed and 1 or 0, math.floor(tokens)}

脚本中有两个容易忽略的地方。第一,首次访问时把 last_refill 初始化为当前时间,而不是 0,否则会用当前时间减 0 得到巨大差值,瞬间把桶补满,使隐藏的旧键获得错误放行。第二,补充动作只在 refill > 0 时执行,避免每次请求都写 Redis。即使 refill 等于 0,后续仍会执行 HSET 写回当前令牌数,才能保证无令牌时状态准确。

Lua 脚本由 Redis 服务端执行,执行期间不会处理其他命令,因此脚本内部的读改写天然是原子的。只要所有限流请求都走这个脚本,就不会出现两个请求同时判断令牌足够后一起扣减的情况。另一个好处是网络往返少,客户端只需一次 EVAL 就能拿到是否允许的结果,适用于高频调用的入口。

Java 客户端调用示例

实际项目中,通常把 Lua 脚本定义成常量,通过 Jedis 或 Lettuce 发送到 Redis。下面的示例使用 Jedis,先声明一段脚本文本,再在 tryAcquire 方法中把限流键、容量、速率和请求令牌数作为参数传入。方法返回 true 表示本次请求可以放行,返回 false 表示需要限流。

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import java.util.Arrays;
import java.util.Collections;

public class RedisTokenBucketLimiter {

    private static final String LUA_SCRIPT =
        "local key = KEYS[1]\n" +
        "local capacity = tonumber(ARGV[1])\n" +
        "local rate = tonumber(ARGV[2])\n" +
        "local requested = tonumber(ARGV[3])\n" +
        "local time = redis.call('TIME')\n" +
        "local now_ms = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)\n" +
        "local data = redis.call('HMGET', key, 'tokens', 'ts')\n" +
        "local tokens = tonumber(data[1])\n" +
        "local last_refill = tonumber(data[2])\n" +
        "if tokens == nil then\n" +
        "  tokens = capacity\n" +
        "  last_refill = now_ms\n" +
        "end\n" +
        "local delta = math.max(0, now_ms - last_refill)\n" +
        "local refill = delta * (rate / 1000.0)\n" +
        "if refill > 0 then\n" +
        "  tokens = math.min(capacity, tokens + refill)\n" +
        "  last_refill = now_ms\n" +
        "end\n" +
        "local allowed = false\n" +
        "if tokens >= requested then\n" +
        "  tokens = tokens - requested\n" +
        "  allowed = true\n" +
        "end\n" +
        "redis.call('HSET', key, 'tokens', tokens, 'ts', last_refill)\n" +
        "redis.call('PEXPIRE', key, 60000)\n" +
        "return {allowed and 1 or 0, math.floor(tokens)}";

    private final JedisPool jedisPool;

    public RedisTokenBucketLimiter(JedisPool jedisPool) {
        this.jedisPool = jedisPool;
    }

    public boolean tryAcquire(String key, int capacity, double rate, int requested) {
        try (Jedis jedis = jedisPool.getResource()) {
            Object result = jedis.eval(LUA_SCRIPT,
                Collections.singletonList(key),
                Arrays.asList(String.valueOf(capacity), String.valueOf(rate), String.valueOf(requested)));
            return ((Long) result) == 1L;
        }
    }
}

在 Java 字符串拼接中,Lua 脚本的换行通过 \n 表达,这样可以保持脚本可读性,也避免把大段文本写在一行。若使用的客户端版本较新,eval 方法返回类型可能因脚本返回数组而变化,这里因为返回值固定为数组,Jedis 通常返回 Long 或包含一个元素的 List,不同版本需做一次类型判断,避免强转异常。

生产环境不建议每次请求都重新创建 Jedis,应使用连接池复用连接。也不太建议开启 EVALSHA 缓存逻辑之外的复杂降级,如果 Redis 短暂不可用,可以选择直接放行或直接拒绝,具体取决于业务可承受的风险。对于关键路径,建议配置超时时间,防止连接池被限流调用拖垮。

容量、速率与精度调优

桶容量 capacity 并不等于最大 QPS,而是允许的单次突发量。如果每秒生成 10 个令牌,容量也是 10,那么空桶后第 1 秒最多能取走 20 个令牌,其中 10 个是积攒的存量,10 个是这一秒新增的。这个细节经常被误解,于是把容量设置成每秒限流值,结果高峰瞬间还是放过了接近两倍流量。如果希望严格限制任意 1 秒内的通过量,应把容量设置得更小,或者直接改用滑动窗口方案。

速率参数需要统一单位。上文脚本传入的是每秒令牌数,内部转换为毫秒增量。由于 Redis TIME 命令能返回微秒级时间,毫秒计算足够覆盖绝大多数业务。如果限流间隔极短或要求纳秒级精度,单靠 Redis 时间戳和 Lua 脚本还不够,建议在客户端预生成时间参数或者使用专业网关组件。

还有一个维护性问题:每个限流键必须在限流周期结束后清理,否则会占用内存。脚本中设置了 PEXPIRE,过期时间应当大于桶容量除以速率得到的最长静默时间,同时也要考虑业务方希望保留多久的窗口状态。如果使用 Redis Cluster,还应注意 Lua 脚本中只能访问同一个哈希槽内的键,通常限流键就在一个槽上,不会跨键,但多键操作时需用哈希标签保证落到同一节点。

Redis限流令牌桶算法Lua原子操作修改时间:2026-09-24 19:03:34

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0924/61423.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。