首页 > 其他分享 >实战解析:接口限流的一次简单实践

实战解析:接口限流的一次简单实践

时间:2024-04-08 17:59:01浏览次数:19  
标签:请求 IP 接口 计数器 限流 import 解析 ipAddress

1.写这篇文章的来由

有一段时间里,博客总是三天两头被打,其中就遇到了恶意刷接口的手段,对方明显使用的代码IP,由于博客并没有做这方面的措施,加上被大量盗刷的接口刚好是数据量最大的一篇文章数据,所以不出意外的,博客没多久就崩了。服务器状态也是各种异常。所以吃一堑长一智吧算是,我也没想到面对一个个人小破站,对面也是饥不择食….真大黑客啊兄弟们!!!

请在此添加图片描述


2.接口限流的常见手段

​ 现在来说,做限流的各种方案其实已经相对很成熟了,这里也是大致列举了几种常用的解决方案,但不会全部都细说。

毕竟很多都还是自己没有实际使用过的,光搞理论是没什么意义的,所以后续有时间打算一个个揪出来细搞,起码得到用过了再写篇文章总结一下吧。

Java中常用的限流解决方案:

  • 计数器
  • 滑动窗口
  • 漏桶
  • 令牌桶
  • Redis+Lua分布式限流

由于我博客采用的就是计数器方案,所以这里主要记录一下整个大致的限流原理以及实践过程。

上面几种方案中,计数器算是最简单的限流算法了。原理就是在指定的时间间隔内,对接口的请求次数进行限制,具体到我的博客为例,我是针对每个IP进行的请求限制,对请求进行计数,判断请求数量与阈值的情况,决定是否需要限流,每个IP触发限流之后会有一定的时间周期,计数器到时清零即可。

这就是计数器限流基本的原理。具体的实现上,我选用了Redis作为了计数限流的中间件,所以也可以理解为,这是Redis+计数器的一种实现方式。具体执行的逻辑如下:

  • 设置好计数器count,每过一次请求计数器就+1,同时记录对应的请求IP
  • 当下一个请求到来之际,首先通过IP判断对应的计数器是否达到了限流的频次,以及本次请求是否还在设定的请求周期内;
  • 如果请求已触发限流阈值,则针对该IP开启限流,后面的所有请求均直接拒绝。
  • 当被限流IP达到时间周期满之后,将count重置,计数器进入下一轮的就绪状态。

原理也是蛮简单的,我也是蛮喜欢这种方式的(床言床语???)。下面开始具体的实操部分。


3.计数器限流实践

首先确定实现的具体方案,上面说了,我这里用的是Redis作为限流计数器的记录以及限流状态的重置等操作。具体限流的逻辑直接写以Java带代码写在了项目业务中。

特别的,由于是通过IP来限流的,所以这里需要用大的几个处理IP地址的工具类就先贴出来。

个人习惯,我贴代码会将所有import的包都一起贴进来,这样是方便后续回顾或者学习的时候处理一些包的问题,之前就遇到过很多类似的问题(可能对小白不太友好),代码是有了,结果在导包的时候要么是对用到的哪些包不明所以,要么是同名的包过多,不知道怎么选择。

3.1 IP工具类

import eu.bitwalker.useragentutils.UserAgent;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.lionsoul.ip2region.DataBlock;
import org.lionsoul.ip2region.DbConfig;
import org.lionsoul.ip2region.DbSearcher;
import org.lionsoul.ip2region.Util;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.util.FileCopyUtils;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.net.UnknownHostException;


@Slf4j
@Component
public class IpUtils {
    
    
    /**
     * 获取ip地址
     */
    public static String getIpAddress(HttpServletRequest request) {
        String ipAddress = request.getHeader("X-Real-IP");
        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("x-forwarded-for");
        }
        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("Proxy-Client-IP");
        }
        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("HTTP_CLIENT_IP");
        }
        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("HTTP_X_FORWARDED_FOR");
        }
        if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getRemoteAddr();
            if ("127.0.0.1".equals(ipAddress) || "0:0:0:0:0:0:0:1".equals(ipAddress)) {
                //根据网卡取本机配置的IP
                InetAddress inet = null;
                try {
                    inet = InetAddress.getLocalHost();
                } catch (UnknownHostException e) {
                    log.error("getIpAddress exception:", e);
                }
                assert inet != null;
                ipAddress = inet.getHostAddress();
            }
        }
        return StringUtils.substringBefore(ipAddress, ",");
    }

    private static DbSearcher searcher;
    private static Method method;

    /**
     * 在服务启动时加载 ip2region.db 到内存中
     */
    @PostConstruct
    private void initIp2regionResource() throws Exception {
        InputStream inputStream = new ClassPathResource("/ip/ip2region.db").getInputStream();
        //将 ip2region.db 转为 ByteArray
        byte[] dbBinStr = FileCopyUtils.copyToByteArray(inputStream);
        DbConfig dbConfig = new DbConfig();
        searcher = new DbSearcher(dbConfig, dbBinStr);
        //二进制方式初始化 DBSearcher,需要使用基于内存的查找算法 memorySearch
        method = searcher.getClass().getMethod("memorySearch", String.class);
    }

    /**
     * 获取ip地址的归属地
     */
    public static String getIpSource(String ipAddress) {
        if (ipAddress == null || !Util.isIpAddress(ipAddress)) {
            log.error("Error: Invalid ip address");
            return "";
        }
        try {
            DataBlock dataBlock = (DataBlock) method.invoke(searcher, ipAddress);
            String ipInfo = dataBlock.getRegion();
            if (!StringUtils.isEmpty(ipInfo)) {
                ipInfo = ipInfo.replace("|0", "");
                ipInfo = ipInfo.replace("0|", "");
                return ipInfo;
            }
        } catch (Exception e) {
            log.error("getCityInfo exception:", e);
        }
        return "";
    }

    public static String getIpProvince(String ipSource) {
        String[] strings = ipSource.split("\\|");
        if (strings[1].endsWith("省")) {
            return StringUtils.substringBefore(strings[1], "省");
        }
        return strings[1];
    }

    /**
     * 获取访问设备
     */
    public static UserAgent getUserAgent(HttpServletRequest request) {
        return UserAgent.parseUserAgentString(request.getHeader("User-Agent"));
    }
}

3.2 定义限流注解

为了使用方便,我这里选择了注解的方式,这样在使用的时候只需要在需要进行限流的请求Controller上添加一个注解即可。就像这样:

请在此添加图片描述

自定义的限流注解其实很简单,主要包含限流的Key,限流周期以及请求计数器。当然,这些数据都是完全可以自定义的,并没有什么约定俗成,具体工具自己的业务需要决定就好。

import java.lang.annotation.*;
/**
 * @author: 八尺妖剑
 * @date: 2022/10/19 12:34
 * @email: [email protected]
 * @blog: https://www.waer.ltd
 * @Description: 自定义注解:接口限流
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimit {
    /**
     * 限流的key
     */
    String key() default "limit";

    /**
     * 周期:单位秒
     * @return
     */
    int cycles() default 5;

    /**
     * 请求次数
     * @return
     */
    int count() default 1;
}

3.3 自定义拦截器

这里使用到了拦截器,主要作用就是拦截处理所有的请求进行拦截,主要用到的preHandle方法。所有的限流逻辑都在这里实现。所以这部分挺重要的。

/**
 * @author: 八尺妖剑
 * @date: 2022/10/19 12:38
 * @email: [email protected]
 * @blog: https://www.waer.ltd
 * @Description: 拦截器:处理接口限流
 */
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
    @Resource
    private RedisTemplate<String,Integer> redisTemplate;
    @Autowired
    private EmailUtils emailUtils;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 如果请求的是方法,则需要做校验
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            // 获取目标方法上是否有指定注解
            RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
            if (rateLimit == null) {
                //说明目标方法上没有 RateLimit 注解
                return true;
            }
            // 说明目标方法上有 RateLimit 注解,所以需要校验这个请求是不是在刷接口
            // 获取请求IP地址
            String ip = IpUtils.getIpAddress(request);
            // 请求url路径
            String uri = request.getRequestURI();
            //存到redis中的key
            String key = "RateLimit:" + ip + ":" + uri;
            // 缓存中存在key,在限定访问周期内已经调用过当前接口
            if (redisTemplate.hasKey(key)) {
                // 访问次数自增1
                redisTemplate.opsForValue().increment(key, 1);
                Integer count = redisTemplate.opsForValue().get(key);
                // 超出访问次数限制
                if (count > rateLimit.count()) {
                    String from = IpUtils.getIpSource(ip);
                    EmailDTO emailDTO = SendEmailForRateLimit(ip,uri,from);
                    CompletableFuture.supplyAsync(()->{
                        return count==50 ? true : false;
                    }).thenApplyAsync(num->CompletableFuture.supplyAsync(() -> {
                        //System.out.println("num:" + num);
                        if(num) {
                            emailUtils.sendHtmlMail(emailDTO);
                        }
                        return "邮件发送完成";
                    }));
                    throw  new BizException(StatusCodeEnum.RATE_LIMIT_REQUEST);
                }
                // 未超出访问次数限制,不进行任何操作,返回true
            } else {
                // 第一次设置数据,过期时间为注解确定的访问周期
                redisTemplate.opsForValue().set(key, 1, rateLimit.cycles(), TimeUnit.SECONDS);
            }
            return true;
        }
        //如果请求的不是方法,直接放行
        return true;
    }

代码中已经写了详细的注释,所以就不再具体展开,需要注意的是,其中涉及到邮件发送的部分是我自己增加的一个安全提醒的部分逻辑,所以这部分可以忽略掉,不算在限流逻辑中也是没有任何毛病的。


4.实际使用之后的效果

到这一步,所有的工作都完成了,前面也提到过使用是非常简单的,我们只需要在要进行限流的请求方法上加上注解@RateLimit(cycles = 125,count = 3)即可,至于括号内的限流参数,那就根据自己的需求设置了,比如我这里写的就是125秒内同一个IP只能进行3次请求,否则就会触发限流,请求拒绝。

正常请求

请在此添加图片描述

请求限流

请在此添加图片描述

Redis中记录的数据

请在此添加图片描述

注意,限流触发的提示信息建议自己写一个,我承认,我自己这个提示确实不太友好,这主要是当时被对面搞那么一出,就很气人,所以在语言提示上就有些不够友好,如果需要自定义,只需要修改下面的常量数据就可。

请在此添加图片描述


5.关于计数器限流方案的一些总结

通过上面一波湿滑操作,我们已经以通过计数器这种方式具体应用到了实际的项目中,但这并不是故事的结束,每一种方法都有它独到的优势,自然也会有自己的不足,对于计数器实现的限流方案,其实还是有不少问题的。

考虑下面这种情况:

​ 假设对于某一些接口的需求是每分钟允许的请求上限是100次,如果某用户在最后那第59秒最后几毫秒瞬间直接给你来100个请求,当这一秒结束之后,计数器完成清零工作,此时该用户在下一秒的时候又给你整100个请求过来,啪一下就过来了,很快啊,那么1秒内这个很皮的用户就发送了2倍的请求,显然在这个情况下,一切也都是符合计数器限流原理的。
​ 这就是该方法的缺陷(不能很好的处理时间单位的边界),这种情况的存在,可能会导致系统一不小心就承受了太多,甚至击穿系统,所以这也是为什么还有其他几种方案的原因之一。

至此就完成了一次接口限流的操作实践。最后,纸上得来终觉浅啊哥

标签:请求,IP,接口,计数器,限流,import,解析,ipAddress
From: https://blog.csdn.net/2302_76401343/article/details/137432733

相关文章

  • Vue2中使用iframe展示文件流(PDF)以及blob类型接口错误展示返回值
    需求使用iframe展示后端接口传输来的文件流(pdf),如果接口返回错误则弹出提示html部分<iframe:src="url"width="100%"/>接口部分//接口封装已忽略,注意:如需接口接收文件流,请在请求中加入responseType:'blob'以及type:"application/json;chartset=UTF-8"function......
  • uniapp 小程序接口调用封装
    目的:接口封装让代码更简洁,操作更方便,出错率更低。第一步:建立引用目录 第二步:封装request.jsconstBASE_URL="http://"//公共请求头地址constrequest=(url,method,data)=>{ returnnewPromise((resolve,reject)=>{ uni.request({ url:BASE_URL+url,......
  • 突破编程_C++_网络编程(Windows 套接字(API 接口(1)))
    1初始化与清理1.1WSAStartupWSAStartup函数用于初始化Winsock库,并指定应用程序所需的Winsock版本。它允许应用程序与WinsockDLL(动态链接库)建立联系,并准备Winsock环境以供后续使用。(1)函数原型如下:intWSAStartup(WORDwVersionRequested,LPWSADATAlpWSAData......
  • 模型调用接口查找数据
    fromlangchain.chainsimportAPIChainfromlangchain_community.llms.ollamaimportOllamallm=Ollama(model="qwen:7b")api_docs="""BASEURL:https://api.python.langchain.comAPIDocumentation:TheAPIendpoint/en/latest/la......
  • 开源数据库OpenGauss的SQL解析源码分析
    开源数据库OpenGauss的SQL解析源码分析OpenGauss数据库体系概述openGauss是关系型数据库,采用客户端/服务器,单进程多线程架构;支持单机和一主多备部署方式,同时支持备机可读、双机高可用等特性。从代码结构体系结构的角度来说,oepnGauss的第一个组成部分是通信管理。openGa......
  • 接口--接口服务
    importjsonfromUSSyunwei.binimport*importrequestsfromUSSapi.bascidataimport*#接口服务defApi_server(port_name,Goal_hierarchy="content",assert_data="",passError=False,**kwargs):"""port_name:接口名称,heade......
  • openGauss数据库源码解析——慢SQL检测
    openGauss数据库源码解析——慢SQL检测慢SQL检测的定义:基于历史SQL语句信息进行模型训练,并用训练好的模型进行SQL语句的预测,利用预测结果判断该SQL语句是否是潜在的慢SQL。当发现潜在的慢SQL后,开发者便可以进行针对性优化或者风险评估,以防业务上线后发生问题。慢......
  • 接口校验易宝分账状态异常单据重推
    importtimefromUSSyunwei.binimport*fromUSSapi.Apiserverimport*fromUSSapi.bascidataimport*sql=f"""selectorder_no,sfdj_receipt_status,sfdj_failure_reason,site_receipt_status,site_failure_reason,site_id,receipt_status,failure_reason,......
  • 利用Java实现每周二上午十点定时调用接口的方法
    摘要:在软件开发中,定时任务是一项常见的需求,特别是需要定期执行一些特定操作的场景。本文将介绍如何利用Java编程语言实现每周二上午十点定时调用接口的功能。通过使用Java中的定时任务调度工具,我们可以轻松地实现这一功能,从而满足各种业务需求。正文:在Java中实现定时任务......
  • 谢启鸿高等代数第四版习题7.7部分习题解析part2.以及部分第7章复习题
    7.7部分定理:以为特征值的K阶若当块个数为11.设n阶矩阵A的特征值全为1,求证:对任意的正整数K,与A相似。证明:=(易证故此处不再证明)而且的特征值全为1。的特征值为1的k阶若当块的个数为接下来只需证明相似于即可;即证明两者有相同的约当标准型.由书上7.8节的数学归纳可以知道......