设计一个最近访问计数器

最近访问计数器

在之前的项目中,我们涉及到一种限流方案:当用户在短时间内访问量超过一定限制时,需要对该用户进行拉黑处理。因此我们需要设计一个高效且线程安全的计数器,以统计用户最近一段时间内的访问量。

本文将展示一种高效的、基于环形数组的、无锁的、一定程度上线程安全的访问计数器,它可以精确地统计最近几秒的用户访问次数。

为了实现这个计数器,我们使用以下策略:

  • 数组存储:使用一个固定大小的数组来存储每秒的访问次数。
  • 时间轮转:通过当前时间对数组大小取模,确定当前时间在数组中的位置。
  • 线程安全:使用原子数组和原子变量来保证多线程环境下的操作安全。
  • 过期处理:当时间跨度超过数组大小时,将过期的时间段对应的数组位置置零。

假设我们要统计过去5秒(包括当前秒)的访问次数。我们可以使用一个长度为5的数组来存储过去5秒每秒的访问次数。通过秒数对5取模,可以计算出该秒在数组中的索引位置。同时使用一个lastWriteTime变量来记录最后一次写入的时间戳(秒级)。

计数器对外提供两个核心方法:

  • increment():将当前时刻的访问计数+1。
  • getCount():获取最近5秒内的总访问次数。

increment()的逻辑中,如果当前秒lastWriteTime不一致,尝试通过CAS更新lastWriteTime。如果更新成功,则当前线程负责清理从lastWriteTime当前秒的过期数据。 最后在当前秒对应索引位置原子地将访问次数加1。

getCount()的逻辑中,首先计算当前秒lastWriteTime 的差值 diff。若差值大于或等于数组长度(本例为5),说明过去5秒没有任何访问,直接返回0。
若差值小于数组长度,则从 lastWriteTime 对应索引位置开始,向前累加 5 - diff 个元素,计算总和并返回。

使用原子数组来避免同一时刻多个线程同时执行加1的线程安全问题。

代码实现如下:

public class AccessCounter {

    private static final int SIZE = 5;
    private final AtomicIntegerArray counts;
    private final AtomicLong lastWriteTime;

    public AccessCounter() {
        counts = new AtomicIntegerArray(SIZE);
        lastWriteTime = new AtomicLong(Instant.now().getEpochSecond());
    }

    public int getCount() {
        long currentTime = Instant.now().getEpochSecond();
        long lastTime = lastWriteTime.get();
        long diff = currentTime - lastTime;
        if (diff >= SIZE) return 0;
        int total = 0;
        for (int i = 0; i < SIZE - diff; i++) {
            int index = (int) ((lastTime - i + SIZE) % SIZE);
            total += counts.get(index);
        }
        return total;
    }

    public void increment() {
        long currentTime = Instant.now().getEpochSecond();
        long lastTime = lastWriteTime.get();
        // 尝试更新 lastWriteTime,如果更新成功,说明当前线程负责清理过期槽位
        if (currentTime != lastTime && lastWriteTime.compareAndSet(lastTime, currentTime)) {
            long diff = currentTime - lastTime;
            for (int i = 1; i <= Math.min(diff, SIZE); i++) {
                int index = (int) ((lastTime + i) % SIZE);
                counts.set(index, 0);
            }
        }
        int currentIndex = (int) (currentTime % SIZE);
        counts.incrementAndGet(currentIndex);
    }
}

其实严格来说,这个计数器还有有一点点问题的。在 increment 方法中,如果两个线程在秒切换后的时刻同时进来,那么第一个cms成功的线程负责对当前秒的对应位置置零,第二个线程则会直接对数组对应位置的元素加1。如果第二个线程先执行加1,第一个线程再进行置零,统计上就少了一次。但是这个问题在我本地压测没测出来,且这个计数器的精确度不是很重要, 就没管了。

最后附一个测试代码

    public static void main(String[] args) throws InterruptedException {
        AccessCounter counter = new AccessCounter();
        int size = 20;
        int[] times = new int[size];
        int[] sums = new int[size];
        for (int i = 0; i < size; i++) {
            times[i] = new Random().nextInt(20);
            if (i >= 5 && i <= 7) times[i] = 0;
            if (i >= 15 && i <= 17) times[i] = 0;
        }
        for (int i = 0; i < size; i++) {
            for (int j = 0; j < times[i]; j++) {
                counter.increment();
            }
            sums[i] = counter.getCount();
            Thread.sleep(1000);
        }

        for (int i = 0; i < size; i++) {
            System.out.printf("%-5s", i);
        }
        System.out.println();
        for (int i = 0; i < size; i++) {
            System.out.printf("%-5s", times[i]);
        }
        System.out.println();
        for (int i = 0; i < size; i++) {
            System.out.printf("%-5s", sums[i]);
        }
    }
posted @ 2025-03-14 22:44  zzzggb  阅读(65)  评论(0)    收藏  举报