1. 接口防刷的必要性与常见场景
最近在重构公司的一个对外API服务时,遇到了严重的接口刷取问题。某个合作伙伴的客户端程序出现了bug,导致以每秒上百次的频率重复调用我们的计费接口,不仅造成了服务器资源浪费,还产生了大量异常订单数据。这次事故让我深刻认识到,接口防刷机制不是可有可无的"锦上添花",而是保障系统稳定运行的"生命线"。
典型的接口刷取场景主要有以下几种:
- 恶意攻击:竞争对手或黑客故意高频调用接口,消耗服务器资源
- 客户端bug:移动端或前端代码逻辑错误导致循环调用
- 爬虫行为:自动化脚本大量获取数据
- 人为误操作:用户反复点击或刷新页面
这些行为轻则导致服务器负载升高,重则引发数据错乱甚至系统崩溃。特别是在电商、金融、社交等业务场景中,防刷机制更是必不可少的基础设施。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主流防刷方案对比分析
2.1 传统方案及其局限性
在早期的项目中,我们尝试过几种基础的防刷方案:
-
IP限流:通过Nginx限制单个IP的访问频率
- 优点:实现简单,运维层面即可配置
- 缺点:容易被代理IP绕过,且会误伤共用IP的用户
-
验证码:在敏感操作前要求输入验证码
- 优点:能有效阻止自动化脚本
- 缺点:用户体验差,不适合高频调用的API接口
-
Token机制:每次请求需要携带动态生成的token
- 优点:安全性较高
- 缺点:实现复杂,客户端需要额外处理逻辑
这些方案要么防护效果有限,要么对用户体验影响较大,我们需要寻找更优雅的解决方案。
2.2 基于Redis的计数器方案
Redis因其高性能和丰富的数据结构,成为实现防刷机制的首选存储方案。其核心优势在于:
- 原子性操作:INCR和EXPIRE命令可以保证计数和过期时间的原子性
- 高性能:单节点可达10万+ QPS,满足高并发场景
- 丰富的数据结构:String、Hash、Sorted Set等结构适合不同防刷策略
- 持久化选项:可根据需求选择RDB或AOF持久化策略
特别是Redis的EXPIRE特性,可以非常方便地实现滑动时间窗口的计数,这是传统数据库难以高效实现的。
3. 优雅实现方案设计
3.1 整体架构设计
我们的防刷系统采用分层设计,各层职责明确:
code复制[客户端] -> [API网关] -> [防刷拦截器] -> [业务逻辑]
↘ [Redis集群]
- 客户端:无需特殊处理,保持原有调用方式
- API网关:负责基础认证和流量控制
- 防刷拦截器:核心防刷逻辑实现层
- Redis集群:存储计数器和配置信息
这种架构的优点是业务无侵入,只需在拦截器层实现防刷逻辑,业务代码无需任何修改。
3.2 核心组件实现
3.2.1 自定义注解设计
我们定义了一个@RateLimit注解,用于声明式配置防刷规则:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String key() default ""; // Redis key前缀
int limit() default 10; // 时间窗口内允许的最大请求数
int expire() default 60; // 时间窗口长度(秒)
String message() default "操作过于频繁,请稍后再试";
}
使用示例:
java复制@RateLimit(key = "order:create", limit = 5, expire = 60)
public Result createOrder(@RequestBody OrderDTO dto) {
// 业务逻辑
}
3.2.2 拦截器实现
通过实现HandlerInterceptor接口,我们创建了RateLimitInterceptor:
java复制public class RateLimitInterceptor implements HandlerInterceptor {
private final RedisTemplate<String, Object> redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
Method method = ((HandlerMethod) handler).getMethod();
RateLimit rateLimit = method.getAnnotation(RateLimit.class);
if (rateLimit != null) {
String key = buildRedisKey(rateLimit.key(), request);
Long count = redisTemplate.opsForValue().increment(key, 1);
if (count == 1) {
redisTemplate.expire(key, rateLimit.expire(), TimeUnit.SECONDS);
}
if (count > rateLimit.limit()) {
response.setContentType("application/json");
response.getWriter().write(JsonUtils.toJson(Result.error(rateLimit.message())));
return false;
}
}
return true;
}
private String buildRedisKey(String prefix, HttpServletRequest request) {
// 组合用户ID、IP、接口路径等信息构建唯一key
return String.format("%s:%s:%s",
prefix,
RequestUtils.getUserId(request),
RequestUtils.getClientIP(request));
}
}
3.2.3 Redis数据结构设计
我们采用String类型存储计数器,key的组成规则为:
code复制{接口标识}:{用户ID}:{客户端IP}
例如:
code复制order:create:12345:192.168.1.100
这种设计可以实现:
- 按接口粒度控制
- 按用户维度限制
- 防止同一用户不同IP绕过限制
4. 高级特性与优化
4.1 分布式环境下的同步控制
在集群环境下,需要考虑计数器的同步问题。我们采用Redis+Lua脚本的方案保证原子性:
lua复制local key = KEYS[1]
local limit = tonumber(ARGV[1])
local expire = tonumber(ARGV[2])
local current = redis.call('GET', key)
if current and tonumber(current) >= limit then
return 0
else
redis.call('INCR', key)
if current == nil then
redis.call('EXPIRE', key, expire)
end
return 1
end
Java调用代码:
java复制String script = "上述Lua脚本内容";
RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Long result = redisTemplate.execute(redisScript,
Collections.singletonList(key),
String.valueOf(limit),
String.valueOf(expire));
4.2 动态规则配置
通过将规则配置存储在Redis中,可以实现动态调整:
java复制@RateLimit(key = "order:create", configKey = "order_create_limit")
public Result createOrder(@RequestBody OrderDTO dto) {
// 业务逻辑
}
// 拦截器中
String configKey = rateLimit.configKey();
if (!StringUtils.isEmpty(configKey)) {
RateLimitConfig config = getConfigFromRedis(configKey);
// 使用动态配置覆盖注解配置
}
4.3 漏桶算法实现
对于需要更平滑限流的场景,可以实现漏桶算法:
java复制public class LeakyBucket {
private final String key;
private final int capacity;
private final int rate;
public boolean tryAcquire() {
long now = System.currentTimeMillis();
String value = redisTemplate.opsForValue().get(key);
BucketState state = parseBucketState(value);
// 计算漏出量
long leaked = (now - state.lastTime) / 1000 * rate;
state.water = Math.max(0, state.water - leaked);
state.lastTime = now;
if (state.water < capacity) {
state.water++;
redisTemplate.opsForValue().set(key, state.toString());
return true;
}
return false;
}
private static class BucketState {
long lastTime;
int water;
// 序列化/反序列化方法
}
}
5. 实战经验与避坑指南
5.1 性能优化技巧
-
Redis Pipeline:批量执行多个计数操作
java复制redisTemplate.executePipelined((RedisCallback<Object>) connection -> { for (String key : keys) { connection.stringCommands().incr(key.getBytes()); } return null; }); -
本地缓存:对于高频接口,可以先在本地JVM缓存计数,定期同步到Redis
java复制@Component public class LocalRateLimiter { private final Cache<String, Long> localCache = Caffeine.newBuilder() .expireAfterWrite(1, TimeUnit.SECONDS) .maximumSize(10_000) .build(); public boolean tryAcquire(String key, int limit) { Long count = localCache.get(key, k -> 0L); if (count < limit) { localCache.put(key, count + 1); return true; } return false; } }
5.2 常见问题排查
-
Redis连接超时
- 现象:防刷功能偶尔失效
- 排查:检查Redis连接池配置,适当增大maxTotal和maxIdle
- 解决:添加降级逻辑,当Redis不可用时临时放宽限制
-
Key设计不合理导致内存暴涨
- 现象:Redis内存使用率快速上升
- 排查:检查Key的过期时间是否设置,IP维度Key是否过多
- 解决:优化Key设计,添加合适的过期时间
-
限流粒度太粗
- 现象:正常用户也被限制
- 排查:检查Key的组合方式,是否应该去掉IP维度
- 解决:调整Key组成,结合用户ID和设备ID等多维度
5.3 监控与告警
完善的监控体系可以帮助及时发现防刷系统的异常:
-
Redis监控指标
- 内存使用率
- 命令执行耗时
- 连接数
-
业务监控指标
- 被拦截请求数
- 各接口的请求频率分布
- 用户投诉率变化
-
告警规则
- Redis不可用超过1分钟
- 拦截率突增50%以上
- 单个接口拦截数异常偏高
6. 扩展思考与最佳实践
在实际项目中,我们发现防刷策略需要根据业务特点灵活调整:
-
电商系统:
- 下单接口:严格限制(如5次/分钟)
- 查询接口:宽松限制(如100次/分钟)
- 秒杀接口:结合令牌桶算法
-
社交平台:
- 发帖/评论:用户维度限制
- 点赞/关注:IP+设备维度限制
- 消息推送:应用维度限制
-
金融系统:
- 转账操作:多因素认证+严格频率限制
- 余额查询:中等频率限制
- 对账单下载:时间窗口限制(如每小时1次)
一个通用的建议是:先监控分析正常的业务流量模式,再据此制定合理的防刷阈值,避免"拍脑袋"决策。我们的经验是,可以先设置一个较宽松的限制,然后根据监控数据逐步收紧,直到找到一个既不影响正常用户使用,又能有效阻止滥用的平衡点。
最后需要强调的是,防刷系统不是"一劳永逸"的,需要持续迭代优化。我们团队的做法是每月review一次防刷规则的有效性,根据新的攻击方式和业务变化调整策略。同时保持与业务部门的密切沟通,确保防刷措施不会误伤正常用户。
