使用singleflight防止快取擊穿(Java)

zheski發表於2020-09-20

快取擊穿

在使用快取時,我們往往是先根據key從快取中取資料,如果拿不到就去資料來源載入資料,寫入快取。但是在某些高併發的情況下,可能會出現快取擊穿的問題,比如一個存在的key,在快取過期的一刻,同時有大量的請求,這些請求都會擊穿到DB,造成瞬時DB請求量大、壓力驟增。

一般解決方案

首先我們想到的解決方案就是加鎖,一種辦法是:拿到鎖的請求,去載入資料,沒有拿到鎖的請求,就先等待。這種方法雖然避免了併發載入資料,但實際上是將併發的操作序列化,會增加系統延時。

singleflight

singleflight是groupcache這個專案的一部分,groupcache是memcache作者使用golang編寫的分散式快取。singleflight能夠使多個併發請求的回源操作中,只有第一個請求會進行回源操作,其他的請求會阻塞等待第一個請求完成操作,直接取其結果,這樣可以保證同一時刻只有一個請求在進行回源操作,從而達到防止快取擊穿的效果。下面是參考groupcache原始碼,使用Java實現的singleflight程式碼:

//代表正在進行中,或已經結束的請求
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這種控制機制不僅可以用在快取擊穿的問題上,理論上可以解決各種分層結構的高併發效能問題。

相關文章