接口防刷怎么实现?8 年 Java 开发:从被刷欠费到分层防御(附可复用代码)

295 阅读13分钟

接口防刷怎么实现?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 池”“设备农场” 绕过限流,这时候需要 “风控系统” 做智能拦截。

核心思路:多维度鉴权(不止限流)
  1. 设备指纹:识别作弊设备(比如篡改 IMEI、MAC 的手机);
  2. 行为分析:判断请求是否符合正常用户行为(比如正常用户 10 秒内不会连续点击 5 次发送短信);
  3. 黑名单:把频繁作弊的 IP、设备、用户 ID 加入黑名单,直接拦截;
  4. 验证码:高风险请求强制验证(比如短信接口加图形验证码、滑块验证)。
实战案例:短信接口的风控防刷
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. 坑 1:限流维度太单一
    只按 IP 限流,黑产用 IP 池就能绕过;只按手机号限流,黑产用接码平台也能绕过 ——必须多维度结合(IP + 手机号 + 设备 ID)。
  2. 坑 2:Redis 脚本不原子
    早年我用 Redis 的incrget分开操作,高并发下出现 “超限”——一定要用 Lua 脚本,保证计数、判断、过期的原子性。
  3. 坑 3:没处理 Redis 宕机
    Redis 挂了,限流就失效了 ——要加降级策略,比如 Redis 宕机时切换到单机限流(Guava),避免接口完全暴露。
  4. 坑 4:误拦正常用户
    比如某公司的办公 IP 被限流,导致员工无法正常使用 ——要加白名单机制,把内部 IP、测试账号、VIP 用户加入白名单,跳过限流。
  5. 坑 5:限流阈值写死
    秒杀活动时阈值不够,日常时阈值太高 ——要动态调整阈值,比如用 Nacos 配置中心,实时修改限流次数,不用重启服务。

四、总结:接口防刷的 “分层防御” 思维

最后送大家一张 “接口防刷分层图”,八年实战亲测有效:

┌─────────────────────────────────────────┐
│  业务层防御  │ 短信加有效期、验证码、库存校验  │
├─────────────────────────────────────────┤
│  技术层防御  │ 单机限流(滑动窗口/令牌桶)     │
├─────────────────────────────────────────┤
│  分布式防御  │ Redis+Lua多维度限流           │
├─────────────────────────────────────────┤
│  智能防御    │ 风控系统(设备指纹+黑名单)    │
└─────────────────────────────────────────┘

接口防刷不是 “靠一个技术就能搞定”,而是分层防御—— 小项目用单机限流 + 业务校验,中大型项目用 Redis+Lua,超大规模用风控系统。

记住:最好的防刷方案,是 “技术 + 业务” 的结合 —— 比如短信接口加 60 秒有效期,就算被刷了,黑产拿到的验证码也没用;登录接口加图形验证码,脚本就很难自动化操作。

下次再做接口防刷,别再只会说 “用 Redis 限流” 了 —— 把这篇文章的分层思路讲清楚,面试官会觉得你不仅懂技术,还有实战思维。