ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AI站点上线即崩盘?Spring Boot四道锁防御体系实战

AI站点上线即崩盘?Spring Boot四道锁防御体系实战 1. 事故复盘AI 站点上线当天被刷爆的真实场景1.1 从“上线即巅峰”到“上线即崩盘”去年年底我帮一个做 AI 工具站的朋友处理过一次非常典型的上线事故。项目本身不复杂Spring Boot 单体后端前端调几个模型接口做封装业务逻辑就是用户注册、额度分配、调用计费、结果返回。开发阶段一切正常压测也跑过QPS 峰值撑到 800 左右没问题。结果正式对外发布不到三个小时监控开始疯狂告警接口响应时间从 120ms 飙到 4.8sRedis 连接池打满MySQL 慢查询堆积服务器 CPU 直接顶到 100%。一开始我们以为是流量超预期毕竟 AI 类站点上线初期确实容易吸引尝鲜用户。但拉日志一看不对劲。同一个 IP 在 10 分钟内调了 2 万多次接口注册接口被批量脚本刷了 6000 多个账号而且请求参数高度规律明显是自动化工具在跑。更麻烦的是对方不只是刷注册还在高频调用计费接口试图通过并发漏洞薅额度。这就是典型的黑产“三板斧”批量注册、接口高频调用、并发薅羊毛。那次事故让我彻底意识到一件事AI 站点和普通业务站点的防御逻辑完全不同。普通站点可能只需要防 SQL 注入和 XSS但 AI 站点因为接口价值高、调用成本高、额度可直接变现天然就是黑产的重点目标。你如果不做资产防御上线就等于把钱包挂在门口。1.2 为什么传统防御手段在 AI 站点上不够用很多开发者第一反应是加个验证码、上个 WAF、配个 Nginx 限流就完事了。我一开始也这么想但实际跑下来发现这些手段只能挡住最底层的脚本小子稍微有点技术的黑产团队很容易绕过。验证码的问题在于现在打码平台成本极低一次识别几分钱批量注册场景下根本挡不住。WAF 的问题在于它主要防的是 Web 攻击特征比如 SQL 注入、XSS、文件包含但对于“合法请求高频调用”这种业务层滥用识别能力很弱。Nginx 限流的问题在于它通常基于 IP 做粗粒度控制而黑产手里有大量代理 IP 池单 IP 限流很容易被分散绕过。所以我在复盘之后重新设计了一套后端资产防御体系核心思路不是“堵”而是“锁”。堵是被动的锁是主动的。具体来说我在 Spring Boot 后端铸造了四道锁Redis 分布式锁防并发薅羊毛、HMAC 签名防参数篡改、Nonce 防重放攻击、Sentinel 限流防高频刷接口。这四道锁层层递进分别对应不同的攻击面组合起来才能形成有效防御。下面我会把这四道锁的选型逻辑、实现细节、踩坑经验全部拆开讲代码可以直接抄作业但更重要的是理解每一层为什么这么设计。2. 第一道锁Redis 分布式锁把并发薅羊毛堵在门外2.1 为什么选 Redis 而不是数据库悲观锁并发薅羊毛的典型场景是这样的用户账户里有 10 次免费额度黑产用脚本同时发起 20 个请求每个请求都去查额度、判断够不够、扣减、调用模型。如果这个流程没有并发控制20 个请求可能都查到“还有 10 次”然后各自扣减最终额度被扣成负数但模型已经被调用了 20 次。最直接的解决方案是数据库悲观锁SELECT ... FOR UPDATE但问题很明显AI 接口调用本身耗时较长可能 2 到 10 秒如果锁持有时间这么长数据库连接会被迅速耗尽吞吐量直接崩掉。而且 AI 站点的计费扣减频率很高数据库行锁竞争会非常激烈。Redis 分布式锁的优势在于它是在内存层面做互斥加锁和解锁的耗时在毫秒级不会占用数据库连接。而且 Redis 本身支持原子操作SET key value NX PX一条命令就能完成加锁非常适合这种短临界区、高并发的场景。我最终选的是Redisson作为 Redis 分布式锁的客户端而不是自己手写SETNX。原因很简单手写锁要考虑锁续期、锁误删、可重入、锁等待等一系列问题稍不注意就是生产事故。Redisson 把这些都封装好了RLock用起来跟ReentrantLock几乎一样学习成本低稳定性经过大量生产验证。2.2 Redisson 分布式锁的实操配置与代码首先在pom.xml里引入依赖dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.2/version /dependency然后在application.yml里配置 Redis 连接spring: data: redis: host: 127.0.0.1 port: 6379 password: your_password database: 0 timeout: 3000ms lettuce: pool: max-active: 32 max-idle: 16 min-idle: 8 max-wait: 2000ms这里有个细节要注意Redisson 默认使用的是它自己的连接池配置不完全依赖 Spring Data Redis 的 Lettuce 配置。如果你发现 Redisson 的连接数不对需要在 Redisson 的配置类里单独设置。我一般会显式写一个RedissonConfigConfiguration public class RedissonConfig { Value(${spring.data.redis.host}) private String host; Value(${spring.data.redis.port}) private int port; Value(${spring.data.redis.password}) private String password; Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis:// host : port) .setPassword(password) .setConnectionPoolSize(32) .setConnectionMinimumIdleSize(8) .setTimeout(3000) .setRetryAttempts(3) .setRetryInterval(1500); return Redisson.create(config); } }加锁的核心代码逻辑如下以额度扣减为例Service public class QuotaService { Autowired private RedissonClient redissonClient; Autowired private QuotaMapper quotaMapper; public boolean deductQuota(Long userId, int cost) { String lockKey quota:lock: userId; RLock lock redissonClient.getLock(lockKey); try { // 尝试加锁最多等待 3 秒锁自动释放时间 10 秒 boolean locked lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { throw new BizException(操作过于频繁请稍后重试); } // 临界区查额度、判断、扣减 int remaining quotaMapper.getRemainingQuota(userId); if (remaining cost) { return false; } quotaMapper.deductQuota(userId, cost); return true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException(系统繁忙); } finally { // 只有当前线程持有锁才释放避免误删别人的锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }2.3 锁粒度与锁超时的经验之谈锁粒度是分布式锁最容易踩坑的地方。我见过有人直接用lock:quota作为全局锁结果所有用户的额度扣减全部串行化QPS 直接掉到个位数。正确的做法是按用户维度加锁quota:lock:{userId}这样不同用户之间互不影响只有同一用户的并发请求才会互斥。锁超时时间也需要仔细权衡。设置太短业务还没执行完锁就自动释放了并发问题依然存在设置太长一旦某个线程异常没释放锁其他请求会长时间阻塞。我的经验是锁超时时间设置为业务平均耗时的 3 到 5 倍。比如额度扣减平均耗时 200ms锁超时设 10 秒足够覆盖极端情况。同时一定要用tryLock带等待时间不要用无参的lock()否则请求会无限阻塞线程池很快被打满。注意Redisson 的看门狗机制默认每 10 秒续期一次前提是你没有显式指定 leaseTime。如果你指定了 leaseTime看门狗不会生效锁会在到期后自动释放。所以如果你不确定业务最长耗时建议不指定 leaseTime让看门狗自动续期。3. 第二道锁HMAC 签名让参数篡改无处遁形3.1 为什么光有 HTTPS 还不够很多人觉得上了 HTTPS 就安全了参数不会被篡改。这个认知只对了一半。HTTPS 保证的是传输层安全防止中间人窃听和篡改但它防不住客户端本身的伪造请求。黑产完全可以自己构造一个 HTTP 请求带上合法的 Token然后修改请求参数比如把cost1改成cost0把userId123改成userId456。服务端如果只校验 Token 合法性不校验参数完整性就会被薅。HMAC 签名的作用就是保证请求参数的完整性。客户端和服务端共享一个密钥客户端把所有请求参数按规则拼接后用 HMAC-SHA256 计算签名服务端收到请求后用同样的规则重新计算签名两者一致才放行。这样即使黑产拿到了 Token只要不知道密钥就无法伪造合法签名。3.2 HMAC 签名的参数拼接规则设计签名规则的设计直接决定了防御强度。我踩过的坑是一开始只对业务参数签名没把时间戳和随机数加进去结果黑产可以重放之前的请求。后来改成所有请求参数 时间戳 随机数一起参与签名安全性大幅提升。具体规则如下收集所有请求参数包括 query 参数和 body 参数排除sign字段本身。按参数名 ASCII 码从小到大排序。拼接成key1value1key2value2的格式。末尾追加timestamp{timestamp}nonce{nonce}。使用 HMAC-SHA256 算法以服务端分配的secretKey为密钥对拼接字符串计算签名。签名结果转为十六进制小写字符串。服务端校验逻辑Component public class HmacSignValidator { Value(${app.sign.secret-key}) private String secretKey; private static final long MAX_TIMESTAMP_DIFF 5 * 60 * 1000L; // 5分钟 public boolean validate(MapString, String params, String sign, long timestamp, String nonce) { // 1. 校验时间戳防止过期请求重放 long now System.currentTimeMillis(); if (Math.abs(now - timestamp) MAX_TIMESTAMP_DIFF) { throw new BizException(请求已过期); } // 2. 校验 nonce 是否已使用下一道锁详细讲 // ... // 3. 重新计算签名 String expectedSign calculateSign(params, timestamp, nonce); return expectedSign.equals(sign); } private String calculateSign(MapString, String params, long timestamp, String nonce) { TreeMapString, String sorted new TreeMap(params); StringBuilder sb new StringBuilder(); for (Map.EntryString, String entry : sorted.entrySet()) { if (sign.equals(entry.getKey())) { continue; } sb.append(entry.getKey()).append().append(entry.getValue()).append(); } sb.append(timestamp).append(timestamp).append(nonce).append(nonce); try { Mac mac Mac.getInstance(HmacSHA256); SecretKeySpec keySpec new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), HmacSHA256); mac.init(keySpec); byte[] rawHmac mac.doFinal(sb.toString().getBytes(StandardCharsets.UTF_8)); return bytesToHex(rawHmac); } catch (Exception e) { throw new BizException(签名计算失败); } } private String bytesToHex(byte[] bytes) { StringBuilder hex new StringBuilder(); for (byte b : bytes) { hex.append(String.format(%02x, b)); } return hex.toString(); } }3.3 密钥管理与签名校验的避坑要点密钥管理是 HMAC 方案里最容易被忽视的环节。我见过有人把secretKey硬编码在客户端代码里结果被反编译直接拿到。正确的做法是每个用户分配独立的 secretKey服务端加密存储客户端首次登录时通过安全通道下发之后本地加密保存。这样即使某个用户的密钥泄露也只影响单个用户不会波及全站。另一个坑是签名校验的性能问题。HMAC-SHA256 本身很快单次计算在微秒级但如果你的接口 QPS 很高每次请求都重新计算签名CPU 消耗也不容忽视。我的优化方案是对签名校验结果做短时缓存同一个sign timestamp nonce组合在 5 分钟内只校验一次后续请求直接命中缓存。这样既保证了安全性又降低了 CPU 压力。提示签名校验失败时不要返回具体失败原因统一返回“请求非法”。否则黑产可以通过错误信息推断你的签名规则逐步试探绕过。4. 第三道锁Nonce 防重放让旧请求彻底失效4.1 重放攻击的原理与危害重放攻击是黑产最常用的手段之一。原理很简单黑产通过某种方式截获了一个合法请求比如从客户端日志、代理工具、或者自己抓包然后把这个请求原封不动地重复发送。如果服务端不做防重放这个请求每次都会被正常处理相当于黑产用同一个请求反复薅额度。HMAC 签名只能保证参数没被篡改但防不住重放。因为重放请求的签名是合法的参数也没变服务端校验签名会通过。所以必须引入 NonceNumber used once机制保证每个请求只能被处理一次。4.2 基于 Redis 的 Nonce 去重实现Nonce 的实现思路是客户端每次请求生成一个随机字符串作为 nonce服务端收到后检查这个 nonce 是否已经使用过。如果用过直接拒绝如果没用过存入 Redis 并设置过期时间然后放行。Redis 的SET key value NX EX命令天然适合这个场景原子性保证并发安全Component public class NonceValidator { Autowired private StringRedisTemplate redisTemplate; private static final String NONCE_PREFIX nonce:; private static final long NONCE_EXPIRE_SECONDS 300; // 5分钟 public boolean validateAndMark(String nonce) { if (StringUtils.isBlank(nonce)) { throw new BizException(nonce 不能为空); } String key NONCE_PREFIX nonce; Boolean success redisTemplate.opsForValue() .setIfAbsent(key, 1, NONCE_EXPIRE_SECONDS, TimeUnit.SECONDS); if (Boolean.FALSE.equals(success)) { throw new BizException(请求重复请勿重放); } return true; } }Nonce 的过期时间需要和签名的时间戳校验窗口保持一致。比如时间戳允许 5 分钟误差那 Nonce 也设置 5 分钟过期。这样既能覆盖所有合法请求的时间窗口又不会让 Redis 里堆积太多无用数据。4.3 Nonce 生成策略与存储优化Nonce 的生成必须保证足够随机不能有规律。我一般用UUID.randomUUID().toString().replace(-, )或者SecureRandom生成 32 位随机字符串。千万不要用时间戳或者自增 ID 作为 Nonce那样黑产很容易预测并伪造。存储优化方面如果站点 QPS 很高Nonce 的写入量会很大。假设峰值 QPS 5000每个 Nonce 存 5 分钟那就是 150 万个 key。这个量级对 Redis 来说不算大但要注意内存占用。我的做法是Nonce 的 value 存空字符串或者极短标记不要存业务数据同时给 Redis 配置合理的 maxmemory 和淘汰策略比如allkeys-lru防止内存打满。另外如果你的站点是多机房部署Nonce 校验必须走同一个 Redis 集群否则用户在 A 机房请求一次在 B 机房又能请求一次防重放就失效了。这一点在分布式部署时特别容易忽略。注意Nonce 校验和签名校验的顺序很重要。应该先校验签名再校验 Nonce。因为签名校验能过滤掉大部分非法请求减少 Nonce 的无效写入。如果反过来黑产可以用大量随机 Nonce 刷爆你的 Redis。5. 第四道锁Sentinel 限流把高频刷接口挡在门外5.1 为什么选 Sentinel 而不是 Nginx 限流Nginx 限流基于 IP粒度粗容易被代理 IP 池绕过。Sentinel 是阿里开源的流量治理组件支持基于 QPS、线程数、热点参数等多种限流策略而且可以做到接口级别、用户级别的细粒度控制。对于 AI 站点来说不同接口的价值不同限流策略也应该不同。比如注册接口可能限制单 IP 每分钟 5 次而模型调用接口可能限制单用户每分钟 20 次。这种细粒度控制Nginx 很难做到Sentinel 却很擅长。Sentinel 的另一个优势是它和 Spring Boot 集成非常方便通过SentinelResource注解就能给单个接口配置限流规则不需要改 Nginx 配置也不需要重启服务。5.2 Sentinel 集成 Spring Boot 的完整配置首先引入依赖dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-sentinel/artifactId version2023.0.1.0/version /dependency然后在application.yml里配置 Sentinel 控制台地址spring: cloud: sentinel: transport: dashboard: 127.0.0.1:8858 port: 8719 eager: true接着定义一个限流规则配置类在应用启动时加载规则Configuration public class SentinelRuleConfig { PostConstruct public void initRules() { ListFlowRule rules new ArrayList(); // 注册接口单 IP 每分钟最多 5 次 FlowRule registerRule new FlowRule(); registerRule.setResource(register); registerRule.setGrade(RuleConstant.FLOW_GRADE_QPS); registerRule.setCount(5); registerRule.setLimitApp(default); rules.add(registerRule); // 模型调用接口单用户每分钟最多 20 次 FlowRule invokeRule new FlowRule(); invokeRule.setResource(modelInvoke); invokeRule.setGrade(RuleConstant.FLOW_GRADE_QPS); invokeRule.setCount(20); rules.add(invokeRule); FlowRuleManager.loadRules(rules); } }在接口上使用SentinelResource注解RestController public class ModelController { SentinelResource(value modelInvoke, blockHandler handleBlock) PostMapping(/api/model/invoke) public Result invoke(RequestBody InvokeRequest request) { // 业务逻辑 return Result.success(); } public Result handleBlock(InvokeRequest request, BlockException ex) { return Result.fail(请求过于频繁请稍后重试); } }5.3 限流规则调优与热点参数限流限流规则的阈值设置需要结合业务实际情况。设得太松挡不住黑产设得太紧正常用户也会被误伤。我的经验是先观察一周的正常流量曲线取 P99 峰值的 1.5 倍作为初始阈值然后根据实际拦截情况逐步调整。对于 AI 站点我特别推荐使用 Sentinel 的热点参数限流。比如模型调用接口可以针对userId做热点限流某个用户调用特别频繁时单独限制他而不影响其他用户。配置方式如下ParamFlowRule paramRule new ParamFlowRule(modelInvoke) .setParamIdx(0) // 第一个参数即 userId .setCount(10); // 单用户每秒最多 10 次 ParamFlowRuleManager.loadRules(Collections.singletonList(paramRule));这样即使某个用户被黑产盗用也不会拖垮整个系统。热点参数限流是 Sentinel 相比其他限流组件的一大优势特别适合 AI 站点这种用户维度差异大的场景。提示Sentinel 的规则默认存在内存里应用重启后会丢失。生产环境建议配合 Nacos 做规则持久化把规则配置在 Nacos 配置中心Sentinel 监听 Nacos 配置变更实现动态调整限流规则不需要重启服务。6. 四道锁的协同与常见问题排查6.1 四道锁的执行顺序与协同逻辑四道锁不是孤立存在的它们有明确的执行顺序顺序错了会导致性能问题或者防御漏洞。我推荐的执行链路是Sentinel 限流请求进入后最先执行把明显超量的请求直接挡掉减少后续处理压力。HMAC 签名校验过滤掉参数被篡改的请求。Nonce 防重放过滤掉重复请求。Redis 分布式锁在业务临界区保证并发安全。这个顺序的逻辑是越轻量的校验越靠前越重的校验越靠后。Sentinel 限流几乎无开销签名校验是 CPU 计算Nonce 需要访问 Redis分布式锁需要持有 Redis 锁并执行数据库操作。这样排列可以让非法请求在早期就被拦截避免浪费后面的资源。6.2 常见问题速查表在实际运行中我遇到过不少问题整理成速查表方便排查问题现象可能原因排查思路解决方案接口响应突然变慢Redis 连接池打满查看 Redis 连接数和慢查询日志调大连接池检查是否有大 key签名校验频繁失败客户端参数排序规则不一致对比客户端和服务端拼接字符串统一排序规则增加调试日志Nonce 校验误报重复Redis 主从同步延迟检查 Redis 部署模式改用单节点或 Redisson 的 RedLock限流规则不生效Sentinel 未正确加载规则查看 Sentinel 控制台规则列表检查PostConstruct是否执行分布式锁释放异常线程池复用导致锁误删检查lock.isHeldByCurrentThread()确保 finally 中判断持有者再释放Redis 命令超时网络抖动或大 key 阻塞查看redis-cli --latency优化大 key增加超时重试6.3 我踩过的三个大坑与独家避坑技巧第一个坑是Redis 序列化问题。Redisson 默认使用 Kryo 序列化如果你同时用 Spring Data Redis 的StringRedisTemplate两者序列化方式不一致会导致读出来的数据乱码。我的解决方案是统一使用 String 序列化在 Redisson 配置里显式设置config.setCodec(new StringCodec())避免混用。第二个坑是Sentinel 限流后的异常处理。默认情况下Sentinel 限流会抛出FlowException如果你没有全局异常处理用户会看到 500 错误页面。我的做法是配置全局异常处理器捕获BlockException并返回统一的友好提示同时记录限流日志方便后续分析。第三个坑是Nonce 的 Redis key 过期时间设置过长。一开始我设了 24 小时结果 Redis 内存增长很快。后来改成 5 分钟和签名时间戳窗口对齐内存占用直接降了 90%。这个细节看似小但在高 QPS 场景下影响很大。提示四道锁的日志一定要打全包括请求 ID、用户 ID、签名结果、Nonce、限流状态。出问题时这些日志是排查的唯一线索。我一般用 MDC 把请求 ID 透传到所有日志里方便串联整个请求链路。7. 防御体系上线后的效果与后续优化方向这套四道锁上线之后效果非常明显。上线第一周注册接口的异常请求拦截率达到了 92%模型调用接口的并发薅羊毛事件从每天几十次降到零Redis 和 MySQL 的负载也恢复到了正常水平。更重要的是黑产发现这个站点“不好啃”之后很快就转移了目标攻击频率大幅下降。后续我还在持续优化几个方向。一是引入行为分析通过分析用户的请求频率、参数分布、时间规律识别出疑似黑产的账号提前封禁。二是动态调整限流阈值根据实时流量自动升降阈值避免大促期间误伤正常用户。三是密钥轮换机制定期更换 HMAC 密钥降低密钥泄露风险。如果你也在做 AI 站点或者任何高价值接口的后端我建议至少把 Redis 分布式锁和 Sentinel 限流先加上这两道锁的实现成本最低防御效果最直接。HMAC 和 Nonce 可以后续迭代补充。记住一点防御不是一次性的工作而是持续对抗的过程。黑产的手段在进化你的防御体系也要跟着进化。
返回列表