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 签名的参数拼接规则设计
签名规则的设计直接决定了防御强度。我踩过的坑是:一开始只对业务参数签名,没把时间戳和随机数加进去,结果黑产可以重放之前的请求。后来改成所有请求参数 + 时间戳 + 随机数一起参与签名,安全性大幅提升。
具体规则如下:
- 收集所有请求参数(包括 query 参数和 body 参数),排除
sign字段本身。 - 按参数名 ASCII 码从小到大排序。
- 拼接成
key1=value1&key2=value2的格式。 - 末尾追加
×tamp={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(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 四道锁的执行顺序与协同逻辑
四道锁不是孤立存在的,它们有明确的执行顺序,顺序错了会导致性能问题或者防御漏洞。我推荐的执行链路是:
- 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 可以后续迭代补充。记住一点:防御不是一次性的工作,而是持续对抗的过程。黑产的手段在进化,你的防御体系也要跟着进化。