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: ilikexff@gmail.com
* @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: ilikexff@gmail.com
* @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倍的请求,显然在这个情况下,一切也都是符合计数器限流原理的。
这就是该方法的缺陷(不能很好的处理时间单位的边界),这种情况的存在,可能会导致系统一不小心就承受了太多,甚至击穿系统,所以这也是为什么还有其他几种方案的原因之一。
至此就完成了一次接口限流的操作实践。最后,纸上得来终觉浅啊哥!