你的API正在被薅羊毛,而你什么都没做

2026-10-17 17 0

上周五晚上,我的手机炸了。不是微信炸,是监控报警炸。

一个内部接口,QPS 从来没超过50,突然飙到了 8000。DB 连接池瞬间打满,整个服务雪崩,前端页面全体阵亡。

查日志,找原因——不是黑客,是运营提了一个数据导出需求,本地脚本跑了 200 个并发请求。

没有限流。没有熔断。就这么裸奔着上了生产。

那一刻我意识到:限流这件事,不到出事那天,你永远觉得它不重要。


第一层:单机限流——你的第一道护城河

先从最简单说起。单机限流就是在单机维度控制请求速率,算法主要有三种:

计数器:最朴素,也最容易被穿

原理:用计数器记录时间窗口内的请求数,超了就拒绝。

let count = 0;
const windowMs = 1000; // 1秒

function rateLimit() {
  const now = Date.now();
  if (now - windowStart > windowMs) {
    windowStart = now;
    count = 0;
  }
  if (count >= limit) {
    return false; // 拒绝
  }
  count++;
  return true;
}

这代码有什么问题?临界突变。假设限制 100 QPS,在窗口最后一毫秒来了 100 个请求,下一窗口第一毫秒又来 100 个——实际上 2 毫秒内处理了 200 个请求,算法还觉得自己很称职。

但说实话,如果不是什么高并发场景,这招也够用了。毕竟不是每个系统都会被人专门盯着薅。

滑动窗口:计数器的进化版

滑动窗口把时间切成多个小段,每段记录请求数,统计时看最近这一整段的总量。

const slots = new Array(10).fill(0); // 10个槽,每槽100ms
let currentSlot = 0;

function slidingWindow() {
  const now = Date.now();
  const slotMs = 100;
  const expectedSlot = Math.floor(now / slotMs) % 10;

  // 重置过期的槽
  while (currentSlot !== expectedSlot) {
    currentSlot = (currentSlot + 1) % 10;
    slots[currentSlot] = 0;
  }

  const total = slots.reduce((a, b) => a + b, 0);
  if (total >= limit) return false;
  slots[currentSlot]++;
  return true;
}

滑动窗口比固定窗口平滑得多,但实现复杂度也上来了一个档次。

令牌桶:允许一定程度的突发流量

令牌桶是我最喜欢的单机限流算法。核心思想:桶里有令牌,每个请求消耗一个令牌,没令牌就等或者拒绝。同时,令牌会以固定速率往桶里放。

class TokenBucket {
  constructor(private capacity: number, private refillRate: number) {
    this.tokens = capacity;
    this.lastRefill = Date.now();
  }

  allow(): 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;
  }
}

令牌桶妙在哪?平时桶是满的,突然来一波流量可以直接拿令牌处理,不至于把合法请求误杀。这对有突发流量场景的服务非常友好。


第二层:分布式限流——当单机不够用的时候

单机限流只能保护单机,但生产环境都是多节点部署。用户请求打进来,Nginx 分发到不同机器,每台机器各自限流——等于没限流。

这时候你需要分布式限流。

Redis + Lua:最常见的组合

思路很简单:用 Redis 存计数器,原子操作保证多节点读到同一个数。

-- slide_window.lua
local key = KEYS[1]
local window = tonumber(ARGV[1])      -- 窗口大小 ms
local limit = tonumber(ARGV[2])       -- 上限
local now = tonumber(ARGV[3])

local window_start = now - window

-- 移除窗口外的旧记录
redis.call(ZREMRANGEBYSCORE, key, 0, window_start)

-- 统计当前窗口内请求数
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 -- 拒绝
const result = await redis.eval(
  slideWindowScript,
  1,
  `rate_limit:${userId}`,
  window,
  limit,
  Date.now()
);

if (!result) {
  ctx.status = 429;
  ctx.body = { error: 请求太频繁,请稍后再试 };
  return;
}

Redis ZSET 的 score 存时间戳,ZREMRANGEBYSCORE 清理过期数据,ZCARD 计数。这一套下来,多节点共享同一个限流状态,稳。

Redisson + 滑动窗口:更优雅的方案

如果你用 Redisson,它自己就带 RRateLimiter,基于令牌桶实现,API 极度友好:

RedissonClient redisson = Redisson.create(config);
RRateLimiter limiter = redisson.getRateLimiter("api:user:123");

// 初始化:每秒补充5个令牌,最多存10个
limiter.trySetRate(RateType.OVERALL, 5, 1, RateIntervalUnit.SECONDS);

if (!limiter.tryAcquire(1)) {
  throw new RateLimitException("触发限流");
}
// 业务逻辑

分布式令牌桶,不需要自己写 Lua,几行搞定。唯一的代价是你得引入 Redisson 这个大家伙,轻量级项目慎用。


第三层:网关限流——统一入口,统一策略

分布式限流解决了多节点协同问题,但每个接口各自配置又带来了新的混乱——A 接口限制 100 QPS,B 接口限制 200 CUR,用户问起来你也说不清楚依据是什么。

所以需要网关层统一限流。所有流量过网关,网关统一配置策略。

用 Kong 或者 APISIX 的话,配置非常简单:

plugins:
  - name: rate-limiting
    config:
      policy: redis
      redis_host: redis-master
      minute: 60
      fault_tolerant: true
      hide_client_headers: false

APISIX 的好处是支持自定义 клю,你可以用用户 ID、IP、接口名做不同维度的限流:

plugins:
  - name: rate-limiting
    config:
      key: remote_addr  # 按IP限
      policy: redis
      rate: 100
      burst: 50

网关限流还有个隐藏福利:它能扛住一些基础攻击。恶意爬虫、扫描工具,通常不会带 Authorization header,直接 IP 维度限掉就行。


限流之外的配套:熔断和降级

光有限流不够。流量被拦了,用户还在刷,服务依赖的 DB/Redis 本身可能已经半死不活。

限流是入口拦截,熔断是出口保护。

熔断器模式(Circuit Breaker)的核心逻辑:统计调用失败率,超过阈值就「跳闸」,不再发起真实调用,直接返回降级数据。等系统恢复,再慢慢放流量进来试探。

// 伪代码:熔断器三状态
enum State { CLOSED, OPEN, HALF_OPEN }

function call() {
  switch (state) {
    case CLOSED:
      try {
        return realCall();
      } catch (e) {
        failureCount++;
        if (failureCount > threshold) {
          state = OPEN;
        }
      }
    case OPEN:
      return fallback(); // 直接降级
    case HALF_OPEN:
      // 放一个请求试试水
      const result = realCall();
      state = CLOSED;
      return result;
  }
}

常用的熔断库:Sentinel(阿里)、Hystrix(Netflix,已停止维护但还有很多公司在用)、Resilience4j(Java生态)。选哪个看你技术栈,Java 用 Sentinel 或 Resilience4j,Node 用 Opossum。


说点真心话

限流这个话题,很多人觉得「简单,不就是计数器吗」。

但真正线上出过事的都知道,限流做不好,要么是误杀正常用户(限流算法不平滑),要么是形同虚设(单机限流没同步),要么是连带崩全站(没配熔断)。

建议每个后端服务都配上这三层:

  • 第一层:网关限流 — 统一入口,按接口/IP/用户配置
  • 第二层:分布式限流 — 多节点协同,保护共享资源
  • Third层:单机限流 + 熔断 — 最后防线,保护进程不崩溃

不怕一万,就怕万一。那些「我们的接口不会被薅的」的服务,最后都上了新闻。

限流这事儿,宁可备而不用,不可用时无备。

相关文章

REST API设计翻车现场:那些年我们一起踩过的坑
为什么说90%的后端API都是对前端同事的隐性谋杀
我见过最离谱的API设计,看完我沉默了…
RESTful API设计避坑指南:我用尸体铺出来的经验
你的API报错400,客户当场问候你全家:论Error Response设计
限流:你的服务离崩溃,只差一个双十一

发布评论