高并发下漏洞桶限流设计方案 - Redis
背景
在我们做社区的时候,经常会出现发水帖的同学。对于这种恶意刷帖的,我们的运营同学很是头疼,而且这种还不能在网关进行ip之类的过滤,只能基于单个单个用户进行处理,我们经常策略就是:每分钟发帖次数不能超过2个,超过后就关小黑屋10分钟。
出现场景:
- 上面讲的发帖的防刷机制。
- 广告流量的防刷。
- 接口请求失败进行熔断机制处理。
- ......
解决方案
对于这种“黑恶”请求,我们必须要做到是关小黑屋,当然有的系统架构比较大的,在网关层面就已经进行关了,我们这里是会在业务层来做,因为咱业务不是很大,当然同学们也可以把这个移植到网关层,这样不用穿透到我们业务侧,最少能够减少我们机房内部网络流量。
流程说明
- 接口发起请求,服务端获取这个接口用户唯一标识(用户id,电话号码...).
- 判断该用户是否被锁住,如果锁住就直接返回错误码。
- 未锁住就将该请求标记,亦或者叠加(叠加有坑,往下面看)。
- 进行计算当前用户在一定时间内是否超过我们设置的阈值。如果未超过直接返回。
- 如果超过,那么就进行锁定,再返回,下次请求的时候再进行判断。
具体方案
以我们场景为例子,使用redis和切面来做分布式锁和原子计数器,时间内叠加,判断叠加值是否超过阈值。
这个方案,在很多人设计的时候,都会考虑,看起来也没有太大问题,主要流程是:
一、pom文件引入aop切面,redis
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
二、创建RateLimiter,RateLimiterAspect配置文件,工具类
import com.bzfar.enums.LimitType;
import java.lang.annotation.*;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {
/**
* 限流key
*/
String key() default "rate_limit:";
/**
* 限流时间,单位秒
*/
int time() default 60;
/**
* 限流次数
*/
int count() default 100;
/**
* 限流类型
*/
LimitType limitType() default LimitType.DEFAULT;
}
import com.aspose.words.net.System.Data.DataException;
import com.bzfar.HeadContext;
import com.bzfar.enums.LimitType;
import com.bzfar.util.RedisUtil;
import com.bzfar.utils.IpUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
@Aspect
@Component
@Slf4j
public class RateLimiterAspect {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private RedisUtil redisUtil;
@Before("@annotation(rateLimiter)")
public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
String key = rateLimiter.key();
Long time = new Long(rateLimiter.time());
int count = rateLimiter.count();
String combineKey = getCombineKey(rateLimiter, point);
String keyCode = key + combineKey.hashCode();
int number = 1;
if(redisUtil.hasKey(keyCode)){
number = (Integer)redisUtil.get(keyCode);
++number;
}
redisUtil.set(keyCode , number , time);
if(number > count){
throw new DataException("访问过于频繁,请稍候再试");
}
}
@After("@annotation(rateLimiter)")
public void doAfter(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
String combineKey = getCombineKey(rateLimiter, point);
redisTemplate.delete(combineKey);
}
public String getCombineKey(RateLimiter rateLimiter, JoinPoint point) {
StringBuffer stringBuffer = new StringBuffer(rateLimiter.key());
if (rateLimiter.limitType() == LimitType.IP) {
stringBuffer.append(IpUtil.getIp()).append("-");
}
if(rateLimiter.limitType() == LimitType.USER){
stringBuffer.append(HeadContext.getToken()).append("-");
}
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
Class<?> targetClass = method.getDeclaringClass();
stringBuffer.append(targetClass.getName()).append("-").append(method.getName());
return stringBuffer.toString();
}
}
三、创建ip获取工具类,和限流方法枚举
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Objects;
@Slf4j
public class IpUtil {
// 多次反向代理后会有多个ip值 的分割符
private static final String IP_UTILS_FLAG = ",";
// 未知IP
private static final String UNKNOWN = "unknown";
// 本地 IP
private static final String LOCALHOST_IP = "0:0:0:0:0:0:0:1";
private static final String LOCALHOST_IP1 = "127.0.0.1";
public static String getIp(){
// 根据 HttpHeaders 获取 请求 IP地址
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
String ip = request.getHeader("X-Forwarded-For");
if (StringUtils.isEmpty(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("x-forwarded-for");
if (ip != null && ip.length() != 0 && !UNKNOWN.equalsIgnoreCase(ip)) {
// 多次反向代理后会有多个ip值,第一个ip才是真实ip
if (ip.contains(IP_UTILS_FLAG)) {
ip = ip.split(IP_UTILS_FLAG)[0];
}
}
}
if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
//兼容k8s集群获取ip
if (StringUtils.isEmpty(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
ip = Objects.requireNonNull(request.getRemoteAddr());
if (LOCALHOST_IP1.equalsIgnoreCase(ip) || LOCALHOST_IP.equalsIgnoreCase(ip)) {
//根据网卡取本机配置的IP
InetAddress iNet = null;
try {
iNet = InetAddress.getLocalHost();
} catch (UnknownHostException e) {
log.error("getClientIp error: ", e);
}
assert iNet != null;
ip = iNet.getHostAddress();
}
}
return ip;
}
}
import io.swagger.annotations.ApiModel;
import lombok.Getter;
@ApiModel("限流类型")
@Getter
public enum LimitType {
/** 默认策略全局限流 */
DEFAULT,
/** ip限流 */
IP,
/** 用户id限流 */
USER
}
四、接口注解限流
@RateLimiter(time = 60,count = 20 , limitType = LimitType.IP) //代表一分钟限制访问20次同一ip
@PostMapping("materialLogin")
@ApiOperation("登录")
@RateLimiter(time = 60,count = 20 , limitType = LimitType.IP)
public HttpResult<LoginVO> materialLogin(@Validated @RequestBody BaseLoginDto dto) {
return HttpResult.ok();
}
总结
- 在开始的时候,我一直在想第一个方案的问题所在,后来在讨论方案时候,总是发现时间移动,数值应该是会更改,可在第一个方案内,我们的请求量是不会更改,我们时间段已经固化成数值了。
- 整体的方案设计我们使用到的Redis的有序集合来做,当然有更好的方案欢迎大家来推荐哈,这个对于redis的读写压力很大的,但是作为临时的数据存储,这个场景还是比较符合。
- 我们redis的所有操作建议使用原子化来进行,这个可以使用官方提供的lua脚本来将多个语句合并成一个语句,并且lua执行速率也是很高。