一、前言 什么是API接口防刷?
API接口防刷是一套重要的技术措施,其核心目标是保护后端服务免受恶意或过度的请求,确保系统的稳定性和数据的安全性。
1、为什么你的接口会被“刷”? 在深入技术之前,我们必须理解“刷”的本质。它本质上是一种对服务器资源的恶意或过度争夺。
恶意攻击 :竞争对手或黑客通过脚本、僵尸网络,高频访问你的登录接口、短信接口、下单接口,目的是拖垮你的服务,造成经济损失。
“黄牛”行为 :在秒杀、抢票、限量优惠券等场景下,“黄牛”利用程序高频请求,抢占正常用户资源,再进行倒卖,严重破坏业务公平性。
代码BUG或爬虫 :客户端循环调用BUG,或被恶意爬虫疯狂抓取数据,消耗大量服务器带宽和计算资源。
其核心危害不言而喻:
服务器资源耗尽 :CPU、内存、数据库连接池被占满,正常业务无法进行。
高昂的成本 :如果使用云服务,莫名的流量会带来高昂的账单。特别是短信、邮件等需要付费的第三方服务接口。
数据失真与业务不公 :抢购活动形同虚设,真实用户怨声载道,品牌形象受损。
安全风险 :可能成为DDoS攻击的入口,导致整个应用瘫痪。
二、快速开始 1、防刷的第一道防线:IP级限流 想象一下,一个水龙头如果开得太大,水会溅得到处都是。我们首先给它加一个“限流阀”。基于IP的限流就是这个最简单的“阀”。
思路 :在单位时间内,同一个IP地址访问特定接口的次数不能超过我们设定的阈值。
技术实现:Spring Boot + AOP + Redis
我们选择Redis是因为它读写速度快,且天然支持过期时间,非常适合做计数器。
①、添加依赖 在你的pom.xml中引入必要的依赖。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-aop</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > </dependency > <dependency > <groupId > org.apache.commons</groupId > <artifactId > commons-pool2</artifactId > </dependency >
②、自定义一个防刷注解 注解就像给接口贴上一个标签,声明它需要被保护
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Inherited @Target({ElementType.METHOD,ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface RequestLimit { String key () default "" ; int second () default 1 ; int maxCount () default 1 ; }
③、编写AOP切面 AOP(面向切面编程)允许我们在不侵入原有业务代码的情况下,在方法执行前后“织入”新的逻辑,防刷逻辑的核心。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 @Aspect @Component @Slf4j public class RateLimitAspect { @Autowired private RedisTemplate<String, Object> redisTemplate; @Around("(@annotation(requestLimit) || @within(requestLimit))") public Object around (ProceedingJoinPoint joinPoint, RequestLimit requestLimit) throws Throwable { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes == null ) { return joinPoint.proceed(); } HttpServletRequest request = attributes.getRequest(); String ip = IpUtil.getClientIpAddress(request); MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); requestLimit = getTagAnnotation(method, RequestLimit.class); String methodName = method.getName(); String key = requestLimit.key().isEmpty() ? methodName : requestLimit.key(); String redisKey = "rate_limit:" + key + ":" + ip; ValueOperations<String, Object> valueOps = redisTemplate.opsForValue(); Integer currentCount = (Integer) valueOps.get(redisKey); if (currentCount == null ) { valueOps.set(redisKey, 1 , requestLimit.second(), TimeUnit.SECONDS); } else if (currentCount < requestLimit.maxCount()) { valueOps.increment(redisKey); } else { log.warn("IP【{}】访问接口【{}】过于频繁,已被限流" , ip, methodName); return R.fail(ApiResultEnum.REQUEST_LIMIT); } return joinPoint.proceed(); } public <A extends Annotation > A getTagAnnotation (Method method, Class<A> annotationClass) { Annotation methodAnnotate = method.getAnnotation(annotationClass); Annotation classAnnotate = method.getDeclaringClass().getAnnotation(annotationClass); return (A) (methodAnnotate!= null ?methodAnnotate:classAnnotate); } }
④、在Controller使用示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @RestController @RequestMapping("/index") @RequestLimit(maxCount = 5,second = 10) public class IndexController { @GetMapping("/test1") @RequestLimit public R test () { return R.ok(); } @GetMapping("/test2") public R test2 () { return R.ok(); } }
在Controller上有一个默认的@RequestLimit(maxCount = 5,second = 10)限流,如果这个类下的其他接口有使用@RequestLimit则覆盖默认注解,否则都走类上注解。
我们测试请求接口
然后控制台打印
⑤、这个方案的优缺点
优点 :实现简单,能有效阻止“无脑刷”。
缺点 :
误杀问题 :在局域网或公司出口IP相同的情况下,所有用户会共享一个IP限额,导致误伤正常用户。
易绕过 :对于专业的攻击者,他们可以通过代理IP池轻松绕过IP限制。
2、用户级限流 为了解决IP限流的缺陷,我们引入更精细的维度——用户。这要求用户必须处于登录状态。
思路 :在单位时间内,同一个用户ID(或Token)访问特定接口的次数不能超过阈值。
我们只需对上面的AOP切面进行小幅改造:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 @Around("(@annotation(requestLimit) || @within(requestLimit))") public Object around (ProceedingJoinPoint joinPoint, RequestLimit requestLimit) throws Throwable { HttpServletRequest request = attributes.getRequest(); String token = request.getHeader("Authorization" ); String userId = parseUserIdFromToken(token); if (userId == null ) { userId = IpUtil.getClientIpAddress(request); } String methodName = method.getName(); String key = requestLimit.key().isEmpty() ? methodName : requestLimit.key(); String redisKey = "rate_limit:" + key + ":" + userId; return joinPoint.proceed(); }
①、这个方案的优缺点
优点 :精准到人,避免了IP限流的误杀问题,防护效果更好。
缺点 :
要求用户必须登录,对匿名接口不友好。
如果攻击者盗取大量账号,依然可以发起攻击。
3、令牌桶限流 使用Redis + Lua实现分布式令牌桶
令牌桶算法的核心原理 :
以固定速率向桶中添加令牌
每个请求需要获取一个令牌才能被处理
如果桶中没有令牌,则拒绝请求
允许突发流量(桶中有积累的令牌时)
为什么选择 Redis + Lua?
原子性 :Lua脚本在Redis中原子执行,避免并发问题
高性能 :减少网络往返,所有逻辑在Redis服务器端完成
分布式 :Redis作为集中式存储,适合集群环境
灵活性 :可自定义各种参数和逻辑
①、自定义防刷注解 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface TokenBucketRateLimit { String key () default "" ; double rate () default 10.0 ; int capacity () default 20 ; int tokens () default 1 ; String message () default "请求过于频繁,请稍后再试" ; }
②、Lua脚本
在resource下面新建一个tokenRate.lua 文件,编辑如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 local key = KEYS[1 ]local rate = tonumber (ARGV[1 ])local capacity = tonumber (ARGV[2 ])local now = tonumber (ARGV[3 ])local requested = tonumber (ARGV[4 ])local fill_time = capacity / ratelocal ttl = math .floor (fill_time * 2 ) local last_tokens = tonumber (redis.call("get" , key))if last_tokens == nil then last_tokens = capacity end local last_refreshed = tonumber (redis.call("get" , key .. ":ts" ))if last_refreshed == nil then last_refreshed = now end local delta = math .max (0 , now - last_refreshed)local filled_tokens = math .min (capacity, last_tokens + (delta * rate))local allowed = filled_tokens >= requestedlocal new_tokens = filled_tokenslocal allowed_num = 0 if allowed then new_tokens = filled_tokens - requested allowed_num = 1 redis.call("setex" , key, ttl, new_tokens) redis.call("setex" , key .. ":ts" , ttl, now) else redis.call("setex" , key, ttl, new_tokens) redis.call("setex" , key .. ":ts" , ttl, last_refreshed) end return {allowed_num, new_tokens, capacity}
③、Lua脚本配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Configuration public class RedisConfig { @Value("classpath:tokenRate.lua") private org.springframework.core.io.Resource luaFile; @Bean public RedisScript<List> tokenBucketScript () { DefaultRedisScript<List> redisScript = new DefaultRedisScript <>(); redisScript.setLocation(luaFile); redisScript.setResultType(List.class); return redisScript; } }
④、令牌桶限流服务类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 @Service @Slf4j public class TokenBucketRateLimiter { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private RedisScript<List> tokenBucketScript; public boolean tryAcquire (String key, double rate, int capacity, int tokenRequest) { long now = System.currentTimeMillis() / 1000 ; List<String> keys = Arrays.asList(key); List<Long> result = (List<Long>) redisTemplate.execute(tokenBucketScript, keys, rate, capacity, now, tokenRequest); if (ObjectUtils.isEmpty(result)) { log.warn("令牌桶限流脚本执行异常, key: {}" , key); return false ; } boolean allowed = result.get(0 ) == 1 ; return allowed; } }
⑤、令牌桶切片核心逻辑 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 @Aspect @Component @Slf4j public class TokenBucketRateLimitAspect { @Autowired private TokenBucketRateLimiter rateLimiter; private final ExpressionParser parser = new SpelExpressionParser (); @Around("@annotation(rateLimit)") public Object around (ProceedingJoinPoint joinPoint, TokenBucketRateLimit rateLimit) throws Throwable { String key = buildRateLimitKey(joinPoint, rateLimit); boolean allowed = rateLimiter.tryAcquire(key,rateLimit.rate(),rateLimit.capacity(),rateLimit.tokens()); if (!allowed) { log.warn("接口限流触发 - key: {}, 方法: {}" , key, joinPoint.getSignature().getName()); return R.fail(rateLimit.message()); } return joinPoint.proceed(); } private String buildRateLimitKey (ProceedingJoinPoint joinPoint, TokenBucketRateLimit rateLimit) { String key = rateLimit.key(); if (key.isEmpty()) { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); String className = method.getDeclaringClass().getSimpleName(); String methodName = method.getName(); String userKey = getUserKey(); return String.format("rate_limit:%s:%s:%s" , className, methodName, userKey); } if (key.contains("#" )) { return parseSpel(key, joinPoint); } return key; } private String getUserKey () { try { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes != null ) { HttpServletRequest request = attributes.getRequest(); String userId = (String) request.getAttribute("userId" ); if (userId != null ) { return "user:" + userId; } return "ip:" + IpUtil.getClientIpAddress(request); } } catch (Exception e) { log.debug("获取用户标识失败" , e); } return "anonymous" ; } }
⑥、controller示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @RestController @RequestMapping("/tokenRate") public class TokenRateController { @GetMapping("/sendSms") @TokenBucketRateLimit(rate = 0.1, capacity = 2, message = "短信发送过于频繁") public R sendSms () { return R.ok(); } @TokenBucketRateLimit(key = "'search:' + #keyword", rate = 5.0, capacity = 10) @GetMapping("/search") public R search (@RequestParam String keyword) { return R.ok("搜索结果:" +keyword); } }
接口测试如下:
后台日志打印如下:
三、总结 本文介绍了三种常见的API接口防刷方案:IP级限流、用户级限流和令牌桶限流。它们各有优缺点,适用于不同的场景。
IP级限流 :实现简单,但容易误伤和绕过。
用户级限流 :更精准,但需要用户登录,且可能被批量账号攻击。
令牌桶限流 :能够应对突发流量,且更平滑,但实现相对复杂。
IP级限流和用户级限流,其实原理一样都是使用固定窗口计数器算法,固定窗口计数器算法存在临界问题(突刺现象),例如在窗口切换瞬间可能允许双倍请求通过。
令牌桶限流 :系统是以恒定速率生成令牌,请求需获取令牌才能被处理,流量比较平滑,因为有桶容量允许短暂的突发流量。
除了本文提到的固定窗口计数器和令牌桶算法,还有其他限流算法如滑动窗口计数器、漏桶算法等,感兴趣的同学可以去了解学习。每种算法都有其适用场景,需要根据实际需求选择。
资源获取: 本文完整代码已上传至 GitHub,欢迎 Star ⭐ 和 Fork
欢迎分享你的经验 : 在实际工作中,你有哪些独到的见解或踩坑经验?欢迎在评论区交流讨论,让我们一起进步