你的接口正在被恶意刷?来聊聊接口限流的正确打开方式

2026-07-15 9 0

想象一下这个场景:凌晨三点,你被短信轰炸叫醒,报警显示服务器CPU打满,数据库连接数爆表。你赶紧打开监控一看——好家伙,某IP在用每秒几千次的频率疯狂调用你们的搜索接口。

这不是DDOS,但效果差不多。你的接口没做任何防护,就这么裸奔着。

欢迎来到真实的后端世界。在这个世界里,你永远不要相信调用方的善意。每一个没人管的接口,都可能成为别人眼中的免费资源。

今天咱们就好好聊聊接口限流(Rate Limiting)这件事,从算法原理到实战落地,一次讲透。

为什么你的接口需要限流?

很多人觉得限流是大公司才需要操心的事,自己项目用户量又不大,装什么安全措施?

我懂这种想法。但现实会教你做人:

  • 你的某个接口响应特别快,正好被爬虫看上了,一夜之间被抓取几十万条
  • 某个产品搞秒杀活动,流量瞬间十倍暴增,没限流的接口直接雪崩
  • 有人手滑写了死循环调用你的接口,QPS直接起飞

限流不只是防攻击,它是保护你自己系统的最后一道防线。就像开车要系安全带,不是为了防止出车祸,是为了万一出事还能保住一条命。

固定窗口算法:入门级方案

最简单的限流思路:规定一个时间窗口内,只允许N次请求。这就是固定窗口(Fixed Window)算法。

// 伪代码示例
const WINDOW_SIZE = 60 * 1000; // 1分钟
const MAX_REQUESTS = 100;

const records = {}; // { ip: [timestamp1, timestamp2...] }

function isAllowed(ip) {
  const now = Date.now();
  const windowStart = Math.floor(now / WINDOW_SIZE) * WINDOW_SIZE;
  
  if (!records[ip]) records[ip] = [];
  
  // 清理过期的记录
  records[ip] = records[ip].filter(t => t >= windowStart);
  
  if (records[ip].length >= MAX_REQUESTS) {
    return false;
  }
  
  records[ip].push(now);
  return true;
}

看起来很简单对吧?但固定窗口有个致命问题——边界突变

假设限制是每分钟100次。一个用户在第59秒发送了100次请求,然后在第61秒又发送了100次请求。表面上他在2秒内请求了200次,但因为跨越了窗口边界,两次都通过了。

更坑的是,如果同一时刻大量用户的请求集中在一个窗口边界上,会产生惊群效应——流量突然翻倍,系统被打个措手不及。

滑动窗口算法:更精确的控制

滑动窗口(Sliding Window)算法可以解决边界问题。它不固定窗口边界,而是动态往前滑动

function isAllowed(ip) {
  const now = Date.now();
  const windowStart = now - WINDOW_SIZE; // 永远以当前时间为基准往前推
  
  if (!records[ip]) records[ip] = [];
  
  // 只保留窗口内的请求
  records[ip] = records[ip].filter(t => t > windowStart);
  
  if (records[ip].length >= MAX_REQUESTS) {
    return false;
  }
  
  records[ip].push(now);
  return true;
}

滑动窗口比固定窗口更平滑,但需要更多的存储空间——每次判断都要遍历整个窗口内的请求列表。如果你的QPS很高,这个list可能会很长。

而且,这两种方案在单机环境下没问题,一旦上了分布式,要共享这个records就麻烦了——你总不能把所有请求记录存在内存里吧?

令牌桶算法:允许一定程度的突发

令牌桶(Token Bucket)是一种更聪明的算法。核心思想是:

  • 系统以固定速率往桶里放令牌
  • 每次请求需要从桶里取一个令牌
  • 桶有容量上限,拿光了就得等

这意味着什么?允许一定程度的突发流量。如果桶是满的,你可以一次性取出多个令牌处理突发请求,而不是傻等着。

class TokenBucket {
  constructor(rate, capacity) {
    this.rate = rate;        // 每秒补充的令牌数
    this.capacity = capacity; // 桶的容量
    this.tokens = capacity;   // 当前令牌数
    this.lastRefill = Date.now();
  }

  refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.rate);
    this.lastRefill = now;
  }

  tryConsume() {
    this.refill();
    if (this.tokens >= 1) {
      this.tokens -= 1;
      return true;
    }
    return false;
  }
}

令牌桶的好处是:平时流量低的时候积累令牌,流量高峰的时候消耗。这种削峰填谷的特性让它成为很多场景下的首选。

Guava的RateLimiter就是令牌桶实现。Nginx的limit_req模块也用的是类似思想。

漏桶算法:严格的均匀输出

漏桶(Leaky Bucket)和令牌桶正好相反——不管进来多少水,漏出去的速度是固定的。

class LeakyBucket {
  constructor(rate, capacity) {
    this.rate = rate;        // 漏出速率(每秒处理数)
    this.capacity = capacity; // 桶的容量
    this.water = 0;          // 当前水量
    this.lastLeak = Date.now();
  }

  leak() {
    const now = Date.now();
    const elapsed = (now - this.lastLeak) / 1000;
    const leaked = elapsed * this.rate;
    this.water = Math.max(0, this.water - leaked);
    this.lastLeak = now;
  }

  tryConsume() {
    this.leak();
    if (this.water < this.capacity) {
      this.water += 1;
      return true;
    }
    return false;
  }
}

漏桶算法保证的是绝对均匀的输出。不管流量高峰多猛,系统总是以固定速率处理请求。这对保护后端慢服务特别有用。

但它的代价是:不允许任何突发。如果你的桶满了,新请求直接被拒,哪怕前一秒系统完全空闲。

分布式环境下怎么玩?

单机限流?那不叫限流,叫自欺欺人。

真正上了规模,你需要在多台机器之间共享限流状态。这时候就得靠Redis了。

-- Redis实现滑动窗口限流
local key = KEYS[1] -- 用户标识
local window = tonumber(ARGV[1]) -- 窗口大小(ms)
local limit = tonumber(ARGV[2]) -- 限制数量
local now = tonumber(ARGV[3]) -- 当前时间戳

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)

if count < limit then
  redis.call('ZADD', key, now, now .. '-' .. math.random())
  redis.call('PEXPIRE', key, window)
  return 1 -- 允许
end

return 0 -- 拒绝

这段Lua脚本实现了一个基于Redis的有序集合(ZSET)的滑动窗口限流:

  1. 移除窗口外的历史记录
  2. 统计窗口内请求数
  3. 未超限则加入新请求,设置过期时间

用Redis的好处是:天然支持过期自动清理,而且多实例共享状态。代价是每次请求都要访问Redis,网络延迟是躲不过去的坎

实战中的几个坑

算法会了,代码能跑了,是不是就完事了?too young。

坑1:限流返回什么状态码?

很多人返回200然后在body里说"请求太频繁"——这是错误的。正确做法是返回429 Too Many Requests,并在响应头里加上Retry-After告诉客户端多久后重试。

坑2:限流粒度选错了

按IP限流可能被NAT困住(一公司的人共用一个IP),按用户ID限流可能被并发请求困住(同一用户开多个标签页)。最好组合多种维度:IP + UserID + 接口路径。

坑3:忽略了预热和动态调整

固定的限流阈值在很多场景下不够灵活。大促期间临时提高阈值?某个接口特别重要要单独保护?建议使用配置中心来动态调整限流规则,不要把阈值写死在代码里。

总结

限流不是什么高大上的技术,但绝对是后端开发必须掌握的基本功

选型建议:

  • 单机场景 → 内存+滑动窗口,简单够用
  • 需要支持突发 → 令牌桶(Guava RateLimiter)
  • 保护慢后端 → 漏桶算法
  • 分布式场景 → Redis + Lua,滑动窗口或令牌桶皆可

最后送大家一句话:永远不要相信调用方的数量,永远不要假设流量会按照你的预期来。限流是防御性编程的精髓,宁可少放一个请求,也不要让整个系统为它陪葬。

好了,今天的分享就到这里。如果觉得有用,转发给你那个写接口从不考虑限流的同事。

相关文章

当AI开始整活:这些新奇玩法让我差点忘了正经工作
还在为部署AI工具掉头发?我来帮你搞定一切
为什么你的API总被前端骂?5个让我后悔三年的设计失误
被开除的DBA告诉你:为什么我赌上职业生涯也要选PostgreSQL
我曾经以为API设计很简单,直到踩了这些坑
async/await 正在偷偷谋杀你的 Node.js 服务:我用三次线上事故换来的教训

发布评论