独立开发中·📚18 篇日记·📝最近更新:2026年07月12日
🛡️

一夜600万次请求:我的API限流保卫战

三个IP一夜间发了600万次API请求,云服务账单暴涨到¥3200。

水哥
2026-06-308 分钟阅读

600 万次请求,一夜之间

周一早上打开 Grafana,一条红色的曲线让我怀疑是不是眼睛坏了。

00:00 - 00:30: 12,000 次请求
00:30 - 01:00: 187,000 次请求
01:00 - 01:30: 1,200,000 次请求
01:30 - 02:00: 3,400,000 次请求
...
总计:600 万次请求,来自 3 个 IP 地址

有人在爬我的所有平台数据。不只是爬公开内容,而是接口层级的全量扫描。GET /api/restaurants?page=1page=2page=3…… 一直爬到最后一页。

服务器没挂——底层做了自动扩容。但云服务商发来了账单预警:本月 API 网关费用预估 ¥3,200,正常月均 ¥80。

第一反应:IP 封禁

# 先手动封
iptables -A INPUT -s 45.xxx.xxx.xxx -j DROP
iptables -A INPUT -s 103.xxx.xxx.xxx -j DROP
iptables -A INPUT -s 156.xxx.xxx.xxx -j DROP

停了 20 分钟。然后新的 IP 出现了,换了一组。显然对方用了代理池。手动封 IP 等于打地鼠。

从限流到反爬体系的建立

1. 单 IP 速率限制

// src/middleware/rateLimit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(60, '1 m'), // 每分钟 60 次
  analytics: true,
  prefix: 'ratelimit:api',
})

export async function rateLimitMiddleware(request: Request) {
  const ip = request.headers.get('x-forwarded-for') || 'unknown'
  const { success, limit, remaining } = await ratelimit.limit(ip)

  if (!success) {
    return new Response(JSON.stringify({
      error: '请求过于频繁,请稍后再试',
      retryAfter: 60,
    }), {
      status: 429,
      headers: {
        'Retry-After': '60',
        'X-RateLimit-Limit': limit.toString(),
        'X-RateLimit-Remaining': remaining.toString(),
      }
    })
  }
  return null // 放行
}

2. 分层限流策略

不同接口的流量特征不同,不能用同一套规则。我按风险等级对接口分了八层:

const RATE_LIMIT_TIERS = {
  // 公开接口:允许搜索引擎和正常用户访问
  public:    { rpm: 300, burst: 100 },  // 每分钟 300 次

  // 半公开:需要正常浏览行为
  browse:    { rpm: 60,  burst: 20 },

  // 搜索接口:容易被滥用
  search:    { rpm: 30,  burst: 10 },

  // 写操作:最严格
  write:     { rpm: 10,  burst: 5 },

  // 登录/注册:防止撞库
  auth:      { rpm: 5,   burst: 2 },
}

// 通过 Next.js Route Segment 配置
// src/app/api/restaurants/route.ts
export const runtime = 'edge'
export const maxDuration = 30

// 中间件里根据路由前缀匹配限流等级

3. 行为指纹

IP 封禁不够,要识别爬虫的行为特征:

interface BehaviorFingerprint {
  ip: string
  userAgent: string
  // 这些指标暴露爬虫
  avgInterval: number       // 请求间隔,正常人是 2-10 秒,爬虫是 50ms
  intervalStdDev: number    // 间隔方差,爬虫几乎为 0
  sessionDuration: number   // 会话时长,爬虫持续数小时
  uniqueEndpoints: number   // 访问了多少不同路径
  tokenPresent: boolean     // 有没有 token
  jsExecuted: boolean       // JS 有没有执行(爬虫通常不执行 JS)
}

function isBot(fp: BehaviorFingerprint): boolean {
  let score = 0

  // 请求间隔太规律(机器人特征)
  if (fp.avgInterval < 0.5 && fp.intervalStdDev < 0.1) score += 40

  // 会话持续时间过长(正常人不会连续浏览 6 小时)
  if (fp.sessionDuration > 6 * 3600) score += 20

  // 访问过多不同端点(正常用户专注某个功能)
  if (fp.uniqueEndpoints > 50) score += 20

  // 没有 token 却大量请求(游客正常行为不会如此密集)
  if (!fp.tokenPresent && fp.avgInterval < 1) score += 20

  return score >= 50
}

4. 蜜罐接口(最终杀招)

部署了一个隐藏的蜜罐 API 来捕获爬虫:

// src/app/api/_hidden/route.ts
// 这个接口在 HTML 里不可见,正常用户永远不会请求
export async function GET() {
  const ip = headers().get('x-forwarded-for')!
  // 任何请求这个接口的 IP 自动加入永久黑名单
  await redis.sadd('blacklist:permanent', ip)
  return Response.json({ ok: true })
}

在前端 HTML 里插入一个隐藏链接:

<!-- 正常用户看不到这个链接,只有爬虫会爬 -->
<a href="/api/_hidden" style="display:none" aria-hidden="true"></a>

效果惊人:蜜罐上线 24 小时内捕获了 47 个爬虫 IP。 全部永久拉黑。

最终效果

指标优化前优化后改善
API 网关月费¥3,200¥85-97.3%
爬虫拦截率~30%99.7%+332%
正常用户误伤0%0%保持
恶意请求600 万/天<800/天-99.99%

关键点:合理的限流没有误伤任何正常用户。因为正常用户每分钟最多点二三十个页面,60 RPM 怎么都够用。

收获:防守能力是产品的基础设施

做产品的前几个月,我所有的精力都在功能开发上:新功能、新页面、新交互。从来没想过——如果有一天别人想搞你,你有没有防御能力?

这次经历让我明白:

  1. 公共 API 必须有限流。 不需要考虑"会不会影响用户体验",正常用户根本碰不到限流阈值。

  2. 爬虫和攻击者会不断升级手段,你的防御也要持续迭代。 IP 封禁 < 限流 < 行为分析 < 蜜罐。防守是分层体系,不是单点方案。

  3. API 按量计费的云服务一定要设预算告警。 我的云函数一天跑了 ¥3,200 我才发现,如果再多跑两天……

  4. 最好的防守是不引人注意。 如果你不是大平台,没人专门搞你。大部分恶意流量是自动化扫描。做好基础防御就够了。

写在最后

那个晚上我两点才睡,在后台实时看着 Grafana 上的请求曲线,像看一场没有硝烟的战争。每封一个 IP,新的 IP 就浮现。直到限流规则生效,曲线才像退潮一样降下去。

做平台就像造房子——不光要会盖,还要会筑墙。因为互联网上没有栅栏,每个门都是开着的。不守门的产品,再好的功能也是为别人做的。

相关文章