1. 事故复盘AI 站点上线当天被刷爆的真实场景1.1 从“上线即巅峰”到“上线即崩盘”那天凌晨两点我盯着监控面板上那条几乎垂直向上的请求曲线心里只有一个念头这波要凉。我们团队花三个月打磨的 AI 站点白天刚对外发布晚上就被一波异常流量冲得七零八落。接口响应时间从 80ms 飙到 4sRedis 连接池告警数据库 CPU 打满几个核心推理接口直接超时熔断。事后拉日志一看典型的黑产手法脚本批量注册、接口高频轮询、参数重放、缓存穿透一套组合拳下来普通限流根本扛不住。这篇文章不讲虚的就复盘我在 Spring Boot 后端亲手落地的四道资产防御锁。核心关键词就几个Spring Boot、Redis、HMAC、Nonce、限流。适合谁看正在做 AI 类站点、开放平台、API 网关的后端同学尤其是那种“接口一公开就被人盯上”的场景。我会把每一道锁的设计思路、参数计算、代码骨架、踩坑记录全部摊开讲你能直接抄作业也能根据自己业务调整。先说结论四道锁分别是签名校验锁HMAC Nonce、分布式限流锁Redis Lua、缓存防护锁空值缓存 布隆过滤、资产熔断锁Sentinel 降级策略。它们不是孤立存在的而是层层递进像四道闸门把黑产流量从“能进来”逐步压缩到“进不来、刷不动、拖不垮”。1.2 为什么普通限流挡不住黑产很多人第一反应是加个 Nginx 限流或者 Spring Boot 自带的 RateLimiter 就完事了。我试过真不行。原因有三第一单机限流在集群环境下形同虚设黑产用代理池分散请求每台机器看着都不超阈值加起来早就爆了第二限流只能挡频率挡不住“合法参数重放”攻击者拿到一个有效请求就能反复刷第三AI 站点往往有大量计算密集型接口一旦被刷GPU 资源瞬间被占满恢复时间远超普通 Web 接口。所以我的思路是先验身份再控频率后防穿透最后兜底熔断。这四步对应四道锁缺一不可。下面逐层拆解。2. 第一道锁HMAC Nonce 签名校验把伪造请求挡在门外2.1 为什么选 HMAC 而不是普通 Token普通 Token 比如 JWT本质是“一次签发多次使用”只要泄露就能一直被重放。HMAC 不一样它要求每次请求都带上基于密钥和请求内容计算出的签名服务端重新计算比对签名不对直接拒绝。更关键的是HMAC 可以绑定时间戳和随机数Nonce让每个请求具备“一次性”特征。我选 HMAC-SHA256原因很实际性能足够好Java 原生支持密钥管理简单不需要引入额外重型依赖。对比 RSA 签名HMAC 计算速度快一个数量级适合高频 API 场景。对比 OAuth2HMAC 更轻量不需要复杂的授权服务器。2.2 Nonce 的设计与 Redis 去重Nonce 是一个随机字符串每次请求必须不同。服务端收到后先检查这个 Nonce 是否已经用过。如果用过了说明是重放攻击直接拒绝。问题来了Nonce 存哪里内存单机可以集群不行。数据库太慢。答案是 Redis。具体做法以nonce:{appId}:{nonce}为 key设置过期时间比如 5 分钟用SETNX命令。如果返回 1说明是第一次用放行返回 0说明重复拒绝。过期时间要略大于请求允许的最大时间偏差比如时间戳允许偏差 3 分钟Nonce 就存 5 分钟留点缓冲。这里有个坑Redis 的SETNX和EXPIRE是两条命令不是原子的。如果SETNX成功后服务宕机key 就永不过期了。正确做法是用SET key value NX EX seconds一条命令搞定或者用 Lua 脚本保证原子性。2.3 签名计算的完整参数与代码骨架签名串怎么拼我定的规则是appId timestamp nonce method path bodyHash然后用连接最后用 HMAC-SHA256 计算。bodyHash 是请求体的 SHA256 值防止 body 被篡改。时间戳允许偏差 3 分钟超过就拒绝。public boolean verifySignature(String appId, String timestamp, String nonce, String method, String path, String body, String sign) { // 1. 校验时间戳偏差 long ts Long.parseLong(timestamp); if (Math.abs(System.currentTimeMillis() - ts) 3 * 60 * 1000) { return false; } // 2. 校验 Nonce 是否重复 String nonceKey nonce: appId : nonce; Boolean firstUse redisTemplate.opsForValue() .setIfAbsent(nonceKey, 1, 5, TimeUnit.MINUTES); if (Boolean.FALSE.equals(firstUse)) { return false; } // 3. 重新计算签名 String bodyHash DigestUtils.sha256Hex(body); String raw appId timestamp nonce method path bodyHash; String expected hmacSha256(raw, appSecret); return expected.equals(sign); }注意appSecret绝对不能硬编码在代码里我放在配置中心加密存储启动时解密加载。另外签名比对要用常量时间比较防止时序攻击Java 里可以用MessageDigest.isEqual。2.4 实操心得签名校验的三个避坑点第一个坑GET 请求的 body 为空bodyHash 要统一处理成空字符串的 SHA256不能直接跳过否则签名规则不一致。第二个坑URL 编码问题path 和参数在拼接前要统一解码或编码否则客户端和服务端算出来的签名不一样。第三个坑时钟同步服务器时间必须用 NTP 同步否则时间戳偏差校验会误杀正常请求。我吃过一次亏某台机器时间慢了 4 分钟导致那台机器上所有请求全被拒排查了半天。3. 第二道锁Redis Lua 分布式限流集群环境下精准控频3.1 为什么单机限流在集群下会失效假设你有 10 台机器每台限流 100 QPS黑产用 1000 个代理 IP 分散请求每台机器只收到 100 QPS看起来没超但总流量已经 1000 QPS 了。更糟的是负载均衡策略如果不够均匀某台机器可能瞬间被打到 200 QPS直接崩。所以限流必须做成分布式的全局共享一个计数器。Redis 天然适合做这个共享计数器因为它是单线程模型命令执行是原子的。但要注意简单的INCREXPIRE组合不是原子的高并发下会出问题。正确做法是用 Lua 脚本把“判断 计数 设置过期”打包成一个原子操作。3.2 滑动窗口 vs 固定窗口我为什么选令牌桶固定窗口限流有个致命问题窗口边界突刺。比如限制每分钟 100 次攻击者在第 59 秒发 100 次第 61 秒再发 100 次两秒内实际发了 200 次但两个窗口都没超。滑动窗口能解决这个问题但实现复杂Redis 里要用 ZSET 存时间戳内存开销大。我最终选的是令牌桶算法用 Redis Lua 实现。令牌桶的好处是允许一定程度的突发流量同时长期速率可控。对于 AI 站点来说正常用户可能偶尔连续调几次接口令牌桶不会误杀而黑产持续高频请求桶很快空了直接被限。3.3 Lua 脚本实现令牌桶的完整代码核心思路用 Redis Hash 存两个字段tokens表示当前令牌数lastRefillTime表示上次补充时间。每次请求进来先计算从上次补充到现在应该加多少令牌然后判断是否够用。-- KEYS[1]: 限流 key -- ARGV[1]: 桶容量 -- ARGV[2]: 补充速率每秒 -- ARGV[3]: 当前时间戳毫秒 -- ARGV[4]: 本次请求消耗令牌数 local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local now tonumber(ARGV[3]) local requested tonumber(ARGV[4]) local bucket redis.call(HMGET, KEYS[1], tokens, lastRefillTime) local tokens tonumber(bucket[1]) local lastRefillTime tonumber(bucket[2]) if tokens nil then tokens capacity lastRefillTime now end local delta math.max(0, now - lastRefillTime) local refill delta * rate / 1000 tokens math.min(capacity, tokens refill) local allowed tokens requested if allowed then tokens tokens - requested end redis.call(HMSET, KEYS[1], tokens, tokens, lastRefillTime, now) redis.call(EXPIRE, KEYS[1], math.ceil(capacity / rate) 1) return allowed and 1 or 0参数怎么定我拿 AI 推理接口举例单用户正常调用频率约 0.2 QPS突发最多 5 次。所以桶容量设 10补充速率设 0.5/秒。这样正常用户永远够用黑产持续刷的话每秒只能拿到 0.5 个令牌很快就被限死。3.4 限流维度与降级策略限流不能只按 IP因为黑产会用代理池。我做了三层维度按 appId 限流针对合作方、按 userId 限流针对登录用户、按 IP 限流针对匿名请求。优先级是 userId appId IP。如果某个维度触发限流返回 429 状态码并在响应头里带上Retry-After告诉客户端多久后重试。降级策略也很关键。限流触发后不能直接返回错误就完事要区分场景。对于查询类接口可以返回缓存数据对于推理类接口可以返回“当前排队人数较多请稍后重试”的友好提示而不是冷冰冰的 500。实操心得Lua 脚本里的时间戳一定要用 Redis 服务器时间不要用应用服务器时间。因为集群里各台机器时间可能有偏差用 Redis 的TIME命令获取统一时间能避免令牌计算错乱。4. 第三道锁缓存防护空值缓存 布隆过滤挡住穿透4.1 缓存穿透是怎么把数据库打垮的黑产刷接口有个惯用套路用大量不存在的 ID 去查数据。比如你的接口是/api/user/{id}他就用随机 ID 疯狂请求。这些 ID 在缓存里查不到请求全部落到数据库数据库扛不住就崩了。这就是缓存穿透。AI 站点尤其危险因为很多接口背后是昂贵的模型推理或向量检索一旦穿透不只是数据库连 GPU 资源都会被浪费。我遇到过最狠的一次攻击者用 10 万个不存在的用户 ID 轮询数据库 QPS 从 200 飙到 8000直接打满。4.2 空值缓存简单但有效的第一层防护最直接的方案是空值缓存查数据库如果没查到就在 Redis 里存一个空值比如NULL过期时间设短一点比如 60 秒。这样下次同样的 ID 再来直接命中缓存返回空不会落到数据库。但空值缓存有两个问题第一如果攻击者每次用不同的 ID空值缓存就失效了因为每个 ID 都是新的第二空值缓存会占用大量 Redis 内存如果攻击者用海量随机 IDRedis 可能被撑爆。所以空值缓存只能作为第一层必须配合布隆过滤器。4.3 布隆过滤器用极小内存判断“一定不存在”布隆过滤器的核心思想是用多个哈希函数把一个元素映射到一个位数组的多个位置全部置 1。查询时如果任何一个位置是 0那这个元素一定不存在如果全是 1可能存在有误判率。它的优势是内存占用极小判断“不存在”非常准确。我用 Redis 的 Bitmap 实现布隆过滤器。假设要存 100 万个用户 ID误判率控制在 1%根据公式计算位数组大小 m -n * ln(p) / (ln2)^2 ≈ 958 万位约 1.2MB哈希函数个数 k (m/n) * ln2 ≈ 7 个。1.2MB 换 100 万 ID 的过滤能力非常划算。public boolean mightContain(String key) { for (int i 0; i HASH_COUNT; i) { long hash hash(key, i); long index hash % BIT_SIZE; Boolean bit redisTemplate.opsForValue().getBit(BLOOM_KEY, index); if (Boolean.FALSE.equals(bit)) { return false; // 一定不存在 } } return true; // 可能存在 }4.4 布隆过滤器的更新与误判处理布隆过滤器有个硬伤不支持删除。如果某个 ID 被删除了布隆过滤器里还是 1会误判为存在。对于用户 ID 这种场景一般不会删除问题不大。但如果业务有删除操作就要用计数布隆过滤器或者定期重建。误判怎么处理布隆过滤器说“可能存在”时不能直接放行还要查一次缓存或数据库确认。所以完整流程是布隆过滤器判断不存在 → 直接拒绝判断可能存在 → 查缓存 → 缓存没有 → 查数据库 → 数据库没有 → 写空值缓存。这样即使有 1% 的误判也只有 1% 的请求会落到数据库完全扛得住。注意布隆过滤器的位数组大小和哈希个数要根据实际数据量计算不要拍脑袋定。数据量增长后要提前扩容或者用可扩展布隆过滤器。我一般会在系统启动时预热把现有 ID 全部灌进去。5. 第四道锁Sentinel 熔断降级兜住最后的底线5.1 为什么限流之后还需要熔断限流是“控制入口流量”熔断是“保护后端资源”。两者解决的不是同一个问题。假设限流配置失误或者攻击流量刚好卡在阈值边缘后端某个服务开始变慢线程池逐渐被占满最终整个服务雪崩。熔断的作用是当某个接口的错误率或响应时间超过阈值时自动切断对该接口的调用快速失败给后端喘息时间。我选 Sentinel 而不是 Hystrix原因很实际Sentinel 的规则配置更灵活支持 QPS、线程数、错误率、响应时间多种维度而且有控制台可以动态调整规则不需要重启服务。对于 AI 站点这种流量波动大的场景动态调整能力非常重要。5.2 Sentinel 规则配置与 Nacos 动态推送Sentinel 的规则可以硬编码但生产环境必须动态化。我用 Nacos 做配置中心Sentinel 从 Nacos 拉取规则修改后实时生效。核心配置如下spring: cloud: sentinel: datasource: flow: nacos: server-addr: ${nacos.addr} >SentinelResource(value aiInference, blockHandler handleBlock, fallback handleFallback) public InferenceResult infer(InferenceRequest request) { return aiService.infer(request); } public InferenceResult handleBlock(InferenceRequest request, BlockException ex) { return InferenceResult.busy(当前请求过多请稍后重试); } public InferenceResult handleFallback(InferenceRequest request, Throwable t) { return InferenceResult.error(服务暂时不可用); }实操心得blockHandler处理的是 Sentinel 规则触发的限流/熔断fallback处理的是业务异常。两者要分开写不要混在一起。另外降级方法的参数列表必须和原方法一致最后多一个BlockException或Throwable参数否则 Sentinel 找不到。5.4 四道锁的协同工作流程四道锁不是串行执行的而是有优先级和协作关系。请求进来后先过签名校验锁验证身份通过后过限流锁控制频率然后过缓存防护锁挡住穿透最后如果后端还是扛不住熔断锁兜底。整个链路可以用一个责任链模式串起来每道锁独立配置互不干扰。防御锁核心组件拦截目标触发后动作签名校验锁HMAC Redis Nonce伪造请求、重放攻击返回 401分布式限流锁Redis Lua 令牌桶高频刷接口返回 429缓存防护锁布隆过滤器 空值缓存缓存穿透直接返回空资产熔断锁Sentinel Nacos后端雪崩降级返回6. 常见问题与排查技巧实录6.1 签名校验失败但客户端说参数没错这是最常见的排查场景。我一般按这个顺序查第一检查时间戳偏差客户端和服务端时间是否同步第二检查 Nonce 是否重复有时候客户端重试机制会复用同一个 Nonce第三检查 bodyHashGET 请求和 POST 请求的 body 处理方式不同第四检查签名串拼接顺序多一个空格少一个都会导致签名不一致。我写了个调试接口把服务端计算的原始串返回给客户端比对排查效率提升很多。6.2 Redis 限流脚本报错或超时Lua 脚本在 Redis 里执行是原子的但如果脚本逻辑太复杂会阻塞 Redis 主线程。我踩过一次坑脚本里用了KEYS命令遍历所有 key数据量大时直接卡死。后来改成只操作指定的 key问题解决。另外Redis 连接超时也会导致限流失效要配置合理的超时时间和重试策略。redis command timed out这个报错我见过太多次基本都是连接池不够或者网络抖动加监控告警能提前发现。6.3 布隆过滤器误判率过高误判率高的原因通常是位数组太小或者哈希函数太少。我一般用这个公式反推如果实际数据量是 n期望误判率是 p那么位数组大小 m -n * ln(p) / (ln2)^2哈希个数 k (m/n) * ln2。如果发现误判率高先检查这两个参数是否匹配。另外哈希函数的质量也很重要我用的是 MurmurHash分布均匀比简单的取模哈希好很多。6.4 Sentinel 规则不生效Sentinel 规则不生效九成是配置问题。检查这几点Nacos 里的>
阅读完成 · 觉得有帮助?