☰
AI站点上线即崩盘?Spring Boot四道锁防御体系实战
2026/10/8 10:17:30 网站建设 项目流程

1. 事故复盘:AI 站点上线当天被刷爆的真实场景

1.1 从“上线即巅峰”到“上线即崩盘”

去年年底,我帮一个做 AI 工具站的朋友处理过一次非常典型的上线事故。项目本身不复杂,Spring Boot 单体后端,前端调几个模型接口做封装,业务逻辑就是用户注册、额度分配、调用计费、结果返回。开发阶段一切正常,压测也跑过,QPS 峰值撑到 800 左右没问题。结果正式对外发布不到三个小时,监控开始疯狂告警:接口响应时间从 120ms 飙到 4.8s,Redis 连接池打满,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> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.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 的配置类里单独设置。我一般会显式写一个RedissonConfig:

@Configuration 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,然后修改请求参数,比如把cost=1改成cost=0,把userId=123改成userId=456。服务端如果只校验 Token 合法性,不校验参数完整性,就会被薅。

HMAC 签名的作用就是保证请求参数的完整性。客户端和服务端共享一个密钥,客户端把所有请求参数按规则拼接后用 HMAC-SHA256 计算签名,服务端收到请求后用同样的规则重新计算签名,两者一致才放行。这样即使黑产拿到了 Token,只要不知道密钥,就无法伪造合法签名。

3.2 HMAC 签名的参数拼接规则设计

签名规则的设计直接决定了防御强度。我踩过的坑是:一开始只对业务参数签名,没把时间戳和随机数加进去,结果黑产可以重放之前的请求。后来改成所有请求参数 + 时间戳 + 随机数一起参与签名,安全性大幅提升。

具体规则如下:

  1. 收集所有请求参数(包括 query 参数和 body 参数),排除sign字段本身。
  2. 按参数名 ASCII 码从小到大排序。
  3. 拼接成key1=value1&key2=value2的格式。
  4. 末尾追加&timestamp={timestamp}&nonce={nonce}。
  5. 使用 HMAC-SHA256 算法,以服务端分配的secretKey为密钥,对拼接字符串计算签名。
  6. 签名结果转为十六进制小写字符串。

服务端校验逻辑:

@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(Map<String, 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(Map<String, String> params, long timestamp, String nonce) { TreeMap<String, String> sorted = new TreeMap<>(params); StringBuilder sb = new StringBuilder(); for (Map.Entry<String, 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 签名只能保证参数没被篡改,但防不住重放。因为重放请求的签名是合法的,参数也没变,服务端校验签名会通过。所以必须引入 Nonce(Number 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> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId> <version>2023.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() { List<FlowRule> 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 四道锁的执行顺序与协同逻辑

四道锁不是孤立存在的,它们有明确的执行顺序,顺序错了会导致性能问题或者防御漏洞。我推荐的执行链路是:

  1. Sentinel 限流:请求进入后最先执行,把明显超量的请求直接挡掉,减少后续处理压力。
  2. HMAC 签名校验:过滤掉参数被篡改的请求。
  3. Nonce 防重放:过滤掉重复请求。
  4. 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 可以后续迭代补充。记住一点:防御不是一次性的工作,而是持续对抗的过程。黑产的手段在进化,你的防御体系也要跟着进化。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询