上线当天一切都很顺,直到某个晚上:验证码短信的账单比平时涨了几十倍,或者日志里突然刷出一片来自同一个出口 IP 的登录失败。前者烧的是钱,后者烧的是账号安全。
多数人的第一反应是两个动作——加个图形验证码,或者在最前面配一条限流规则,然后以为这事就到此为止。真出过事的人会知道,这两个动作经常拦不住:限流配了,账单照样涨;验证码加了,脚本照样过。
问题不在工具不好用,而在动手之前有四个问题没回答清楚:拿什么当计数键、计数器放在哪里、拦到什么程度、以及钱的上限钉在哪里。这篇把四个决定逐个拆开。
一、先分清要拦的是哪三种流量
同一个限流规则不可能同时服务好三种目标,因为它们的特征完全不同。
第一种是正常用户的突发。活动开抢、热门文章转发进来,短时间内请求量自然上升。这类流量拦错了就是事故,策略要宽,靠突发额度(burst)兜住,只拦持续的高频。
第二种是单个账号或号码的重复尝试。撞库、反复点「获取验证码」、密码暴力枚举。特征很明确:同一身份重复动作。这类要按账号、手机号、邮箱这个维度计数。
第三种是自动化脚本的批量刷取。短信轰炸、接口爬取、漏洞扫描。特征是同一个来源对大量不同目标发起请求。
三种目标对应三个维度。只用「每个 IP 每分钟 N 次」打天下,结果一定是:阈值定低了误伤正常用户,定高了拦不住脚本——因为脚本可以换 IP,而正常用户可能共用出口。
二、决定一:拿什么当计数键
最直觉的键是 IP,但它在两个场景下会失效。
场景一:应用躲在代理或 CDN 后面。这时程序读到的 REMOTE_ADDR 是上一跳的地址。轻则所有请求被算成同一个人(限流变成全站熔断),重则识别到的全是代理 IP,限流形同虚设。正确做法是在请求入口让 nginx 按可信来源还原真实 IP——关键是 set_real_ip_from 只写自家代理和 CDN 的地址段,写成 0.0.0.0/0 等于把判断权交给攻击者。
场景二:NAT 出口。一栋写字楼、一所学校、一个运营商 NAT 后面可能共用同一个公网 IP。按 IP 限流会让一批互不相关的人共享同一份额度。Cloudflare 的官方文档在讲 Workers 限流时把这条写得很直接:不建议用 IP 或地区作为限流键,因为这些在多数合法场景下会被多个用户共享,容易误伤。
所以有登录态或手机号时,优先按身份维度计数,IP 只作为匿名流量的兜底。日常的取舍大致是这样:
| 计数键 | 误伤风险 | 绕过难度 | 更适合的场景 |
|---|---|---|---|
| 匿名来源 IP | 高(NAT 共用) | 低(换 IP 即绕) | 未登录接口的兜底 |
| 登录用户 ID | 低 | 高(需持有账号) | 已登录接口 |
| 手机号 / 邮箱 | 低 | 高 | 验证码、找回密码 |
| API Key | 低 | 中(密钥泄露即失守) | 开放接口、合作方调用 |
三、决定二:计数器放在哪里
这一层决定了限流是「真的在算」还是「看起来在算」。
进程内存计数器是最常见的错误。多个 worker、多台实例各存一份,实际阈值等于配置值乘以进程数——配了每分钟 5 次,五个 worker 就是每分钟 25 次。这不是理论推导,Django REST framework 的官方文档就承认,它内置的限流类在使用默认的本地内存缓存时只适合简单部署,并且「内置实现存在竞态,高并发下可能放过多余请求」。
nginx 的共享内存好在哪、差在哪。limit_req_zone 用的是共享内存,同一台机器上的多个 worker 共享一份计数,不会乘 worker 数;但多台实例、多个机房之间不共享,实际阈值要乘以实例数。CDN 侧同理:Cloudflare 官方说明限流计数器按数据中心独立、不支持全网统一计数,阈值分散在多个数据中心时行为会不同。
结论很直接:要全局准确的限流,计数器必须放在所有实例都能看到的地方(Redis 这类共享存储),并且计数动作本身要原子。这里有个常被忽略的竞态:先 INCR 再 EXPIRE 是两步,如果进程在两步之间挂掉,这个键就永不失效——既漏了内存,也让这个客户端被永久拦住。Redis 官方文档给的写法是把它放进 Lua 脚本,只在第一次自增时设置过期时间。
Django 项目里有一个更省事的等价写法:cache.add() 的语义是「键不存在才写入」,Redis 后端把它实现为 SET NX,天然是原子的。
from django.core.cache import cache
def allow(key, limit, window=60):
# add() 只在键不存在时写入,Redis 后端即 SET NX
# 过期时间只在这里设置,避免 INCR 与 EXPIRE 分离
if cache.add(f"rl:{key}", 1, window):
return True
try:
return cache.incr(f"rl:{key}") <= limit
except ValueError: # 键刚好过期,按未超限处理
return True
为什么这么写:把「建键」和「设过期」合并成一个原子操作,剩下的只剩自增。要注意 Django 官方明确说过 incr() 不保证原子——在支持原子自增的后端上(memcached、Redis)没问题,换成数据库或文件缓存后端就得自己加锁。这条代码是固定窗口计数,跨窗口边界时理论上有瞬时翻倍,要求严格精度时再换滑动窗口实现。
四、决定三:拦到什么程度
只有「拒绝」一种动作,既不够用,也容易把正常用户一起拦掉。实际可用的是一套阶梯:
- 软限:放行但打标记,让非核心逻辑降级(跳过统计写入、延迟图片生成);
- 拒绝:返回 429 加
Retry-After,这是 RFC 6585 定义的标准语义; - 人机校验:要求过图形或行为验证码后再继续;
- 临时封禁:冷却期,例如 10 分钟内不再受理;
- 拉黑:只留给反复触发的固定来源。
两个细节容易做错。第一,Retry-After 在 RFC 里是 MAY 而不是 MUST,429 响应也不得被缓存;常见的 X-RateLimit-* 响应头只是社区约定,到目前为止没有正式标准,所以别指望客户端会解析它。第二,别静默失败——返回 200、什么都不提示,用户看到的现象是「验证码一直没收到」,只会反复重试,把问题放大。
登录锁定是这一层里最需要斟酌的。以 django-axes 为例,它的 AXES_LOCKOUT_PARAMETERS 支持按 IP、按用户名、按 User-Agent,以及它们的组合来锁定,默认是按 IP。纯按 IP 锁在共享出口场景会连累整片用户;纯按用户名锁则意味着攻击者可以故意把别人的账号锁死,但好处是不必存储 IP,对隐私更友好。常见的折中是「用户名 + IP」组合锁:同一账号从同一来源连续失败才计数。
五、决定四:把预算上限钉死
前三个决定解决的是「拦不拦得住」,第四个解决的是「花不花得起」。验证码短信、邮件、第三方按次计费的接口,被刷的后果直接是账单,而这类接口不可能靠限流做到绝对安全——攻击者只要找到足够多的手机号和来源,总能撬动一部分流量。
服务商侧的默认兜底要先确认。以短信为例,腾讯云的默认频率策略是同一号码同一内容 30 秒内最多 1 条、同一手机号一个自然日最多 2 条(2024 年 5 月 22 日前创建的应用默认是 10 条/天),控制台可以调整阈值、配置发送超量提醒与验证码防盗刷监控;运营商侧还有更硬的上限。这些是有用的托底,但它保护的是通道,不保护你的业务逻辑——被刷的量只要不触发服务商阈值,账单照样涨。
应用侧必须自己做的四件事:单号码发送间隔(一般 60–120 秒)、单号码日上限、单来源日上限、业务前置校验(先过图形或行为校验再发短信)。需要提醒的是,图形验证码在攻防中已经被大量自动化工具识别破解,它是一层成本,不是一道墙,不能作为唯一防线。
最后是一条硬规则:给每个按量计费接口设一个当日预算上限,触顶即熔断并告警。目标是让事故损失有天花板,而不是假设自己能拦住所有攻击。同时把发送量的异常曲线接进监控——正常情况下每天几十条的量突然变成几万条,这本身就是最有效的告警信号。
六、四个坑
坑一:只在 nginx 做限流。多实例部署时阈值被实例数放大,且应用层的重试、定时任务、后台脚本都绕过这层。nginx 适合拦粗粒度的洪水流量,身份维度的精细限流必须在应用里做。
坑二:把进程内存计数器当分布式限流。症状很隐蔽——单机压测完全正确,上线后阈值自动放大好几倍。判断标准很简单:计数器所在的地方,是不是每个实例都能读到同一份。
坑三:按 IP 限制登录。共享出口的正常用户会被一起拦掉,而攻击者换 IP 就绕过了。登录这类有明确身份的接口,按账号维度计数才是对的。
坑四:限流命中后静默处理。返回 200 或空响应,用户不知道发生了什么,只会继续重试。明确的错误文案加上等待秒数,既省流量也省客服。
写在最后
限流这件事的难点不在配置,而在先把目标想清楚:拦的是突发、是单账号滥用、还是脚本刷取;键选身份还是选来源;计数放在进程里还是共享存储里;动作是拒绝还是分级处置;以及钱的上限有没有钉死。四个问题回答完,选什么工具、写什么参数几乎是自然结果。
如果这篇文章帮你避开了一次账单惊喜,欢迎转给正在做验证码接口的同事。
参考文章
- Django 官方文档:缓存的低层 API(incr/decr 原子性说明)
- Django REST framework 官方文档:Throttling
- nginx 官方文档:ngx_http_realip_module 模块
- RFC 6585:Additional HTTP Status Codes(429 Too Many Requests)
- Redis 官方文档:INCR 命令与限流模式
- 腾讯云短信官方文档:安全相关问题(默认频率限制与防盗刷建议)