前几天帮某团队排查一个线上接口告警,日志翻出来的时候我人有点麻:同一个IP五秒钟之内打了四十多次请求,数据库连接数直接被打满。这种场景后端同学应该都不陌生——不管是活动羊毛党、爬虫脚本,还是前端异常重试,防止同一IP同一时间段内多次访问这件事,几乎是每个接口流量到一定量级之后都会撞上的坎。这篇文章就围绕这个主题,把我这几年实际用过的方案、选型逻辑、参数设置和踩过的坑完整梳理一遍,适合后端开发、运维,以及任何准备自己做防刷限流的同学参考。
很多朋友一上来就写代码,把“IP限频”四个字做成一个接口,然后上线,结果要么限不住,要么把正常用户全误伤了。这两种极端我都经历过,而且都极其痛苦。所以这篇文章不会只给一段代码完事,我会把“为什么这么做”也讲清楚,等你看完,至少能根据自己业务的实际情况,设计出一套不会被线上环境打脸的限流方案。
1. 先想明白:到底哪些接口需要按IP掐掉访问频率
做限流之前,最忌讳的事情就是“全站统一限频”。你拿一个IP维度套到所有接口上,一定会出事。我见过最经典的翻车案例,是某团队给全部接口加了一个“同一IP每分钟最多60次”的限制,结果第二天上午十点,一个几百人规模的公司办公网出口IP把官网接口给打限了——因为那个公司所有人访问官网都走同一个公网出口。
所以第一步不是写代码,而是先想清楚:你的接口到底属于哪一类。
1.1 必须限的:匿名高风险接口和敏感操作
有这么几类接口,天然就是被高频访问的重灾区:
- 登录/注册接口:暴力破解密码、撞库,全靠同一个来源IP反复尝试。
- 验证码接口:发短信验证码是按条计费的,不限频等于给攻击者发短信的自由,几分钟就能把预算刷光。
- 投票/抽奖/秒杀接口:活动入口一旦公开,脚本刷票是必然事件,尤其是未登录状态下也能参与的匿名活动。
- 搜索/查询接口:爬虫抓数据最常打的类型,一个IP可以瞬间把全量数据翻一遍。
- 非登录状态下的写操作:比如匿名提交表单、举报、反馈,这类接口没有账号维度可以追溯,IP几乎是唯一能用的抓手。
这类接口的共同特征是:用户不需要登录就能触发,或者触发成本很低。没有账号维度的情况下,你不按IP限,就真的什么限制都没有了。
1.2 不能硬限的:共享出口和NAT环境
按IP限频最大的天敌,是网络地址转换。公司办公网、学校校园网、大型住宅小区宽带,经常几百人共用同一个公网出口IP。你在日志里看到的“同一个IP在同一时间段大量访问”,很可能不是一个人在搞事,而是三百个正常用户恰好在同一时间刷开了你的页面。
我在实际项目中遇到过一个具体案例:某高校的校园网出口IP在午休时段单个IP的请求量达到了每分钟上千次,从数据上看完全像攻击行为。但点开详情一看,UA五花八门、登录态各不相同,实际是几百个学生在用不同设备同时访问。这种情况下如果按一个IP限制每分钟200次,那就是把整个学校的人都挡在门外。
所以判断标准很简单:如果有更精确的维度(账号、设备ID)可用,IP权限必须放宽;如果没有,IP限额要给足余量,而不是卡着正常用户的上限来设计。
1.3 “同一时间段”怎么定义:窗口粒度是个业务问题
标题里的“同一时间段”听起来很简单,真正落地时会变成两个参数:窗口大小和允许次数。比如“一分钟内最多5次”和“一小时内最多30次”,虽然都是限制,但语义完全不同。
窗口粒度必须跟着业务场景走。以短信验证码为例,常见的规则组合是“1分钟最多1条、10分钟最多3条、1小时最多5条”——这是因为短信通道有成本,而且正常人不会在1分钟内反复要求发送验证码。再比如登录接口,暴力破解的特点是高频反复尝试,规则可以设计成“15分钟最多5次失败尝试、1小时最多10次”,超过之后进入验证码流程而不是直接封IP。
这里有一个很关键的经验:阈值不能拍脑袋定。上线之前先翻一下线上访问日志,看正常用户的请求间隔和频率分布,取P95或者P99的值,再在这个基础上放宽3到5倍,作为初始阈值。如果你连正常用户长什么样都不知道,直接套一个网上找的“60秒100次”,本质上是在掷骰子。
2. 三个方案的内存账单:窗口、滑动窗口和令牌桶的取舍
确定需求之后,才轮到选算法。很多人以为限流就是“每段时间最多N次”,但实际做下来有三个常见方案,各自有非常明显的优缺点。理解它们的差异,比背代码重要得多。
2.1 固定窗口:实现最便宜,但窗口边缘会放进两倍流量
固定窗口的思路非常直观:把时间切成固定大小的桶,比如按每一分钟分桶,统计这个IP在当前这一分钟里已经发起了多少次请求,超过限制就拒绝,等下一分钟桶翻转后重新计数。
用伪代码表示就是:取当前时间戳除以窗口大小得到的商作为桶号,在这个桶号上累计计数。这个方案的优点是简单,一个计数器就能完成,内存占用极小,Redis里一个key搞定。但它的缺点也极其经典——窗口边界问题。
举个例子:你设定的限制是“每分钟最多100次”。用户在59分59秒发了100次请求,过了1秒来到60分00秒,新窗口开启,又可以发100次。也就是说,在这短短两秒内,系统实际承受了200次请求。这不是理论上的风险,我在压测中真实复现过,下游服务的耗时曲线会在窗口边界处出现明显的尖刺。
这个问题的本质,是限流标准在边界处发生了瞬时重置。要是你的下游服务容量和限流阈值是紧耦合的,比如阈值100次的同时数据库最多只能扛120QPS,那么这种边界尖刺就可能成为压垮系统的最后一根稻草。
2.2 滑动窗口:把“往前数一个窗口”变成常态
滑动窗口的思路是:不再按固定桶号统计,而是记录下这个IP每一次请求的具体时间点,每次判断时,把“当前时间往前推一个窗口”这段时间内的所有请求数统计出来,看是否已达上限。
在Redis里最常见的实现是用有序集合,score存请求时间戳,member存唯一ID。每次请求时,先把窗口之外的旧数据删掉,再统计集合内的元素数量,如果小于阈值就放行并加入本次请求时间戳。
-- KEYS[1]:限流key,例如 rate:{ip}:{接口} -- ARGV[1]:当前毫秒时间戳 -- ARGV[2]:窗口大小,单位毫秒 -- ARGV[3]:允许的请求次数 local key = KEYS[1] local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local limit = tonumber(ARGV[3]) redis.call('ZREMRANGEBYSCORE', key, 0, now - window) local count = redis.call('ZCARD', key) if count >= limit then return 0 end redis.call('ZADD', key, now, now .. ':' .. math.random(1000000)) redis.call('PEXPIRE', key, window) return 1这段脚本的逻辑,等价于你每次问“过去这一分钟内我到底放过多少个请求进来”的时候,它永远从当前时刻往回看。固定窗口在边界一秒之内可能放进双倍流量,滑动窗口则完全消除了这个问题,从55秒到65秒之间的任何一秒检查,窗口都是连续滑动的。
代价也很直接:内存开销大。固定窗口一分钟只需要存一个计数器,滑动窗口一分钟可能要存几百上千个请求记录。对于高频接口,比如每秒几百QPS的查询接口,如果用这个方案做全局限流,Redis里会攒下海量的member记录。所以滑动窗口更适合用在验证码、登录、下单这些对精确性要求高、但频率本身不高的关键路径上。
2.3 令牌桶/漏桶:需要“匀速”的时候才用
如果需求不只是“限制次数”,而是“限制速率”——比如保护下游数据库,不希望任何一秒内出现突发流量,那就要用到令牌桶或者漏桶。
漏桶的思路是:请求进来先入桶,桶按固定速率往外放行,桶满则溢出丢弃。令牌桶则相反:以固定速率往桶里放令牌,每个请求要取到一个令牌才能被放行,桶里可以积攒最多一定数量的令牌。两者都能把流量整形得更平滑,区别在于令牌桶允许有限度的突发流量,漏桶则是绝对的匀速。
三者的取舍我用一个表格说清楚:
| 方案 | 实现成本 | 是否能精确到窗口内任意时刻 | 是否允许突发 | 典型场景 |
|---|---|---|---|---|
| 固定窗口 | 极低 | 否,窗口边界可能双倍 | 是 | 日常兜底,粗粒度防刷 |
| 滑动窗口 | 中 | 是 | 是 | 关键接口精确限频 |
| 令牌桶/漏桶 | 中高 | 是 | 令牌桶有限允许,漏桶不允许 | 保护下游,控制请求速率平滑 |
实际选型的时候,我给大多数业务防刷场景的建议是:固定窗口做兜底,滑动窗口做关键路径,令牌桶留给网关层保护后端服务。如果你用的是Nginx,它的limit_req模块本身就实现了基于漏桶算法的IP限流,做突发拦截非常方便,不需要自己重复造轮子。
3. 落地到代码:从一条Redis命令到一套限流组件
方案定了之后,真正的坑才开始出现。你以为不就是“INCR一个key,超过就拒绝”吗?我当初也是这么想的,直到被线上数据教育了几次。
3.1 原子性:INCR和EXPIRE分开写,等于埋雷
最常见的Redis限流写法是:INCR一个key得到当前计数,如果计数是1就顺手EXPIRE设置过期时间,然后判断计数是否超过阈值。这段逻辑看起来没问题,但要知道,INCR和EXPIRE是两条独立的命令,中间哪怕隔了零点几毫秒,只要进程在这两条命令之间崩溃,key就会变成一个没有过期时间的永久计数器。线上跑一段时间之后,Redis里会积累一堆永不清理的key,内存一点点涨上去,最终把Redis打挂。
这个问题的根因不在Redis本身,而是“检查计数、设置过期、判断结果”这三个动作不是一个原子操作。别的方案是用Lua脚本,把整个逻辑塞进一次原子执行:
-- KEYS[1]:限流key -- ARGV[1]:限制次数 -- ARGV[2]:窗口大小,单位秒 local current = redis.call('INCR', KEYS[1]) if current == 1 then redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2])) end if current > tonumber(ARGV[1]) then return 0 end return 1把这段脚本缓存在Redis里,业务代码通过EVALSHA调用,既保证原子性,又避免了每次传输整个脚本的性能损耗。这是目前基于Redis做固定窗口限流的推荐姿势。注意,如果你用的是Redis集群模式,需要确保同一个限流key的脚本能落在同一个节点上,也就是说key中应该包含IP或接口标识这些固定的部分,而不是裸用时间桶变量。
3.2 key设计:别把全站限流和单接口限流混在一起
很多初版限流组件的key只写成rate:{ip}:{bucket},意思是“这个IP在当前时间桶里总共能做多少次请求”。这种设计有一个严重问题:接口之间互相拖累。如果攻击者专门找个不重要的查询接口猛打,同一个IP的其他所有接口(包括登录、下单)都会被一起限住,等于攻击者免费获得了一个让所有正常用户都无法使用的机会。
正确的做法是,key里带上接口维度或业务维度:
rate:{ip}:{interface}:{bucket}:单接口按IP限频,核心方案。rate:{ip}:total:{bucket}:IP全局请求量限制,只用来兜底极端突发。rate:{userId}:{interface}:{bucket}:登录状态下按账号限频,比IP更精准。
我在项目里通常是三层一起上:第一层Nginx按IP做全局速率拦截,阈值放得很宽;第二层业务层按IP+接口做精确限频;登录接口额外加账号维度限频。每一层的key语义不同,各有各的职责,不会互相覆盖。
3.3 阈值设计:分接口、分窗口、分维度
阈值设计没有万能公式,但可以参考我实际项目里用过的一组配置,再按你自己的真实日志去修正:
| 接口类型 | 窗口大小 | 限制次数 | 维度 |
|---|---|---|---|
| 验证码发送 | 60秒 | 1次 | IP |
| 验证码发送 | 3600秒 | 5次 | IP |
| 登录失败 | 900秒 | 5次 | 账号 |
| 登录失败 | 3600秒 | 10次 | IP |
| 匿名投票 | 60秒 | 3次 | IP |
| 普通读接口 | 60秒 | 120次 | IP |
| 非登录写接口 | 10秒 | 10次 | IP |
老读者应该能看出来,验证码和登录用的是“多级窗口”,短窗口限高频,长窗口限总量。这是防破解的标配设计,因为攻击者如果只在短窗口上被卡住,他可以拉长节奏慢慢试,长窗口能兜住这种慢速攻击。
还有一点:给用户留缓冲。比如登录失败3次之后,不要直接封死,而是要求用户等30秒再试——响应码用429,并带Retry-After头告诉客户端什么时候可以重试。这种软性限流比一刀切的“封IP”体验好得多。
3.4 多实例部署:本地缓存方案为什么必然泄洪
如果服务部署在单机上,用一个内存里的计数器也行。但现在的服务几乎都是多实例部署,你搞一个本地内存的限流Map,四台机器就意味着每个IP的阈值被放大了四倍。
假设你设计的是“每个IP每分钟最多100次”,四台实例各自都可以放行100次,最终实际放进来的流量可能到400次。你以为自己在限流,其实只是把阈值乘了个实例数的系数。这就是为什么我强烈建议:只要是上了多实例的服务,限流计数必须走集中式存储,Redis几乎是目前最合适的选择。
如果公司对Redis依赖有顾虑,也可以考虑本地内存限流 + 实例感知的方案,比如把阈值除以实例数,或者按实例ID分片计数。但说实话,这种方案的复杂度并不比直接用Redis低,而且一旦某台实例重启、扩容,分片逻辑就会出问题。能用Redis就用Redis,这是结论。
4. 别迷信IP:共享出口、代理池和IPv6下的真实局限
IP限流做久了你会发现一个残酷的事实:IP这个维度,在真实互联网环境里远远没有我们想象的那么可靠。不是说IP限流没用,而是它只能作为第一道防线,不能作为唯一防线。
4.1 共享出口和NAT:误伤是常态而不是意外
前面已经提到,公司、学校、小区宽带都会出现大量用户共享一个出口IP的情况。线上限流命中日志里,你看到“某个IP访问频率异常”,一定要先确认这个IP背后站着的是一个人还是一群人。
我的经验是:限流策略上线前,先拿历史日志把TOP N的IP拉出来做“人肉审计”。看看那些高频IP对应的UA、登录态、设备型号是不是高度多样。如果来自同一IP的访问横跨了各种设备型号和多种登录账号,那大概率是共享出口,阈值要特意放宽,或者改为只触发验证码,而不是直接拒绝。
4.2 代理池和秒拨IP:攻击者永远不会只用同一个IP
攻击者手里有成百上千个代理IP,可以通过代理池让每个IP只发少量请求,也可以借助“秒拨”能力不断更换出口IP。这种情况下,你限制“同一IP同一时间段内多次访问”能起到的效果,就变成单纯抬高攻击者的成本——他需要凑更多IP才能维持原来的攻击速率。
所以更务实的定位是:IP限流是一个廉价但低精度的过滤器,真正拦高级攻击要靠组合拳。通用的组合是:网关层IP限流挡最粗暴的僵尸型攻击,业务层账号维度限流挡有登录行为的攻击,遇到可疑IP直接引导到验证码,验证码解决不了的风控再介入。
4.3 通过请求头取IP,别直接信任X-Forwarded-For
这是我在网上见人踩得最多、也最容易被人忽视的一个坑。很多团队为了省事,直接从X-Forwarded-For头里取IP,结果攻击者只需要手动改一下请求头,每次携带不同的X-Forwarded-For值,你的限流就完全不设防了。
正确的做法是:直接取TCP连接层的对端IP,或者在经过可信CDN/负载均衡的情况下,只信任固定一跳传来的IP头,并且要做严格的来源校验。不要把客户端能随意伪造的字段直接当成IP来限流,否则你写的限流代码只是在防君子不防小人。
4.4 IPv6:一个攻击者可能拥有整个子网
IPv6环境下,一个用户可能拥有多个临时地址,动态变化频繁。攻击者甚至可以控制一个/64甚至/48子网下的海量地址。按单个IPv6地址限流基本等于没限。
处理方案是按子网前缀聚合,最常见的做法是对/64子网做聚合统计。实现时注意不要简单粗暴地截断字符串,要通过位运算计算前缀,否则会在前缀边界上出错。
5. 线上踩坑实录:从限流失效到全公司误伤
理论讲再多,都不如两个真实案例来讲得清楚。这里分享两个我实际参与的排查过程,一个是因为限流失效导致的线上事故,一个是阈值设计失误导致的误伤事故。
5.1 案例一:限流组件没生效的完整排查链路
现象是某活动接口上线了IP限流,但压测时同一个IP依然能高频通过。我们当时没有直接改代码,而是一层一层往下查。
第一步,先看业务日志里限流模块有没有被触发,发现日志中根本没出现限流关键词。第二步,检查配置中心,发现限流配置已经推送成功,说明规则本身是存在的。第三步,查看Redis里的限流key是否存在,发现key存在且计数在增长,说明计数逻辑在走。第四步,把请求日志里解析出的IP字段和TCP连接层实际IP做对比,发现了关键问题:代码里用的客户端IP是从X-Forwarded-For头里取的,压测工具在每次请求里随机伪造了不同的值。限流组件确实在运行,但它眼里看到的IP一直在变,自然永远限不住。
修复方式有两个方向:一是把IP来源改成TCP层连接IP,或者经过可信代理链路之后拿到的真实IP;二是保留X-Forwarded-For但增加校验和来源过滤,只信任已知的代理节点。我们当时选了前者,改完后压测就正常了。
但问题没有完全结束。第二个阶段我们发现,即使IP来源正确,多台应用实例各自的本地内存缓存导致阈值被放大了。压测请求被负载均衡分发到四台实例,每台实例各自独立判断,计数器互不共享,最终真正打进来的请求量是预期的四倍。这次我们把限流计数迁到Redis统一管理,才彻底解决。
5.2 案例二:一个出口IP把全校师生挡在门外
另一个案例是我参与的一个高校官网项目。上线限流后的第二天,接到大量用户反馈说网站访问不了。打开限流日志,发现某个IP触发了“60秒120次”的限制,频繁被拒绝。
从数据上看,这个IP确实“作恶”了,请求频率远超阈值。但进一步查看这个IP下的请求详情,UA涵盖了各种型号的手机和电脑浏览器,同时存在超过三十个不同的登录态账号。结论很明显,这是校园网统一出口,几百个学生共用了一个公网IP,总请求量自然轻松超过单IP阈值。
最终的修复方案是:校园网出口IP段单独设置一份更宽松的限额,同时把主要限制维度从IP切换成账号;未登录状态下的查询接口不做严格IP限制,而是靠验证码兜底。这个案例给我的教训是:限流阈值上线前,必须先做人群画像分析,确认你的“同一IP”到底对应“同一人”还是“同一群人”。
5.3 上线限流前必做的三件事
经过这些踩坑,我现在上线任何限流规则都要走三道流程:
第一,翻日志。至少统计一周的正常访问日志,找出正常用户单次会话内对每个接口的请求频率分布,按P99甚至P999来设定初始阈值。没有这一步,你设的阈值就是在盲人摸象。
第二,灰度。不要全量上线,先放5%的流量,观察被限用户比例。正常业务的被限率应该在万分之几的量级,如果超过0.1%,说明阈值或维度设计有问题,得回头重新分析。
第三,监控。限流组件必须暴露指标:命中次数、被限制的用户后续是否放弃访问、业务成功率是否变化。限流本身有没有误伤,要靠数据说话,而不是靠感觉。
这几件套做完,你才算把“防止同一IP同一时间段内多次访问”这件事从口号变成了真正可靠的能力。我自己现在做新项目,已经习惯先接好限流组件再开始写业务逻辑,虽然前期看起来多花了时间,但后期几乎不用再为刷量、撞库、爬虫这种事熬夜救火。