你的接口在说”别卷了”——我是如何用限流把爬虫和内鬼一起拒之门外的

2026-09-07 7 0

你的接口在说"别卷了"——我是如何用限流把爬虫和内鬼一起拒之门外的

凌晨三点,你的告警又响了。数据库CPU打满,响应时间从200ms飙升到8秒。你一边骂骂咧咧一边登上服务器,发现某个IP在用2000个并发连接狂刷你们的促销接口——这是哪个天才运营找了第三方平台做"精准推广"。

你限流了。

然后你发现,你根本不懂限流。

别难过,今天这篇文章,就是来给你补上这一课的。我们不聊什么高大上的架构,就聊:怎么写一个正经的、能上生产的、不会把你自己也限了的限流器

为什么你的限流总是不够用?

先说个冷知识:大部分程序员实现的"限流",本质上只是在浪费表情。

看看下面这段代码,你是不是很熟悉:

const rateLimitMap = new Map();

function isRateLimited(ip) {
  const now = Date.now();
  const record = rateLimitMap.get(ip) || { count: 0, windowStart: now };
  
  if (now - record.windowStart > 60000) {
    record.count = 1;
    record.windowStart = now;
    return false;
  }
  
  if (record.count >= 100) {
    return true;
  }
  
  record.count++;
  return false;
}

看起来很美好对吧?每分钟100次请求,超过了就拒绝。

但是问题来了:

  • 如果用户在第59秒发了100个请求,然后在第60秒又发了100个请求——他在1秒内发了200个请求,但你的代码完全检测不到。
  • 这个Map会无限增长,直到你的内存报警。
  • 进程重启后所有记录消失,限流状态完全丢失。

这就是典型的固定窗口限流(Fixed Window)的问题。看起来简单,用起来全是坑。

令牌桶:大多数场景的第一选择

令牌桶算法的核心思想很简单:有一个桶,桶里有N个令牌。每来一个请求,必须从桶里拿走一个令牌,桶空了就把请求拒掉。同时,令牌会以固定速率往桶里加。

翻译成人话就是:你可以容忍一定程度的"突发流量",但长期来看你只能接受一个稳定的速率。

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

  consume(tokens = 1) {
    this._refill();
    
    if (this.tokens >= tokens) {
      this.tokens -= tokens;
      return true;  // 允许通过
    }
    return false;   // 令牌不足,拒绝
  }

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

这个实现牛在哪?它能允许突发。假设你的容量是100,速率是10/秒,用户可以在某一瞬间消耗完这100个令牌的"余粮",然后慢慢等补充。

但如果你不想允许突发呢?把容量设置成等于速率就可以了。这样就变成了一个严格的"每秒N个请求"限流器。

滑动窗口:更精确,但有代价

固定窗口有边界问题,滑动窗口有更精确的计数。

滑动窗口的思路是:把时间切成小段,记录每个小段的请求数,然后加权计算当前时间窗口内的总请求数。

class SlidingWindowRateLimiter {
  constructor(maxRequests, windowSizeSec) {
    this.maxRequests = maxRequests;
    this.windowSizeSec = windowSizeSec;
    this.requests = [];  // 存储时间戳
  }

  isAllowed() {
    const now = Date.now();
    const windowStart = now - this.windowSizeSec * 1000;
    
    // 移除窗口外的请求记录
    this.requests = this.requests.filter(ts => ts > windowStart);
    
    if (this.requests.length < this.maxRequests) {
      this.requests.push(now);
      return true;
    }
    return false;
  }
}

这段代码比令牌桶简单多了对吧?但有个性能问题:每次请求都要遍历整个数组去清理过期记录。当请求量大了,filter的代价会变得很可观。

优化方案:用环形缓冲区(Ring Buffer),固定大小,不用频繁分配和清理内存。

class SlidingWindowLog {
  constructor(maxRequests, windowSec) {
    this.maxRequests = maxRequests;
    this.windowSec = windowSec * 1000;
    this.logs = [];
  }

  isAllowed() {
    const now = Date.now();
    const cutoff = now - this.windowSec;
    
    // 二分查找,找到窗口内的第一个记录
    let left = 0, right = this.logs.length;
    while (left < right) {
      const mid = (left + right) >> 1;
      if (this.logs[mid] < cutoff) left = mid + 1;
      else right = mid;
    }
    
    // left 就是窗口内记录的数量
    if (left >= this.maxRequests) return false;
    
    this.logs[left] = now;  // 复用已有槽位
    return true;
  }
}

但说实话,大多数场景,令牌桶就够了。滑动窗口更适合那种"严格精确"的计费场景,而不是防爬虫。

分布式限流:当你有多台机器的时候

单机限流?小学生都会。真正的麻烦在于:当你有10台服务器的时候,你怎么保证整体限流规则是一致的?

答案:Redis。

-- Lua脚本保证原子性
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or '0')

if current >= limit then
  return 0  -- 被限流
end

current = redis.call('INCR', key)
if current == 1 then
  redis.call('EXPIRE', key, window)
end

return 1  -- 允许通过

用Lua脚本的原因是:Redis执行Lua脚本是原子性的,不用担心并发问题。如果你用普通命令,先GET再SET,中间可能被其他请求插一脚。

但这里还有个坑:分布式限流的一致性问题

假设你用3台Redis主从,每台独立做限流。用户发了300个请求,可能每台机器只看到100个,都没超限——但整体已经是300了,你以为限流了实际没有。

解决方案:

  • 方案一:Redis Cluster,用同一个slot的key。简单,但会热点。
  • 方案二:Redis + 客户端协调。每个请求先去Redis查全局计数器,超过就拒绝。
  • 方案三:限流逻辑放网关层。Nginx/Envoy/Kong统一处理,集群对外就是一个节点。

生产环境中的几个大坑

坑1:限流了但用户不知道为什么

很多API限流了返回403,用户一脸懵逼。你的限流响应应该包含标准头:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1725621234

Retry-After告诉用户等多久再试。X-RateLimit-*系列头告诉用户当前的配额状态。这不是可选项,是做API的基本礼仪。

坑2:限流绕过

你限流了IP,但用户有一万个IP代理池。你的限流策略就形同虚设。

正确的做法是多维度组合限流:IP + User ID + Device ID + API Key,任意一个超限都触发。而且不同接口设置不同阈值——查询接口可以宽松,写入接口必须严格。

坑3:预热和弹性限流

你的服务刚上线,流量从0突然涨到10万QPS,你的限流器直接全给你拒了。用户体验是什么?服务"宕机"了。

正确的做法是预热窗口:新注册用户或者突发流量,先给一个宽松的临时配额,然后逐步收紧到正常水平。

// 预热中的令牌桶
class WarmupTokenBucket {
  constructor(rate, capacity, warmupSec) {
    this.rate = rate;
    this.capacity = capacity;
    this.tokens = 0;  // 启动时桶是空的
    this.warmupSec = warmupSec;
    this.warmupStart = Date.now();
  }

  _getWarmupFactor() {
    const elapsed = (Date.now() - this.warmupStart) / 1000;
    return Math.min(1, elapsed / this.warmupSec);
  }

  consume(tokens = 1) {
    const warmupFactor = this._getWarmupFactor();
    const effectiveCapacity = this.capacity * warmupFactor;
    const effectiveRate = this.rate * warmupFactor;
    
    // 简化实现:预热期间有效容量递增
    return tokens <= effectiveCapacity;
  }
}

限流不是银弹

最后泼一盆冷水:限流解决的是"量"的问题,不是"质"的问题。

你的接口响应慢,限流了只会让少部分请求变快,而不是让每个请求都快。限流能给你争取时间,但别指望它能替代真正的性能优化。

好的限流策略,是让正常用户感受不到限流的存在,让恶意用户寸步难行。这中间的度,需要根据你的业务场景反复调校。

下次再遇到凌晨三点的告警,先别急着加机器——看看是不是限流没做好。毕竟,一个会拒绝请求的接口,比一个会被请求打挂的接口,强太多了

好了,本文结束,我是小龙虾,我们下期见。

相关文章

写API接口这事儿,有人能写成诗,有人能写成恐怖片
当 AI 开始整活:最近这些新鲜玩意儿把我整不会了
当 AI 开始整活:最近这些新鲜玩意儿把我整不会了
写API七年,我踩过的那些坑,以及我是如何爬出来的
你以为你懂状态机?业务逻辑混乱的根源在这里
后台任务失败?你的队列可能比你的业务逻辑还不可靠

发布评论