计数器算法

简介: 《微服务-熔断机制》中提到了计数器,这篇详细学习一下计数器算法之前的有次面试,碰到了计数器的的题目Q:线上服务,设计一个拦截器,一个IP如果短时间内请求次数过多,就屏蔽A:使用map,key为ip,值为次数与时间Q:请求相当大,会直接冲垮内存,怎么办?A:使用redis,像redis cluster,绝对可以满足Q: 写下伪代码A:bbbbbbb其实计数器在互联网开发中很常见,当时刚转互联网比较无知,面试得很烂。

《微服务-熔断机制》中提到了计数器,这篇详细学习一下计数器算法

之前的有次面试,碰到了计数器的的题目

Q:线上服务,设计一个拦截器,一个IP如果短时间内请求次数过多,就屏蔽

A:使用map,key为ip,值为次数与时间

Q:请求相当大,会直接冲垮内存,怎么办?

A:使用redis,像redis cluster,绝对可以满足

Q: 写下伪代码

A:bbbbbbb

其实计数器在互联网开发中很常见,当时刚转互联网比较无知,面试得很烂。

计数器法

计数器法是限流算法里最简单也是最容易实现的一种算法。比如我们规定,对于A接口来说,我们1分钟的访问次数不能超过100个。那么我们可以这么做:在一开 始的时候,我们可以设置一个计数器counter,每当一个请求过来的时候,counter就加1,如果counter的值大于100并且该请求与第一个 请求的间隔时间还在1分钟之内,那么说明请求数过多;如果该请求与第一个请求的间隔时间大于1分钟,且counter的值还在限流范围内,那么就重置 counter,具体算法的示意图如下:

image.png

public class Counter {
    public long timeStamp = getNowTime();
    public int reqCount = 0;
    public final int limit = 100; // 时间窗口内最大请求数
    public final long interval = 1000; // 时间窗口ms
    public boolean grant() {
        long now = getNowTime();
        if (now < timeStamp + interval) {
            // 在时间窗口内
            reqCount++;
            // 判断当前时间窗口内是否超过最大请求控制数
            return reqCount <= limit;
        }
        else {
            timeStamp = now;
            // 超时后重置
            reqCount = 1;
            return true;
        }
    }
}

以上是示意代码,先忽视其中的并发问题,最大的问题是临界问题

image.png

从上图中我们可以看到,假设有一个恶意用户,他在0:59时,瞬间发送了100个请求,并且1:00又瞬间发送了100个请求,那么其实这个用户在 1秒里面,瞬间发送了200个请求。我们刚才规定的是1分钟最多100个请求,也就是每秒钟最多1.7个请求,用户通过在时间窗口的重置节点处突发请求, 可以瞬间超过我们的速率限制。用户有可能通过算法的这个漏洞,瞬间压垮我们的应用。

滑动窗口

滑动窗口,又称rolling window

image.png

在上图中,整个红色的矩形框表示一个时间窗口,在我们的例子中,一个时间窗口就是一分钟。然后我们将时间窗口进行划分,比如图中,我们就将滑动窗口 划成了6格,所以每格代表的是10秒钟。每过10秒钟,我们的时间窗口就会往右滑动一格。每一个格子都有自己独立的计数器counter,比如当一个请求 在0:35秒的时候到达,那么0:30~0:39对应的counter就会加1。

那么滑动窗口怎么解决刚才的临界问题的呢?我们可以看上图,0:59到达的100个请求会落在灰色的格子中,而1:00到达的请求会落在橘黄色的格 子中。当时间到达1:00时,我们的窗口会往右移动一格,那么此时时间窗口内的总请求数量一共是200个,超过了限定的100个,所以此时能够检测出来触 发了限流。

我再来回顾一下刚才的计数器算法,我们可以发现,计数器算法其实就是滑动窗口算法。只是它没有对时间窗口做进一步地划分,所以只有1格。

由此可见,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。

实现一

根据上面滑动窗口的定义,实现很简单了

  1. 需要一个map,当然是并发安全的,key为时间
  2. 统计窗口内的请求总数

这儿有个以这种方式实现的 https://github.com/zhuxingsheng/yammer-metrics/blob/master/metrics-core/src/main/java/com/codahale/metrics/SlidingTimeWindowReservoir.java

摘点核心的片段:

//一个并发安全map,skip list有序
this.measurements = new ConcurrentSkipListMap<Long, Long>();
/**
 * 获取map的key,以时间纳秒值为key
 * @return
 */
private long getTick() {
    for (; ; ) {
        final long oldTick = lastTick.get();
        //每纳秒处理的请求很多,减少compareAndSet的失败次数,这儿*COLLISION_BUFFER
        final long tick = clock.getTick() * COLLISION_BUFFER;
        // ensure the tick is strictly incrementing even if there are duplicate ticks
        final long newTick = tick > oldTick ? tick : oldTick + 1;
        if (lastTick.compareAndSet(oldTick, newTick)) {
            return newTick;
        }
    }
}
private void trim() {
        //清除window之前的计数
        measurements.headMap(getTick() - window).clear();
    }
缺点

实现简单,但有个问题,map的key是在不停的增加,删除,给GC带来了压力

实现二

考虑key的复用,使用环形结构

image.png

通过取模来达到这个效果

初始化:

private int window; //计算窗口
//整个循环数组窗口
private int ringWindow=window+30; 
requestCounter = new AtomicInteger[ringWindow];
failedCounter = new AtomicInteger[ringWindow];
for (int i = 0; i < ringWindow; i++) {
    requestCounter[i] = new AtomicInteger(0);
    failedCounter[i] = new AtomicInteger(0);
}
initCounterTimeInSecond =
TimeUnit.NANOSECONDS.toSeconds(System.nanoTime());

计算窗口内的次数

private long countTotal(AtomicInteger[] caculateCounter){
        int currentIndex = getIndex();
        long sum = 0;
        for (int i = 0; i < window; i++) {
            //这儿并不是直接计算window中的所有counter,
            //而是从currentIndex向前倒取window个
            int index = ((currentIndex + ringWindow) -i) 
            % this.ringWindow;
            sum += caculateCounter[index].get();
        }
        return sum;
    }

为什么需要ringWindow,直接window就可以?这儿有个奇技淫巧。

CLEAN_UP_BUFFER=10;
public void cleanupFutureCounter() {
        int currentIndex = getIndex();
        for (int i = 1 ; i <= CLEAN_UP_BUFFER; i++) {
            int index = (currentIndex + i) % this.ringWindow;
            requestCounter[index].set(0);
            failedCounter[index].set(0);
        }
    }

这儿会有个定时任务,每5秒会去清空未来10秒的数据

因为在一环数组全部填充完成后,下一轮开始时,需要清空,哪个地方是起点,无法区分,所以ringwindow预留点位置用来清空

实现三

还有一些是加锁,当然会是轻量的CAS;每一个轮回完成后,都需要标记开始位置,并清空环。

漏桶算法

漏桶(Leaky Bucket)算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率.示意图如下:

image.png

我们将算法中的水换成实际应用中的请求,我们可以看到漏桶算法天生就限制了请求的速度。当使用了漏桶算法,我们可以保证接口会以一个常速速率来处理请求。所以漏桶算法天生不会出现临界问题

public class LeakyDemo {
public long timeStamp = getNowTime();
public int capacity; // 桶的容量
public int rate; // 水漏出的速度
public int water; // 当前水量(当前累积请求数)
public boolean grant() {
    long now = getNowTime();
    water = max(0, water - (now - timeStamp) * rate); // 先执行漏水,计算剩余水量
    timeStamp = now;
    if ((water + 1) < capacity) {
    // 尝试加水,并且水还未满
    water += 1;
    return true;
    }
    else {
    // 水满,拒绝加水
    return false;
    }
    }
}

令牌桶算法(Token Bucket)

令牌桶算法(Token Bucket)和 Leaky Bucket 效果一样但方向相反的算法,更加容易理解.随着时间流逝,系统会按恒定1/QPS时间间隔(如果QPS=100,则间隔是10ms)往桶里加入Token(想象和漏洞漏水相反,有个水龙头在不断的加水),如果桶已经满了就不再加了.新请求来临时,会各自拿走一个Token,如果没有Token可拿了就阻塞或者拒绝服务

image.png

令牌桶的另外一个好处是可以方便的改变速度. 一旦需要提高速率,则按需提高放入桶中的令牌的速率. 一般会定时(比如100毫秒)往桶中增加一定数量的令牌, 有些变种算法则实时的计算应该增加的令牌的数量.

public class TokenBucketDemo {
public long timeStamp = getNowTime();
public int capacity; // 桶的容量
public int rate; // 令牌放入速度
public int tokens; // 当前令牌数量
public boolean grant() {
    long now = getNowTime();
    // 先添加令牌
    tokens = min(capacity, tokens + (now - timeStamp) * rate);
    timeStamp = now;
    if (tokens < 1) {
    // 若不到1个令牌,则拒绝
    return false;
    }
    else {
    // 还有令牌,领取令牌
    tokens -= 1;
    return true;
    }
    }
}

临界问题

我 们再来考虑一下临界问题的场景。在0:59秒的时候,由于桶内积满了100个token,所以这100个请求可以瞬间通过。但是由于token是以较低的 速率填充的,所以在1:00的时候,桶内的token数量不可能达到100个,那么此时不可能再有100个请求通过。所以令牌桶算法可以很好地解决临界问 题。

下图比较了计数器(左)和令牌桶算法(右)在临界点的速率变化。我们可以看到虽然令牌桶算法允许突发速率,但是下一个突发速率必须要等桶内有足够的 token后才能发生:

image.png

Guava RateLimiter

在guava中,有现成的实现

RateLimiter使用的是一种叫令牌桶的流控算法,RateLimiter会按照一定的频率往桶里扔令牌,线程拿到令牌才能执行,比如你希望自己的应用程序QPS不要超过1000,那么RateLimiter设置1000的速率后,就会每秒往桶里扔1000个令牌。

有两种方式,SmoothBursty和SmoothWarmingUp

create(double permitsPerSecond)
根据指定的稳定吞吐率创建RateLimiter
这里的吞吐率是指每秒多少许可数(通常是指QPS,每秒多少查询)
create(double permitsPerSecond, long warmupPeriod, TimeUnit unit)
根据指定的稳定吞吐率和预热期来创建RateLimiter
这里的吞吐率是指每秒多少许可数(通常是指QPS,每秒多少个请求量),
在这段预热时间内,
RateLimiter每秒分配的许可数会平稳地增长直到预热期结束时达到其最大速率。(只要存在足够请求数来使其饱和)

SmoothBursty通过平均速率和最后一次新增令牌的时间计算出下次新增令牌的时间的,另外需要一个桶暂存一段时间内没有使用的令牌(即可以突发的令牌数)。另外RateLimiter还提供了tryAcquire方法来进行无阻塞或可超时的令牌消费。

因为SmoothBursty允许一定程度的突发,会有人担心如果允许这种突发,假设突然间来了很大的流量,那么系统很可能扛不住这种突发。因此需要SmoothWarmingUp一种平滑速率的限流工具,从而系统冷启动后慢慢的趋于平均固定速率(即刚开始速率小一些,然后慢慢趋于我们设置的固定速率)

public static void testSmoothWarmingUp(){
        //每秒5个,1500ms后达到正常速率
        RateLimiter rateLimiter = RateLimiter.create(5,1500, TimeUnit.MILLISECONDS);
        List<Runnable> tasks = new ArrayList<Runnable>();
        for (int i = 0; i < 10; i++) {
            tasks.add(new Request(i));
        }
        ExecutorService executorService = Executors.newCachedThreadPool();
        for(Runnable task:tasks) {
            System.out.println("等待时间:" + rateLimiter.acquire());
            executorService.execute(task);
        }
        executorService.shutdown();
    }

运行结果:

等待时间:0.0
0 handle request 1528693920502
等待时间:0.54311
1 handle request 1528693921052
等待时间:0.433531
2 handle request 1528693921486
等待时间:0.332679
3 handle request 1528693921819
等待时间:0.229785
4 handle request 1528693922049
等待时间:0.199668
5 handle request 1528693922249
等待时间:0.199845
6 handle request 1528693922449
等待时间:0.199757
7 handle request 1528693922649
等待时间:0.19981
8 handle request 1528693922849
等待时间:0.199732
9 handle request 1528693923049

可以看出前面几个等待时间长,速度慢;1500ms后,速率达到正常的每秒5的速度

其实这算是一种漏桶算法的变异,在令牌桶中控制一个移除令牌的速度就是个漏桶了。

总结

计数器 VS 滑动窗口

计数器算法是最简单的算法,可以看成是滑动窗口的低精度实现。滑动窗口由于需要存储多份的计数器(每一个格子存一份),所以滑动窗口在实现上需要更多的存储空间。也就是说,如果滑动窗口的精度越高,需要的存储空间就越大。

漏桶算法 VS 令牌桶算法

漏桶算法和令牌桶算法最明显的区别是令牌桶算法允许流量一定程度的突发。因为默认的令牌桶算法,取走token是不需要耗费时间的,也就是说,假设桶内有100个token时,那么可以瞬间允许100个请求通过。

令牌桶算法由于实现简单,且允许某些流量的突发,对用户友好,所以被业界采用地较多。当然我们需要具体情况具体分析,只有最合适的算法,没有最优的算法

参考

接口限流算法总结

目录
相关文章
|
算法 Java Sentinel
限流算法(计数器、滑动时间窗口、漏斗、令牌)原理以及代码实现
> 本文会对这4个限流算法进行详细说明,并输出实现限流算法的代码示例。 > 代码是按照自己的理解写的,很简单的实现了功能,还请大佬们多多交流找bug。
626 0
|
7月前
|
算法 Java Spring
用 Spring Boot 实现秒杀系统的流量控制:计数器算法与令牌桶算法
用 Spring Boot 实现秒杀系统的流量控制:计数器算法与令牌桶算法
130 0
|
算法 安全
常见的限流算法分析以及手写实现(计数器、漏斗、令牌桶)
限流是指在高并发、大流量请求的情况下,限制新的流量对系统的访问,从而保证系统服务的安全性。
1575 0
常见的限流算法分析以及手写实现(计数器、漏斗、令牌桶)
|
算法 Java
【Java 虚拟机原理】垃圾回收算法 ( Java 虚拟机内存分区 | 垃圾回收机制 | 引用计数器算法 | 引用计数循环引用弊端 )
【Java 虚拟机原理】垃圾回收算法 ( Java 虚拟机内存分区 | 垃圾回收机制 | 引用计数器算法 | 引用计数循环引用弊端 )
144 0
|
2月前
|
算法 安全 数据安全/隐私保护
基于game-based算法的动态频谱访问matlab仿真
本算法展示了在认知无线电网络中,通过游戏理论优化动态频谱访问,提高频谱利用率和物理层安全性。程序运行效果包括负载因子、传输功率、信噪比对用户效用和保密率的影响分析。软件版本:Matlab 2022a。完整代码包含详细中文注释和操作视频。
|
11天前
|
算法 数据安全/隐私保护 索引
OFDM系统PAPR算法的MATLAB仿真,对比SLM,PTS以及CAF,对比不同傅里叶变换长度
本项目展示了在MATLAB 2022a环境下,通过选择映射(SLM)与相位截断星座图(PTS)技术有效降低OFDM系统中PAPR的算法实现。包括无水印的算法运行效果预览、核心程序及详尽的中文注释,附带操作步骤视频,适合研究与教学使用。
|
19天前
|
算法 数据挖掘 数据安全/隐私保护
基于FCM模糊聚类算法的图像分割matlab仿真
本项目展示了基于模糊C均值(FCM)算法的图像分割技术。算法运行效果良好,无水印。使用MATLAB 2022a开发,提供完整代码及中文注释,附带操作步骤视频。FCM算法通过隶属度矩阵和聚类中心矩阵实现图像分割,适用于灰度和彩色图像,广泛应用于医学影像、遥感图像等领域。
|
20天前
|
算法 调度
基于遗传模拟退火混合优化算法的车间作业最优调度matlab仿真,输出甘特图
车间作业调度问题(JSSP)通过遗传算法(GA)和模拟退火算法(SA)优化多个作业在并行工作中心上的加工顺序和时间,以最小化总完成时间和机器闲置时间。MATLAB2022a版本运行测试,展示了有效性和可行性。核心程序采用作业列表表示法,结合遗传操作和模拟退火过程,提高算法性能。
|
21天前
|
存储 算法 决策智能
基于免疫算法的TSP问题求解matlab仿真
旅行商问题(TSP)是一个经典的组合优化问题,目标是寻找经过每个城市恰好一次并返回起点的最短回路。本文介绍了一种基于免疫算法(IA)的解决方案,该算法模拟生物免疫系统的运作机制,通过克隆选择、变异和免疫记忆等步骤,有效解决了TSP问题。程序使用MATLAB 2022a版本运行,展示了良好的优化效果。
|
20天前
|
机器学习/深度学习 算法 芯片
基于GSP工具箱的NILM算法matlab仿真
基于GSP工具箱的NILM算法Matlab仿真,利用图信号处理技术解析家庭或建筑内各电器的独立功耗。GSPBox通过图的节点、边和权重矩阵表示电气系统,实现对未知数据的有效分类。系统使用MATLAB2022a版本,通过滤波或分解技术从全局能耗信号中提取子设备的功耗信息。
下一篇
无影云桌面