AI 站点上线被黑产刷爆了?复盘我在 Spring Boot 后端铸造的 4 道资产防御锁
对于做传统 Web 或移动端开发的工程师来说,接口被爬虫脚本刷几次,大不了是多耗几百兆 CDN 流量或者让 MySQL 的 CPU 飙升一阵子,还不至于致命。
但当你做的是 AI 原生应用(如 AI 智能体、AI 生图、长文本深度推理) 时,性质就彻底变了:
- 一个
gpt-image-2.5生图调用成本可能在几毛钱到一块钱; - 一个 Claude 3.5 / GPT-6 长上下文推理请求可能消耗数万 Token;
- 一个复杂的 Agent 自主规划流程需要并发调用四五次工具接口。
三个月前,我们团队上线了一个提供“免登录极速体验”的 AI 创作站点。上线当晚,我们在群里举杯庆祝自然流量突破 5000 UV;然而第二天一早醒来,OpenAI 中转后台的几千美元预充值余额被扣到仅剩 $12,报警邮件塞满了邮箱:
监控显示,某几个特定海外代理 IP 段,在凌晨 3 点至 5 点之间,利用自动化多线程无头浏览器并发狂刷核心生图接口。原本设计给真实用户体验的免费额度,被羊毛党用脚本在 2 小时内刷掉了数万次调用!
如果后端防御仅仅依靠简单的“Spring Security + 账号密码鉴权”,在专业的黑灰产面前简直如同纸糊。
为了彻底阻断黑产薅羊毛、避免巨额账单击穿公司财务,我们在 Spring Boot 3 + Redis 架构下重新设计并落地了 4 道环环相扣的“资产级防御锁”。上线两个多月来,日均拦截上万次恶意嗅探与重放请求,API 成本直降 85%,系统零误杀。
本文将把这 4 道核心防御锁的设计理念、时序交互与完整生产代码全景复盘分享出来。
一、防御全景:从边缘入口到数据库落盘的 4 道重锁
在防御架构中,核心思想是:分层设防、层层衰减、前置拦截。越往后阻断,消耗的服务器计算和网络资源越大;最理想的状态是让 95% 的恶意请求在网关过滤器和 Redis 内存层就被直接阻断并拒之门外。
flowchart TD
subgraph Ingress [1. 第一道锁:边缘入口与风控前置]
Req[外部 HTTP 客户端请求]
FP[Canvas/Audio 硬件指纹 Hash]
IPCheck[IP 代理池/IDC 机房黑名单拦截]
end
subgraph SecurityFilter [2. 第二道锁:时间戳与 HMAC 防重放锁]
TS[时间戳漂移校验 (±30s)]
Nonce[Nonce 随机数防重放 (Redis 5分钟缓存)]
Sign[HMAC-SHA256 签名验签]
end
subgraph RateLimiter [3. 第三道锁:动态滑动窗口与并发信号量]
SlideWin[Redis ZSet 动态滑动窗口限流]
SemLock[任务级分布式信号量 (Max In-Flight = 20)]
end
subgraph CoreEngine [4. 第四道锁:原子预扣与异常熔断状态机]
CAS[MySQL CAS 条件原子预扣 (HOLDING)]
CircuitBreaker[上游超时与连续失败熔断器]
Consume[成功核销 (CONSUMED)]
Refund[异常冲正 (REFUNDED)]
end
Req --> FP --> IPCheck
IPCheck -->|违规特征拦截 403| Block1[阻断: 触发图形滑块验证]
IPCheck -->|正常流量| TS
TS --> Nonce --> Sign
Sign -->|签名不符或重放 401| Block2[阻断: 非法嗅探请求]
Sign -->|验签通过| SlideWin
SlideWin --> SemLock
SemLock -->|突发洪峰或超出频次 429| Block3[阻断: 频次超限等待]
SemLock -->|获得调度令牌| CAS
CAS --> CircuitBreaker
CircuitBreaker -->|执行成功| Consume
CircuitBreaker -->|熔断/失败| Refund
二、第一道锁:客户端指纹与反自动化探测 (Fingerprint Guard)
黑产最常用的武器是 Python requests、aiohttp 配合动态代理池。单纯限制 IP 在分布式代理面前毫无效果。
1. 核心策略:环境指纹交叉校验
我们在前端 SDK 引入了轻量级环境探针:
- 采集浏览器
Canvas渲染指纹、WebAudio 频率指纹、GPU 渲染器名称(WebGL unmaskedRenderer); - 探测自动化特有属性:
window.navigator.webdriver、window._phantom、document.__selenium_unwrapped等; - 将采集参数在前端进行混淆哈希,生成唯一的
X-Device-Fingerprint。
2. 后端黑产机房 IP 库清洗
在网关层集成 IP 数据云或 MaxMind 数据库,如果请求来源 IP 属于 AWS、阿里云、腾讯云、DigitalOcean 等数据中心(Datacenter / IDC)机房 IP,且访问的是试用/生图接口,一律判定为自动化爬虫代理,直接拦截并要求强制完成点选验证码。
三、第二道锁:时间戳 + Nonce + HMAC 接口防篡改签名
很多初创站点的接口,黑客抓包后拷贝出 curl 就可以无限重放调取。第二道锁的核心目标就是:让每一次请求只能被消费一次,且请求体(Body)与参数不可被篡改。
1. 签名要素设计
X-Timestamp:当前毫秒时间戳。服务器允许漂移误差不超过 30 秒,防止跨时区延时,同时限制过期重放;X-Nonce:客户端生成的 32 位 UUID 随机串,存入 Redis,5 分钟内不可重复;X-Signature:客户端利用预埋的 SecretKey,对URI + Timestamp + Nonce + Body进行 HMAC-SHA256 散列计算。
2. Spring Boot 3 防重放过滤器生产级实现
package com.tudazi.ai.security.filter;
import com.tudazi.ai.security.util.HmacUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
@Slf4j
@Component
@RequiredArgsConstructor
public class RequestSignatureValidationFilter extends OncePerRequestFilter {
private final StringRedisTemplate redisTemplate;
private static final String HMAC_SECRET = "EnterpriseProductionSecureKey_2026";
private static final long MAX_TIME_DRIFT_MS = 30_000L; // 允许最大 30 秒时钟误差
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String path = request.getRequestURI();
// 仅对高敏感 AI 资产消费接口进行严格签名校验
if (!path.startsWith("/api/v1/ai/generate") && !path.startsWith("/api/v1/credits/consume")) {
filterChain.doFilter(request, response);
return;
}
String timestampStr = request.getHeader("X-Timestamp");
String nonce = request.getHeader("X-Nonce");
String signature = request.getHeader("X-Signature");
// 1. 必填请求头校验
if (timestampStr == null || nonce == null || signature == null) {
log.warn("防重放拦截: 缺少核心安全头 Path={}", path);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing security headers");
return;
}
// 2. 时间戳防过期:防止捕获旧请求在数小时后重放
long requestTime;
try {
requestTime = Long.parseLong(timestampStr);
} catch (NumberFormatException e) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid timestamp");
return;
}
long now = System.currentTimeMillis();
if (Math.abs(now - requestTime) > MAX_TIME_DRIFT_MS) {
log.warn("防重放拦截: 请求已过期或时钟漂移严重 Diff={}ms", Math.abs(now - requestTime));
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Request expired");
return;
}
// 3. Nonce 防重放:利用 Redis SETNX 确保随机数单次有效
String nonceKey = "security:nonce:" + nonce;
Boolean isFirstUse = redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", Duration.ofMinutes(5));
if (Boolean.FALSE.equals(isFirstUse)) {
log.warn("防重放拦截: 检测到重复 Nonce 重放攻击 Nonce={}", nonce);
response.sendError(HttpServletResponse.SC_CONFLICT, "Replay attack detected");
return;
}
// 4. 重复读包装 RequestBody 并验签
CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(request);
String body = StreamUtils.copyToString(wrappedRequest.getInputStream(), StandardCharsets.UTF_8);
String payloadToSign = wrappedRequest.getMethod() + path + timestampStr + nonce + body;
String calculatedSign = HmacUtils.sha256Hex(HMAC_SECRET, payloadToSign);
if (!calculatedSign.equalsIgnoreCase(signature)) {
log.warn("防重放拦截: HMAC 签名不匹配 Expected={} Actual={}", calculatedSign, signature);
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Invalid signature");
return;
}
filterChain.doFilter(wrappedRequest, response);
}
}
四、第三道锁:基于 Redis ZSet 的动态滑动窗口限流
传统的计数器限流(如 INCR 配合 1 分钟过期)存在致命缺陷:临界时间双倍突变(在 00:59 发起 60 次请求,在 01:00 又发起 60 次请求,相当于 2 秒内涌入了 120 次请求打崩上游)。
我们采用 Redis Sorted Set (ZSet) 滑动时间窗口,将时间戳作为 score 和 member,真正做到纳秒级平滑限制。
1. 核心 Lua 脚本实现 (保证原子性)
-- KEYS[1]: 限流 key (例如 rate:limit:user_id:1001)
-- ARGV[1]: 当前时间戳 (毫秒)
-- ARGV[2]: 窗口大小 (毫秒, 如 60000 代表 1 分钟)
-- ARGV[3]: 窗口内最大允许调用次数 (如 10 次)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max_limit = tonumber(ARGV[3])
local clear_before = now - window
-- 1. 移除窗口外的历史过期记录
redis.call('ZREMRANGEBYSCORE', key, 0, clear_before)
-- 2. 获取当前滑动窗口内的总调用次数
local current_requests = redis.call('ZCARD', key)
-- 3. 判断是否超出配额
if current_requests < max_limit then
-- 未超限,记录本次请求
redis.call('ZADD', key, now, now)
-- 设置 key 过期时间为窗口大小
redis.call('PEXPIRE', key, window)
return 1 -- 允许放行
else
return 0 -- 触发限流阻断
end
2. 全局分布式信号量锁:限制未完成任务数(In-Flight Limiter)
除了限制频次外,更重要的是限制正在进行中的调用总数。
如果用户或者黑客在 1 秒内通过 10 个线程发起了 10 次合法的长文本生图,后端立刻拉起 10 路调用涌向上游,依然会耗尽本地线程。
解法:使用 Redis 维护并发 Semaphore:单用户同时只能有 1 个生图任务处于 RUNNING 状态,新任务必须排队或者拒绝,彻底扼杀并发打架。
五、第四道锁:分布式资产状态机与异常熔断 (Asset State Machine)
即使前面三道锁都放行了,如果数据库扣款不是原子的,或者上游生图报错时系统没有自愈能力,账本依然会产生严重坏账。
1. 资产状态机:禁止直接扣款,必须两阶段提交
-- 预扣冻结:利用数据库条件行锁进行悲观保护,杜绝超扣
UPDATE t_user_wallet
SET
frozen_credits = frozen_credits + :cost,
balance_credits = balance_credits - :cost,
version = version + 1
WHERE user_id = :userId
AND balance_credits >= :cost;
2. 异常熔断与智能保活(Circuit Breaker)
当调用 OpenAI 或本地推理集群时:
- 若连续 5 次上游返回
502 / 504 / 429,触发熔断器(Resilience4j),暂停向公网接单 60 秒,并给用户返回“AI 算力节点拥堵中,请稍后重试”,避免无效请求源源不断消耗系统重试资源; - 失败时自动触发补偿任务(Compensating Transaction),原路解冻用户积分,日志记录完整请求快照。
六、生产避坑经验与效果总结
在落地这套多维防御系统后,我们总结了 3 条最宝贵的实战经验:
- 防重放过滤器严禁读取未包装的 InputStream:Servlet 的
request.getInputStream()默认只能被读取一次。如果在过滤器中为了算 HMAC 读取了流,后续 Spring MVC 的@RequestBody解析就会报HttpMessageNotReadableException: Stream closed。必须使用ContentCachingRequestWrapper或自定义CachedBodyHttpServletRequest。 - 切忌用客户端 IP 单一做限流维度:真实用户在公司局域网或校园网内共用同一个出口公网 IP。如果单纯根据 IP 做 1 分钟 10 次限制,会造成整栋办公楼所有用户被集体误杀。正确的做法是组合维度:
DeviceFingerprint + UserID + C段IP联合限流。 - 给黑客返回迷惑性错误,不要暴露防御细节:被第一道锁拦截时,返回普通的
403 Forbidden或引导弹窗;不要在 Response 里写“检测到你使用了无头浏览器”,避免给黑产逆向调试提供反馈信息。
读者探讨
在你的系统架构中,是否也曾遭遇过爬虫盗刷高价值接口的经历?你们目前是如何设计防重放与分布式限流的?欢迎在评论区分享你的防御秘籍与架构思考!