之前我们商城的抢购接口用的是内存限流(Express-Rate-Limit 那一套),本来单机跑得稳稳当当。结果上月大促搞多节点部署,加了负载均衡,瞬间就被恶意羊毛党给钻了空子。分布式的流量在几个实例之间轮询,单机限流器完全没法同步状态。而且有些高并发请求在极短时间内倾泻进来,多个节点同时去读写同一个全局 Redis 计数器,由于非原子操作的“读-改-写”时序竞争,导致实际限制的值远远超出了设定的阈值。

要搞定分布式下的精准限流,且防范恶意刷单,绝对不能依赖 Node.js 的内存状态。我们必须把限流的状态集中到 Redis 中存储。更关键的是,我们必须保证“检查余额”和“消耗令牌”这两个动作是绝对原子的。要做到这一点,最好的方案就是在 Redis 内部用 Lua 脚本跑限流逻辑。因为 Redis 执行 Lua 脚本是单线程并发安全的,相当于在数据库层面上加了一把天然的原子分布式锁。

相比于滑动窗口容易在窗口临界点出现两倍流量突刺的问题,我们在生产中采用了更加平滑的“令牌桶(Token Bucket)”算法。下面是我们用 ioredis 实现的 Lua 令牌桶分布式限流器的完整逻辑:

import Redis from 'ioredis';

const redis = new Redis({
  host: '127.0.0.1',
  port: 6379,
});

// 定义 Redis 令牌桶 Lua 脚本
// KEYS[1]: 限流的 Key (例如 ratelimit:user_12345:api_checkout)
// ARGV[1]: 桶的最大容量 (Max Capacity)
// ARGV[2]: 令牌填充速率 (Fill Rate, 每秒产生的令牌数)
// ARGV[3]: 当前请求消耗的令牌数 (usually 1)
// ARGV[4]: 当前系统时间戳 (Unix Timestamp in seconds)
const tokenBucketScript = `
  local key = KEYS[1]
  local max_capacity = tonumber(ARGV[1])
  local fill_rate = tonumber(ARGV[2])
  local tokens_to_consume = tonumber(ARGV[3])
  local now = tonumber(ARGV[4])

  -- 获取桶的当前状态
  local data = redis.call('HMGET', key, 'tokens', 'last_updated')
  local tokens = tonumber(data[1])
  local last_updated = tonumber(data[2])

  if not tokens then
    -- 首次请求,初始化桶为满令牌状态
    tokens = max_capacity
    last_updated = now
  else
    -- 计算距离上次更新流逝的时间,并补充相应的令牌
    local elapsed = math.max(0, now - last_updated)
    local generated = elapsed * fill_rate
    tokens = math.min(max_capacity, tokens + generated)
    last_updated = now
  end

  -- 判断令牌是否足够扣减
  if tokens >= tokens_to_consume then
    tokens = tokens - tokens_to_consume
    -- 保存桶最新的状态
    redis.call('HMSET', key, 'tokens', tokens, 'last_updated', last_updated)
    -- 设置 Key 的过期时间,防止冷 Key 占用 Redis 内存
    redis.call('EXPIRE', key, 86400)
    return 1 -- 允许通过
  else
    -- 令牌不足,保存补充后的令牌状态,但不进行扣减
    redis.call('HMSET', key, 'tokens', tokens, 'last_updated', last_updated)
    redis.call('EXPIRE', key, 86400)
    return 0 -- 限流拦截
  end
`;

export async function checkRateLimit(
  key: string,
  maxCapacity: number,
  fillRate: number,
  tokensNeeded: number = 1
): Promise<boolean> {
  const nowInSeconds = Math.floor(Date.now() / 1000);
  
  try {
    // 使用 EVAL 运行 Lua 脚本,保证执行的原子性
    const result = await redis.eval(
      tokenBucketScript,
      1, // KEYS 数组的长度
      `ratelimit:${key}`,
      maxCapacity.toString(),
      fillRate.toString(),
      tokensNeeded.toString(),
      nowInSeconds.toString()
    );
    
    return result === 1;
  } catch (error) {
    console.error('Rate limiting Redis execution failed:', error);
    // 降级策略:如果 Redis 崩了,建议放行或者采用本地兜底限流,防止限流器挂掉导致整个业务不可用
    return true; 
  }
}

// 示例:在中间件中使用限流器
async function handleCheckoutRequest(userId: string) {
  const isAllowed = await checkRateLimit(`user:${userId}:checkout`, 10, 2); // 桶大小 10,每秒恢复 2 个令牌
  if (isAllowed) {
    console.log(`[Checkout] User ${userId} request processed successfully.`);
  } else {
    console.log(`[Rate Limit] User ${userId} request blocked (Too Many Requests).`);
  }
}

// 模拟连续高并发请求
(async () => {
  for (let i = 0; i < 15; i++) {
    await handleCheckoutRequest('user_999');
    // 模拟极小的时间间隔
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
})();

有两点一定要在实践中注意。第一,由于使用的是系统的 Unix 时间戳,精度是秒级的,如果有极高精度(微秒级)的硬性需求,需要使用 Redis 的 TIME 命令获取 Redis 服务器的时间,或者传入毫秒时间戳,否则在同一秒内的多次并发请求可能会因为时间戳没变而无法正确补充令牌。第二,由于 Lua 脚本在 Redis 中是阻塞式单线程执行的,千万不要在脚本中写极其复杂的循环逻辑或者大范围 Key 扫描,否则会把整个 Redis 实例卡死。

另外,限流器的 Key 过期时间(TTL)也是个学问。我们这里随便设了个 24 小时(86400),但在高频海量用户的场景下,如果不及时清理过期的限流状态,Redis 内存会膨胀得非常厉害。因此过期时间最好设置为 max_capacity / fill_rate 的数倍即可,在桶装满且无后续请求时让它自动释放。

大家在自己的大促场景下是用这种 Redis + Lua 还是更倾向于在网关层(比如 Kong 或 Nginx API Gateway)直接挂载限流模块?