
先说一个我自己的场景。线上有个 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 groupIdcom.example/groupId artifactIdanti-spider-spring-boot-starter/artifactId version1.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 FilterRegistrationBeanAntiSpiderFilter antiSpiderFilter( AntiSpiderProperties properties, StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { AntiSpiderFilter filter new AntiSpiderFilter(properties, redisTemplate, objectMapper); FilterRegistrationBeanAntiSpiderFilter 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 秒35 次超过阈值直接拒绝并接入图片验证码列表、分页查询60 秒60120 次结合 UA/页码参数判断是否为遍历采集下单、支付10 分钟1020 次需额外加签名和业务风控批量导出、后台管理不适用不适用不走公开接口走独立权限链路短信验证码接口我单独讲一下。这类接口是盗刷的高发区单个手机号 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 改成 IPUA这样不同客户端类型能分开计数二是在置信度较高的场景下改用业务侧的用户 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都显式写进去。这里还有个细节如果配置了CredentialsAccess-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 别忘了数据签名和敏感接口加密反爬防刷解决的是“谁在请求”和“能不能高频请求”但盗刷问题还有一个层面是“请求是不是伪造的”。如果攻击者抓包篡改参数比如改下单数量、改优惠券金额单纯限流是挡不住的。所以在登录、下单、支付这类敏感接口上我强烈建议加接口签名机制。服务端定义一套固定的参数排序和签名规则客户端用私钥签名服务端验签后再执行业务逻辑。没有正确签名的请求直接在入口处丢弃这样即使用了你的接口也做不了非法操作。最后说点我个人的体会。反爬防刷不是配一次就一劳永逸的静态规则攻击者一直在变脚本工具也在迭代。依赖的作用是把通用能力沉淀下来保证你不会每次面对新的攻击手段都从零开始。我线上跑这套方案的核心感受是规则要松紧有度、可调可观测拦截日志一定要完整分析要比防御本身更重要。先把挡下来的人看清楚才能知道下一步该加哪块盾牌。如果你们正准备给自己的服务做反爬建议不要一上来就追求各种花哨算法先把限流、指纹、名单这三板斧做好把日志和监控补上再逐步升级到验证码和设备指纹。后面遇到具体问题欢迎随时交流。