设计一个最近访问计数器
最近访问计数器
在之前的项目中,我们涉及到一种限流方案:当用户在短时间内访问量超过一定限制时,需要对该用户进行拉黑处理。因此我们需要设计一个高效且线程安全的计数器,以统计用户最近一段时间内的访问量。
本文将展示一种高效的、基于环形数组的、无锁的、一定程度上线程安全的访问计数器,它可以精确地统计最近几秒的用户访问次数。
为了实现这个计数器,我们使用以下策略:
- 数组存储:使用一个固定大小的数组来存储每秒的访问次数。
- 时间轮转:通过当前时间对数组大小取模,确定当前时间在数组中的位置。
- 线程安全:使用原子数组和原子变量来保证多线程环境下的操作安全。
- 过期处理:当时间跨度超过数组大小时,将过期的时间段对应的数组位置置零。
假设我们要统计过去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]);
}
}

浙公网安备 33010602011771号