详解Java分布式IP限流和防止恶意IP攻击方案
前言
限流是分布式系统设计中经常提到的概念,在某些要求不严格的场景下,使用GuavaRateLimiter就可以满足。但是GuavaRateLimiter只能应用于单进程,多进程间协同控制便无能为力。本文介绍一种简单的处理方式,用于分布式环境下接口调用频次管控。
如何防止恶意IP攻击某些暴露的接口呢(比如某些场景下短信验证码服务)?本文介绍一种本地缓存和分布式缓存集成方式判断远程IP是否为恶意调用接口的IP。
分布式IP限流
思路是使用redisincr命令,完成一段时间内接口请求次数的统计,以此来完成限流相关逻辑。
privatestaticfinalStringLIMIT_LUA=
"localmy_limit=redis.call('incr',KEYS[1])\n"+
"iftonumber(my_limit)==1then\n"+
"redis.call('expire',KEYS[1],ARGV[1])\n"+
"return1\n"+
"elseiftonumber(my_limit)>tonumber(ARGV[2])then\n"+
"return0\n"+
"else\n"+
"return1\n"+
"end\n";
这里为啥时候用lua脚本来实现呢?因为要保证incr命令和expire命令的原子性操作。KEYS[1]代表自增key值,ARGV[1]代表过期时间,ARGV[2]代表最大频次,明白了这些参数的含义,整个lua脚本逻辑也就不言而喻了。
/**
*@paramlimitKey限制Key值
*@parammaxRate最大速率
*@paramexpireKey过期时间
*/
publicbooleanaccess(StringlimitKey,intmaxRate,intexpire){
if(StringUtils.isBlank(limitKey)){
returntrue;
}
StringcacheKey=LIMIT_KEY_PREFIX+limitKey;
returnREDIS_SUCCESS_STATUS.equals(
this.cacheService.eval(
LIMIT_LUA
,Arrays.asList(cacheKey)
,Arrays.asList(String.valueOf(expire),String.valueOf(maxRate))
).toString()
);
}
publicvoidunlimit(StringlimitKey){
if(StringUtils.isBlank(limitKey)){
return;
}
StringcacheKey=LIMIT_KEY_PREFIX+limitKey;
this.cacheService.decr(cacheKey);
}
access方法用来判断limitKey是否超过了最大访问频次。缓存服务对象(cacheService)的eval方法参数分别是lua脚本、keylist、valuelist。
unlimit方法其实就是执行redisdecr操作,在某些业务场景可以回退访问频次统计。
防止恶意IP攻击
由于某些对外暴露的接口很容易被恶意用户攻击,必须做好防范措施。最近我就遇到了这么一种情况,我们一个快应用产品,短信验证码服务被恶意调用了。通过后台的日志发现,IP固定,接口调用时间间隔固定,明显是被人利用了。虽然我们针对每个手机号每天发送短信验证码的次数限制在5次以内。但是短信验证码服务每天这样被重复调用,会打扰用户并产生投诉。针对这种现象,简单的做了一个方案,可以自动识别恶意攻击的IP并加入黑名单。
思路是这样的,针对某些业务场景,约定在一段时间内同一个IP访问最大频次,如果超过了这个最大频次,那么就认为是非法IP。识别了非法IP后,把IP同时放入本地缓存和分布式缓存中。非法IP再次访问的时候,拦截器发现本地缓存(没有则去分布式缓存)有记录这个IP,直接返回异常状态,不会继续执行正常业务逻辑。
Guava本地缓存集成Redis分布式缓存
publicabstractclassAbstractCombineCache{ privatestaticLoggerLOGGER=LoggerFactory.getLogger(AbstractCombineCache.class); protectedCache localCache; protectedICacheServicecacheService; publicAbstractCombineCache(Cache localCache,ICacheServicecacheService){ this.localCache=localCache; this.cacheService=cacheService; } publicCache getLocalCache(){ returnlocalCache; } publicICacheServicegetCacheService(){ returncacheService; } publicVget(Kkey){ //只有LoadingCache对象才有get方法,如果本地缓存不存在key值,会执行CacheLoader的load方法,从分布式缓存中加载。 if(localCacheinstanceofLoadingCache){ try{ return((LoadingCache )localCache).get(key); }catch(ExecutionExceptione){ LOGGER.error(String.format("cachekey=%sloadingerror...",key),e); returnnull; }catch(CacheLoader.InvalidCacheLoadExceptione){ //分布式缓存中不存在这个key LOGGER.error(String.format("cachekey=%sloadingfail...",key)); returnnull; } }else{ returnlocalCache.getIfPresent(key); } } publicvoidput(Kkey,Vvalue,intexpire){ this.localCache.put(key,value); StringcacheKey=keyinstanceofString?(String)key:key.toString(); if(valueinstanceofString){ this.cacheService.setex(cacheKey,(String)value,expire); }else{ this.cacheService.setexObject(cacheKey,value,expire); } } }
AbstractCombineCache这个抽象类封装了guava本地缓存和redis分布式缓存操作,可以降低分布式缓存压力。
防止恶意IP攻击缓存服务
publicclassIPBlackCacheextendsAbstractCombineCache{ privatestaticLoggerLOGGER=LoggerFactory.getLogger(IPBlackCache.class); privatestaticfinalStringIP_BLACK_KEY_PREFIX="wmhipblack_"; privatestaticfinalStringREDIS_SUCCESS_STATUS="1"; privatestaticfinalStringIP_RATE_LUA= "localip_rate=redis.call('incr',KEYS[1])\n"+ "iftonumber(ip_rate)==1then\n"+ "redis.call('expire',KEYS[1],ARGV[1])\n"+ "return1\n"+ "elseiftonumber(ip_rate)>tonumber(ARGV[2])then\n"+ "return0\n"+ "else\n"+ "return1\n"+ "end\n"; publicIPBlackCache(Cache localCache,ICacheServicecacheService){ super(localCache,cacheService); } /** *@paramipKeyIP *@parammaxRate最大速率 *@paramexpire过期时间 */ publicbooleanipAccess(StringipKey,intmaxRate,intexpire){ if(StringUtils.isBlank(ipKey)){ returntrue; } StringcacheKey=IP_BLACK_KEY_PREFIX+ipKey; returnREDIS_SUCCESS_STATUS.equals( this.cacheService.eval( IP_RATE_LUA ,Arrays.asList(cacheKey) ,Arrays.asList(String.valueOf(expire),String.valueOf(maxRate)) ).toString() ); } /** *@paramipKeyIP */ publicvoidremoveIpAccess(StringipKey){ if(StringUtils.isBlank(ipKey)){ return; } StringcacheKey=IP_BLACK_KEY_PREFIX+ipKey; try{ this.cacheService.del(cacheKey); }catch(Exceptione){ LOGGER.error(String.format("%s,ipaccessremoveerror...",ipKey),e); } } }
没有错,IP_RATE_LUA这个lua脚本和上面说的限流方案对应的lua脚本是一样的。
IPBlackCache继承了AbstractCombineCache,构造函数需要guava的本地Cache对象和redis分布式缓存服务ICacheService对象。
ipAccess方法用来判断当前ip访问次数是否在一定时间内已经达到了最大访问频次。
removeIpAccess方法是直接移除当前ip访问频次统计的key值。
防止恶意IP攻击缓存配置类
@Configuration
publicclassIPBlackCacheConfig{
privatestaticfinalStringIPBLACK_LOCAL_CACHE_NAME="ip-black-cache";
privatestaticLoggerLOGGER=LoggerFactory.getLogger(IPBlackCacheConfig.class);
@Autowired
privateLimitConstantslimitConstants;
@Bean
publicIPBlackCacheipBlackCache(@AutowiredICacheServicecacheService){
GuavaCacheBuildercacheBuilder=newGuavaCacheBuilder(IPBLACK_LOCAL_CACHE_NAME);
cacheBuilder.setCacheBuilder(
CacheBuilder.newBuilder()
.initialCapacity(100)
.maximumSize(10000)
.concurrencyLevel(10)
.expireAfterWrite(limitConstants.getIpBlackExpire(),TimeUnit.SECONDS)
.removalListener((RemovalListener)notification->{
StringcurTime=LocalDateTime.now().toString();
LOGGER.info(notification.getKey()+"本地缓存移除时间:"+curTime);
try{
cacheService.del(notification.getKey());
LOGGER.info(notification.getKey()+"分布式缓存移除时间:"+curTime);
}catch(Exceptione){
LOGGER.error(notification.getKey()+"分布式缓存移除异常...",e);
}
})
);
cacheBuilder.setCacheLoader(newCacheLoader(){
@Override
publicObjectload(Stringkey){
try{
Objectobj=cacheService.getString(key);
LOGGER.info(String.format("从分布式缓存中加载key=%s,value=%s",key,obj));
returnobj;
}catch(Exceptione){
LOGGER.error(key+"从分布式缓存加载异常...",e);
returnnull;
}
}
});
CachelocalCache=cacheBuilder.build();
IPBlackCacheipBlackCache=newIPBlackCache(localCache,cacheService);
returnipBlackCache;
}
}
注入redis分布式缓存服务ICacheService对象。
通过GuavaCacheBuilder构建guava本地Cache对象,指定初始容量(initialCapacity)、最大容量(maximumSize)、并发级别、key过期时间、key移除监听器。最终要的是CacheLoader这个参数,是干什么用的呢?如果GuavaCacheBuilder指定了CacheLoader对象,那么最终创建的guava本地Cache对象是LoadingCache类型(参考AbstractCombineCache类的get方法),LoadingCache对象的get方法首先从内存中获取key对应的value,如果内存中不存在这个key则调用CacheLoader对象的load方法加载key对应的value值,加载成功后放入内存中。
最后通过ICacheService对象和guava本地Cache对象创建IPBlackCache(防止恶意IP攻击缓存服务)对象。
拦截器里恶意IP校验
定义一个注解,标注在指定方法上,拦截器里会识别这个注解。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public@interfaceIPBlackLimit{
//统计时间内最大速率
intmaxRate();
//频次统计时间
intduration();
//方法名称
Stringmethod()defaultStringUtils.EMPTY;
}
拦截器里加入ipAccess方法,校验远程IP是否为恶意攻击的IP。
/**
*@parammethod需要校验的方法
*@paramremoteAddr远程IP
*/
privatebooleanipAccess(Methodmethod,StringremoteAddr){
if(StringUtils.isBlank(remoteAddr)||!AnnotatedElementUtils.isAnnotated(method,IPBlackLimit.class)){
returntrue;
}
IPBlackLimitipBlackLimit=AnnotatedElementUtils.getMergedAnnotation(method,IPBlackLimit.class);
try{
Stringip=remoteAddr.split(",")[0].trim();
StringcacheKey="cipb_"+(StringUtils.isBlank(ipBlackLimit.method())?ip:String.format("%s_%s",ip,ipBlackLimit.method()));
StringbeginAccessTime=(String)ipBlackCache.get(cacheKey);
if(StringUtils.isNotBlank(beginAccessTime)){
LocalDateTimebeginTime=LocalDateTime.parse(beginAccessTime,DateTimeFormatter.ISO_LOCAL_DATE_TIME),endTime=LocalDateTime.now();
Durationduration=Duration.between(beginTime,endTime);
if(duration.getSeconds()>=limitConstants.getIpBlackExpire()){
ipBlackCache.getLocalCache().invalidate(cacheKey);
returntrue;
}else{
returnfalse;
}
}
booleanaccess=ipBlackCache.ipAccess(cacheKey,ipBlackLimit.maxRate(),ipBlackLimit.duration());
if(!access){
ipBlackCache.removeIpAccess(cacheKey);
StringcurTime=LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
ipBlackCache.put(cacheKey,curTime,limitConstants.getIpBlackExpire());
}
returnaccess;
}catch(Exceptione){
LOGGER.error(String.format("method=%sï¼remoteAddr=%s,ipaccesscheckerror.",method.getName(),remoteAddr),e);
returntrue;
}
}
remoteAddr取的是X-Forwarded-For对应的值。利用remoteAddr构造cacheKey参数,通过IPBlackCache判断cacheKey是否存在。
如果是cacheKey存在的请求,判断黑名单IP限制是否已经到达有效期,如果已经超过有效期则清除本地缓存和分布式缓存的cacheKey,请求合法;如果没有超过有效期则请求非法。
否则是cacheKey不存在的请求,使用IPBlackCache对象的ipAccess方法统计一定时间内的访问频次,如果频次超过最大限制,表明是非法请求IP,需要往IPBlackCache对象写入“cacheKey=当前时间”。
总结
本文的两种方案都使用redisincr命令,如果不是特殊业务场景,redis的key要指定过期时间,严格来讲需要保证incr和expire两个命令的原子性,所以使用lua脚本方式。如果没有那么严格,完全可以先setex(设置key,value,过期时间),然后再incr(注:incr不会更新key的有效期)。本文的设计方案仅供参考,并不能应用于所有的业务场景。
到此这篇关于详解Java分布式IP限流和防止恶意IP攻击方案的文章就介绍到这了,更多相关Java分布式IP限流和防止恶意IP内容请搜索毛票票以前的文章或继续浏览下面的相关文章希望大家以后多多支持毛票票!