接口防刷怎么实现?8 年 Java 开发:从被刷欠费到分层防御(附可复用代码)
六年前我负责的短信接口,被黑产用脚本刷了 30 万条,月底收到运营商账单时,财务拿着发票找到我:“这超出预算 10 倍,你得给个说法”。我盯着日志里 “1 分钟内同一 IP 请求 200 次” 的记录,才意识到:接口防刷不是 “可选功能”,而是业务上线前必须焊死的安全阀。
今天就从 8 年 Java 开发的实战角度,把接口防刷的 “业务场景→技术选型→代码落地→避坑指南” 讲透 —— 不管你是做登录、支付还是短信接口,看完都能直接套用,再也不用怕被刷到欠费。
一、先想清楚:哪些接口要防刷?被刷了有啥后果?
别上来就堆技术,先明确 “防什么”—— 不是所有接口都需要防刷,重点盯紧这 4 类高风险接口:
| 接口类型 | 被刷后果 | 真实案例(我踩过的坑) |
|---|---|---|
| 短信验证码接口 | 短信费用暴增、手机号被骚扰 | 黑产刷短信注册小号,1 天耗光 3 个月短信预算 |
| 登录 / 注册接口 | 账号被盗、垃圾账号泛滥 | 批量注册账号薅羊毛,导致活动成本超支 |
| 下单 / 支付接口 | 库存超卖、恶意下单占库存 | 秒杀活动被脚本刷单,真实用户抢不到商品 |
| 优惠券 / 积分接口 | 营销成本失控、正常用户权益受损 | 优惠券被批量刷走,转手倒卖获利 |
比如去年做电商秒杀,我们没给下单接口加防刷,开抢 10 秒内就被脚本下单 5000 笔,库存直接清零,真实用户全在骂 —— 最后只能紧急下线活动,损失了近百万 GMV。这就是血的教训:防刷要 “提前布局”,不是出问题再补。
二、接口防刷的技术方案:从单机到分布式,按业务选对方案
八年实战总结:接口防刷的核心是 “限流”+“鉴权” ,不同业务量级对应不同方案,别一上来就用最复杂的分布式方案。
1. 入门级:单机接口防刷(适合小项目 / 内部系统)
如果你的接口只部署在单台机器,并发量不高(比如日均 10 万请求),用 Java 自带的工具就能搞定,简单又高效。
方案 1:固定窗口限流(最简单,但有漏洞)
原理:把时间分成固定窗口(比如 1 分钟),每个窗口内限制请求次数(比如 100 次),超过就拦截。
代码实现:用AtomicInteger做计数器,配合ScheduledExecutorService定时重置计数器。
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 固定窗口限流:1分钟内最多100次请求
*/
public class FixedWindowRateLimiter {
// 每个窗口的请求上限
private final int limit = 100;
// 窗口时间(1分钟)
private final long windowTime = 60 * 1000;
// 当前窗口的请求数
private final AtomicInteger currentCount = new AtomicInteger(0);
public FixedWindowRateLimiter() {
// 定时重置计数器(每1分钟重置一次)
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
currentCount.set(0);
System.out.println("固定窗口重置,当前请求数清零");
}, 0, windowTime, TimeUnit.MILLISECONDS);
}
// 判断是否允许请求(true=允许,false=拦截)
public boolean allowRequest() {
return currentCount.incrementAndGet() <= limit;
}
// 测试:模拟1分钟内120次请求
public static void main(String[] args) throws InterruptedException {
FixedWindowRateLimiter limiter = new FixedWindowRateLimiter();
for (int i = 1; i <= 120; i++) {
if (limiter.allowRequest()) {
System.out.println("请求" + i + ":允许通过");
} else {
System.out.println("请求" + i + ":被拦截(超过1分钟100次限制)");
}
Thread.sleep(500); // 模拟请求间隔
}
}
}
优缺点分析:
-
✅ 优点:代码简单,无依赖,适合单机低并发;
-
❌ 缺点:有 “临界问题”—— 比如 23:59:59 发起 100 次请求,00:00:01 又发起 100 次,2 秒内实际请求 200 次,突破限制。
适用场景:内部管理系统接口(比如后台数据查询),并发量低,对临界问题不敏感。
方案 2:滑动窗口限流(解决固定窗口漏洞,推荐单机高并发)
原理:把固定窗口拆成多个小时间片(比如 1 分钟拆成 6 个 10 秒的时间片),用 “滑动窗口” 计算最近 1 分钟的总请求数 —— 比如当前时间是 00:00:15,窗口包含 00:00:05-00:00:15 的时间片,这样就解决了临界问题。
代码实现:用数组存储每个时间片的请求数,定时滑动窗口。
import java.util.Arrays;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 滑动窗口限流:1分钟(拆6个10秒时间片)内最多100次请求
*/
public class SlidingWindowRateLimiter {
private final int limit = 100; // 总请求上限
private final int windowSize = 6; // 时间片数量(1分钟=6*10秒)
private final long timeSlice = 10 * 1000; // 每个时间片10秒
// 存储每个时间片的请求数(原子类保证线程安全)
private final AtomicInteger[] timeSliceCounts;
private int currentIndex = 0; // 当前时间片的索引
public SlidingWindowRateLimiter() {
// 初始化时间片数组
timeSliceCounts = new AtomicInteger[windowSize];
for (int i = 0; i < windowSize; i++) {
timeSliceCounts[i] = new AtomicInteger(0);
}
// 定时滑动窗口(每10秒切换一次时间片,重置当前时间片的请求数)
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
currentIndex = (currentIndex + 1) % windowSize; // 循环切换索引
timeSliceCounts[currentIndex].set(0); // 重置当前时间片的请求数
System.out.println("滑动窗口切换,当前时间片索引:" + currentIndex);
}, timeSlice, timeSlice, TimeUnit.MILLISECONDS);
}
// 判断是否允许请求
public boolean allowRequest() {
// 1. 计算当前窗口内的总请求数
int total = Arrays.stream(timeSliceCounts)
.mapToInt(AtomicInteger::get)
.sum();
// 2. 总请求数未超过上限,当前时间片请求数+1
if (total < limit) {
timeSliceCounts[currentIndex].incrementAndGet();
return true;
}
return false;
}
// 测试:模拟临界情况(23:59:55到00:00:05发起120次请求)
public static void main(String[] args) throws InterruptedException {
SlidingWindowRateLimiter limiter = new SlidingWindowRateLimiter();
for (int i = 1; i <= 120; i++) {
if (limiter.allowRequest()) {
System.out.println("请求" + i + ":允许通过");
} else {
System.out.println("请求" + i + ":被拦截(超过1分钟100次限制)");
}
Thread.sleep(800); // 模拟高频请求
}
}
}
优缺点分析:
-
✅ 优点:解决固定窗口的临界问题,精度高,适合单机高并发(比如日均 100 万请求);
-
❌ 缺点:只支持单机,多实例部署时会失效(比如 2 台机器各限 100 次,实际总请求 200 次)。
适用场景:单实例部署的接口(比如中小项目的登录、短信接口)。
方案 3:令牌桶限流(应对突发流量,秒杀场景首选)
原理:系统按固定速率往 “令牌桶” 里放令牌(比如每秒放 10 个),请求过来时需要拿一个令牌才能通过,没有令牌就拦截 —— 支持突发流量(比如桶里存 100 个令牌,瞬间来了 50 个请求,能一次性通过)。
代码实现:用 Guava 的RateLimiter(Google 官方工具,已封装好令牌桶逻辑,不用重复造轮子)。
<!-- 先加Guava依赖 -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version> <!-- 选最新稳定版 -->
</dependency>
import com.google.common.util.concurrent.RateLimiter;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 令牌桶限流:用Guava实现,每秒允许10个请求,桶容量100(支持突发流量)
*/
@RestController
public class TokenBucketController {
// 创建令牌桶:每秒放10个令牌,桶最大容量100(突发流量最多处理100个)
private final RateLimiter rateLimiter = RateLimiter.create(10.0, 100, TimeUnit.SECONDS);
// 秒杀下单接口(需要防刷)
@GetMapping("/seckill/order")
public String seckillOrder() {
// tryAcquire(0):不等待,没有令牌直接返回false
if (rateLimiter.tryAcquire(0)) {
// 有令牌,执行下单逻辑
return "下单成功!";
} else {
// 没令牌,拦截请求
return "请求过于频繁,请稍后再试~";
}
}
}
优缺点分析:
-
✅ 优点:支持突发流量,代码简洁(Guava 已封装),适合秒杀、抢购等场景;
-
❌ 缺点:单机方案,多实例时无法统一限流(比如 2 台机器各有 100 个令牌,实际能处理 200 个请求)。
适用场景:单实例的秒杀、抢购接口,需要应对突发流量。
2. 进阶级:分布式接口防刷(中大型项目必用)
当你的项目部署在多台机器(比如微服务集群),单机方案就失效了 —— 这时候需要一个 “全局统一的计数器”,Redis 就是最佳选择(支持高并发、原子操作)。
核心方案:Redis + Lua 限流(原子性保证,分布式首选)
为什么用 Lua? 因为限流需要 “计数 + 判断 + 过期” 三个操作,Lua 能保证这三个操作的原子性,避免高并发下的 “超卖” 问题(比如两个请求同时读到计数 99,都 + 1 变成 100,突破限制)。
原理:用 Redis 的incr命令计数,expire设置过期时间,Lua 脚本保证 “计数→判断→返回” 原子性,支持按 IP、用户 ID、手机号等维度限流。
代码实现(Spring Boot + Redis + Lua)
第一步:定义 Lua 脚本(放在 resources/lua 目录下)
-- redis_limit.lua:按key(比如IP、用户ID)限流,参数:key、limit、expire(秒)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local expire = tonumber(ARGV[2])
-- 1. 计数+1(incr命令:不存在则创建,初始值0,+1后返回1)
local currentCount = redis.call('incr', key)
-- 2. 如果是第一次计数,设置过期时间(避免key永久存在)
if currentCount == 1 then
redis.call('expire', key, expire)
end
-- 3. 判断是否超过限制(currentCount <= limit:允许请求,返回1;否则返回0)
if currentCount <= limit then
return 1
else
return 0
end
第二步:封装 Redis Lua 工具类
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Collections;
@Component
public class RedisRateLimiter {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 加载Lua脚本
private final DefaultRedisScript<Long> limitScript;
// 初始化Lua脚本
public RedisRateLimiter() {
limitScript = new DefaultRedisScript<>();
limitScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/redis_limit.lua")));
limitScript.setResultType(Long.class); // 脚本返回值类型(1=允许,0=拦截)
}
/**
* 分布式限流
* @param key 限流维度(比如"ip:192.168.1.1"、"user:1001")
* @param limit 每个周期的请求上限
* @param expire 周期时间(秒)
* @return true=允许请求,false=拦截请求
*/
public boolean allowRequest(String key, int limit, int expire) {
// 调用Lua脚本,参数:KEYS=[key],ARGV=[limit, expire]
Long result = stringRedisTemplate.execute(
limitScript,
Collections.singletonList(key),
String.valueOf(limit),
String.valueOf(expire)
);
// 脚本返回1=允许,0=拦截(防止null,默认返回false)
return result != null && result == 1;
}
}
第三步:用 Spring 拦截器全局拦截接口
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 接口防刷拦截器:按IP限流(1分钟最多100次请求)
*/
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
@Resource
private RedisRateLimiter redisRateLimiter;
// 限流配置:1分钟(60秒)最多100次请求
private static final int LIMIT = 100;
private static final int EXPIRE = 60;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
// 1. 获取限流维度(这里按IP,也可以按用户ID、手机号)
String ip = getClientIp(request);
String limitKey = "rate_limit:ip:" + ip;
// 2. 调用Redis限流工具
if (!redisRateLimiter.allowRequest(limitKey, LIMIT, EXPIRE)) {
// 3. 拦截请求,返回429(Too Many Requests)
response.setStatus(429);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{"code":429,"msg":"请求过于频繁,请1分钟后再试"}");
return false;
}
// 4. 允许请求,继续执行后续逻辑
return true;
}
// 获取客户端真实IP(处理反向代理、负载均衡场景)
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 处理多个IP的情况(X-Forwarded-For可能返回多个IP,取第一个)
return ip != null ? ip.split(",")[0].trim() : ip;
}
}
第四步:注册拦截器,指定需要防刷的接口
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Resource
private RateLimitInterceptor rateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册拦截器,拦截需要防刷的接口(比如短信、登录、下单)
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/api/sms/send") // 短信接口
.addPathPatterns("/api/user/login") // 登录接口
.addPathPatterns("/api/order/create"); // 下单接口
// 排除不需要防刷的接口(比如静态资源、健康检查)
// .excludePathPatterns("/static/**", "/actuator/health");
}
}
优缺点分析:
-
✅ 优点:分布式环境下全局统一限流,支持多维度(IP、用户 ID、手机号),Lua 保证原子性,高并发安全;
-
❌ 缺点:依赖 Redis(需要保证 Redis 高可用,比如 Redis Cluster),Lua 脚本写错会导致限流失效。
适用场景:微服务集群、分布式系统的接口(比如电商的登录、下单、短信接口)。
3. 高级级:风控系统防刷(超大规模业务)
当你的业务到了 “亿级用户”(比如腾讯、阿里),单纯的限流就不够了 —— 黑产会用 “IP 池”“设备农场” 绕过限流,这时候需要 “风控系统” 做智能拦截。
核心思路:多维度鉴权(不止限流)
- 设备指纹:识别作弊设备(比如篡改 IMEI、MAC 的手机);
- 行为分析:判断请求是否符合正常用户行为(比如正常用户 10 秒内不会连续点击 5 次发送短信);
- 黑名单:把频繁作弊的 IP、设备、用户 ID 加入黑名单,直接拦截;
- 验证码:高风险请求强制验证(比如短信接口加图形验证码、滑块验证)。
实战案例:短信接口的风控防刷
import org.springframework.stereotype.Service;
@Service
public class SmsRiskControlService {
@Resource
private RedisRateLimiter redisRateLimiter;
@Resource
private BlackListService blackListService;
@Resource
private DeviceFingerprintService deviceFingerprintService;
/**
* 短信接口风控校验(多维度防刷)
* @param phone 手机号
* @param ip IP地址
* @param deviceId 设备ID(设备指纹)
* @return 校验结果(true=通过,false=拦截)
*/
public boolean checkSmsRisk(String phone, String ip, String deviceId) {
// 1. 先查黑名单(直接拦截黑名单中的IP/手机号/设备)
if (blackListService.isInBlackList("ip:" + ip)
|| blackListService.isInBlackList("phone:" + phone)
|| blackListService.isInBlackList("device:" + deviceId)) {
return false;
}
// 2. 设备指纹校验(判断是否为作弊设备)
if (deviceFingerprintService.isCheatDevice(deviceId)) {
// 作弊设备加入黑名单(24小时)
blackListService.addToBlackList("device:" + deviceId, 24 * 60 * 60);
return false;
}
// 3. 多维度限流(手机号:1小时最多5次,IP:1小时最多20次,设备:1小时最多10次)
boolean phoneLimit = redisRateLimiter.allowRequest("sms:limit:phone:" + phone, 5, 3600);
boolean ipLimit = redisRateLimiter.allowRequest("sms:limit:ip:" + ip, 20, 3600);
boolean deviceLimit = redisRateLimiter.allowRequest("sms:limit:device:" + deviceId, 10, 3600);
if (!phoneLimit || !ipLimit || !deviceLimit) {
return false;
}
// 4. 所有校验通过
return true;
}
}
适用场景:超大规模业务(比如互联网大厂的短信、支付、登录接口),需要对抗专业黑产。
三、八年开发的避坑指南:这些错别再犯!
- 坑 1:限流维度太单一
只按 IP 限流,黑产用 IP 池就能绕过;只按手机号限流,黑产用接码平台也能绕过 ——必须多维度结合(IP + 手机号 + 设备 ID)。 - 坑 2:Redis 脚本不原子
早年我用 Redis 的incr和get分开操作,高并发下出现 “超限”——一定要用 Lua 脚本,保证计数、判断、过期的原子性。 - 坑 3:没处理 Redis 宕机
Redis 挂了,限流就失效了 ——要加降级策略,比如 Redis 宕机时切换到单机限流(Guava),避免接口完全暴露。 - 坑 4:误拦正常用户
比如某公司的办公 IP 被限流,导致员工无法正常使用 ——要加白名单机制,把内部 IP、测试账号、VIP 用户加入白名单,跳过限流。 - 坑 5:限流阈值写死
秒杀活动时阈值不够,日常时阈值太高 ——要动态调整阈值,比如用 Nacos 配置中心,实时修改限流次数,不用重启服务。
四、总结:接口防刷的 “分层防御” 思维
最后送大家一张 “接口防刷分层图”,八年实战亲测有效:
┌─────────────────────────────────────────┐
│ 业务层防御 │ 短信加有效期、验证码、库存校验 │
├─────────────────────────────────────────┤
│ 技术层防御 │ 单机限流(滑动窗口/令牌桶) │
├─────────────────────────────────────────┤
│ 分布式防御 │ Redis+Lua多维度限流 │
├─────────────────────────────────────────┤
│ 智能防御 │ 风控系统(设备指纹+黑名单) │
└─────────────────────────────────────────┘
接口防刷不是 “靠一个技术就能搞定”,而是分层防御—— 小项目用单机限流 + 业务校验,中大型项目用 Redis+Lua,超大规模用风控系统。
记住:最好的防刷方案,是 “技术 + 业务” 的结合 —— 比如短信接口加 60 秒有效期,就算被刷了,黑产拿到的验证码也没用;登录接口加图形验证码,脚本就很难自动化操作。
下次再做接口防刷,别再只会说 “用 Redis 限流” 了 —— 把这篇文章的分层思路讲清楚,面试官会觉得你不仅懂技术,还有实战思维。