代码实现
- 自定义限流注解 RateLimiter
自定义限流注解:包含限流key前缀、时间窗口大小、时间窗口内允许的请求数、限流提示信息、限流维度
window时间窗口大小 和 limit时间窗口内允许的请求数 这两个参数 取决于自己的系统的并发量
● 例如可以设置 1s 内 允许 100 个请求,则QPS最高是100
● 例如可以设置 0.5s 允许100个请求,则QPS最高是200
package com.hmdp.limiter.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {
/**
* 限流key前缀
*/
String key() default "rate_limit:";
/**
* 时间窗口大小(秒)
*/
int window() default 10;
/**
* 时间窗口内允许的请求数
*/
int limit() default 20;
/**
* 限流提示信息
*/
String message() default "系统繁忙,请稍后再试";
/**
* 限流维度(默认按方法限流)
*/
LimitType type() default LimitType.METHOD;
enum LimitType {
/**
* 按调用方IP限流
*/
IP,
/**
* 按用户ID限流
*/
USER,
/**
* 按方法限流/全局限流(默认)
*/
METHOD
}
}
- 限流切面处理 RateLimiterAspect
package com.hmdp.limiter.aop;
import com.hmdp.limiter.annotation.RateLimiter;
import com.hmdp.limiter.exception.RateLimitException;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Collections;
@Aspect
@Component
public class RateLimiterAspect {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 限流Lua脚本
private static final DefaultRedisScript<Long> SLIDING_WINDOW_SCRIPT;
static {
SLIDING_WINDOW_SCRIPT = new DefaultRedisScript<>();
SLIDING_WINDOW_SCRIPT.setLocation(new ClassPathResource("limiter.lua"));
SLIDING_WINDOW_SCRIPT.setResultType(Long.class);
}
// 前置拦截 注解了rateLimiter的方法
@Before("@annotation(rateLimiter)")
public void doBefore(JoinPoint point, RateLimiter rateLimiter) {
System.out.println("进入切面逻辑!!!");
// 获取注解上的参数
String key = rateLimiter.key();
long window = rateLimiter.window();
long limit = rateLimiter.limit();
// 构建完整的限流key
String fullKey = buildRateLimitKey(point, rateLimiter, key);
// 执行限流脚本
Long result = executeSlidingWindowScript(fullKey, window, limit);
// 如果返回0表示被限流
if (result != null && result == 0) {
throw new RateLimitException(rateLimiter.message());
}
}
/**
* 执行滑动窗口限流脚本
*
* @param key 限流key
* @param window 时间窗口(秒)
* @param limit 限制请求数量
* @return 当前窗口内请求数量计数(如果被限流返回0)
*/
public Long executeSlidingWindowScript(String key, Long window, Long limit) {
long now = System.currentTimeMillis();
System.out.printf("key:%s, window:%d, limit:%d\n", key, window, limit);
return stringRedisTemplate.execute(
SLIDING_WINDOW_SCRIPT,
Collections.singletonList(key),
window.toString(), limit.toString(), Long.toString(now)
);
}
/**
* 构建限流key
*/
private String buildRateLimitKey(JoinPoint point, RateLimiter rateLimiter, String baseKey) {
StringBuilder keyBuilder = new StringBuilder(baseKey);
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
// 添加类名和方法名
keyBuilder.append(method.getDeclaringClass().getName())
.append(":")
.append(method.getName());
// 根据限流类型添加额外维度
switch (rateLimiter.type()) {
case IP:
keyBuilder.append(":ip:").append(getClientIp());
break;
case USER:
keyBuilder.append(":user:").append(getCurrentUserId());
break;
case METHOD:
default:
// 方法级限流使用默认key
break;
}
return keyBuilder.toString();
}
/**
* 获取客户端IP
*/
private String getClientIp() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
/**
* 获取当前用户ID(需要根据实际系统实现)
*/
private String getCurrentUserId() {
// 这里需要根据你的认证系统实现
// 例如从SecurityContext获取认证用户
return "anonymous"; // 默认返回匿名用户
}
}
- 滑动窗口限流Lua脚本 limiter.lua
local key = KEYS1
local window = tonumber(ARGV1) -- 时间窗口(单位为秒)
local limit = tonumber(ARGV2) -- 时间窗口内限制的次数
local now = tonumber(ARGV3) -- 当前时间(单位为毫秒)
-- 校验传进来的参数是否为空,若为空直接返回错误
if not window or not limit or not now then
return redis.error_reply("Invalid input parameters")
end
window = window * 1000 -- 单位由秒->毫秒
-- 删除超出时间窗口的数据
-- Redis命令ZREMRANGEBYSCORE: 从有序集合中移除指定分数区间的成员。 删除所有分数在 [0,now-window) 的请求记录
-- now是当前时间戳,window是时间窗口, 0是最小分数,now-window是时间窗口外最大的分数,这样就能删除超出窗口的元素
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 获取当前窗口内的请求数量
-- Redis命令ZCARD: 返回有序集合中成员的数量,因为已经删除了超出时间窗口的元素,所以直接返回集合的成员数量即可获取当前时间窗口的请求数量
local current = redis.call('ZCARD', key)
-- 若"当前时间窗口内的请求数量" 小于 "时间窗口内限制的次数"
if current < limit then
-- 添加当前请求(使用毫秒时间戳+随机数作为member,时间戳作为score)
math.randomseed(now)
local random = math.random(1000000)
redis.call('ZADD', key, now, now ... '-' ... random)
-- 更新过期时间
redis.call('EXPIRE', key, window / 1000)
return current + 1
else
-- 若"当前时间窗口内的请求数量" 大于等于 "时间窗口内限制的次数", 则应该限制本次请求,返回 0,表示限流
return 0
end
关键点在于Lua脚本中实现的滑动窗口限流算法
利用Redis的ZSet数据类型,首先传递进Lua脚本的参数为
● 限流键名key (不同的接口/资源,使用不同的key,不同的维度,也要使用不同的key)
● 时间窗口大小 window
● 限流阈值 limit 在时间窗口内允许访问的次数
● 当前时间戳 now
进入Lua脚本的逻辑
- 移除时间窗口之外的数据 redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
- 获取当前时间窗口内的已请求数量 redis.call('ZCARD', key)
- 判断当前时间窗口内的已请求数量 是否 小于 limit 限制次数
a. 若小于,则把本次请求加入key中,以当前时间戳为分数score,以当前时间戳+随机数为成员member,更新过期时间,本次请求可放行,后续可执行业务逻辑
b. 若大于或等于,则返回本次请求应该限流 - 限流异常类 RateLimitException
package com.hmdp.limiter.exception;
public class RateLimitException extends RuntimeException {
public RateLimitException(String message) {
super(message);
}
}
- 全局异常处理 WebExceptionAdvice
package com.hmdp.config;
import com.hmdp.dto.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class WebExceptionAdvice {
// 全局异常处理捕捉,记录通用日志,返回给前端通用响应 Result.fail("服务器异常")
@ExceptionHandler(RuntimeException.class)
public Result handleRuntimeException(RuntimeException e) {
log.error(e.toString(), e);
return Result.fail("服务器异常:" + e.getMessage());
}
}
使用示例
- 在Controller方法上使用限流注解
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/coupon")
public class CouponController {
/**
* 全局秒杀接口限流(全局限流)
* 10秒内最多允许100次请求
*/
@GetMapping("/seckill")
@RateLimiter(
key = "coupon:seckill:global",
window = 10,
limit = 100,
message = "秒杀活动太火爆,请稍后再试",
type = RateLimiter.LimitType.GLOBAL
)
public String seckillCoupon() {
// 秒杀业务逻辑
return "秒杀成功";
}
/**
* 按用户限流的优惠券领取接口
* 每个用户60秒内最多领取3张优惠券
*/
@GetMapping("/claim")
@RateLimiter(
key = "coupon:claim:",
window = 60,
limit = 3,
message = "您领取优惠券过于频繁,请稍后再试",
type = RateLimiter.LimitType.USER
)
public String claimCoupon() {
// 优惠券领取逻辑
return "领取成功";
}
/**
* 按IP限流的商家信息查询接口
* 每个IP每秒最多查询5次商家信息
*/
@GetMapping("/merchant")
@RateLimiter(
key = "merchant:info:",
window = 1,
limit = 5,
message = "查询过于频繁,请稍后再试",
type = RateLimiter.LimitType.IP
)
public String getMerchantInfo() {
// 查询商家信息
return "商家信息";
}
}
- 测试限流效果
package com.hmdp.controller;
import com.hmdp.dto.Result;
import com.hmdp.limiter.annotation.RateLimiter;
import com.hmdp.service.IVoucherOrderService;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
@Resource
private IVoucherOrderService voucherOrderService;
// @PostMapping("seckill/{id}")
@GetMapping("seckill/{id}") // 注意为了测试限流效果,这里直接改成了Get请求方式,直接用网页刷新就请求了 http://127.0.0.1:8081/voucher-order/seckill/1
@RateLimiter(
key = "coupon:seckill:",
window = 10,
limit = 5,
message = "秒杀活动太火爆,请稍后再试",
type = RateLimiter.LimitType.METHOD
)
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
// return voucherOrderService.seckillVoucher(voucherId);
return Result.ok(); // 为了测试限流效果,就不执行内部逻辑了,如果没有被限流就直接返回成功
}
}
这里设置的参数的意思是 在window=10秒窗口时间内,限制访问次数是limit=5次,若10秒内超过5次则会限流(即限制访问),滑动窗口的方式限流
正常情况返回:
若被限流则会返回:
日志打印:
Redis中ZSet记录的数据:
总结
实现的整体流程
- 自定义限流注解:包含参数 限流key前缀、时间窗口大小、时间窗口内允许的请求数、限流提示信息、限流维度,其中限流维度支持按 全局限流、用户限流、IP限流,不同的维度适用于不同的场景
有了这个自定义限流注解后,我们就可以把注解加到对应的接口上,并设置对应的参数
我们需要拦截所有加了自定义限流注解的接口方法,也就是还需要实现AOP切面
- 限流切面处理:获取到注解上的参数,限流key前缀、时间窗口大小、时间窗口内允许的请求数、限流提示信息、限流维度,执行限流Lua脚本,且把参数传递进去。
限流Lua脚本如何实现呢,这其中就是滑动窗口限流算法实现
- 限流Lua脚本:实现滑动窗口限流算法,利用Redis的ZSet数据类型实现
a. 删除超出时间窗口的数据,Redis命令ZREMRANGEBYSCORE,从有序集合中移除指定分数区间的成员
b. 获取当前窗口内的请求数量,Redis命令ZCARD: 返回有序集合中成员的数量,因为已经删除了超出时间窗口的元素,所以直接返回集合的成员数量即可获取当前时间窗口的请求数量
c. 若"当前时间窗口内的请求数量" 小于 "时间窗口内限制的次数",则添加当前请求(使用毫秒时间戳+随机数作为member,时间戳作为score)。若"当前时间窗口内的请求数量" 大于等于 "时间窗口内限制的次数", 则应该限制本次请求,返回 0,表示限流
解释:限流维度的不同,本质上就是Redis的Key的构造。如果这个不能理解的话,面试时可不说。
特点和应用
这套滑动窗口限流方案具有以下特点:
- 精确控制:基于时间窗口的请求计数,避免固定窗口的边界问题
- 灵活维度:支持 全局、IP、用户 级别的限流
- 高效实现:使用Redis ZSET和Lua脚本保证原子性操作
- 易集成:通过注解和AOP无缝集成到Spring Boot应用中
在你的优惠券秒杀系统中,可以这样应用:
● 对秒杀接口使用全局限流,防止系统过载
● 对用户领取优惠券使用用户级限流,防止刷券
● 对商家信息查询使用IP级限流,防止爬虫
当系统压力过大时,限流器会自动拒绝超额请求,返回预设的友好提示信息,保证核心业务的稳定运行。
话术 - 限流模块
设计思路:每一个点,需要有对应的场景,不要为了技术而技术,且根据自己的思路要能够自圆其说
简历上的亮点
● 滑动窗口限流: 使用 Redis + AOP + 注解实现限流,支持全局、IP、用户多维度,防止系统过载、刷券、爬虫;
话术/逐字稿
滑动窗口限流
● 滑动窗口限流: 使用 Redis + AOP + 注解实现限流,支持全局、IP、用户多维度,防止系统过载、刷券、爬虫;
是这样的,我先说一下背景
背景阐述
我考虑到有几种特殊情况,例如
● 第一种情况,优惠券秒杀时流量过大,其实需要限流,保证系统的可用性。
● 第二种情况,在领取无限制的优惠券时,为了避免恶意刷券,其实可以针对用户进行限流。
● 第三种情况,针对现在的爬虫,爬取商家信息,其实可以针对IP进行限流。
问题剖析
所以针对这几种情况,我的系统其实需要一个限流组件。来完成这几种特殊情况的限制,保证系统的可用性、安全性、稳定性
方案构思
那如何实现这个限流组件呢?首先我对这个限流组件首先有几点需求
● 第一,无侵入式
● 第二,高性能
● 第三,容易使用
首先无侵入式和容易使用,可以使用AOP切面结合自定义注解实现,而高性能可以使用Redis实现
那么限流,要使用哪种限流算法呢?
我了解有固定窗口、滑动窗口、漏桶、令牌桶 这四种限流算法
- 首先固定窗口的方式,在临界点会有允许两倍流量的临界问题,所以这个算法是不合适的。
- 而漏桶算法核心是请求以固定的速率被处理,不管请求的突发性。它的工作方式类似于一个底部有孔的桶,水(请求)以任意速率流入桶中,但只能以恒定的速率流出。如果桶满了,多余的请求会被丢弃。缺点:漏桶算法无法处理突发流量。即使系统有空闲资源,请求也只能以固定速率处理,这对于秒杀场景来说不够灵活。在优惠券秒杀时,我们希望系统在有能力的情况下尽可能处理更多请求,而漏桶的固定流出速率会导致系统资源无法充分利用,在突发流量时大量请求被丢弃,用户体验较差
- 而令牌桶算法以固定速率生成令牌放入桶中,每个请求需要获取一个令牌才能被处理。如果桶中有令牌,请求可以立即被处理,允许一定程度的突发流量(因为桶中积累的令牌可以被一次性使用)。优点:令牌桶允许突发流量,这在某些场景下是优势。缺点:如果桶中积累了令牌,恶意用户可以利用这些令牌在瞬间发送大量请求,也就是所谓的脉冲攻击,导致其他用户无令牌可用。
最终选择了滑动窗口算法实现限流,将时间窗口细分为多个小窗口,统计滑动窗口内的总请求数。随着时间推移,窗口向前滑动,旧的小窗口数据过期。
● 第一它可以提供精确的控制。
● 第二滑动窗口通过限制每个时间窗口内的请求数量,通常能够有效应对脉冲攻击。即使攻击者试图在短时间内发起大量请求,由于每个时间窗口内的请求数是有上限的,滑动窗口可以有效抑制这种突发流量,防止恶意脉冲攻击。
上述分析完毕后,那么具体的流程细节:首先我要
- 自定义限流注解:包含参数 限流key前缀、时间窗口大小、时间窗口内允许的请求数、限流提示信息、限流维度,其中限流维度支持按 全局限流、用户限流、IP限流,不同的维度适用于不同的场景
有了这个自定义限流注解后,我们就可以把注解加到对应的接口上,并设置对应的参数
我们需要拦截所有加了自定义限流注解的接口方法,也就是还需要实现AOP切面
- 限流切面处理:获取到注解上的参数, 限流key前缀、时间窗口大小、时间窗口内允许的请求数、限流提示信息、限流维度,执行限流Lua脚本,且把参数传递进去。
限流Lua脚本如何实现呢,这其中就是滑动窗口限流算法实现
- 限流Lua脚本:实现滑动窗口限流算法,利用Redis的ZSet数据类型实现
a. 删除超出时间窗口的数据,Redis命令ZREMRANGEBYSCORE,从有序集合中移除指定分数区间的成员
b. 获取当前窗口内的请求数量,Redis命令ZCARD: 返回有序集合中成员的数量,因为已经删除了超出时间窗口的元素,所以直接返回集合的成员数量即可获取当前时间窗口的请求数量
c. 若"当前时间窗口内的请求数量" 小于 "时间窗口内限制的次数",则添加当前请求(使用毫秒时间戳+随机数作为member,时间戳作为score)。若"当前时间窗口内的请求数量" 大于等于 "时间窗口内限制的次数", 则应该限制本次请求,返回 0,表示限流
复盘总结
通过实现这样的限流组件,我们就可以很方便的在需要的接口上加入限流。
常见面试题
- 问题:什么是滑动窗口限流算法
答:滑动窗口限流是一种流量控制策略,用于控制在一定时间内允许执行的操作数量或请求频率。它的工作方式类似于一个滑动时间窗口,在窗口内允许的操作数量是固定的,窗口会随着时间的推移不断滑动。
首先需要把时间划分成多个连续的时间片段,每一个片段都有一个固定的时间间隔,如1s、1h等。
然后再定义一个时间窗口,比如10s,随着时间的推移,这个窗口不断的向右移动。为了实现限流的功能,我们通常需要定义一个计数器,统计时间窗口内的请求数。
当时间窗口移动时,需要把上一个时间片段中的请求数减掉,当有新的请求或操作到达系统时,系统会检查窗口内的计数是否已满。如果计数未满,请求被允许执行;如果计数已满,请求被拒绝或进入等待队列,或执行其他限流操作。
滑动窗口限流的主要优点是可以在时间内平滑地控制流量,而不是简单地设置固定的请求数或速率。这使得系统可以更灵活地应对突发流量或峰值流量,而不会因为固定速率的限制而浪费资源或降低系统性能。
滑动窗口限流可以在分布式系统、API服务、网络通信等各种应用场景中使用,以确保系统的稳定性和可用性,防止过多的请求或操作对系统造成负担或崩溃。
-
问题:为什么要用Lua脚本
答: 假如不使用Lua脚本,会有并发问题,例如两个用户请求先后执行Redis命令ZCARD,返回当前时间窗口内的请求数均为99,然后这两个用户请求都认为不应该限流,那么就都放行了,但实际上限制数量是100,这两个请求应该有一个被拦截下来,所以限流逻辑内的 "查看当前时间窗口的请求数量"和"判断请求数量与阈值的大小比较"和"添加当前请求",这几个操作必须连贯,有原子性的要求,所以使用Lua脚本,另外使用Lua脚本同时也能减少多次网络往返,提升性能
-
问题:你了解其他限流算法吗,固定窗口、漏桶、令牌桶,区别是什么?
我了解有固定窗口、滑动窗口、漏桶、令牌桶 这四种限流算法
-
首先固定窗口的方式,在临界点会有允许两倍流量的临界问题,所以一般是不使用这个算法的。
-
而漏桶算法核心是请求以固定的速率被处理,不管请求的突发性。它的工作方式类似于一个底部有孔的桶,水(请求)以任意速率流入桶中,但只能以恒定的速率流出。如果桶满了,多余的请求会被丢弃。缺点:漏桶算法无法处理突发流量。即使系统有空闲资源,请求也只能以固定速率处理,
-
而令牌桶算法以固定速率生成令牌放入桶中,每个请求需要获取一个令牌才能被处理。如果桶中有令牌,请求可以立即被处理,允许一定程度的突发流量(因为桶中积累的令牌可以被一次性使用)。优点:令牌桶允许突发流量,这在某些场景下是优势。缺点:如果桶中积累了令牌,恶意用户可以利用这些令牌在瞬间发送大量请求,也就是所谓的脉冲攻击,导致其他用户无令牌可用。
滑动窗口算法实现限流,将时间窗口细分为多个小窗口,统计滑动窗口内的总请求数。随着时间推移,窗口向前滑动,旧的小窗口数据过期。
● 第一它可以提供精确的控制。
● 第二滑动窗口通过限制每个时间窗口内的请求数量,通常能够有效应对脉冲攻击。即使攻击者试图在短时间内发起大量请求,由于每个时间窗口内的请求数是有上限的,滑动窗口可以有效抑制这种突发流量,防止恶意脉冲攻击。
算法 核心缺陷 适用场景
固定窗口 窗口切换时2倍流量冲击 低频管理后台
漏桶 无法处理合法突发流量 数据库写入保护
令牌桶 黑客利用桶容量发起脉冲攻击 内部API调用
滑动窗口 内存占用稍高 秒杀/防刷/爬虫防护