基于redis实现API接口访问次数限制

一,概述

日常开发中会有一个常见的需求,需要限制接口在单位时间内的访问次数,比如说某个免费的接口限制单个IP一分钟内只能访问5次。该怎么实现呢,通常大家都会想到用redis,确实通过redis可以实现这个功能,下面实现一下。

二,常见错误

固定时间窗口

有人设计了一个在每分钟内只允许访问1000次的限流方案,如下图01:00s-02:00s之间只允许访问1000次。这种设计的问题在于,请求可能在01:59s-02:00s之间被请求1000次,02:00s-02:01s之间被请求了1000次,这种情况下01:59s-02:01s间隔0.02s之间被请求2000次,很显然这种设计是错误的。

三, 实现

1,基于滑动时间窗口

在指定的时间窗口内次数是累积的,超过阈值,都会限制。

2,流程如下

3,代码实现

前提:pom文件引入redis,Spring AOP等

(1)添加注解RequestLimit
java 复制代码
package com.xxx.demo.aspect;

import java.lang.annotation.*;


/**
 * 接口访问频率注解,默认一分钟只能访问10次
 */
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestLimit {
    // 限制时间 单位:秒(默认值:一分钟)
    long period() default 60;
    // 允许请求的次数(默认值:10次)
    long count() default 10;
}
(2)添加切面实现注解的限制访问逻辑
java 复制代码
package com.xxx.demo.aspect;

import com.xgd.demo.commons.ErrorCode;
import com.xgd.demo.handler.BusinessException;
import com.xgd.demo.util.IpUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.log4j.Log4j2;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.concurrent.TimeUnit;

/**
 * @date 2024/11/8 上午8:43
 */
@Aspect
@Component
@Log4j2
public class RequestLimitAspect {
    @Autowired
    RedisTemplate redisTemplate;

    @Pointcut("@annotation(requestLimit)")
    public void controllerAspect(RequestLimit requestLimit) {}

    @Around("controllerAspect(requestLimit)")
    public Object doAround(ProceedingJoinPoint joinPoint, RequestLimit requestLimit) throws Throwable {
        // 从注解中获取限制次数和窗口时间
        long period = requestLimit.period();
        long limitCount = requestLimit.count();

        // 请求
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        assert attributes != null;
        HttpServletRequest request = attributes.getRequest();
        String ip = IpUtil.getIpFromRequest(request);
        String uri = request.getRequestURI();
        //设置客户端访问的key
        String key = "req_limit_".concat(uri).concat(ip);

        ZSetOperations zSetOperations = redisTemplate.opsForZSet();
        // 添加当前时间戳,分数为当前时间戳
        long currentMs = System.currentTimeMillis();
        zSetOperations.add(key, currentMs, currentMs);
        // 设置窗口时间作为过期时间
        redisTemplate.expire(key, period, TimeUnit.SECONDS);
        // 移除掉不在窗口里的数据
        zSetOperations.removeRangeByScore(key, 0, currentMs - period * 1000);
        // 查询窗口内已经访问过的次数
        Long count = zSetOperations.zCard(key);
        if (count > limitCount) {
            log.error("接口拦截:{} 请求超过限制频率【{}次/{}s】,IP为{}", uri, limitCount, period, ip);
            throw new BusinessException(ErrorCode.REQUEST_LIMITED.getCode(), ErrorCode.REQUEST_LIMITED.getMessage());
        }

        // 继续执行请求
        return  joinPoint.proceed();
    }
}

上面里面请求被拦截,是抛出了一个自定义的业务异常,大家可以根据自己的情况自己定义。

(3)同时附上上面中引用到自定义工具类
java 复制代码
package com.xxx.demo.util;

import jakarta.servlet.http.HttpServletRequest;
import java.util.Objects;

/**
 * @date 2024/11/8 上午9:06
 */
public class IpUtil {
    private static final String X_FORWARDED_FOR_HEADER = "X-Forwarded-For";
    private static final String X_REAL_IP_HEADER = "X-Real-IP";

    /**
     * 从请求中获取IP
     *
     * @return IP;当获取不到时,返回null
     */
    public static String getIpFromRequest(HttpServletRequest request ) {
        return getRealIp(request);
    }

    /**
     * 获取请求的真实IP,优先级从高到低为:<br/>
     * 1.从请求头X-Forwarded-For中获取ip,并且只获取第一个ip(从左到右) <br/>
     * 2.从请求头X-Real-IP中获取ip <br/>
     * 3.使用{@link HttpServletRequest#getRemoteAddr()}方法获取ip
     *
     * @param request 请求对象,必须不能为null
     * @return ip
     */
    private static String getRealIp(HttpServletRequest request) {
        Objects.requireNonNull(request, "request must be not null");

        String ip = request.getHeader(X_FORWARDED_FOR_HEADER);
        if (ip != null && !ip.isBlank()) {
            int delimiterIndex = ip.indexOf(',');
            if (delimiterIndex != -1) {
                // 如果存在多个ip,则取第一个ip
                ip = ip.substring(0, delimiterIndex);
            }

            return ip;
        }

        ip = request.getHeader(X_REAL_IP_HEADER);
        if (ip != null && !ip.isBlank()) {
            return ip;
        } else {
            return request.getRemoteAddr();
        }
    }
}
(4)使用注解

这里限制为10秒内只允许访问3次,超过就抛出异常

(5)访问测试

前3次访问,接口正常访问

后面的访问,返回自定义异常的结果

如果对你有帮助,记得点赞关注哟!

相关推荐
如意机反光镜裸34 分钟前
如何快速将Excel数据导入到SQL Server数据库
数据库
飞升不如收破烂~1 小时前
redis的List底层数据结构 分别什么时候使用双向链表(Doubly Linked List)和压缩列表(ZipList)
redis
不爱学习的啊Biao1 小时前
初识mysql数据库
数据库·mysql·oracle
一条晒干的咸魚1 小时前
【Web前端】实现基于 Promise 的 API:alarm API
开发语言·前端·javascript·api·promise
宛唐羁客2 小时前
ODBC连接PostgreSQL数据库后,网卡DOWN后,客户端进程阻塞问题解决方法
网络·数据库
吴半杯2 小时前
Redis-monitor安装与配置
数据库·redis·缓存
是桃萌萌鸭~3 小时前
mysqldbcompare 使用及参数详解
数据库·mysql
gma9993 小时前
brpc 与 Etcd 二次封装
数据库·c++·rpc·etcd
ö Constancy3 小时前
设计LRU缓存
c++·算法·缓存
小王码农记3 小时前
vue中路由缓存
前端·vue.js·缓存·typescript·anti-design-vue