限流介绍
为什么要限流?
今天儿童节第二天,俗称‘62节’(杭州的一个说法,哈哈哈,不知道其他地方有没有)。马上又到了618,很多朋友都会在这天上某东、某宝等平台抢购各种商品。对于抢购,顾名思义就是大量用户同时发起下单请求,此时系统将面临突发的大量用户请求,若处理不好,可能导致系统宕机直接被流量打垮导致无法对外提供服务。那为了防止出现这种情况,解决方案当然也有很多,例如无脑增加机器硬件设施,通过监控进行动态扩容,停止其他暂时不重要的服务,全力保障抢购服务的稳定性等等。当然限流也是其中一种方式。
例如,12306购票系统,在面对高并发的情况下,就是采用了限流。 在流量高峰期间经常会出现提示语;“当前排队人数较多,请稍后再试!”
限流方式?算法?
先看一张图
固定窗口限流
固定窗口限流,把窗口想象成时间,即在固定时间内限制请求次数,如上图,1-13数字上面的第 1 秒包含 1~6 ,第 2 秒包含 7~12 ,每秒内包含 6 个请求数,这样看上去没有任何问题。但是仔细想想,假如我在第 1 秒中的前 100 毫秒突然请求6次,但是在最后 900 毫秒就已经被限流了,需要等到下一个 1 秒才能刷新请求数,相当于会有个空窗期或者说请求不均匀(均匀请求应该是 1/6 秒)
滑动窗口限流
滑动窗口限流和固定窗口相比,滑动窗口在连续的请求中,能做到请求比较均匀,如上图,1-13数字下面第 1 秒包含 1~6 ,第 2 秒包含 2~7(应该是第 1/6 -7/6 秒,这里没表示清楚)。每过 1/6 秒往后移动一格(即可以请求一次)。
计数器
计数器是最简单粗暴的限流算法,通过控制单位时间内的并发数进行限流。
如:用AtomicInteger、redis自增统计当前并发数,如超过最大值则拒绝请求。
桶算法
包含漏桶和令牌桶
漏桶:假设现在有一个水桶,把请求比做是水,它的原理就是注水漏水的过程,往漏桶中以任意速率流入水,以固定的速率流出水。当水超过桶的容量时,水会溢出,即请求被丢弃了,因为桶容量是不变的,保证了整体的速率。(有点类似线程池的拒绝策略,当队列满了后拒绝后续任务)
令牌桶:令牌桶其实是漏桶的一种改进,假设有一个管理员,以固定的速率往一个桶里面放令牌。每个请求先判断桶内是否有令牌,如果有则请求成功,如果没有则请求失败。当然桶也是有固定容量的,超过容量也就不会继续放令牌。
限流案例
本次案例采用Spring AOP的方式实现,以下代码仍有优化的地方,如实现方式可以使用策略模式优化代码等
/**
* 定义限流类型
* @Author h-bingo
* @Date 2023-04-26 09:28
* @Version 1.0
*/
public enum LimitType implements DescEnum {
DEFAULT("全局默认"),
USER("根据用户限流"),
IP("根据IP限流"),
;
private String desc;
LimitType(String desc) {
this.desc = desc;
}
@Override
public String getDesc() {
return desc;
}
}
/**
* 定义限流方式
* @Author h-bingo
* @Date 2023-04-26 11:22
* @Version 1.0
*/
public enum LimitRealize implements DescEnum {
TOKEN_BUCKET("令牌桶"),
SLIDING_WINDOW("滑动窗口"),
FIXED_WINDOW("固定窗口"),
;
private String desc;
LimitRealize(String desc) {
this.desc = desc;
}
@Override
public String getDesc() {
return desc;
}
}
/**
* 定义限流注解 默认每 60 秒每 ip 访问 100 次
* <p>
* 默认采用redission令牌桶的方式
*
* @Author h-bingo
* @Date 2023-04-26 09:26
* @Version 1.0
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {
/**
* 时间 单位: s
*
* @return
*/
long time() default 60;
/**
* 请求次数
*
* @return
*/
long count() default 100;
/**
* 限流类型
*
* @return
*/
LimitType limitType() default LimitType.IP;
/**
* 限流实现方式
*
* @return
*/
LimitRealize limitRealize() default LimitRealize.TOKEN_BUCKET;
}
/**
* 限流实现
* @Author h-bingo
* @Date 2023-04-26 09:32
* @Version 1.0
*/
@Slf4j
@Aspect
@ConditionalOnMissingBean(RateLimiterAspect.class)
public class RateLimiterAspect implements InitializingBean {
private static final String LIMITER_KEY = "rateLimiter:";
@Autowired
private RedisService redisService;
@Resource(name = "rateLimiterScript")
private RedisScript<Long> redisScript;
@Autowired
private RedissonClient redissonClient;
@Autowired(required = false)
private LimitUserFactory limitUserFactory;
@Pointcut("@annotation(com.bingo.study.common.component.limiter.annotation.RateLimiter)")
public void rateLimiter() {
}
@Before("rateLimiter()&&@annotation(limiter)")
public void doBefore(JoinPoint point, RateLimiter limiter) {
if (LimitRealize.TOKEN_BUCKET == limiter.limitRealize()) { // 令牌桶
tokenBucket(point, limiter);
} else if (LimitRealize.FIXED_WINDOW == limiter.limitRealize()) { // 固定窗口
fixedWindow(point, limiter);
} else if (LimitRealize.SLIDING_WINDOW == limiter.limitRealize()) { // 滑动窗口
slidingWindow(point, limiter);
}
}
private void tokenBucket(JoinPoint point, RateLimiter limiter) {
String redisLimiterKey = getRedisLimiterKey(point, limiter);
RRateLimiter rateLimiter = redissonClient.getRateLimiter(redisLimiterKey);
// 设置令牌桶速率
rateLimiter.trySetRate(RateType.OVERALL, limiter.count(), limiter.time(), RateIntervalUnit.SECONDS);
if (!rateLimiter.tryAcquire(1)) {
log.info("方法已限流: {}", redisLimiterKey);
throw new RateLimiterException("访问过于频繁,请稍候再试");
}
}
private void fixedWindow(JoinPoint point, RateLimiter limiter) {
String redisLimiterKey = getRedisLimiterKey(point, limiter);
Long number = redisService.redisTemplate().execute(redisScript, Collections.singletonList(redisLimiterKey),
limiter.count(), limiter.time());
if (number != null && number > limiter.count()) {
log.info("方法已限流: {}", redisLimiterKey);
throw new RateLimiterException("访问过于频繁,请稍候再试");
}
}
private void slidingWindow(JoinPoint point, RateLimiter limiter) {
String redisLimiterKey = getRedisLimiterKey(point, limiter);
long currentTime = SystemClock.now();
Set<Object> range = redisService.opsForZSet().rangeByScore(redisLimiterKey,
currentTime - limiter.time() * 1000, currentTime);
int currentCount = range == null ? 0 : range.size();
if (currentCount >= limiter.count()) { // size 从0开始
log.info("方法已限流: {}", redisLimiterKey);
throw new RateLimiterException("访问过于频繁,请稍候再试");
}
// 移除过期的
redisService.opsForZSet().removeRangeByScore(redisLimiterKey, 0, currentTime - limiter.time() * 1000);
// 添加当前时间值
redisService.opsForZSet().add(redisLimiterKey, currentTime + RandomUtil.randomInt(), currentTime);
}
/**
* 获取限流key
* <p>
* 组成:applicationName + {@link RateLimiterAspect#LIMITER_KEY} + 方法名 + 限流方式 + (ip)(用户id)
*
* @Param [point, limiter]
* @Return java.lang.String
* @Date 2023-04-26 11:46
*/
private String getRedisLimiterKey(JoinPoint point, RateLimiter limiter) {
StringBuilder builder = new StringBuilder(LIMITER_KEY);
builder.append(AspectUtil.getMethodIntactName(point));
builder.append(":").append(limiter.limitRealize().name());
if (limiter.limitType() == LimitType.IP) {
builder.append(":").append(IPUtil.getIpAddr(ServletUtil.getRequest()));
} else if (limiter.limitType() == LimitType.USER) {
if (limitUserFactory == null) {
log.warn("未实现获取用户id方法: {}", LimitUserFactory.class.getTypeName());
throw new RateLimiterException("未实现获取用户id方法");
}
builder.append(":").append(limitUserFactory.getUserId());
}
return RedisKeyUtil.getCacheKey(builder.toString(), false, true);
}
@Override
public void afterPropertiesSet() throws Exception {
log.info("接口限流功能已开启,请在需要限流接口添加: @RateLimiter");
}
}
/**
* 开启限流功能注解,这种方式在上一期 Spring中的@Import注解妙用 中讲过
* @Author h-bingo
* @Date 2023-04-26 10:39
* @Version 1.0
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import({RateLimiterAspect.class})
public @interface EnableRateLimiter {
}
测试结果
接口如下:这里我们设置的 10 秒请求5次
这里我们使用Apipost进行测试
从日志结果可以看到,只有5个请求拿到令牌,其他请求没有拿到令牌则直抛异常,通过全局异常捕获处理。
结束语
本次分享关于限流的案例到此就结束了。欢迎指正,也希望和大家共同探讨和进步~