基于Redis ZSet+AOP+注解实现限流注解算法

代码实现

  1. 自定义限流注解 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
}

}

  1. 限流切面处理 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"; // 默认返回匿名用户
}

}

  1. 滑动窗口限流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脚本的逻辑

  1. 移除时间窗口之外的数据 redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
  2. 获取当前时间窗口内的已请求数量 redis.call('ZCARD', key)
  3. 判断当前时间窗口内的已请求数量 是否 小于 limit 限制次数
    a. 若小于,则把本次请求加入key中,以当前时间戳为分数score,以当前时间戳+随机数为成员member,更新过期时间,本次请求可放行,后续可执行业务逻辑
    b. 若大于或等于,则返回本次请求应该限流
  4. 限流异常类 RateLimitException
    package com.hmdp.limiter.exception;

public class RateLimitException extends RuntimeException {

public RateLimitException(String message) {

super(message);

}

}

  1. 全局异常处理 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());
}

}

使用示例

  1. 在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 "商家信息";
}

}

  1. 测试限流效果

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记录的数据:

总结

实现的整体流程

  1. 自定义限流注解:包含参数 限流key前缀、时间窗口大小、时间窗口内允许的请求数、限流提示信息、限流维度,其中限流维度支持按 全局限流、用户限流、IP限流,不同的维度适用于不同的场景

有了这个自定义限流注解后,我们就可以把注解加到对应的接口上,并设置对应的参数

我们需要拦截所有加了自定义限流注解的接口方法,也就是还需要实现AOP切面

  1. 限流切面处理:获取到注解上的参数,限流key前缀、时间窗口大小、时间窗口内允许的请求数、限流提示信息、限流维度,执行限流Lua脚本,且把参数传递进去。

限流Lua脚本如何实现呢,这其中就是滑动窗口限流算法实现

  1. 限流Lua脚本:实现滑动窗口限流算法,利用Redis的ZSet数据类型实现
    a. 删除超出时间窗口的数据,Redis命令ZREMRANGEBYSCORE,从有序集合中移除指定分数区间的成员
    b. 获取当前窗口内的请求数量,Redis命令ZCARD: 返回有序集合中成员的数量,因为已经删除了超出时间窗口的元素,所以直接返回集合的成员数量即可获取当前时间窗口的请求数量
    c. 若"当前时间窗口内的请求数量" 小于 "时间窗口内限制的次数",则添加当前请求(使用毫秒时间戳+随机数作为member,时间戳作为score)。若"当前时间窗口内的请求数量" 大于等于 "时间窗口内限制的次数", 则应该限制本次请求,返回 0,表示限流

解释:限流维度的不同,本质上就是Redis的Key的构造。如果这个不能理解的话,面试时可不说。

特点和应用

这套滑动窗口限流方案具有以下特点:

  1. 精确控制:基于时间窗口的请求计数,避免固定窗口的边界问题
  2. 灵活维度:支持 全局、IP、用户 级别的限流
  3. 高效实现:使用Redis ZSET和Lua脚本保证原子性操作
  4. 易集成:通过注解和AOP无缝集成到Spring Boot应用中
    在你的优惠券秒杀系统中,可以这样应用:
    ● 对秒杀接口使用全局限流,防止系统过载
    ● 对用户领取优惠券使用用户级限流,防止刷券
    ● 对商家信息查询使用IP级限流,防止爬虫
    当系统压力过大时,限流器会自动拒绝超额请求,返回预设的友好提示信息,保证核心业务的稳定运行。

话术 - 限流模块

设计思路:每一个点,需要有对应的场景,不要为了技术而技术,且根据自己的思路要能够自圆其说

简历上的亮点

● 滑动窗口限流: 使用 Redis + AOP + 注解实现限流,支持全局、IP、用户多维度,防止系统过载、刷券、爬虫;

话术/逐字稿

滑动窗口限流

● 滑动窗口限流: 使用 Redis + AOP + 注解实现限流,支持全局、IP、用户多维度,防止系统过载、刷券、爬虫;

是这样的,我先说一下背景

背景阐述

我考虑到有几种特殊情况,例如

● 第一种情况,优惠券秒杀时流量过大,其实需要限流,保证系统的可用性。

● 第二种情况,在领取无限制的优惠券时,为了避免恶意刷券,其实可以针对用户进行限流。

● 第三种情况,针对现在的爬虫,爬取商家信息,其实可以针对IP进行限流。

问题剖析

所以针对这几种情况,我的系统其实需要一个限流组件。来完成这几种特殊情况的限制,保证系统的可用性、安全性、稳定性

方案构思

那如何实现这个限流组件呢?首先我对这个限流组件首先有几点需求

● 第一,无侵入式

● 第二,高性能

● 第三,容易使用

首先无侵入式和容易使用,可以使用AOP切面结合自定义注解实现,而高性能可以使用Redis实现

那么限流,要使用哪种限流算法呢?

我了解有固定窗口、滑动窗口、漏桶、令牌桶 这四种限流算法

  1. 首先固定窗口的方式,在临界点会有允许两倍流量的临界问题,所以这个算法是不合适的。
  2. 而漏桶算法核心是请求以固定的速率被处理,不管请求的突发性。它的工作方式类似于一个底部有孔的桶,水(请求)以任意速率流入桶中,但只能以恒定的速率流出。如果桶满了,多余的请求会被丢弃。缺点:漏桶算法无法处理突发流量。即使系统有空闲资源,请求也只能以固定速率处理,这对于秒杀场景来说不够灵活。在优惠券秒杀时,我们希望系统在有能力的情况下尽可能处理更多请求,而漏桶的固定流出速率会导致系统资源无法充分利用,在突发流量时大量请求被丢弃,用户体验较差
  3. 而令牌桶算法以固定速率生成令牌放入桶中,每个请求需要获取一个令牌才能被处理。如果桶中有令牌,请求可以立即被处理,允许一定程度的突发流量(因为桶中积累的令牌可以被一次性使用)。优点:令牌桶允许突发流量,这在某些场景下是优势。缺点:如果桶中积累了令牌,恶意用户可以利用这些令牌在瞬间发送大量请求,也就是所谓的脉冲攻击,导致其他用户无令牌可用。
    最终选择了滑动窗口算法实现限流,将时间窗口细分为多个小窗口,统计滑动窗口内的总请求数。随着时间推移,窗口向前滑动,旧的小窗口数据过期。
    ● 第一它可以提供精确的控制。
    ● 第二滑动窗口通过限制每个时间窗口内的请求数量,通常能够有效应对脉冲攻击。即使攻击者试图在短时间内发起大量请求,由于每个时间窗口内的请求数是有上限的,滑动窗口可以有效抑制这种突发流量,防止恶意脉冲攻击。

上述分析完毕后,那么具体的流程细节:首先我要

  1. 自定义限流注解:包含参数 限流key前缀、时间窗口大小、时间窗口内允许的请求数、限流提示信息、限流维度,其中限流维度支持按 全局限流、用户限流、IP限流,不同的维度适用于不同的场景

有了这个自定义限流注解后,我们就可以把注解加到对应的接口上,并设置对应的参数

我们需要拦截所有加了自定义限流注解的接口方法,也就是还需要实现AOP切面

  1. 限流切面处理:获取到注解上的参数, 限流key前缀、时间窗口大小、时间窗口内允许的请求数、限流提示信息、限流维度,执行限流Lua脚本,且把参数传递进去。

限流Lua脚本如何实现呢,这其中就是滑动窗口限流算法实现

  1. 限流Lua脚本:实现滑动窗口限流算法,利用Redis的ZSet数据类型实现
    a. 删除超出时间窗口的数据,Redis命令ZREMRANGEBYSCORE,从有序集合中移除指定分数区间的成员
    b. 获取当前窗口内的请求数量,Redis命令ZCARD: 返回有序集合中成员的数量,因为已经删除了超出时间窗口的元素,所以直接返回集合的成员数量即可获取当前时间窗口的请求数量
    c. 若"当前时间窗口内的请求数量" 小于 "时间窗口内限制的次数",则添加当前请求(使用毫秒时间戳+随机数作为member,时间戳作为score)。若"当前时间窗口内的请求数量" 大于等于 "时间窗口内限制的次数", 则应该限制本次请求,返回 0,表示限流

复盘总结

通过实现这样的限流组件,我们就可以很方便的在需要的接口上加入限流。

常见面试题

  1. 问题:什么是滑动窗口限流算法
    答:滑动窗口限流是一种流量控制策略,用于控制在一定时间内允许执行的操作数量或请求频率。它的工作方式类似于一个滑动时间窗口,在窗口内允许的操作数量是固定的,窗口会随着时间的推移不断滑动。

首先需要把时间划分成多个连续的时间片段,每一个片段都有一个固定的时间间隔,如1s、1h等。

然后再定义一个时间窗口,比如10s,随着时间的推移,这个窗口不断的向右移动。为了实现限流的功能,我们通常需要定义一个计数器,统计时间窗口内的请求数。

当时间窗口移动时,需要把上一个时间片段中的请求数减掉,当有新的请求或操作到达系统时,系统会检查窗口内的计数是否已满。如果计数未满,请求被允许执行;如果计数已满,请求被拒绝或进入等待队列,或执行其他限流操作。

滑动窗口限流的主要优点是可以在时间内平滑地控制流量,而不是简单地设置固定的请求数或速率。这使得系统可以更灵活地应对突发流量或峰值流量,而不会因为固定速率的限制而浪费资源或降低系统性能。

滑动窗口限流可以在分布式系统、API服务、网络通信等各种应用场景中使用,以确保系统的稳定性和可用性,防止过多的请求或操作对系统造成负担或崩溃。

  1. 问题:为什么要用Lua脚本

    答: 假如不使用Lua脚本,会有并发问题,例如两个用户请求先后执行Redis命令ZCARD,返回当前时间窗口内的请求数均为99,然后这两个用户请求都认为不应该限流,那么就都放行了,但实际上限制数量是100,这两个请求应该有一个被拦截下来,所以限流逻辑内的 "查看当前时间窗口的请求数量"和"判断请求数量与阈值的大小比较"和"添加当前请求",这几个操作必须连贯,有原子性的要求,所以使用Lua脚本,另外使用Lua脚本同时也能减少多次网络往返,提升性能

  2. 问题:你了解其他限流算法吗,固定窗口、漏桶、令牌桶,区别是什么?

    我了解有固定窗口、滑动窗口、漏桶、令牌桶 这四种限流算法

  3. 首先固定窗口的方式,在临界点会有允许两倍流量的临界问题,所以一般是不使用这个算法的。

  4. 而漏桶算法核心是请求以固定的速率被处理,不管请求的突发性。它的工作方式类似于一个底部有孔的桶,水(请求)以任意速率流入桶中,但只能以恒定的速率流出。如果桶满了,多余的请求会被丢弃。缺点:漏桶算法无法处理突发流量。即使系统有空闲资源,请求也只能以固定速率处理,

  5. 而令牌桶算法以固定速率生成令牌放入桶中,每个请求需要获取一个令牌才能被处理。如果桶中有令牌,请求可以立即被处理,允许一定程度的突发流量(因为桶中积累的令牌可以被一次性使用)。优点:令牌桶允许突发流量,这在某些场景下是优势。缺点:如果桶中积累了令牌,恶意用户可以利用这些令牌在瞬间发送大量请求,也就是所谓的脉冲攻击,导致其他用户无令牌可用。

    滑动窗口算法实现限流,将时间窗口细分为多个小窗口,统计滑动窗口内的总请求数。随着时间推移,窗口向前滑动,旧的小窗口数据过期。

    ● 第一它可以提供精确的控制。

    ● 第二滑动窗口通过限制每个时间窗口内的请求数量,通常能够有效应对脉冲攻击。即使攻击者试图在短时间内发起大量请求,由于每个时间窗口内的请求数是有上限的,滑动窗口可以有效抑制这种突发流量,防止恶意脉冲攻击。

    算法 核心缺陷 适用场景

    固定窗口 窗口切换时2倍流量冲击 低频管理后台

    漏桶 无法处理合法突发流量 数据库写入保护

    令牌桶 黑客利用桶容量发起脉冲攻击 内部API调用

    滑动窗口 内存占用稍高 秒杀/防刷/爬虫防护

相关推荐
imDwAaY1 小时前
Redis 也能做消息队列?从 Stream 的存储讲到消费确认
数据库·redis·缓存
@Mike@1 小时前
13-数据库学习笔记(查询执行处理模型)
数据库·笔记·学习
All for pursuit.2 小时前
【回溯-7】494.目标和
数据结构·c++·算法·leetcode
白杨尚青2 小时前
C++入门篇(十三):vector(上)——动态数组:构造、空间增长与迭代器失效
开发语言·c++·笔记·算法·stl
Jasmine_llq2 小时前
《P17461 [GESP202609 八级] 生成树计数》
算法·栈·tarjan 算法·边双连通分量·深度优先搜索 dfs·tarjan 保存当前路径节点·模乘法
Jasmine_llq2 小时前
《P17462 [GESP202609 八级] 末班车》
算法·dijkstra 算法·最大化版本,求最长可行值·建反向图(逆图 / 反转边)·多源 dijkstra 思想·对每个终点单独跑一遍djst·离线预处理 + 在线查询
弈栈录2 小时前
MySQL 事务、索引与锁:后端开发必须掌握的数据库基础
数据库·后端
9624562 小时前
餐饮 SaaS 优惠券系统架构演进(三):优惠计算引擎——商品级计价、冲突策略与优惠分摊
java·数据库·spring boot
染指11102 小时前
135.Agent-多Agent框架-LangChain多智能体(SubAgents子代理)
数据库·人工智能·设计模式·langchain·agent·agents