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=1 → page=2 → page=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 怎么都够用。
收获:防守能力是产品的基础设施
做产品的前几个月,我所有的精力都在功能开发上:新功能、新页面、新交互。从来没想过——如果有一天别人想搞你,你有没有防御能力?
这次经历让我明白:
-
公共 API 必须有限流。 不需要考虑"会不会影响用户体验",正常用户根本碰不到限流阈值。
-
爬虫和攻击者会不断升级手段,你的防御也要持续迭代。 IP 封禁 < 限流 < 行为分析 < 蜜罐。防守是分层体系,不是单点方案。
-
API 按量计费的云服务一定要设预算告警。 我的云函数一天跑了 ¥3,200 我才发现,如果再多跑两天……
-
最好的防守是不引人注意。 如果你不是大平台,没人专门搞你。大部分恶意流量是自动化扫描。做好基础防御就够了。
写在最后
那个晚上我两点才睡,在后台实时看着 Grafana 上的请求曲线,像看一场没有硝烟的战争。每封一个 IP,新的 IP 就浮现。直到限流规则生效,曲线才像退潮一样降下去。
做平台就像造房子——不光要会盖,还要会筑墙。因为互联网上没有栅栏,每个门都是开着的。不守门的产品,再好的功能也是为别人做的。