使用singleflight防止缓存击穿(Java)

2,525 阅读2分钟

缓存击穿

在使用缓存时,我们往往是先根据key从缓存中取数据,如果拿不到就去数据源加载数据,写入缓存。但是在某些高并发的情况下,可能会出现缓存击穿的问题,比如一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到DB,造成瞬时DB请求量大、压力骤增。

一般解决方案

首先我们想到的解决方案就是加锁,一种办法是:拿到锁的请求,去加载数据,没有拿到锁的请求,就先等待。这种方法虽然避免了并发加载数据,但实际上是将并发的操作串行化,会增加系统延时。

singleflight

singleflight是groupcache这个项目的一部分,groupcache是memcache作者使用golang编写的分布式缓存。singleflight能够使多个并发请求的回源操作中,只有第一个请求会进行回源操作,其他的请求会阻塞等待第一个请求完成操作,直接取其结果,这样可以保证同一时刻只有一个请求在进行回源操作,从而达到防止缓存击穿的效果。下面是使用Java实现的代码:

//代表正在进行中,或已经结束的请求
public class Call {
    private byte[] val;
    private CountDownLatch cld;

    public byte[] getVal() {
        return val;
    }

    public void setVal(byte[] val) {
        this.val = val;
    }

    public void await() {
        try {
            this.cld.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public void lock() {
        this.cld = new CountDownLatch(1);
    }

    public void done() {
        this.cld.countDown();
    }
}
//singleflight 的主类,管理不同 key 的请求(call)
public class CallManage {
    private final Lock lock = new ReentrantLock();
    private Map<String, Call> callMap;

    public byte[] run(String key, Supplier<byte[]> func) {
        this.lock.lock();
        if (this.callMap == null) {
            this.callMap = new HashMap<>();
        }
        Call call = this.callMap.get(key);
        if (call != null) {
            this.lock.unlock();
            call.await();
            return call.getVal();
        }
        call = new Call();
        call.lock();
        this.callMap.put(key, call);
        this.lock.unlock();

        call.setVal(func.get());
        call.done();

        this.lock.lock();
        this.callMap.remove(key);
        this.lock.unlock();

        return call.getVal();
    }
}

我们使用CountDownLatch来实现多个线程等待一个线程完成操作,CountDownLatch包含一个计数器,初始化时赋值,countDown()可使计数器减一,当count为0时唤醒所有等待的线程,await()可使线程阻塞。我们同样用CountDownLatch来模拟一个10次并发,测试代码如下:

public static void main(String[] args) {
    CallManage callManage = new CallManage();
    int count = 10;
    CountDownLatch cld = new CountDownLatch(count);
    for (int i = 0; i < count; i++) {
        new Thread(() -> {
            try {
                cld.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            byte[] value = callManage.run("key", () -> {
                System.out.println("func");
                return ByteArrayUtil.oToB("bar");
            });
            System.out.println(ByteArrayUtil.bToO(value).toString());
        }).start();
        cld.countDown();
    }
}

测试结果如下:

func
bar
bar
bar
bar
bar
bar
bar
bar
bar
bar

可以看到回源操作只被执行了一次,其他9次直接取到了第一次操作的结果。

总结

可以看到singleflight可以有效解决高并发情况下的缓存击穿问题,singleflight这种控制机制不仅可以用在缓存击穿的问题上,理论上可以解决各种分层结构的高并发性能问题。