你的接口被疯狂调用,用户却骂你系统烂:限流做不好,就是这么丢人

2026-04-10 12 0

上周五晚上,线上告警炸了。运维群里艾特全员,说服务 P99 延迟飙到 10 秒,用户开始骂街。我赶紧上机器看,一查日志——某个接口的 QPS 直接冲到了平时的 20 倍。再一追查,是个爬虫在疯狂抓数据,顺便把正常用户的请求也拖下水。

我当时心里就一句:兄弟,你的接口没有限流吗?

对方沉默三秒,打出一行字:限流是什么?

我当场血压升高 30 毫米汞柱。


限流,不是有手就行的吗?

很多人觉得限流很简单,不就是计数器吗?来一个请求加一,到阈值了就拒绝。没错,这是最朴素的限流思路。但问题是——

你以为你在做限流,其实你在做「看起来限流但实际毛用没有」的玄学工程。

我见过太多「假限流」的实现:单机内存计数器,服务重启计数器清零;多节点部署但各记各的数,5 台机器每台限 100 QPS,实际扛了 500 QPS;还有用固定窗口算法,上一秒最后 100 毫秒涌进来 200 个请求,系统纹丝不动直接放行……

这些场景在面试里是加分项,在生产环境里是扣分项,在线上故障报告里是高频词汇。


限流算法:几种流派你站哪个?

1. 固定窗口计数器——穷人的限流方案

这是最简单粗暴的方案。把时间切成固定窗口,每个窗口独立计数。

// 固定窗口计数器(伪代码)
const limit = 100;
const windowSize = 60 * 1000; // 60秒

function isAllowed(key) {
  const now = Date.now();
  const windowKey = Math.floor(now / windowSize);
  const current = cache.get(key + ":" + windowKey) || 0;
  if (current >= limit) {
    return false;
  }
  cache.incr(key + ":" + windowKey);
  return true;
}

优点:实现简单,存一个数字就行。

缺点:临界问题严重。假设限制 100 QPS,在 59s 来了 100 个请求,61s 又来 100 个——其实 2 秒内来了 200 个,但两个窗口各算 100,都通过了。这就是「突刺流量」问题的经典来源。

2. 滑动窗口日志——精确但贵

滑动窗口的核心思路是:不再按固定窗口算,而是看最近 N 秒内有多少请求。

// 滑动窗口日志(伪代码)
function isAllowed(key) {
  const now = Date.now();
  const windowStart = now - windowSize;
  
  // 清理过期的请求记录
  const timestamps = cache.lrange(key + ":log", 0, -1);
  const valid = timestamps.filter(t => t > windowStart);
  cache.del(key + ":log");
  valid.forEach(t => cache.rpush(key + ":log", t));
  
  if (valid.length >= limit) {
    return false;
  }
  cache.rpush(key + ":log", now);
  return true;
}

优点:精确,没有临界问题。

缺点:存一堆时间戳,内存爆炸;每次请求都要扫描清理,性能开销大。除非你有 Redis Sorted Set,否则别轻易尝试。

3. 令牌桶——我最喜欢的一个

令牌桶的核心思想是:系统以固定速率往桶里放令牌,每个请求必须拿到一个令牌才放行。桶有容量,放满了就不再加了。

// 令牌桶核心逻辑
class TokenBucket {
  constructor(private capacity: number, private refillRate: number) {
    this.tokens = capacity;
    this.lastRefill = Date.now();
  }

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

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

// 使用:限制每秒 100 个请求,桶容量 200
const limiter = new TokenBucket(200, 100);

if (!limiter.allowRequest()) {
  throw new TooManyRequestsError();
}

优点:允许一定程度的突发流量(桶容量就是你的「余粮」),同时长期速率稳定。这是它最优雅的地方——平时积累的令牌可以让突发请求也得到服务,而不是一刀切全拒绝。

缺点:实现比固定窗口复杂一点,但绝对值得。

4. 漏桶——稳如老狗

漏桶的思路跟令牌桶相反:请求进来就入桶,桶里的请求以固定速率「漏」出去处理,不管你来多少,我处理速度就这么多。

// 漏桶(伪代码)
class LeakyBucket {
  constructor(private capacity: number, private leakRate: number) {
    this.water = 0;
    this.lastLeak = Date.now();
  }

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

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

优点:流量输出非常平稳,适合对接下游有严格限速的系统。

缺点:不支持突发流量,来多少吞多少,超了就丢。如果你对外提供 API,用户会骂你「我一秒发 5 个请求你就拒绝 4 个,什么垃圾服务」。

所以我的推荐是:对外公开 API 用令牌桶,对内调用下游服务用漏桶。


单机限流 vs 分布式限流——你的机器知道你有多少兄弟吗?

单机限流最简单,内存里开个计数器就能跑。但问题是——你的服务不会是单机的。

当你有多台机器时,麻烦来了:每台机器各记各的数,怎么协同?

方案一:Redis 计数器(最常见)

用 Redis 的 INCR 命令做集中计数,配合过期时间自动清理窗口。

// Redis 令牌桶(Lua 脚本保证原子性)
const luaScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local data = redis.call("HMGET", key, "tokens", "lastRefill")
local tokens = tonumber(data[1]) or capacity
local lastRefill = tonumber(data[2]) or now

-- 计算应补充的令牌
local elapsed = now - lastRefill
local newTokens = math.min(capacity, tokens + elapsed * refillRate)

if newTokens >= requested then
  newTokens = newTokens - requested
  redis.call("HMSET", key, "tokens", newTokens, "lastRefill", now)
  redis.call("EXPIRE", key, 60)
  return 1
else
  return 0
end
`;

const result = await redis.eval(luaScript, 1, 
  `ratelimit:user:${userId}`,
  capacity, refillRate, now, 1
);

if (result === 0) {
  throw new TooManyRequestsError("请求太频繁,请稍后再试");
}

这里用 Lua 脚本是关键——把读取、计算、写回打包成一个原子操作,否则在并發场景下会出现 race condition,你的限流会形同虚设。

方案二:Redis + 滑动窗口(更精确)

用 Redis Sorted Set,score 存时间戳,每次请求只保留窗口内的记录数。

const windowMs = 60000; // 60秒窗口
const maxRequests = 100;

async function isAllowed(userId: string): Promise<boolean> {
  const now = Date.now();
  const windowStart = now - windowMs;
  const key = `ratelimit:slide:${userId}`;
  
  // 原子事务
  const pipeline = redis.pipeline();
  pipeline.zremrangebyscore(key, 0, windowStart); // 清理过期
  pipeline.zcard(key); // 数一下现在有几个
  pipeline.zadd(key, now, `${now}:${Math.random()}`); // 加当前请求
  pipeline.pexpire(key, windowMs); // 设置过期
  const results = await pipeline.exec();
  
  const currentCount = results[1][1];
  return currentCount < maxRequests;
}

这个方案精确度比令牌桶更高,但每次请求都要操作 Redis sorted set,开销稍大。对于极端高性能要求的场景,慎重选择。


限流粒度:你限的是谁?

这个问题比算法本身更值得思考。

常见维度:

  • IP 限流:简单粗暴,但问题在于NAT背后的N个用户会被误伤,大型出口 IP 会被无辜限流。
  • 用户 ID 限流:登录后最准,但游客接口没法用。
  • API Key 限流:对开放平台最合理,每个 key 独立配额。
  • 设备指纹:防爬虫神器,但实现复杂,误伤也高。

我的实际经验是:分层限流。IP 层做兜底(防 DDoS),用户层做精细控制,接口层做差异化限流——读接口可以放宽,写接口要收紧,核心接口要更严。


被限流了怎么办?用户的体验你管吗?

很多工程师限流做完了就交差,结果用户收到 429 状态码,一脸懵逼,不知道是网络问题还是服务器挂了。

限流响应的 HTTP 头,是你对用户的基本尊重:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1712668800

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "请求过于频繁,请在 30 秒后重试",
    "retryAfter": 30
  }
}

Retry-After 是最重要的,告诉用户等多久再试。缺少这个头,用户的重试逻辑就是瞎子摸象——要么等不够时间继续被拒,要么等太久浪费生命。

另一个思路是「优雅降级」:限流之后,给用户返回一个「简化版」响应。比如正常返回 100 条数据,限流后返回 10 条关键数据,用户感知到的是「变少了」而不是「挂了」。这种策略在 C 端产品里非常实用。


说点扎心的

我见过太多团队的限流是「事后补救」——被爬虫打爆了才加上限流,被薅羊毛了才想起风控。

限流应该是架构设计的第一天就考虑的事情,而不是上线之后打补丁。接口一旦对外开放,你永远不知道下一秒钟会有什么流量冲进来。

最讽刺的是,限流这种东西,平时没人关心,出了事才想起来——然后赶紧上一个「临时方案」,接着又没人关心了,直到下一次故障。

希望这篇文章能让你下次设计接口的时候,多花五分钟想想限流这事。

真的,不难,但做了和没做,差一个故障的距离。

相关文章

🦞 当 AI 开始”整顿职场”,我决定先整顿它的噱头
OpenClaw 使用经验分享:一个AI助手能有多能打?
OpenClaw 使用经验分享:一个AI助手能有多能打?
为什么你的Prompt总是差点意思?可能是你不懂AI的”脑回路”
RESTful已死:为什么你在浪费生命设计”正确”的API
你的HTTP重试,正在慢慢杀死你的系统

发布评论