先说一个我自己的场景。线上有个 Spring Boot 服务,其中一个商品列表接口,平时 QPS 一百出头,结果连续两个晚上被机器脚本刷到每秒三千多次,数据库 CPU 直接报警。查了日志才发现,这不是普通的访问高峰,而是典型的反爬虫问题。与此同时,另一个短信验证码接口也在被盗刷,一晚上触发了几万条下发,短信费用哗啦啦地烧。那种感觉,就是明知道有人把你接口当提款机,但你一时还真不好下手去堵。
这类问题的解法,说穿了并不复杂:限流、黑白名单、请求指纹、异常行为拦截。但真到自己写实现的时候,坑非常多。分布式环境下计数怎么保证原子性?多个实例到底按什么维度统计?误杀正常用户怎么办?爬虫伪造 IP 头怎么防?每个问题都得折腾一遍。我后来的做法是,把这套逻辑从业务项目里抽出来,封装成一个独立的 Spring Boot 反爬防刷 starter,业务服务里只需要加一个依赖、写几行配置,就能把大部分垃圾流量挡在外面。这篇文章就是把我这套方案的实现思路、核心代码、参数设计思路和实际踩坑经历完整记录下来,给正在被爬虫和接口盗刷折磨的 Spring Boot 开发者参考。
1. 反爬和防刷的本质:先搞清楚我们在挡谁
1.1 反爬虫和接口盗刷,本质上是同一回事
很多人觉得反爬虫是针对搜索引擎爬虫或者数据采集脚本,接口盗刷是针对短信、验证码、优惠券这些有成本或风控价值的接口。听起来是两个方向,但落到行为特征上,它们的攻击方式高度一致:高频、批量、自动化。
爬虫是拿脚本大规模拉数据,比如商品价格、用户信息、订单列表,稍不注意还会把分页接口从头到尾遍历一遍。接口盗刷则是拿你的业务规则刷成本,典型的像短信轰炸、验证码轮询、登录撞库、优惠券套利。这两类攻击的共同点是,它们不是人类操作,计算能力远高于正常用户,而且会伪装成正常请求绕过粗糙的限制。
所以反爬方案真正要解决的核心问题不是区分“人”和“机器人”,而是区分“正常流量”和“异常流量”。正常流量有节奏、有上限、有明确的业务意图,异常流量则通常表现为短时间内远超合理阈值的重复请求。抓住这个特征,防刷逻辑就可以抽象成一组通用规则,和具体业务解耦。
1.2 为什么建议直接用依赖,而不是自己写拦截器
我见过不少团队的做法,是直接在 Controller 或者拦截器里加一段计数逻辑。比如用 ConcurrentHashMap 存 IP 和计数器,到阈值就返回错误。这种实现单机撑一撑还行,但一上多实例就露馅:每个节点的计数器各管各的,加起来远超限制;想清缓存还得写定时任务;想把某个 IP 拉黑只能改代码重启。
自己从零写一套还算靠谱的防刷机制,至少要解决几个问题:分布式原子计数、时间窗口设计、请求指纹提取、黑白名单管理、拦截响应格式、动态配置刷新。光是分布式限流这一项,就需要你对 Redis 和 Lua 脚本有足够深的理解。如果只是业务开发,完全没必要重复造这个轮子。
所以我的观点很明确:把反爬和防刷做成独立依赖,而不是业务代码。这样各项目统一接入、统一升级,规则变了改一处就行,也不容易因为某个同学手写了一个有漏洞的限流器而埋雷。
1.3 一个合格的防刷依赖应该具备什么能力
我封装这个 starter 的时候,给自己列过一张能力清单,大致是下面这些:
| 能力 | 说明 | 自己手写的成本 |
|---|---|---|
| 分布式限流 | 基于 Redis 的原子计数,多实例共享状态 | 高,容易忽略原子性 |
| 请求指纹 | 根据 IP、UA、路径甚至设备信息生成唯一标识 | 中,要踩伪造头部的坑 |
| 黑白名单 | 支持按 IP、指纹、路径配置,动态生效 | 中,需要可管理入口 |
| 多级规则 | 不同接口有不同阈值,支持全局和局部配置 | 中,要做好规则匹配 |
| 标准化拦截响应 | 返回 429、Retry-After、统一 JSON 结构 | 低,但容易被忽略 |
| 可观测性 | 每次拦截记录日志和指标,方便事后分析 | 低,业务方往往想不起来 |
具备这些能力之后,接入方视角只需要关注一件事:我要对哪个接口、限制多少量、窗口多长。其余复杂逻辑,依赖内部解决。
2. 使用方法:一个依赖如何接入项目
2.1 加入依赖
我封装好的依赖,业务项目里只需要加一行 pom 坐标。你的团队如果有内部的制品仓库,替换成自己的坐标就行。
<dependency> <groupId>com.example</groupId> <artifactId>anti-spider-spring-boot-starter</artifactId> <version>1.0.0</version> </dependency>这个依赖会通过 Spring Boot 的自动装配机制识别到,不需要在启动类上手动加注解。前提是项目里有 Redis 连接依赖,因为底层限流要用 Redis 做分布式计数。项目没有任何 Redis 的话,starter 会自动降级成单机内存模式,但我不推荐正式环境这么用,后面会讲原因。
2.2 写全局配置
接入后第一件事是配置规则。以我线上的一组配置为例:
spring: anti-spider: enabled: true whitelist: - "127.0.0.1" - "10.0.0.0/8" blacklist: - "34.207.12.88" rules: - path: /api/v1/goods/** limit: 120 window: 60s - path: /api/v1/auth/sms limit: 5 window: 60s retryAfter: 60这里rules是核心,每个规则由路径、窗口、阈值组成。/api/v1/goods/**表示商品相关接口 60 秒最多 120 次,/api/v1/auth/sms表示短信接口 60 秒最多 5 次。blacklist里的地址直接拒绝,whitelist里的地址完全放行。如果多个规则同时命中同一个接口,取限制更严格的那条生效。
2.3 用注解对单个接口做精细化控制
全局规则适合统一拦截,但总有少数接口表现特殊。有些接口要放宽,有些接口要收紧,所以我给 starter 加了一个注解,可以直接标在 Controller 方法上。
@RestController @RequestMapping("/api/v1/goods") public class GoodsController { @GetMapping("/list") @AntiSpider(limit = 60, window = 60) public Result list(@RequestParam(required = false) String cursor) { // 业务逻辑 } }这个注解优先级比全局规则高。只要方法上标注了@AntiSpider,就走注解定义的阈值;没标明的接口继续走全局配置。这样的设计比较灵活,既不需要给每个接口都写规则,又保留了单独调整的空间。
3. starter 核心代码与原理逐行拆解
3.1 自动装配入口
Spring Boot 3.x 里,自动装配通过META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件声明。
com.example.antispider.AntiSpiderAutoConfiguration配置类长这样:
@AutoConfiguration @ConditionalOnProperty(prefix = "spring.anti-spider", name = "enabled", havingValue = "true") @EnableConfigurationProperties(AntiSpiderProperties.class) public class AntiSpiderAutoConfiguration { @Bean @ConditionalOnMissingBean(AntiSpiderFilter.class) public FilterRegistrationBean<AntiSpiderFilter> antiSpiderFilter( AntiSpiderProperties properties, StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { AntiSpiderFilter filter = new AntiSpiderFilter(properties, redisTemplate, objectMapper); FilterRegistrationBean<AntiSpiderFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(filter); registration.addUrlPatterns("/*"); registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 20); return registration; } }这里有几个关键点。第一,用@ConditionalOnProperty做开关,配置里没启用就不会注册任何 bean,保证依赖本身不影响其他项目。第二,用@ConditionalOnMissingBean允许使用方覆盖默认过滤器,如果你有定制需求,可以自己定义同类型 Bean。第三,过滤器注册顺序设置得比较靠前,确保它在 Spring Security 等框架拦截之前生效,否则请求可能还没到防刷层就被别的逻辑处理掉了。
3.2 请求指纹:不能只依赖 IP
反爬的第一步是识别“谁在请求”。最朴素的做法是直接用 IP,但现实环境下,同一栋办公楼、同一个公司出口,可能几百个用户共用同一个公网 IP。用单一 IP 做计数,很容易误伤一大批正常用户。
我在 starter 里采用的指纹是 IP、User-Agent、路径三者的组合:
private String fingerprint(HttpServletRequest request) { String ip = getClientIp(request); String ua = request.getHeader("User-Agent"); String uri = request.getRequestURI(); return DigestUtils.sha1Hex(String.format("%s|%s|%s", ip, emptyIfNull(ua), uri)); }组合的方案谈不上完美,但成本很低,而且足够应付绝大多数自动化脚本。爬虫工具想要绕过,必须同时伪造 IP 和 UA,这就把攻击门槛抬高了一截。如果业务要求更高的精度,可以在指纹里加 Cookie、设备 ID 或者 TLS 指纹,原理相同,只需要在生成指纹时多拼接几个字段。
特别提醒:获取 IP 时不要直接信任X-Forwarded-For。这个头普通客户端也能伪造,如果不对上游 IP 做校验,爬虫只要在请求里加一个假的 X-Forwarded-For 就能把自己的真实地址藏起来。我在线上踩过这个坑。正确的做法是让网关统一清洗并设置可信的头部,比如 Nginx 后面用 X-Real-IP 或经过校验的 X-Forwarded-For,服务端只信任可信来源传下来的值。
3.3 限流核心:Redis + Lua 滑动窗口
限流是整个依赖的心脏。我实现的是基于 Redis ZSET 的滑动窗口,比固定窗口更平滑,不会出现窗口边界处突然放行一批请求的问题。
local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local limit = tonumber(ARGV[3]) local member = ARGV[4] local key = KEYS[1] redis.call('ZREMRANGEBYSCORE', key, 0, now - window) local current = redis.call('ZCARD', key) if current >= limit then return -1 end redis.call('ZADD', key, now, member) redis.call('PEXPIRE', key, window * 2) return limit - current - 1脚本逻辑很简单:先移除窗口之外的老请求,再统计当前窗口请求数。如果已经达到上限,返回 -1;否则把本次请求的时间戳写入有序集合,并返回剩余配额。PEXPIRE设置两倍窗口的过期时间,是为了防止 Redis 里堆积大量失效 key,给内存做自动清理。
为什么非要用 Lua?因为这段逻辑里“判断并写入”是两步操作,如果拆成两条命令执行,在高并发下会出现两个请求同时通过校验的情况。 Lua 脚本在 Redis 中原子执行,从根上避免了这个竞态问题。用StringRedisTemplate.execute(RedisScript, keys, args)就能运行它。
这里我要强调一个选择:为什么不用本地内存限流,比如 Guava 的 RateLimiter 或 Caffeine?对于单体小项目当然没问题,但一旦服务扩展到多个实例,每个实例各自计数,等于把阈值乘了实例数,防刷效果大打折扣。Redis 虽然多了一次网络开销,但换来的是集群级别的统一视角。所以,只要不是极端在意这零点几毫秒延迟的接口,我都建议把计数放到 Redis。
3.4 拦截顺序与响应策略
过滤器内部的处理顺序是固定的:先查黑名单,命中直接拦截;再查白名单,命中直接放行;接着匹配限流规则;最后交给后续业务处理。
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String fp = fingerprint(request); if (blacklist.contains(fp)) { writeBlocked(response, "blacklist", 0); return; } if (whitelist.matches(request)) { filterChain.doFilter(request, response); return; } Rule rule = matcher.match(request.getRequestURI()); if (rule == null) { filterChain.doFilter(request, response); return; } long remain = rateLimiter.tryAcquire(fp, rule); if (remain < 0) { writeBlocked(response, "ratelimit", rule.getRetryAfterSeconds()); return; } filterChain.doFilter(request, response); }拦截响应要同时返回三个信息:HTTP 状态码、响应体、重试时间。状态码我用 429 Too Many Requests,响应体是统一的 JSON 结构,包含错误码、提示信息和请求 ID,方便客户端展示和排查。重试时间放在Retry-After响应头里,客户端收到后可以按这个时间做退避,而不是反复重试加重服务压力。
拦截逻辑返回前,我会记一条结构化日志,包含指纹、匹配到的规则、剩余配额、拦截类型。这些日志是后续分析攻击来源和优化阈值的重要依据,千万不能省。
4. 限流参数到底怎么定:从业务数据反推
4.1 不要拍脑袋,从业务流量反推阈值
限流参数最关键也最容易翻车。拍脑袋定一个数,很可能把正常用户挡在门外,或者挡了个寂寞。我的方法是从业务口径反推。
第一步,问业务方明确正常场景下单个用户怎么做。比如登录接口,业务方说“用户一分钟内最多操作三次就很多了”。那就先定一个 5 次,留出一定余量。第二步,考虑 App 端是否有重试机制、预加载、切换网络等行为,这些都会增加请求次数,所以要加安全系数。我一般按 1.5 到 3 倍来计算阈值。
拿商品列表接口举例。业务方说用户在 App 里 5 分钟大约刷 30 次商品列表,这是保守估计,实际包含下拉刷新、翻页、预加载,乘一个 3 倍安全系数,我定的规则就是 5 分钟 90 次。低于 90 次的正常用户不会被误杀,高于这个频率的脚本则会被拦截。这里的原则是:宁可先松后紧,上线后观察误杀率再逐步收紧,不要第一天就把阈值卡得很死。
4.2 不同接口的区别配置
同一套规则不可能适配所有接口,我把接口大致分了几类,给不同类型定了不同的初始参数:
| 接口类型 | 推荐窗口 | 推荐阈值 | 补充策略 |
|---|---|---|---|
| 登录、短信验证码 | 60 秒 | 3~5 次 | 超过阈值直接拒绝,并接入图片验证码 |
| 列表、分页查询 | 60 秒 | 60~120 次 | 结合 UA/页码参数判断是否为遍历采集 |
| 下单、支付 | 10 分钟 | 10~20 次 | 需额外加签名和业务风控 |
| 批量导出、后台管理 | 不适用 | 不适用 | 不走公开接口,走独立权限链路 |
短信验证码接口我单独讲一下。这类接口是盗刷的高发区,单个手机号 60 秒只能发 5 次,24 小时最多 20 次,两个规则同时生效。第一个规则挡住短时间内的集中轰炸,第二个规则防止攻击者隔一段时间换一批手机号慢慢刷。两道门槛之后,短信费用基本能控制住。
4.3 压测验证阈值是否合理
规则配置完,必须用压力测试验证效果。我习惯用 wrk 直接打一批超过阈值的流量,确认拦截是否生效,同时用正常频率的请求确认不影响业务。
wrk -t2 -c10 -d30s -R 300 "https://example.com/api/v1/goods/list"上面这条命令会以每秒 300 次的速率打接口。如果规则配置的是 60 秒 120 次,正常应该会看到大量 429 响应。然后再用极低频率的请求跑一遍,确认返回 200 的业务结果正常。压测过程中重点观察两点:Redis 的命中率和内存有没有异常增长;应用日志里拦截记录的分布是否合理。如果所有请求都被某一两个指纹挡住,说明指纹识别太粗;如果拦截寥寥无几,说明阈值太高,需要下调。
5. 实操中的常见坑和排查思路
5.1 同一出口 IP 导致正常用户集体误杀
这是最典型的翻车现场。公司、学校、商场里的用户共用同一个出口 IP,如果规则只按 IP 计数,几百人的请求量轻松触发阈值,一小时内全部被挡。我上线初期就遇到过一次,客服反馈某个园区的大量用户无法访问接口。
排查方法很简单:看拦截日志里的指纹分布,如果多个完全不同的操作集中在同一个 IP 上,基本可以确认是出口 IP 共享问题。解决方向有两个:一是把限流维度从纯 IP 改成 IP+UA,这样不同客户端类型能分开计数;二是在置信度较高的场景下,改用业务侧的用户 ID 或设备 ID 作为指纹来源之一,而不是纯网络层参数。
5.2 爬虫伪造 X-Forwarded-For
我在第三部分就提过这个坑。如果直接读取 X-Forwarded-For 作为真实 IP,爬虫知道你的规则后,可以每发一个请求就轮换这个头里的 IP,你会看到“几十个不同 IP 属于同一个攻击行为”,限流完全失效。
这里最重要的不是写多复杂的代码,而是明确网络边界。在可信网关做好头部清洗,服务端只读可信 IP 字段,其他所有传入的 IP 相关头一律忽略。网络边界不解决,任何应用层的指纹计算都是在沙滩上盖房子。
5.3 Redis 故障时怎么办
防刷依赖强依赖 Redis,如果 Redis 挂了,理论上所有请求都无法通过限流检查。这里有个经典取舍:容灾模式选 fail-closed 还是 fail-open。我选的是 fail-open,也就是 Redis 不可用时放行所有请求,优先保证业务可用性。
但纯 fail-open 也不安全,Redis 抖动的那几分钟,攻击流量同样会畅通无阻。我在 starter 里做了个折中:Redis 异常时自动切换到一个本地 Caffeine 缓存,做 1 秒粒度的近似限流。这个模式能拦住极端的突发流量,但跨实例的准确性会差一点。等 Redis 恢复后,自动切回分布式模式。这种做法相当于给安全组件加了保险丝,不至于一点小故障就把整个服务拖垮。
5.4 拦截了但前端拿不到正确响应
联调时经常出现一个问题:接口返回 429,但前端拿到的跨域信息不对,导致被拦截时 AJAX 报错而不是友好提示。原因在于过滤器非常靠前,Web 框架层还没处理 CORS,响应头里缺少跨域字段。
解决方法是拦截响应中主动加 CORS 头。我在 writeBlocked 方法里把Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers都显式写进去。这里还有个细节:如果配置了Credentials,Access-Control-Allow-Origin就不能通配,必须回显来源域。
5.5 过滤器顺序和 Spring Security 的相爱相杀
如果你用了 Spring Security,防刷过滤器顺序不对会白做。请求先被认证拦截还是先被限流拦截,取决于注册顺序。我的建议是防刷过滤器排在 Spring Security 前面,因为限流挡掉的攻击流量没必要继续走完整的认证授权链路,既节省 CPU,也避免某些绕过认证的路径成为漏网之鱼。用 FilterRegistrationBean 的 setOrder 可以控制,我一般设成 Spring Boot 内部过滤器之上的比较高优先级,比如 -100 附近,同时确保在 servlet container 自己的过滤器之后。
6. 进阶:把反爬能力组合成立体防御
6.1 限流之外加上二次验证
限流能挡掉相当一部分低水平爬虫,但对控制能力强的攻击者不够。这类攻击者会用分布式代理池,每个 IP 的请求量都不超过阈值。这时候要用二次验证来做升级,最常用的就是验证码。
被限流规则拦截时,不一定直接返回 429,可以降级返回200 + requireCaptcha标志,让前端弹出一个行为验证码。用户通过验证后,发放一个短期令牌,携带令牌的请求暂时不受限流影响。这样既没有完全阻断正常用户,又让脚本开发者成本大幅上升。这块逻辑我建议别自己造轮子,接现成的行为验证能力更省心。
6.2 从“拦数量”升级到“识别身份”
单纯基于 IP、UA 的指纹,本质上是“拦数量”。更高级的做法是建设设备指纹。服务端下发一个匿名 Cookie,浏览器端配合 JavaScript 收集屏幕、字体、Canvas 特征,生成一个相对稳定的设备指纹。对爬虫来说,更换 IP 容易,更换一整套设备特征难。设备指纹异常且请求频繁,基本可以断定是脚本行为,比对数量和频率更准。
6.3 别忘了数据签名和敏感接口加密
反爬防刷解决的是“谁在请求”和“能不能高频请求”,但盗刷问题还有一个层面是“请求是不是伪造的”。如果攻击者抓包篡改参数,比如改下单数量、改优惠券金额,单纯限流是挡不住的。所以在登录、下单、支付这类敏感接口上,我强烈建议加接口签名机制。服务端定义一套固定的参数排序和签名规则,客户端用私钥签名,服务端验签后再执行业务逻辑。没有正确签名的请求直接在入口处丢弃,这样即使用了你的接口,也做不了非法操作。
最后说点我个人的体会。反爬防刷不是配一次就一劳永逸的静态规则,攻击者一直在变,脚本工具也在迭代。依赖的作用是把通用能力沉淀下来,保证你不会每次面对新的攻击手段都从零开始。我线上跑这套方案的核心感受是:规则要松紧有度、可调可观测,拦截日志一定要完整,分析要比防御本身更重要。先把挡下来的人看清楚,才能知道下一步该加哪块盾牌。如果你们正准备给自己的服务做反爬,建议不要一上来就追求各种花哨算法,先把限流、指纹、名单这三板斧做好,把日志和监控补上,再逐步升级到验证码和设备指纹。后面遇到具体问题,欢迎随时交流。