按用户 ID 限流上线后,监控显示大部分拦截来自同一个桶——那个桶负责的是所有未登录请求。登录用户各自 100 次每分钟,未登录请求几百万人挤在一个"匿名"桶里共享同样的额度。要么把额度调大到失去意义,要么每天误伤一批正常访客。
匿名请求的麻烦不在"量大",而在于它没有稳定标识。限流、配额、缓存、风控这一整套机制都建立在"知道这是谁"的前提上,匿名请求把这个前提拿掉了。拿掉之后剩下的都是弱标识,而每一种弱标识都有一个特定的失效方式。
1. 弱标识的三个失效方式
没有登录态时能用的标识只有三种,成本逐级上升,可靠性也逐级上升,但没有一种能当作稳定身份。
| 标识 | 稳定性 | 成本 | 主要失效方式 |
|---|---|---|---|
| IP 地址 | 低 | 零 | 共享(NAT、出口网关)、漂移(移动网络切基站)、伪造(代理池) |
| 设备指纹 | 中 | 需前端配合 | 浏览器升级即变、隐私模式归零、可伪造 |
| 会话 Cookie | 中高 | 需下发与存储 | 可清除、可批量伪造、首次请求没有 |
IP 是最便宜也最不可靠的一个。家庭宽带的 IP 可能保持几天不变,也可能每天重新分配;企业出口、学校、运营商级 NAT 背后可能是几千个真实用户共用一个地址。用 IP 做配额粒度,等于把一个楼里的人当成一个人。
// 先看清 IP 的真实集中度,再决定用它做多细的粒度
log_format anon '$remote_addr $http_x_forwarded_for "$request" $status $request_time';
// 统计 Top IP 的请求占比:头部占比过高说明 NAT 严重,IP 粒度不可用
awk '{print $1}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -50 \
| awk '{sum+=$1} END {print "Top50 IP 占比:", sum*100/NR"%"}'
// 注意:nginx 日志里的 remote_addr 是上一跳代理地址,必须先用 real_ip 模块还原真实客户端 IP
这里有个前置问题常常被跳过:如果前面有 CDN 或网关,remote_addr 根本不是客户端 IP。没有正确配置 set_real_ip_from 与 real_ip_header 时,所有请求看起来都来自同一两个地址,基于 IP 的一切策略都会立即失效。
2. NAT 与移动网络:误伤的主要来源
限流的灾难以批量投诉的形式出现,而它们的来源相当集中。
| 网络环境 | 标识表现 | 误伤形态 | 缓解方向 |
|---|---|---|---|
| 企业/学校出口 | 数千用户一个 IP | 一个人触发限流,全公司被拦 | IP 粒度放宽,加会话粒度兜底 |
| 运营商级 NAT | 数百至数千用户一个 IP | 地域性集体投诉 | 不做纯 IP 拦截,改用挑战式验证 |
| 移动网络 | 切换基站即换 IP | 同一用户被当成多个新用户 | 提高 IP 维度的额度,避免按 IP 计数 |
| 公共 WiFi | 大量设备一个 IP | 时段性集中误伤 | 识别出口特征,单独放宽 |
| 代理/VPN 池 | 一个请求源反复换 IP | 限流形同虚设 | 不指望 IP 拦住,靠行为特征 |
识别这几种环境不需要复杂模型,出口 IP 的请求分布特征本身就能区分:
// 用访问日志区分"共享出口"与"单一用户",判断该不该对该 IP 做限流
SELECT client_ip,
COUNT(DISTINCT sid) AS sessions,
COUNT(*) AS reqs,
ROUND(COUNT(*) / NULLIF(COUNT(DISTINCT sid), 0), 1) AS reqs_per_session,
COUNT(DISTINCT ua_hash) AS ua_kinds
FROM access_log
WHERE ts > NOW() - INTERVAL 1 DAY
GROUP BY client_ip
HAVING reqs > 500
ORDER BY reqs DESC;
// sessions 与 ua_kinds 同时很大 → 共享出口,按 IP 限流会误伤
// sessions 很小而 reqs 极大 → 单一来源高频,值得单独处理
误伤的隐蔽性在于它不会产生告警。被拦的用户大部分直接离开,只有极小比例会来投诉;投诉到达客服时,工程师手上已经看不到当时的限流日志。所以匿名场景的限流必须有一个"只记录不拦截"的观察期,用它来估算误伤规模,而不是靠上线后听投诉。
3. 配额的分配与身份迁移
匿名请求该给多少额度,一半是技术问题,一半是产品决策,两者必须分开讨论。
| 决策 | 技术侧能给的输入 | 产品侧要回答的问题 |
|---|---|---|
| 匿名额度是登录的几分之一 | 匿名与登录请求的成本分布差异 | 是否愿意用体验换注册引导 |
| 匿名额度按什么维度分 | 各弱标识的集中度与稳定性 | 哪些用户群体更容易被误伤 |
| 登录后额度是否重置 | 登录前后会话能否关联 | 重置会不会被用来刷 |
| 未登录的浏览是否算入配额 | 各接口的成本量级 | 哪些行为必须允许未登录完成 |
登录后配额重置这个点容易被忽略。如果登录瞬间额度清零,那么"匿名蹭满额度 → 登录 → 再蹭满"就成了一条免费通道。可行的做法是把匿名阶段已用额度带进登录后的账户,或者对登录后的短时间窗做单独计数。
会话标识的生命周期要和配额窗口对齐。配额按分钟计,会话 Cookie 却设成一年,那么一个被限流的会话在 Cookie 存续期内一直是同一个键;反过来会话只有五分钟,配额窗口就没法跨请求累积。这两者不是独立参数,调一个必须调另一个。
// 配额分层:粗粒度兜底、细粒度精确,两层都要有出口
anon_quota:
global: // 总量兜底,防全局打满
limit: 500000/min
by_ip: // 粗粒度,额度给宽,只拦明显异常
limit: 3000/min
exclude_cidrs: ["10.0.0.0/8", "203.0.113.0/24"] // 已知出口
by_session: // 主粒度,匿名请求的真实约束
limit: 120/min
key: "sid"
on_missing_key: issue // 无会话时先下发再计数,不要直接放行
on_login: carry_over // 登录后继承已用额度,避免清零被刷
4. 挑战式验证:把拦截换成举证
纯拒绝是最省事也最伤人的处理。挑战式验证在两者之间加了一档:不直接拒绝,而是要求请求方证明自己是人。
| 方式 | 对正常用户的成本 | 对自动化的成本 | 适用位置 |
|---|---|---|---|
| 图形验证码 | 中(要辨认、要重试) | 低到中(可打码) | 低频、可接受打断的入口 |
| 滑块/轨迹 | 低 | 中 | 通用入口 |
| 无声挑战(JS 执行) | 极低 | 中(需模拟浏览器) | 高频接口前置 |
| 延迟排队 | 中(等待时间) | 高(占用连接) | 高成本接口 |
挑战式验证还有一个绕不开的成本:它对无障碍使用者与老旧设备不友好,而且失败后的重试路径往往比正常路径更脆。因此它适合放在"少数请求"上——也就是前置条件应该足够严格,让正常情况下几乎没人遇到挑战。
把它当限流的下一档而不是替代,才有意义:先记录、再挑战、最后拒绝。顺序反了就会在还没搞清楚正常流量长什么样的时候,先把正常用户挡在外面。
还有个容易忽略的副作用:挑战式验证会产生额外的请求量(失败重试、资源加载),在系统已经很紧张的时候引入它,可能把压力推得更高。它适合放在平时,不适合放在故障中。
5. 排错:五种"限流拦了不该拦的"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 某一时段集中投诉 | 企业出口或公共 WiFi 共享 IP 被限 | 用 IP 集中度统计定位,放宽该 IP 粒度 |
| 日志里所有请求同一 IP | 反代未还原真实客户端 IP | 配置 set_real_ip_from 与 real_ip_header |
| 限流后爬虫流量没下降 | 代理池导致 IP 维度失效 | 停止依赖 IP,改用会话与行为特征 |
| 匿名额度调大后接口被打满 | 单一维度无总量兜底 | 加全局总量层,保留 IP 与全局双层 |
| 用户登录后仍被限流 | 登录后未携带或迁移匿名额度 | 明确迁移规则,日志里打印使用的配额键 |
第二条是最容易排查也最致命的:如果真实 IP 没被还原,那么不仅限流失效,基于地理位置、黑白名单、访问统计的所有下游能力都是错的。这类问题只能在链路层面修,不能在下游想办法。
// 验证真实客户端 IP 是否被正确还原
curl -s -H "X-Forwarded-For: 198.51.100.7" https://TARGET/api/whoami
// 返回的客户端 IP 应为 198.51.100.7;仍是上游代理地址说明 real_ip 配置缺失
// 注意:仅当来源属于可信代理段时才应信任 XFF,否则该头可被随意伪造
6. 边界:什么情况不需要匿名配额体系
| 场景 | 为什么不需要 | 做法 |
|---|---|---|
| 纯内部服务,无匿名入口 | 所有调用方都有身份 | 按服务身份限流即可 |
| 流量极小且无爬虫动机 | 单点策略足够 | 只做全局总量控制 |
| 匿名与登录行为高度重合 | 区分粒度没有收益 | 统一按会话计数 |
| 接口本身成本极低 | 限流收益低于误伤成本 | 不做限流,只做监控 |
| 业务目标是扩大匿名浏览 | 限制匿名与目标冲突 | 配额只防滥用,不做引导 |
前面几节的判据,可以压成一张决策表:
// 匿名流量处置的顺序:每一步都是下一步的前提
1. 真实 IP 是否还原 ← 没做到,后面全部失效
2. IP 集中度是否可接受 ← 头部占比过高,放弃 IP 粒度
3. 会话标识是否已下发 ← 首次请求必须先发再计
4. 是否有全局总量兜底 ← 防止任一维度被绕过后全站打满
5. 是否有只记录不拦截的观察期 ← 用它估算误伤,而不是听投诉
6. 挑战式验证放在最后一档 ← 常态下应极少触发
判断口径可以归纳成一句:匿名配额的目的是防止少数人挤占多数人的资源,不是引导注册。一旦这条目的被混淆,额度就会被当成增长工具来调,而它对误伤的判断标准会同时失效。
7. 自测
- 弱标识有哪三种?各自最主要的失效方式是什么?
- 为什么有 CDN 或反向代理时,必须先用
real_ip模块还原客户端地址。 - 运营商级 NAT 场景下,为什么纯 IP 拦截不可用?
- 登录后配额清零为什么可能成为一条免费通道?如何避免?
- "先记录、再挑战、最后拒绝"这个顺序,为什么不能颠倒?
- 匿名配额的目的如果不是引导注册,那么它的判据应该是什么?