1. 接口防刷的必要性与挑战
在当今互联网应用中,接口暴力攻击已成为系统安全的主要威胁之一。作为一名长期从事后端开发的工程师,我见过太多因为没有做好接口防护而导致的生产事故。去年我们团队就处理过一个典型案例:某电商平台的短信验证码接口被恶意刷取,短短两小时内产生了近5万元的短信费用,同时还导致正常用户无法收到验证码。
1.1 接口暴力攻击的四大危害
服务器资源枯竭是最直接的冲击。恶意请求会像洪水一样涌入:
- 每个请求至少占用1个线程,Tomcat默认200线程池瞬间爆满
- 数据库连接池被占满(如HikariCP默认10连接)
- 一次简单的查询在高压下可能消耗10MB以上内存
- 1Gbps带宽的服务器,每秒约10万次请求就能打满
业务成本失控更令人头疼:
- 短信接口被刷:每条短信成本0.03-0.1元,1万次就是300-1000元
- 支付接口被刷:可能产生大量小额测试交易,触发风控警报
- 第三方API调用:如地图服务,超额部分可能按10倍计费
用户体验崩塌的连锁反应:
- 正常用户请求响应时间从200ms飙升到5s+
- 关键业务接口返回429 Too Many Requests
- 移动端APP出现大面积白屏或卡死
数据安全风险最为致命:
- 暴力破解尝试:6位数字密码理论上100万次必中
- 优惠券/积分被刷:某平台曾一夜被薅走200万积分
- 数据泄露:爬虫通过API批量获取用户隐私信息
1.2 传统防护方案的局限性
早期我们尝试过几种常规方案,但都存在明显缺陷:
固定窗口计数器(如1分钟100次):
java复制// 伪代码示例
if(redis.incr(key) > 100){
throw new RateLimitException();
}
问题在于时间窗口边界会出现请求突增:
- 窗口切换瞬间可能允许200次请求(前1秒和后1秒)
令牌桶算法:
java复制// 伪代码示例
if(tokenBucket.tryAcquire()){
// 通过
}else{
// 限流
}
虽然平滑但实现复杂,且难以精确控制瞬时流量
简单IP黑名单:
java复制if(blacklist.contains(ip)){
return false;
}
容易被绕过(代理IP池),且可能误伤公共出口IP
2. 滑动窗口计数方案设计
经过多次迭代,我们最终采用了基于Redis ZSET的滑动窗口方案。这个设计的精妙之处在于,它既保持了固定窗口的简单性,又解决了边界问题,还能实现毫秒级精度控制。
2.1 核心数据结构设计
使用Redis的ZSET(有序集合)存储请求记录:
- member:请求唯一标识(UUID或雪花ID)
- score:请求时间戳(毫秒精度)
关键操作示例:
java复制// 添加请求记录
redis.zadd(key, System.currentTimeMillis(), requestId);
// 统计窗口内请求数
long count = redis.zcount(key, currentTime - windowSize, currentTime);
2.2 滑动窗口算法流程
完整的工作流程分为五个步骤:
-
请求到达时:
- 生成唯一请求ID
- 记录当前时间戳T1
-
清理过期请求:
- 删除ZSET中score小于(T1 - 窗口大小)的记录
- 使用
ZREMRANGEBYSCORE命令保证原子性
-
统计当前请求数:
- 通过
ZCARD获取集合基数 - 或者用
ZCOUNT指定分数范围
- 通过
-
阈值判断:
- 如果计数 >= 阈值 → 触发限流
- 否则 → 允许通过并记录新请求
-
设置过期时间:
- 对key设置TTL为窗口大小的2倍
- 避免冷数据长期占用内存
2.3 性能优化技巧
在实际压测中,我们发现了几个关键优化点:
管道化操作减少网络往返:
java复制redis.pipelined(pipe -> {
pipe.zremrangeByScore(key, 0, currentTime - windowSize);
pipe.zadd(key, currentTime, requestId);
pipe.zcard(key);
pipe.expire(key, windowSize * 2);
});
Lua脚本保证原子性:
lua复制local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
return 0
else
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1
end
本地缓存减轻Redis压力:
java复制// 使用Caffeine做本地缓存
LoadingCache<String, Long> localCache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.SECONDS)
.build(key -> {
// 只有本地缓存过期时才访问Redis
return redis.zcard(key);
});
3. SpringBoot集成实现
现在让我们看看如何将其优雅地集成到SpringBoot应用中。我们采用注解驱动的方式,让业务代码保持简洁。
3.1 自定义限流注解
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String key() default ""; // 限流key前缀
int limit() default 100; // 窗口期内最大请求数
int window() default 60; // 窗口大小(秒)
LimitType type() default LimitType.IP; // 限流维度
}
public enum LimitType {
IP, // 按调用方IP限流
USER, // 按登录用户ID限流
METHOD, // 按方法签名限流
CUSTOM // 自定义key
}
3.2 切面逻辑实现
核心切面处理类需要完成以下功能:
- 解析注解参数
- 构建Redis key
- 执行限流算法
- 处理限流异常
java复制@Aspect
@Component
public class RateLimitAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
String key = buildKey(joinPoint, rateLimit);
long now = System.currentTimeMillis();
long windowMs = rateLimit.window() * 1000L;
// 使用Lua脚本保证原子性
Long result = redisTemplate.execute(
limitScript,
Collections.singletonList(key),
now, windowMs, rateLimit.limit()
);
if (result == null || result == 0) {
throw new RateLimitException("Too many requests");
}
return joinPoint.proceed();
}
private String buildKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) {
// 根据不同类型构建key
StringBuilder key = new StringBuilder("rate_limit:");
key.append(rateLimit.key());
switch (rateLimit.type()) {
case IP:
key.append(getClientIP());
break;
case USER:
key.append(getCurrentUserId());
break;
case METHOD:
key.append(joinPoint.getSignature().toShortString());
break;
}
return key.toString();
}
}
3.3 异常处理与响应
统一异常处理让API返回规范的错误信息:
java复制@ControllerAdvice
public class GlobalExceptionHandler {
@ResponseBody
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
@ExceptionHandler(RateLimitException.class)
public ResponseResult<Void> handleRateLimit(RateLimitException e) {
return ResponseResult.fail(429, e.getMessage());
}
}
// 标准响应格式
{
"code": 429,
"message": "Too many requests",
"data": null,
"timestamp": 1630000000000
}
4. 多场景实战策略
不同业务场景需要定制化的防刷策略。以下是三个典型场景的配置示例。
4.1 登录接口防护
防止暴力破解密码尝试:
java复制@RateLimit(key = "login:", limit = 5, window = 300, type = LimitType.IP)
@PostMapping("/login")
public ResponseResult<String> login(@RequestBody LoginDTO dto) {
// 登录逻辑
}
关键配置:
- 5次/5分钟 per IP
- 错误次数达到阈值后锁定IP 30分钟
- 建议配合验证码二次验证
4.2 短信接口防护
防止短信轰炸:
java复制@RateLimit(key = "sms:", limit = 1, window = 60, type = LimitType.IP)
@RateLimit(key = "sms:", limit = 10, window = 3600, type = LimitType.IP)
@PostMapping("/sendSms")
public ResponseResult<Void> sendSms(@RequestParam String phone) {
// 发送短信逻辑
}
双重限制:
- 1次/分钟 per IP
- 10次/小时 per IP
- 建议增加手机号维度限制
4.3 支付接口防护
防止小额测试交易:
java复制@RateLimit(key = "pay:", limit = 3, window = 300, type = LimitType.USER)
@PostMapping("/pay")
public ResponseResult<Void> createPayment(@RequestBody PaymentDTO dto) {
// 支付逻辑
}
配置要点:
- 3次/5分钟 per User
- 建议结合金额风控(如单日累计限额)
- 关键操作需二次确认(如支付密码)
5. 高级优化与监控
基础方案上线后,我们还需要持续优化和监控。
5.1 动态规则配置
将限流规则移到配置中心(如Nacos):
yaml复制rateLimit:
rules:
- key: login
limit: 5
window: 300
type: IP
- key: sms
limit: 1
window: 60
type: IP
通过@RefreshScope实现热更新:
java复制@RefreshScope
@Component
public class RateLimitConfig {
@Value("${rateLimit.rules}")
private List<Rule> rules;
}
5.2 分布式限流
在网关层(如Spring Cloud Gateway)增加全局限流:
java复制public class RedisRateLimiter implements RateLimiter {
@Override
public Mono<Response> isAllowed(String routeId, String id) {
// 实现基于令牌桶的分布式限流
}
}
配置示例:
yaml复制spring:
cloud:
gateway:
routes:
- id: sms-service
uri: lb://sms-service
predicates:
- Path=/api/sms/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
5.3 监控与告警
通过Prometheus采集指标:
java复制@Bean
public MeterRegistryCustomizer<MeterRegistry> metrics() {
return registry -> {
registry.gauge("rate_limit_requests", Tags.empty(),
() -> getCurrentRequestCount());
};
}
Grafana监控面板建议指标:
- 各接口QPS与限流触发次数
- Redis内存使用情况
- 异常请求来源IP分布
- 业务损失预估(如短信费用激增告警)
6. 踩坑经验分享
在实际落地过程中,我们总结了以下血泪教训:
时间同步问题:
- 多台服务器时间不同步会导致窗口计算偏差
- 解决方案:所有机器同步NTP,或直接使用Redis的TIME命令
Redis内存暴涨:
- 未设置TTL的key会持续累积
- 优化:确保每个key都有过期时间,定期扫描无过期时间的key
热点key问题:
- 高并发下对同一个key的操作会成为瓶颈
- 解决方案:
- 本地缓存+随机过期时间
- 对key进行分片(如IP后两位作为后缀)
误杀正常流量:
- 公共出口IP(如公司NAT)容易被整体限流
- 改进:对已知企业IP放宽限制,或改用用户维度限流
Lua脚本性能:
- 复杂的脚本会阻塞Redis
- 建议:
- 脚本逻辑尽量简单
- 使用SCRIPT LOAD预加载脚本
- 监控脚本执行时间
最后分享一个实用技巧:在开发环境可以使用内存版的Redis(如Redisson的LocalCachedMap)来模拟限流效果,避免依赖真实Redis服务。