基于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次访问,接口正常访问

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

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

相关推荐
开开心心就好2 小时前
PDF转长图工具,一键多页转图片
java·服务器·前端·数据库·人工智能·pdf·推荐算法
卡拉叽里呱啦3 小时前
深入理解事务一致性和隔离性
数据库
liweiweili1263 小时前
基于金庸武侠小说人物关系设计的完整 SQL 语句,包括数据库创建、表结构定义和示例数据插入
数据库·sql·oracle
RestCloud3 小时前
iPaaS实施的前提是先进行集成关系的梳理
api
赵得C4 小时前
Java 多线程环境下的全局变量缓存实践指南
java·开发语言·后端·spring·缓存
梓沂4 小时前
centos7 安装memcached
数据库·缓存·memcached
她说人狗殊途5 小时前
[特殊字符] MySQL性能参数查询总结
数据库·mysql
TDengine (老段)5 小时前
TDengine 数据订阅支持 MQTT 协议用户手册
大数据·数据库·物联网·时序数据库·iot·tdengine·涛思数据
灵犀物润5 小时前
MySQL 8 与 PostgreSQL 17 对比分析及迁移指南
数据库·mysql·postgresql
用户268001379196 小时前
小红书笔记详情API接口系列,json数据返回
api