4. Redis 单机令牌桶实现:使用 String + Lua 脚本实现原子性令牌获取

在分布式系统限流场景中,原子性是核心要求。Redis 单机令牌桶的经典实现方案采用 String 数据结构 + Lua 脚本,将令牌的获取、扣减、时间窗口刷新封装为一个原子操作,避免并发竞争导致令牌超发。

4.1 为什么选择 String + Lua

Redis 提供了多种数据结构,但令牌桶场景对原子性和性能有特殊要求:

方案 原子性 性能 适用场景
String + Lua 脚本 强(脚本整体执行) 高(单次网络往返) 单机令牌桶、熔断窗口
Redis List + 定时任务 弱(需分布式锁) 中(多次网络往返) 简单队列限流
Redis Sorted Set 中(依赖时间戳) 低(需清理过期成员) 滑动窗口限流

Lua 脚本在 Redis 中执行时具有事务性:脚本内的所有命令要么全部执行成功,要么全部不执行,且执行期间不会被其他客户端命令打断。这天然解决了令牌桶的并发安全问题。

4.2 令牌桶核心数据结构设计

使用一个 Redis String 键存储令牌桶的当前状态,采用 JSON 或自定义格式序列化:

Key:   rate_limiter:{bucket_name}
Value: {
  "tokens":      10,      // 当前可用令牌数
  "last_refill": 1712345678,  // 上次补充时间戳(秒)
  "capacity":    20,      // 桶容量
  "refill_rate": 5        // 每秒补充令牌数
}

实际生产环境中,为了减少序列化开销,通常将字段拆分为多个 String 键,或使用 Hash 结构。但 String + Lua 方案在单机场景下最为简洁高效。

4.3 Lua 脚本实现原子获取令牌

以下 Lua 脚本实现了令牌桶的核心逻辑:按时间补充令牌 → 判断是否足够 → 扣减并返回成功/失败。

-- KEYS[1]: 令牌桶键名
-- ARGV[1]: 请求的令牌数
-- ARGV[2]: 当前时间戳(秒)
-- ARGV[3]: 桶容量
-- ARGV[4]: 每秒补充速率

local key = KEYS[1]
local requested = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local refill_rate = tonumber(ARGV[4])

-- 1. 获取当前桶状态
local bucket_str = redis.call('GET', key)
local tokens, last_refill

if bucket_str then
    -- 解析存储的 JSON 字符串
    local bucket = cjson.decode(bucket_str)
    tokens = bucket.tokens
    last_refill = bucket.last_refill
else
    -- 桶不存在,初始化为满桶
    tokens = capacity
    last_refill = now
end

-- 2. 计算时间流逝并补充令牌
local elapsed = now - last_refill
if elapsed > 0 then
    local refill_tokens = math.floor(elapsed * refill_rate)
    tokens = math.min(capacity, tokens + refill_tokens)
    last_refill = now
end

-- 3. 判断是否足够
if tokens >= requested then
    tokens = tokens - requested
    -- 更新 Redis
    local new_bucket = cjson.encode({
        tokens = tokens,
        last_refill = last_refill,
        capacity = capacity,
        refill_rate = refill_rate
    })
    redis.call('SET', key, new_bucket)
    return 1  -- 成功获取
else
    return 0  -- 令牌不足
end
关键设计点:
  • 时间驱动补充:不在外部定时补充,而是在每次获取令牌时按时间差计算应补充的令牌数,减少 Redis 操作次数。
  • math.floor 向下取整:避免小数令牌累积误差,保证令牌数始终为整数。
  • cjson.encode/decode:Redis 内置的 JSON 处理库,无需额外依赖。

4.4 原子性获取流程

下图展示了客户端调用 Lua 脚本获取令牌的完整流程:

String + Lua 原子获取令牌流程 客户端 Redis 服务器 EVALSHA 脚本 + 参数 Lua 脚本原子执行 ① GET 当前桶状态 ② 计算时间补充令牌 ③ 判断并扣减令牌 String 键值存储 返回 1(成功)或 0(失败) 原子性保证 整个 Lua 脚本在 Redis 中串行执行,期间不会被其他命令打断,杜绝并发超发

4.5 客户端调用示例

在 Java 客户端(Jedis/Redisson)中调用上述 Lua 脚本:

// 加载脚本,获取 SHA
String script = loadScriptFromFile("token_bucket.lua");
String sha = jedis.scriptLoad(script);

// 每次请求时调用
String key = "rate_limiter:api_gateway";
long now = System.currentTimeMillis() / 1000;
int capacity = 20;
int refillRate = 5;
int requestTokens = 1;

Object result = jedis.evalsha(sha, 
    Arrays.asList(key),
    Arrays.asList(
        String.valueOf(requestTokens),
        String.valueOf(now),
        String.valueOf(capacity),
        String.valueOf(refillRate)
    )
);

boolean allowed = ((Long) result) == 1L;
性能注意事项:
  • 脚本缓存:使用 SCRIPT LOAD 预加载脚本,后续通过 EVALSHA 调用,避免每次传输脚本内容。
  • 时间参数:由客户端传入时间戳,避免 Redis 服务器时间与客户端不一致导致限流偏差。
  • 键名设计:每个限流资源使用独立的键,避免全局锁竞争。

4.6 方案对比与选型建议

维度 String + Lua Redis Module (RedisCell) 客户端本地限流
部署复杂度 低(原生 Redis) 中(需加载模块) 低(无中间件依赖)
原子性 强 强 弱(需分布式协调)
性能 高(1次网络往返) 高(C 语言实现) 极高(无网络开销)
灵活性 高(可自定义逻辑) 中(固定算法) 高
推荐场景 通用单机限流 高并发生产环境 允许误差的降级方案
实践建议:对于大多数微服务单机限流场景,String + Lua 方案是性价比最高的选择。它不需要额外部署模块,脚本逻辑可完全自定义,且性能足以支撑每秒数万次的令牌获取请求。当单机 QPS 超过 10 万时,可考虑升级为 Redis Module 方案。