第一次在日志里看到同一个 IP 一分钟内调三百次登录接口时,我就知道这个站躲不过滥用这一课。注册、登录、上传、发帖、点赞,功能都正常,日活也在涨,但有人已经开始拿字典跑密码、注册小号往 AI 接口灌请求、用脚本轰炸上传接口——几分钟就能把模型配额烧掉一大截,让 OSS 账单肉眼可见地涨。
这些事有个共同的名字,叫滥用(abuse)。它的形态千奇百怪,但底层逻辑一致:你的某个资源——账号、接口、算力、存储——被以远高于正常使用的频率消耗掉了。而防滥用这件事,难就难在它没有银弹:你不能把所有请求都挡了,因为正常用户也在用;你也不能不挡,因为成本是真的在烧。
这篇文章拆的是这个项目真实在跑的一套防线。它不是单个中间件,而是一条五层闭环:令牌桶限流挡住峰值、滥用评分盯住累积、分级处置自动升级、应用层快速阻断兜底、重启恢复保证封禁不丢。每一层解决一类问题,层与层之间靠 Redis 串起来。看完你能直接抄,也能理解"为什么封禁一个人不是简单拉黑那么简单"。
防滥用最先要回答的问题是:这个接口,一分钟到底该被调几次? 登录是 5 次还是 50 次,上传是 10 次还是 100 次,AI 生成是 1 次还是 10 次——答案不同,风险完全不同。所以第一层防线不是写死一套限流,而是给每个接口单独标定它的频率预算。
这个项目用 bucket4j 的令牌桶模型实现。核心是一个注解:
@BucketRateLimit(
key = "user_login",
dimension = RateLimitDimension.BOTH,
permitsPerMinute = 5,
permitsPerHour = 50,
burst = true,
message = "登录过于频繁,请稍后再试"
)
拆开看这五个参数,就是一套完整的限流语义:
key 是接口的"业务身份",注册是 user_register、上传是 picture_upload、点赞是 like_do。它不只是日志里好看,还决定了这套系统最狠的一招——给不同端点配不同权重(这个后面讲评分层会展开)。
dimension 决定限流的"颗粒度":按 IP 限、按用户限、还是 IP+用户双维度。IP 维度防的是匿名攻击(没登录就能打的接口);USER 维度防的是登录后的滥用(比如刷点赞);BOTH 是双保险。注意实现里的一个细节:用户维度会从请求属性里取登录用户 ID,取不到就回退成 IP——匿名用户没有用户 ID,但总得有 IP 可限。
permitsPerMinute / permitsPerHour 是多梯度。光限每分钟不够——攻击者可以"慢慢刷",一小时凑个几百次你也没办法。所以同一把桶叠加分钟、小时两级预算:分钟级挡突发,小时级挡慢性。
burst 控制是否允许"透支":突发模式下用 Refill.greedy 贪婪填充,允许短时峰值透支未来的令牌;严格模式用 intervally 间隔填充,一次只补固定量。登录这种高危接口不开 burst,查询类接口可以开——该松的地方松,该紧的地方紧。
message 是触发限流后给用户看的文案。别小看它——前端拿到 42900 这个错误码后,会直接把这个文案弹给用户。文案写得好,用户知道"你太快了";写得差,用户以为你的网站坏了。
这套限流通过 AOP 切面(@Around)织入,对业务代码零侵入。切面里先查管理员白名单——管理员不能把自己限死了,这是第一条铁律;然后 buildKey 按维度拼出限流键(user_login:ip:1.2.3.4 或 user_login:user:123),再 tryConsume 尝试取令牌。
令牌桶本身缓存在 Caffeine 本地缓存里,10 分钟无访问自动淘汰、上限 10 万把。这个取舍很重要:本地缓存 + Redis 降速倍率的组合——桶在本地跑(快,无网络开销),但 Redis 里存着一个"倍率",被处罚的用户/IP 的桶容量会按倍率缩小。后面你会看到,这个倍率正是升级层的"执行器"。
令牌桶解决的是"单次访问是否超频"。但它有个盲区:慢速滥用。攻击者控制频率,每次都在限流线以下,但持续一整天——单个时间窗看毫无问题,累积起来把接口打成重灾区。这时需要第二层:把每一次限流命中记下来,算一笔累积账。
这套系统在限流切面触发时,会调用一个评分服务,用一段 Redis Lua 脚本原子地记账。脚本做了四件事:
第一,按四个窗口累加计数——1 分钟、5 分钟、1 小时、24 小时,每个窗口一个 INCR 键,各自带 TTL。四窗口的意义是"既要看近况,也要看趋势":24 小时窗口里刷了 200 次,比 1 分钟窗口刷 5 次更能说明问题。
第二,给命中加权重——这就是第一层里 key 的真正用途。每个端点有一套权重配置:注册 10、登录 5、上传 2、AI 接口 5、点赞 1.5、普通查询 1。为什么这么分?因为不同接口被滥用的代价完全不同:注册一个账号可能是养号的第一步,AI 接口烧的是真金白银的算力,点赞刷起来可以水军控评。权重就是"这个接口被刷一次,等于普通接口被刷几次"。
第三,算总分——按"端点:权重:时间戳"的格式写进一个 ZSET,用 Redis 的 ZRANGEBYSCORE 统计 24 小时内的加权总分。细节在于:member 里带上微秒级时间戳,是为了防止同一秒内相同权重碰撞,保证每条命中都算数。
第四,控制账本上限——ZSET 超过 5000 条就清理最旧的,防止账本本身变成内存黑洞。
为什么用 Lua?因为**"记一笔 + 算总分"必须是一个原子操作**。如果不原子,两个并发请求可能都读到旧总分,同时触发升级,或者互相覆盖计数。Lua 脚本在 Redis 里单线程执行,天然串行,这是评分系统的正确性基石。
评分本身不产生后果,它只是"记账"。后果由下一层根据账本决定。
账本有了,分数有了,接下来是这套系统最核心的部分:分数怎么翻译成处罚。答案是六级阶梯,每一级对应一种处置力度:
L1 警告通知 阈值 10 —— 站内信 + WebSocket 提醒,不处置
L2 限速降级 阈值 30 —— Redis 写降速倍率,桶容量缩到 50%(恶性 20%)
L3 IP 封禁 阈值 60 —— 该 IP 的 level key 写入,Filter 层直接 403
L4 账号冻结 阈值 120 —— 改库 user_role=ban + 案底入库
L5 长期封禁 阈值 300 —— 封禁 7 天,累犯加倍
L6 永久封禁 阈值 600 —— 永久封禁,Redis 1 年 TTL 兜底
这套阶梯设计里有几个值得细品的决策:
下面是用脚本真实触发一次 IP 封禁的完整日志——三个阶段一目了然:先是参数校验期(令牌在消耗),然后令牌耗尽进入限流,12 次 42900(权重 5)累计 60 分后自动升级 L3,此后所有请求被 Filter 直接 403:

第一,阈值从 10 到 600,跨度是 60 倍。 为什么?因为从"提醒"到"封号"必须给足缓冲。10 分可能只是用户误触了几次按钮,600 分意味着持续不断地恶意操作——中间隔着几十次真实命中的累积,误伤空间被压缩到极小。分级的意义不是"处罚从轻到重",而是"从误伤最小到绝不放过"。
第二,IP 和用户是两条独立的账本,上限不同。 IP 维度最高到 L3(封 IP),用户维度最高到 L6(永久封号)。逻辑很朴素:IP 是共享的,一个 NAT 出口可能坐着几百个正常用户,封 IP 必须保守;而用户是唯一的,账号行为可以重罚。封 IP 是"止血",封用户是"根除"——两者定位不同,力度就该不同。
第三,L2 的处置不是封禁,而是"降速"。 这是全系统最优雅的一招:不把用户挡在门外,而是把他的桶容量缩小——正常用户感受不到差异,但脚本的请求速率立刻被打到没法用。降速倍率写进 Redis(abuse:throttle:user:123 = 0.5),限流切面每次取倍率,令牌桶容量乘上去。降速是"软处置",它给了用户一个"停下来就好"的机会,而不是一刀切死。
第四,累犯加倍。 每个被封的维度记一个 90 天的累犯计数,处罚时长按 倍率^(累犯次数-1) 递增(有上限)。第一次 L3 封 30 分钟,累犯几次就是几小时、几天——让"反正封了也无所谓"的脚本没有第二次机会。加上 30 分钟冷却窗口,同一个维度在冷却期内不会反复触发升级通知,避免刷屏。
第五,处置不是终点,通知是闭环。 每次升级,站内通知 + WebSocket 推送一条 abuse_action 消息,告诉用户"你的行为触发了滥用防护机制",带上等级、原因、解封时间。别觉得这是多余的——用户知道"为什么被封"是防止误伤纠纷的第一道防线,也是让真人用户及时刹车的手段。
前面三层都是"记账 + 降速",真正的硬拦截在第四层:Filter 层快速阻断。这是一个注册在请求链最前端的过滤器,每个请求进来先查一遍"这个人该不该被拦"。
拦截逻辑很直接:如果已登录,查 abuse:level:user:{id};匿名用户查 abuse:level:ip:{ip}。level >= 3 直接返回 403,带上封禁等级和解封时间戳:
{"code":40302,"message":"您的 IP 访问过于频繁,已被暂时封禁","level":3,"unblockAt":1730000000}
实际触发时前端收到的就是这个响应——code、可读文案、封禁等级、解封时间戳一个不少:
这里有几个设计细节是实战里踩出来的:
管理员豁免。 封禁检查对 admin 角色直接跳过。这条看起来理所当然,但漏掉的话后果很惨——某次误封把运营同学自己也封了,后台都进不去。
认证路径白名单。 登录、注册、验证码这些接口不受封禁影响。为什么?因为被封的用户必须能登录——如果他连登录都进不去,就永远无法看到"你被封了"的提示,也永远无法申诉。白名单是"给用户留一扇门"。
Redis 丢失兜底。 封禁信息主要存在 Redis(快、TTL 天然支持过期),但如果 Redis 重启丢了,封禁就失效了。所以所以我们留了一个兜底:用户角色已经被改成 ban(封禁落库了),Redis 查不到时看角色,角色是 ban 就按 L4 处理。内存会丢,数据库不会——所以封禁必须落库,内存只是加速器。
解封时间。 响应里带 unblockAt,前端拿到可以显示"还剩 XX 小时解封",让用户有个盼头,也能大幅减少"为什么我什么都干不了"的客服工单。
说到"封禁落库",这里展开讲一下用户封禁的完整落地,因为它比想象中多几步:
// 1. 改库:角色改为 ban,保证重启不丢
user.setUserRole(BAN_ROLE);
userMapper.updateById(user);
// 2. 写 Redis level key:Filter 据此快速拦截
redis.set("abuse:level:user:" + userId, "4", ttl);
// 3. 留案底:UserBanRecord 表 upsert(同一用户只保留一条活跃记录)
// 用 Redisson 分布式锁防并发重复写
三步各司其职:改库是持久化(数据库是最终真相);Redis 是加速(Filter 层毫秒级判断);案底是审计(谁、什么时候、为什么、封多久,全有记录,管理员能查能解)。
触发封禁后,Redis 里的状态长这样——四个窗口的计数器、加权总分、封禁等级、降速倍率、累犯计数一应俱全:
案底表用 Redisson 分布式锁包着 upsert——同一用户同一时间只可能有一个线程在写案底,防止并发请求同时触发升级导致重复记录。锁没抢到就跳过(说明另一个线程已经在处理了),这是典型的"有锁防重、无锁跳过"。
IP 封禁同样留案底,区别是维度换成了 IP。IP 案底还支持升级时替换:同一 IP 已有一条 L3 记录,再来一次 L5(IP 不会到 L5,但用户升级会),把旧记录标失效、插一条新的——保证"一个 IP 只有一条活跃记录",查询和管理都干净。
到这里你可能已经注意到:封禁的主要状态在 Redis(快但易失),案底在数据库(稳但慢)。那 Redis 重启了怎么办?答案是启动时回灌。
AbuseBootstrapLoader 监听应用启动事件,从数据库把所有活跃封禁记录捞出来,逐条算剩余 TTL 写回 Redis。已经过期的顺手标记失效。这一步让整套系统具备了最终一致性:数据库是源,Redis 是镜像,任何时刻 Redis 丢了都能从库里重建。
还有一个小细节:永久封禁(L6)在 Redis 里没有真正意义的永久,设的是 1 年 TTL 兜底——反正案底是永久的,1 年后 Redis 键过期也没关系,因为 Filter 层查不到 Redis 时还有角色兜底(ban 角色 = 按 L4 处理)。两条兜底互相咬合,任何一条失效另一条都能顶上。
后端再完善,前端不配合也是白搭。这个项目的前端拦截器统一处理两个错误码:
42900(请求过于频繁)——弹出 message.warning,把后端的文案直接展示:"登录过于频繁,请稍后再试"。用户秒懂。这里还有个细节:登录页的图形验证码在限流后会自动刷新——因为连续失败的登录大概率是机器在试,刷新验证码让机器多一层阻碍。
40302(IP/账号被限制)——弹提示并可选展示解封时间。用户知道"不是网站坏了,是我被限制了,XX 小时后恢复"。
前端还做了一件事:全局限流提醒节流。多个接口同时 42900 时,5 秒内只弹一次提醒,避免弹窗轰炸——限流是为了防滥用,前端弹窗本身不能变成新的骚扰。
这套系统最值得借鉴的工程决策,是把全部策略参数抽到了配置文件,代码里只留机制。
abuse:
enabled: true
default-weight: 1.0
cooldown-minutes: 30
endpoint-weights:
"user_register": 10.0 # 防养号
"user_login": 5.0 # 防爆破
"picture_upload": 2.0 # 防存储轰炸
"ai_change_bg": 5.0 # 防算力薅羊毛
"like_do": 1.5 # 防水军点赞
"post_add": 3.0 # 防垃圾发帖
"picture_list_vo": 1.0 # 普通查询
thresholds: { notice: 10, warn: 30, ip-ban: 60, freeze: 120, long-ban: 300, perm-ban: 600 }
durations: { warn: 300, ip-ban: 1800, freeze: 86400, long-ban: 604800 }
recidivism: { enabled: true, multiplier: 2, max-multiplier: 8 }
权重、阈值、时长、累犯策略,全是配置。好处是运营同学改阈值不用动代码重新发版——上线初期阈值要放宽(防误伤),稳定期要收紧(防漏洞),这种动态调优是配置化才做得起的。
从这份配置里还能读出这个平台的安全价值观:查询类接口权重 1(刷浏览无所谓),注册权重 10(养号是万恶之源),AI 权重 5(算力是钱)。防滥用不是一刀切"都限死",而是按资源成本分配防护强度——成本越高的资源,防护越紧。
这套系统落地过程中,有几个坑值得单独记下来。
误封共享 IP。 早期 IP 维度阈值设得太低,结果一个公司 NAT 出口的几十个正常用户全被封了。后来把 IP 维度上限压到 L3、阈值调高,才消停。教训:IP 是共享资源,封 IP 必须比封用户保守得多。
管理员被自己封了。 某次调参忘了加管理员豁免,结果后台都进不去。这条后来被写进代码注释作为铁律。教训:防护系统必须给自己人留后门,而且要在第一版就留。
重复案底。 并发请求同时触发升级,案底表里出现同一个人两条活跃记录。后来加了 Redisson 锁 upsert。教训:任何"写唯一记录"的逻辑都要考虑并发,分布式下尤其如此。
通知刷屏。 升级逻辑每次触发都发通知,结果一个被持续攻击的用户收到几十条警告。后来加了 30 分钟冷却窗口。教训:通知类副作用必须有节流,否则防护系统本身会变成骚扰源。
Redis 重启丢封禁。 上线初期 Redis 重启后封禁全部失效,攻击者立刻卷土重来。后来加了启动回灌 + 角色兜底。教训:凡是"防攻击的状态",都必须能在内存丢失后重建。
把整套防线收起来看,它是一个互相咬合的闭环:
令牌桶限流 → 挡住超频峰值(桶,毫秒级)
滥用评分 → 记下累积账本(Lua 原子,四窗口加权)
分级处置 → 把分数翻译成处罚(L1~L6,IP/用户分级)
Filter 阻断 → 让已封禁进不来(403 + 解封时间)
重启恢复 → 封禁不随重启消失(DB 回灌 + 角色兜底)
每一层都有它不可替代的职责:没有限流,峰值请求直接打穿后端;没有评分,慢速滥用永远抓不到;没有分级,一次误判就把人永久拉黑;没有 Filter,封禁只存在于"记账本里";没有回灌,一次重启就能让攻击者满血复活。这五层里少了任何一层,防线都有洞。
这套系统还有一个贯穿始终的设计哲学,值得单独拎出来说:它惩罚的是"行为",不是"身份"。一次超频只是提醒,多次越界才降速,持续恶意才封禁——每一步都给用户留了"恢复正常"的空间。防滥用和用户体验从来不是对立的,关键是让正常用户永远感觉不到它的存在,让恶意行为每一步都付出越来越高的代价。
如果你也在做一个会被刷的平台,这套"限流 + 评分 + 分级 + 阻断 + 恢复"的闭环可以直接抄。抄的时候记住五件事:接口频率预算必须单独标定(别一刀切);权重要按资源成本配(算力 > 存储 > 查询);IP 和用户要分级处置(IP 保守、用户严格);封禁必须落库(内存会丢);防护系统要给管理员和认证路径留门(别把自己锁死)。
防滥用不是把门焊死,而是让好人畅通无阻,让坏人每一步都更贵。这五层防线做下来,你的平台至少能在一夜之间扛住:字典爆破、验证码轰炸、上传轰炸、AI 薅羊毛、水军刷赞——然后第二天早上起来,看一眼案底表,把封了谁、为什么封,写得明明白白。
这套系统的代码里,每个关键分支都留着注释——"管理员豁免,第一条铁律""认证路径白名单,给用户留一扇门""内存会丢,数据库不会"。这些注释不是写给机器看的,是写给半年后接手的人看的:防滥用是个长期战役,你得让后来的人知道,每一道防线为什么存在。