2026/9/7 16:30:29

高并发系统稳定性基石:防刷、限量与防重的实践指南

高并发系统稳定性基石:防刷、限量与防重的实践指南 1. 三件事为什么必须放在一起做防刷、限量、防重的定位差异先说个我亲身经历的案例。之前做一个积分商城的秒杀活动上线前只压了正常流量结果活动开始十分钟接口直接被打挂了。查日志发现同一台机器在毫秒级内连打了几百个请求而且全是同一个账号在批量刷看似不同的订单号其实是脚本在遍历参数。更尴尬的是有一批订单因为客户端超时重试同一个用户同一件商品创建了两条订单库存扣了两次。当时复盘下来就一句话防刷、限量、防重这三件事缺哪一件都会出事但它们的职责完全不同不能混为一谈。先明确一个概念这三件事在请求链路上的位置和目标是完全不一样的。防刷解决的是“请求是谁发出来的”问题目标是识别并拦截机器脚本、恶意程序把不正常的请求挡在门外。限量解决的是“后端能不能扛得住”的问题目标是控制进入业务层的请求速率哪怕请求全是正常的也不能一次性全部放进来把服务打垮。防重解决的是“同一次操作会不会生效多次”的问题目标是保证一次业务操作只落一次库只扣一次钱只发一次货。我用一个比喻来理解防刷是小区门口的保安先看进来的人是不是业主、是不是陌生面孔限量是楼栋门口的闸机不管你是谁一次只能放过去几个人防止楼道拥挤踩踏防重则是家里的门锁你拿同一把钥匙开门开一次是开门不能因为你多拧了两下就把门给你卸了。三个环节各司其职但必须在一个请求的生命周期里配合起来。所以一个典型的请求链路应该是这样请求到达网关层先做防刷判断这里主要靠IP、设备、用户标识等维度做规则拦截通过之后进入限流层根据接口的容量评估决定这个请求是立即放行、排队等待还是直接拒绝最后才进入业务层在真正处理业务之前做防重校验确认这个操作之前没有执行过。如果顺序反了比如先把请求放进业务层再做防刷高并发下防刷逻辑本身就会成为性能瓶颈如果防重放在最后但前面的环节不设限恶意请求就会把防重的存储层打爆。这里还有一个容易被忽略的点这三个机制虽然目标不同但它们在实现上有很强的复用关系。比如防刷要用Redis计数器统计IP访问频率限量也要用Redis计数器统计窗口内请求数防重也要依赖Redis做分布式锁或者去重标记。也就是说Redis基本上是这三件事的共同底座。理解了这一点你在设计系统的时候就不会把三套逻辑各写各的而是会考虑怎么共用存储、共用连接池、共用监控指标整个架构会清爽很多。2. 防刷识别“人”和“机器”的区别不能只靠封IP防刷是整个兜底体系的第一道门也是最容易被人误判的一道门。很多团队一开始做防刷第一反应就是限制IP访问频率比如一分钟同一个IP最多访问接口10次。这个思路方向没错但在真实场景里单靠IP维度做防刷误杀率和漏杀率都很高。先说说IP维度的局限性。一个公司出口的IP往往是共享的比如写字楼的Wi-Fi一个网段下可能有好几百号员工在同时访问IP限制设得严了正常用户会先被误伤。反过来恶意流量往往使用代理池或秒拨IP每次请求的IP都在变你按IP维度去统计每个IP的请求数都不高根本拦不住。所以IP限制只能作为最基础的策略不能作为唯一手段。更靠谱的做法是组合维度判断。我在项目中常用的组合包括用户ID维度如果登录的话、设备ID维度前端生成的唯一标识、IP维度、User-Agent和浏览器指纹信息。一个正常的用户短时间内在同一设备和同一种浏览器行为上是稳定的而脚本刷量往往是高并发、短间隔、请求序列化非常规律这些特征在行为层很容易暴露。比如一个用户在1秒内发送了20次请求正常用户是做不到的这就可以触发规则。再说验证码。滑块验证码、点选验证码、无感验证码这几类是目前比较主流的方案。我个人的建议是不要一上来就对所有用户弹验证码那样用户体验很差而是要在规则引擎判定当前请求“可疑但不确定”的时候才弹出验证码。比如某IP维度的请求频率超过了正常阈值的80%但没有到硬性拦截线这个请求就进入验证码流程验证通过后再放行。这样既降低了对正常用户的打扰也提高了对机器流量的识别率。防刷的落地实现我一般用Redis来做计数统计然后用Lua脚本保证原子性。为什么不用简单的读改写操作因为在高并发下读到一个计数、加一、写回这三步如果是分开的就会出现并发覆盖导致计数不准。Lua脚本可以在这三步全部在Redis服务端一次执行不会有并发问题。下面是一个典型的防刷计数器实现基于Redis和Lua脚本-- 防刷计数器统计窗口内请求次数 -- KEYS[1]: 统计key比如 rate:limit:ip:1.2.3.4 -- ARGV[1]: 窗口大小秒 -- ARGV[2]: 最大请求次数 local current redis.call(INCR, KEYS[1]) if current 1 then redis.call(EXPIRE, KEYS[1], ARGV[1]) end if current tonumber(ARGV[2]) then return 0 -- 超过阈值拒绝 end return 1 -- 放行这个脚本的逻辑很简单每次请求对key做INCR如果是第一次请求就设置过期时间然后判断当前计数是否超过阈值。这里有一个细节值得注意就是“先INCR再判断”意味着被拦截的请求本身也会计入计数。这个设计是为了防止攻击者用大量恶意请求不断刷新计数导致前面正常请求的计数被挤出窗口实际效果是对恶意流量更敏感但代价是正常用户如果刚好在阈值边缘可能会因为前面几个恶意请求而误伤。如果你们对误伤零容忍也可以改成先判断再INCR但那样恶意流量会更容易绕过计数。阈值怎么设这里没有标准答案必须结合业务场景。我通常的做法是先看历史N天的接口日志统计出正常用户单IP、单设备的请求间隔分布然后取一个“正常用户不会超过、脚本很容易达到”的值。比如一个普通用户浏览商品详情页平均5秒才点一次那么单IP每分钟30次就比较合理但如果是一个查询库存的接口前端每2秒轮询一次那么阈值就要放宽到每分钟60次以上。定阈值时宁可先松后紧也不一上来就设得非常严格否则活动一上线用户没刷起来先被自己的防刷策略给拦了。进阶一点的防刷还会做设备指纹和行为特征分析。设备指纹就是前端采集浏览器、屏幕分辨率、时区、字体、Canvas特征等信息生成了一个相对唯一的设备ID后端判断同一个设备ID的请求频率。行为特征则是分析鼠标轨迹、点击间隔、滚动行为等正常人的鼠标轨迹是有缓动和停顿的脚本的轨迹是直线或者完全随机的这类分析模型需要机器学习介入小团队可以直接用第三方风控服务没必要从零自研。3. 限量把流量关进笼子里算法选型与参数计算限量也就是常说的限流目标很明确保护系统不被超过承载能力的流量打垮。限流的核心是算法面试中也常被问到但真正落地时很多细节值得展开聊。先看四类基础算法。固定窗口计数器是最简单的一种把时间划分成固定大小的窗口比如1秒钟统计这个窗口内的请求数超过阈值就拒绝。实现思路就是你写一个HashMapkey是时间戳value是计数每次请求来的时候对当前窗口计数加一。这个算法有个经典问题叫“临界突刺”假设阈值是100在第一个窗口的最后100毫秒里打进来100个请求在第二个窗口的最初100毫厘里又打进来100个请求那么实际上在200毫秒内就打进了200个请求但每一个窗口看起来都没超过阈值。如果你的接口实际承载能力小于这个数量系统依然会被打垮。滑动窗口计数器就是为了解决固定窗口的临界问题。它把时间窗口进一步细分比如把1分钟分成60个小格子每个格子记录自己的计数滑动窗口每移动一格就淘汰最旧的一格最终统计当前窗口的请求总数。比如你限制每分钟100次60个小格每秒一个当前窗口就是最近60个格子的总和随时在滑动而不是固定锚定在整分钟边界这样就不会出现临界突刺。代价是存储更多每个窗口都要维护一组子计数的状态但用Redis的Hash结构做起来也不复杂。令牌桶算法是比较经典的限流算法。系统以恒定的速率往桶里放令牌比如每秒放10个桶的容量是100超过容量就丢弃令牌。每个请求来的时候先从桶里取一个令牌取到就放行取不到就拒绝。这个算法允许一定程度的突发流量因为桶里可以积攒令牌比如你10秒没用请求桶里最多积了100个令牌这时候突然来100个请求它们都能取到令牌一次性通过这在某些场景下是优点但如果你不希望请求突发太快就要限制桶的容量。漏桶算法则是完全平滑的。想象一个底部有孔的水桶水按照固定的速率从孔里流出去请求进来就像往桶里倒水无论倒得多快流出去的速度都是恒定的。漏桶算法对请求的处理速率是严格固定的所以它天然适合保护下游系统不允许任何突发。但代价是处理延迟会变大流量突然很大的时候桶会满后面的请求直接排队或被丢弃。我用一个表格对比四类算法的适用场景算法是否允许突发实现复杂度适用场景固定窗口会意外突发极低简单控制对精度要求不高滑动窗口控制更精细低需要精确限流的场景令牌桶允许一定突发低Web接口限流、API网关漏桶不允许突发中下游保护、消息消费速率控制实际项目和面试中的建议是日常Web接口优先选令牌桶或滑动窗口因为用户体验相对好允许用户有一定的瞬时爆发下游保护优先选漏桶因为必须严格限制消费速率。限流参数怎么定这是我看到踩坑最多的地方。很多团队上线前不做压测凭感觉把QPS上限设成一个整数比如100结果活动期间正常流量都超过了这个数接口全被限了用户投诉一片。正确的做法是先压测测出单机接口的最大QPS然后乘上机器数量再乘上一个安全系数。比如你单机压测结果是500 QPS有5台机器那么集群理论能力是2500 QPS安全系数取0.7也就是限流阈值设为1750 QPS。为什么要打折因为压测是理想环境线上有网络抖动、GC停顿、数据库慢查询等不确定因素留30%的余量在关键时刻能救命。这里我补充一个参数计算的小例子。假设你们做秒杀活动预估峰值流量是每秒5000个请求每台应用服务器的处理能力是每秒800个请求你有6台服务器理论处理能力是4800。这时候如果你把限流阈值设成5000系统会在流量洪水到来时被打穿。合理的做法是设成4800的0.8倍也就是3840超过3840的请求直接返回“系统繁忙请稍后重试”。流量高峰过去后系统还能用退避策略慢慢消化排队中的请求避免雪崩。单机限流可以选Guava的RateLimiter它是令牌桶实现使用简单但只适用于单进程内。分布式限流要借助Redis和Lua或者直接用现成的框架比如Sentinel。我自己的习惯是逻辑简单的内部服务用Guava就够了面向外部的核心接口用RedisLua做分布式限流这样即使流量分布不均匀也能在集群维度上统一控制总量。分布式限流脚本如下-- 滑动窗口限流 -- KEYS[1]: 限流key -- ARGV[1]: 窗口大小毫秒 -- ARGV[2]: 窗口内最大请求数 local now tonumber(redis.call(TIME)[1] * 1000) redis.call(ZREMRANGEBYSCORE, KEYS[1], 0, now - ARGV[1]) local count redis.call(ZCARD, KEYS[1]) if count tonumber(ARGV[2]) then redis.call(ZADD, KEYS[1], now, now .. - .. math.random()) redis.call(PEXPIRE, KEYS[1], ARGV[1]) return 1 end return 0这个脚本用Redis的ZSET记录每个请求的时间戳每次请求时先删掉窗口外的旧记录再统计窗口内的请求数。注意第4行用了TIME命令取Redis服务器的当前时间而不是用客户端传入的时间避免因为客户端时钟不一致导致滑动窗口错乱。ZADD的score用当前时间戳member用时间戳加随机数这样即使两个请求在同一毫秒到达member也不会冲突。另外限流建议做多级。网关层做粗粒度的全局限流应用层做细粒度的接口限流两者结合。比如网关层限制所有进到集群的请求总量不超过8000 QPS应用层再根据不同接口的优先级分别限制——支付回调接口限500 QPS商品详情接口限2000 QPS。这样做的好处是即使某个接口被恶意针对也只会打爆它自己的额度不会影响其他接口。4. 防重幂等让同一个操作只生效一次防重是这三件事里最容易出错、出事后果也最严重的一个环节。一次重复提交导致用户被扣了两笔钱或者一次重复调用导致库存多扣了一次这种事故的严重性远高于接口短暂不可用。所以我一直强调防重不是可选项而是金融、交易、库存类系统的必备能力。先解释幂等同一操作执行一次和执行多次最终结果一致就叫幂等。比如GET请求天然是幂等的你查十次商品信息结果都一样不会改变数据。但POST、PUT这类写操作就需要专门设计。常见的需要防重的接口包括订单创建、支付回调、库存扣减、消息消费。防重的核心思路是在写操作真正落库之前先检查这个操作是否已经执行过了如果执行过就直接返回第一次的结果不再重复操作。实现这个思路有几种主流方案。第一种方案是数据库唯一约束。这是最可靠的做法把业务上的唯一标识比如订单号、支付流水号设为数据库表的唯一索引如果插入了相同的数据数据库会直接报错程序捕获到主键冲突就说明这是一个重复请求。这个方案简单粗暴但可靠因为数据库的唯一索引是最终的兜底。我在做支付回调防重时会把“支付渠道渠道订单号”设为唯一索引这样同一个支付渠道的同一笔订单回调一万次数据库也只会成功插入一条记录。第二种方案是Redis占坑也就是SETNX。请求进来时用业务唯一标识作为key执行SETNX如果设置成功说明这是第一次请求继续处理业务如果设置失败说明之前已经有请求在处理了当前请求直接返回重复提示。这个方案的优点是简单高效缺点是Redis中的数据会过期窗口期内有效超过过期时间就无法防重了。所以它更适合短时间内的防重比如几秒钟内的重复点击。第三种方案是token机制也叫“预生成令牌”。客户端在发起关键操作之前先向后端申请一个唯一的token后端把token存到Redis客户端提交请求时带上这个token后端拿到token后先删除Redis里的记录删除成功才执行后续业务删除失败就说明操作重复了。这个方案能保证一个token只能被使用一次严格防重。注意删除token和执行业务之间有一个时间窗口如果两个并发请求同时拿到同一个token都会尝试删除只有一个能成功另一个会失败所以不会重复。但如果删除成功之后业务执行失败了用户重试时token已经被删掉了就必须让客户端重新申请token。第四种方案是状态机。在订单类业务里状态本身就是天然防重的手段。例如订单从“待支付”到“已支付”到“已发货”是单向流转的一个已发货的订单不可能再变成已支付。每次收到操作请求时先判断当前状态只有状态匹配才允许流转。我在处理支付回调时就是先查订单状态如果已经是“已支付”说明这笔回调是重复的直接返回成功。这里补充一个实战中容易踩的坑防重键的设计。防重键必须具有业务唯一性不能是那种每次都变的字段。比如你用“用户ID时间戳”作为防重键这个键每次都不一样根本防不住重复。正确做法是用业务上的稳定唯一标识订单号、支付流水号、消息ID、防重token等。如果业务本身没有唯一标识就需要在请求进入时生成一个并且在后来的重试中保持不变。状态机适合那些状态流转明确的业务但要注意并发问题。比如订单支付回调两个线程同时查到订单状态是“待支付”都认为可以更新为“已支付”然后都执行了更新就会重复处理。解决方案是给状态更新加条件update orders set status 已支付 where id ? and status 待支付如果影响行数为0说明订单状态已经变化了当前回调应当视为重复请求。用数据库的行锁或版本号也能解决但update...where的条件更新是最简洁高效的做法。在消息队列消费场景里防重通常使用消息ID。比如RocketMQ的消息自带一个唯一ID消费者在本地或Redis里记录这个ID每次消费前检查ID是否已经被消费过。这里要注意的是检查和消费必须保证原子性否则两个消费者同时读到同一个消息ID都发现没消费过然后都去执行消费逻辑就出问题了。我目前比较稳妥的做法是消费前先用消息ID在数据库里插入一条消费记录消费记录表对消息ID建唯一索引插入成功才真正处理业务插入失败说明之前消费过了直接提交offset。5. 常见问题与排查技巧实录这部分我把自己在项目里踩过的坑和排查思路整理一下都是常规文档里很少写的内容。第一个问题是限流误伤正常用户。活动刚开始的时候我们把限流阈值设得太贴近真实峰值结果大促一开始流量稍微波动了一下一部分用户就被限流了前端弹出“系统繁忙”用户自然以为是网站出了问题。后来我们在限流策略上做了两层优化一是限流阈值不等于拒绝阈值超过阈值的请求先进入排队缓冲比如用阻塞队列队列满才拒绝二是增加“降级放行”策略就是针对登录用户白名单放行只有未登录的匿名请求才受严格限流保护。登录用户是真实用户的比例远高于匿名请求即使请求量稍微超限让登录用户先过对业务的影响也最小。第二个问题是防重逻辑和业务逻辑不在同一个事务里导致的数据不一致。我用过一种防重方案在Redis里SETNX占坑然后执行业务最后删除Redis标记。这个方案有个隐患如果业务执行成功但删除Redis标记失败比如Redis宕机下次重试时SETNX会失败导致用户看到“重复请求”的提示但实际业务已经成功了用户就会困惑。我的处理方案是把SETNX标记和业务执行放进同一个本地事务业务成功才提交事务提交成功后再删除Redis标记。如果删除失败靠标记的过期时间自动清理不会影响下一次新的请求。这个方案不能100%防止极端情况但在绝大多数场景下足够可靠。第三个问题是分布式环境下不同节点的时钟不一致导致限流和防重判断出现偏差。最开始做滑动窗口限流时我用的是应用服务器的本地时间结果有两台服务器时钟相差了5秒导致限流窗口整体偏移部分请求被漏掉了。后来统一改为从Redis的TIME命令取服务端时间所有节点参考同一时钟问题就消失了。做防重时也是同样的逻辑所有涉及时间判断的地方尽量用数据库或Redis的时间不要用各台服务器本地时间。第四个问题是压测和线上表现差距过大。压测的时候接口响应时间很漂亮限流阈值设得很高结果一上线就出问题。复盘发现压测的时候用的全是缓存命中的请求数据库根本没有真实压力而线上流量是缓存未命中居多数据库慢查询拖垮了整个链路。所以我现在的习惯是限流参数定值之前会同时压测两种场景纯缓存命中的情况和部分缓存未命中的混合情况取两者中偏保守的一个结果作为限流依据。还有一个很实操的小技巧关于限流和防重的监控。我把限流拒绝次数、防重拦截次数、验证码弹出次数都接入了监控大盘并且设置了独立的告警项。为什么单独监控因为如果限流拒绝次数突然飙升说明可能有活动流量突增或者某台机器挂了如果防重拦截次数突然飙升很可能是客户端代码出了bug导致请求在无限重试。这些指标是系统健康的晴雨表比单纯看CPU和内存更早发现问题。我在一次排查事故时就是通过防重拦截次数从每分钟几十次涨到几万次定位到前端代码一个循环里重复提交了同一个订单号这个问题的修复花了几分钟但如果没有监控里的防重指标可能要排查很久。6. 兜底之外还有三件容易被忽略的事防刷、限量、防重是核心的兜底三件套但服务要稳定运行还不能忽略配套的基础建设。我在实际运维中发现有三个方面的影响范围和这三件事同样重要。第一是超时控制。很多接口出问题不是被刷垮的而是因为下游接口响应过慢线程池被占满了后续请求全部排队等待最终拖垮整个应用。防刷和限量只能拦住进入的请求拦不住已经进入但卡住的请求。所以一定要给每个外部调用和线程池设置明确的超时时间我用过的最有效的一套参数是HTTP调用默认3秒超时数据库连接池等待时间2秒Redis操作1秒。这些值不等同于线上经验具体要看业务但原则是一样的宁可快速失败也不要默默排队。第二是降级和熔断。当系统已经过载时除了限流拒绝新请求还要考虑关闭一些非核心功能来保住核心链路。比如下单高峰期可以临时关闭评价展示、推荐位等非关键功能把计算资源留给订单和支付。我在这里采用的做法是按照业务重要性给接口分等级一级接口比如下单、支付永远优先保障二级接口比如优惠券列表、积分查询在压力大的时候可以自动降级返回缓存数据三级接口比如个人中心的风控通知、活动推送必要时直接熔断。降级和熔断可以结合Sentinel这类框架来实现配置项很简单但价值巨大。第三是日志和链路追踪。防刷、限流、防重都是在请求链路上加了一些判断逻辑一旦出了问题排查时必须能清晰地看到是哪个环节拦截的。我要求项目里所有拦截点都输出结构化的日志包括拦截类型防刷/限流/防重、拦截维度IP/用户/设备、阈值、当前值、请求ID、链路追踪ID。这样当用户投诉“我明明没刷为什么被拦”时翻日志就能快速还原当时的完整链路。我之前排查过一个诡异问题用户反复反馈接口偶发报错后来靠链路追踪发现是所有请求都被某个IP维度的防刷规则给误拦了而那个IP就是该用户的出口IP因为网段内有人写了个循环脚本在刷接口把整个网段的额度全占了。7. 兜底方案上线前后的经验总结最后分享几点我在多个项目里反复验证下来的体会。先说落地顺序。很多团队一上来就追求大而全防刷要上设备指纹加机器学习限量要上全链路分布式限流防重要上事务消息加分布式锁结果项目周期拉得很长上线前还有很多没验证透。我的建议是分三步走第一步先做最基础的比如IP用户维度的防刷、单机RateLimiter限流、数据库唯一约束防重这一周内就能上线先保证系统有基本的兜底能力第二步再引入RedisLua的分布式防刷和限流把单机策略升级为集群策略第三步才考虑更精细的风控模型和数据一致性方案。每一步上线前都要做充分的压测验证兜底逻辑本身不会成为新的瓶颈。再聊聊阈值动态调整。业务流量不是一成不变的限流和防刷的阈值也应该具备动态调整能力。我现在会把阈值配置放到配置中心里比如Nacos或者Apollo调整配置后可以实时生效不需要重新发布应用。大促前我会提前一天调整限流阈值活动结束再调回来全程不需要重启。这个看似简单的设计在很多关键时刻能省下大量的时间。然后是安全性和业务完整性的取舍。防刷防重机制加多了势必会增加请求链路的复杂度也一定会有误伤。在设计这些机制的时候我始终遵循一个原则任何兜底机制都不能阻断正常业务的主路径。比如防重逻辑判断异常时默认放行而不是默认拦截让业务先跑通然后靠日志事后分析限流触发时返回明确的错误码让客户端能区分“限流了”还是“系统错误”从而决定是重试还是提示用户。错误处理做得好兜底机制本身才不会变成新的故障点。我在实际操作中最深的体会是这些兜底机制的价值不在于平时能不能被看到而在于关键时刻能不能顶住。它们和业务代码最大的不同在于业务代码是“用户操作了什么就执行什么”而兜底代码是“用户再怎么乱操作系统都不会崩”。这种代码往往看起来平淡无奇就是几个判断加几个计数但它们是整个系统稳定运行的地基。如果你准备在项目里加一道安全防线建议先从防刷限量防重这三件事开始它们是性价比最高的投入也是最值得做好做扎实的部分。