智能Bi项目-限流优化
系统优化之前的今天快速的过了一遍,对于限流这方面学习之后做个笔记记录一下。
目的
在这个项目里,限流的目的在于避免一个用户在同一时间内多接口发起多次请求,如果用户直接起多个线程来执行分析功能,在用户给的数据很大的情况下Api余额也会没的很快,所以在这里给他做个限流是有必要的,也是为了网站的安全。
常见算法
常见的限流算法有4种,分别是固定窗口、滑动窗口、漏桶算法和令牌算法。
固定窗口
固定窗口算法就是给一个固定长度的时间段,在这个时间段里你最多执行n次请求,这个算法实现起来非常简单,但容易出现流量突刺的问题,假如你的时间段是10s,阈值是10个请求,我在第9s的时候来了10个请求,然后又在第11s来了10个请求,在短短2s间隔就一下子来了20个请求,总体来看它就已经不符合10s 10个请求的限制了。
滑动窗口
为了解决固定窗口的临界值问题,就有了滑动窗口,滑动窗口算法我的理解在看,就是这个窗口会随着一个时间周期移动,比如我们的窗口长度还是10s,阈值10个请求,然后每隔2s这个窗口就会往前移动,检查的时间段就从0-10变成了2-12,这样到第9s的时候,窗口移动到了8,而9和11s会在同一个窗口内,因此11s的请求就会被限制了。但是这个算法会拒绝限流之后的请求,应用到实际产品中的话对用户是不太友好的。
漏桶算法
这个算法就不会暴力的拒绝限流后的请求了,漏桶算法的话,我把它理解成一个桶,对于进入桶里的速度我们是不确定的,但是我们可以规定它流出桶的速度,转换成业务的话,就是业务请求的速率不确定,但是可以控制请求服务的执行速率,假如我的桶一下子能容纳10个请求,一下子来了11个请求,那么第11个请求就会被拒绝掉,如果这11个请求不是一次性来,那么可能在第11个请求进来的时候前面有请求执行完毕流出桶,就能够进入了。但是由于流出速度限制住了,当流量一下子很大的时候这个算法会处理的比较慢。
令牌桶算法
令牌桶的做法是我每隔一段时间就往桶里放一定数量的令牌,当请求进来时会来桶里获取令牌,如果有令牌则请求正常执行,如果桶里没有令牌那么这个请求就会被限制,发放令牌的时间和数量设计的恰当的话,就能够实现限流的同时也能在大流量的时候保证一定的速度。Guava的RateLimiter就是基于该算法实现的。
限流实现
限流有本地限流和分布式限流,Guava的RateLimiter就适用于本地限流,分布式限流可以采用Redission或者sentinel进行限流。
Redission
项目源码中采用了Redission进行限流,Redission里有限流的工具类,实现限流的方式很简单:以用户Id来定义限流器,设置好令牌的发放数量和间隔后调用tryAcquire方法来尝试获取令牌,返回false则代表令牌此时消耗完,对该请求进行限制:
▼java复制代码@Resource private RedissonClient redissonClient; /** * 限流操作 * * @param key 区分不同的限流器,比如不同的用户 id 应该分别统计 */ public void doRateLimit(String key) { // 创建一个名称为user_limiter的限流器,每秒最多访问 2 次 RRateLimiter rateLimiter = redissonClient.getRateLimiter(key); rateLimiter.trySetRate(RateType.OVERALL, 2, 1, RateIntervalUnit.SECONDS); // 每当一个操作来了后,请求一个令牌 boolean canOp = rateLimiter.tryAcquire(1); if (!canOp) { throw new BusinessException(ErrorCode.TOO_MANY_REQUEST); } }
Guava RateLimiter
我上网查了查guava的使用教程,它创建限流器的方法是RateLimiter.create,我们可以在该方法中指定每秒发放多少个令牌,但这样创建的话好像我们不知道到底是哪个用户的限流器,因此需要一个Map结构来构建用户Id-限流器的对应关系,为了线程安全,采用ConcurrentHashMap来进行存储,为了避免内存泄漏,要及时的清除哈希表里久久未操作的用户限流器,清除可以采用定时任务的方式进行,那么怎么知道清理哪些呢,我们可以按照LRU算法来找出长时间未活动的用户,只需要给限流器带上一个时间戳即可,因此我们的对应关系就变成了用户Id-带时间戳的限流器,这样我们的GuavaLimiterManager就如下所示:
▼java复制代码@Service public class GuavaLimiterManager { // 存储每个用户的 RateLimiter 和最后活动时间 private final Map<String, RateLimiterWithTimestamp> userRateLimiters = new ConcurrentHashMap<>(); // 每个用户每秒最多允许 2 次请求 private static final double REQUESTS_PER_SECOND = 2.0; // 用户最大空闲时间(单位:秒) private static final long MAX_IDLE_TIME_SECONDS = 60; // 1 分钟 /** * 检查用户是否允许发送请求 * * @param userId 用户 ID * @return true 表示允许,false 表示限流 */ public boolean allowRequest(String userId) { // 使用 computeIfAbsent 原子操作 RateLimiterWithTimestamp rateLimiterWithTimestamp = userRateLimiters.computeIfAbsent( userId, k -> new RateLimiterWithTimestamp(RateLimiter.create(REQUESTS_PER_SECOND)) ); // 更新最后活动时间 rateLimiterWithTimestamp.updateTimestamp(); RateLimiter rateLimiter = rateLimiterWithTimestamp.getRateLimiter(); boolean res = rateLimiter.tryAcquire(); if (res){ System.out.println("成功处理"); return true; }else { System.out.println("令牌消耗完,等待中"); return false; } } /** * 清理长时间未活动的用户 */ @Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS) // 每隔 30 秒执行一次 public void cleanUpInactiveUsers() { long currentTime = System.currentTimeMillis(); userRateLimiters.entrySet().removeIf(entry -> { long idleTime = currentTime - entry.getValue().getLastActivityTimestamp(); return idleTime > MAX_IDLE_TIME_SECONDS * 1000; // 转换为毫秒 }); System.out.println("清理完成,当前用户数量: " + userRateLimiters.size()); } /** * 封装 RateLimiter 和最后活动时间 */ private static class RateLimiterWithTimestamp { private final RateLimiter rateLimiter; private long lastActivityTimestamp; public RateLimiterWithTimestamp(RateLimiter rateLimiter) { this.rateLimiter = rateLimiter; this.lastActivityTimestamp = System.currentTimeMillis(); } public RateLimiter getRateLimiter() { return rateLimiter; } public long getLastActivityTimestamp() { return lastActivityTimestamp; } public void updateTimestamp() { this.lastActivityTimestamp = System.currentTimeMillis(); } } }
这里的代码和上面的一些思路是Ai给出来的,所以要善以利用Ai捏。
不过我在测试的时候发现一个问题,就是我规定的每秒放2个令牌,然后我用两个for循环去调用allowRequest方法,第一个for循环请求次数是2,第二个for循环请求是5,代码如下:
▼java复制代码String start = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); String userId = "1"; for (int i = 0; i < 2; i++) { System.out.println("处理请求"+i); guavaLimiterManager.allowRequest(userId); } Thread.sleep(1000); for (int i = 0; i < 5; i++) { System.out.println("处理请求"+i); guavaLimiterManager.allowRequest(userId); } String end = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); System.out.println(start + " -> " + end);
正常来说第一个for循环肯定是2个成功处理的,但实际上第二个请求没有成功获取到令牌,而第二个for循环的第2个请求又成功处理了,且后面的都打印出令牌消耗完,经过上网查资料,我推测应该是一开始可能没有放够2块令牌,而一下子突发的两个请求只有第一个请求拿到了令牌,为了防止突发请求,Guava RateLimiter有一个预热机制,即在预热时间内逐渐发放令牌,RateLimiter.create(2,1,TimeUnit.SECONDS)表示1秒时间预热以达到每秒2个令牌的速度,不过即使这样设置了也好像没用,第二个请求还是令牌消耗完,除非我在第一个for循环里直接让他休眠1s让它能够获得令牌,不过第二个for循环正确输出了出来的话,那应该上面的代码是没什么问题的了。
如果代码中有错或者我后面的想法有问题的,欢迎各位大佬指出
