资讯详情

接口防刷与IP限频实战:从算法选型到线上踩坑全解析

📅 2026/10/14 13:25:24 | 华诺云谱 👁 阅读
接口防刷与IP限频实战:从算法选型到线上踩坑全解析
前几天帮某团队排查一个线上接口告警日志翻出来的时候我人有点麻同一个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同一时间段内多次访问”这件事从口号变成了真正可靠的能力。我自己现在做新项目已经习惯先接好限流组件再开始写业务逻辑虽然前期看起来多花了时间但后期几乎不用再为刷量、撞库、爬虫这种事熬夜救火。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑