原來ReadWriteLock也能開發高效能快取,看完我也能和麵試官好好聊聊了!

語言: CN / TW / HK

大家好,我是冰河~~

在實際工作中,有一種非常普遍的併發場景:那就是讀多寫少的場景。在這種場景下,為了優化程式的效能,我們經常使用快取來提高應用的訪問效能。因為快取非常適合使用在讀多寫少的場景中。而在併發場景中,Java SDK中提供了ReadWriteLock來滿足讀多寫少的場景。本文我們就來說說使用ReadWriteLock如何實現一個通用的快取中心。

本文涉及的知識點有:

文章已收錄到:

https://github.com/sunshinelyz/technology-binghe

https://gitee.com/binghe001/technology-binghe

讀寫鎖

說起讀寫鎖,相信小夥伴們並不陌生。總體來說,讀寫鎖需要遵循以下原則:

  • 一個共享變數允許同時被多個讀執行緒讀取到。
  • 一個共享變數在同一時刻只能被一個寫執行緒進行寫操作。
  • 一個共享變數在被寫執行緒執行寫操作時,此時這個共享變數不能被讀執行緒執行讀操作。

這裡,需要小夥伴們注意的是:讀寫鎖和互斥鎖的一個重要的區別就是:讀寫鎖允許多個執行緒同時讀共享變數,而互斥鎖不允許。所以,在高併發場景下,讀寫鎖的效能要高於互斥鎖。但是,讀寫鎖的寫操作是互斥的,也就是說,使用讀寫鎖時,一個共享變數在被寫執行緒執行寫操作時,此時這個共享變數不能被讀執行緒執行讀操作。

讀寫鎖支援公平模式和非公平模式,具體是在 ReentrantReadWriteLock 的構造方法中傳遞一個boolean型別的變數來控制。

public ReentrantReadWriteLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
    readerLock = new ReadLock(this);
    writerLock = new WriteLock(this);
}

另外,需要注意的一點是:在讀寫鎖中,讀鎖呼叫newCondition()會丟擲UnsupportedOperationException異常,也就是說:讀鎖不支援條件變數。

快取實現

這裡,我們使用ReadWriteLock快速實現一個快取的通用工具類,總體程式碼如下所示。

public class ReadWriteLockCache<K,V> {
    private final Map<K, V> m = new HashMap<>();
    private final ReadWriteLock rwl = new ReentrantReadWriteLock();
    // 讀鎖
    private final Lock r = rwl.readLock();
    // 寫鎖
    private final Lock w = rwl.writeLock();
    // 讀快取
    public V get(K key) {
        r.lock();
        try { return m.get(key); }
        finally { r.unlock(); }
    }
    // 寫快取
    public V put(K key, V value) {
        w.lock();
        try { return m.put(key, value); }
        finally { w.unlock(); }
    }
}

可以看到,在ReadWriteLockCache中,我們定義了兩個泛型型別,K代表快取的Key,V代表快取的value。在ReadWriteLockCache類的內部,我們使用Map來快取相應的資料,小夥伴都都知道HashMap並不是執行緒安全的類,所以,這裡使用了讀寫鎖來保證執行緒的安全性,例如,我們在get()方法中使用了讀鎖,get()方法可以被多個執行緒同時執行讀操作;put()方法內部使用寫鎖,也就是說,put()方法在同一時刻只能有一個執行緒對快取進行寫操作。

這裡需要注意的是:無論是讀鎖還是寫鎖,鎖的釋放操作都需要放到 finally{} 程式碼塊中。

在以往的經驗中,有兩種向快取中載入資料的方式, 一種是:專案啟動時,將資料全量載入到快取中,一種是在專案執行期間,按需載入所需要的快取資料。

接下來,我們就分別來看看全量載入快取和按需載入快取的方式。

全量載入快取

全量載入快取相對來說比較簡單,就是在專案啟動的時候,將資料一次性載入到快取中,這種情況適用於快取資料量不大,資料變動不頻繁的場景,例如:可以快取一些系統中的資料字典等資訊。整個快取載入的大體流程如下所示。

將資料全量載入到快取後,後續就可以直接從快取中讀取相應的資料了。

全量載入快取的程式碼實現比較簡單,這裡,我就直接使用如下程式碼進行演示。

public class ReadWriteLockCache<K,V> {
    private final Map<K, V> m = new HashMap<>();
    private final ReadWriteLock rwl = new ReentrantReadWriteLock();
    // 讀鎖
    private final Lock r = rwl.readLock();
    // 寫鎖
    private final Lock w = rwl.writeLock();
    
    public ReadWriteLockCache(){
        //查詢資料庫
        List<Field<K, V>> list = .....;
        if(!CollectionUtils.isEmpty(list)){
            list.parallelStream().forEach((f) ->{
                m.put(f.getK(), f.getV);
            });
        }
    }
    // 讀快取
    public V get(K key) {
        r.lock();
        try { return m.get(key); }
        finally { r.unlock(); }
    }
    // 寫快取
    public V put(K key, V value) {
        w.lock();
        try { return m.put(key, value); }
        finally { w.unlock(); }
    }
}

按需載入快取

按需載入快取也可以叫作懶載入,就是說:需要載入的時候才會將資料載入到快取。具體來說:就是程式啟動的時候,不會將資料載入到快取,當執行時,需要查詢某些資料,首先檢測快取中是否存在需要的資料,如果存在,則直接讀取快取中的資料,如果不存在,則到資料庫中查詢資料,並將資料寫入快取。後續的讀取操作,因為快取中已經存在了相應的資料,直接返回快取的資料即可。

這種查詢快取的方式適用於大多數快取資料的場景。

我們可以使用如下程式碼來表示按需查詢快取的業務。

class ReadWriteLockCache<K,V> {
    private final Map<K, V> m = new HashMap<>();
    private final ReadWriteLock rwl =  new ReentrantReadWriteLock();
    private final Lock r = rwl.readLock();
    private final Lock w = rwl.writeLock();
    V get(K key) {
        V v = null;
        //讀快取
        r.lock();        
        try {
            v = m.get(key);
        } finally{
            r.unlock();    
        }
        //快取中存在,返回
        if(v != null) {  
            return v;
        }  
        //快取中不存在,查詢資料庫
        w.lock();     
        try {
           //再次驗證快取中是否存在資料
            v = m.get(key);
            if(v == null){ 
                //查詢資料庫
                v=從資料庫中查詢出來的資料
                m.put(key, v);
            }
        } finally{
            w.unlock();
        }
        return v; 
    }
}

這裡,在get()方法中,首先從快取中讀取資料,此時,我們對查詢快取的操作添加了讀鎖,查詢返回後,進行解鎖操作。判斷快取中返回的資料是否為空,不為空,則直接返回資料;如果為空,則獲取寫鎖,之後再次從快取中讀取資料,如果快取中不存在資料,則查詢資料庫,將結果資料寫入快取,釋放寫鎖。最終返回結果資料。

這裡,有小夥伴可能會問:為啥程式都已經新增寫鎖了,在寫鎖內部為啥還要查詢一次快取呢?

這是因為在高併發的場景下,可能會存在多個執行緒來競爭寫鎖的現象。例如:第一次執行get()方法時,快取中的資料為空。如果此時有三個執行緒同時呼叫get()方法,同時執行到 w.lock() 程式碼處,由於寫鎖的排他性。此時只有一個執行緒會獲取到寫鎖,其他兩個執行緒則阻塞在 w.lock() 處。獲取到寫鎖的執行緒繼續往下執行查詢資料庫,將資料寫入快取,之後釋放寫鎖。

此時,另外兩個執行緒競爭寫鎖,某個執行緒會獲取到鎖,繼續往下執行,如果在 w.lock() 後沒有 v = m.get(key); 再次查詢快取的資料,則這個執行緒會直接查詢資料庫,將資料寫入快取後釋放寫鎖。最後一個執行緒同樣會按照這個流程執行。

這裡,實際上第一個執行緒已經查詢過資料庫,並且將資料寫入快取了,其他兩個執行緒就沒必要再次查詢資料庫了,直接從快取中查詢出相應的資料即可。所以,在 w.lock() 後新增 v = m.get(key); 再次查詢快取的資料,能夠有效的減少高併發場景下重複查詢資料庫的問題,提升系統的效能。

讀寫鎖的升降級

關於鎖的升降級,小夥伴們需要注意的是:在ReadWriteLock中,鎖是不支援升級的,因為讀鎖還未釋放時,此時獲取寫鎖,就會導致寫鎖永久等待,相應的執行緒也會被阻塞而無法喚醒。

雖然不支援鎖升級,但是ReadWriteLock支援鎖降級,例如,我們來看看官方的ReentrantReadWriteLock示例,如下所示。

class CachedData {
    Object data;
    volatile boolean cacheValid;
    final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

    void processCachedData() {
        rwl.readLock().lock();
        if (!cacheValid) {
            // Must release read lock before acquiring write lock
            rwl.readLock().unlock();
            rwl.writeLock().lock();
            try {
                // Recheck state because another thread might have
                // acquired write lock and changed state before we did.
                if (!cacheValid) {
                    data = ...
                    cacheValid = true;
                }
                // Downgrade by acquiring read lock before releasing write lock
                rwl.readLock().lock();
            } finally {
                rwl.writeLock().unlock(); // Unlock write, still hold read
            }
        }

        try {
            use(data);
        } finally {
            rwl.readLock().unlock();
        }
    }
}}

資料同步問題

首先,這裡說的資料同步指的是資料來源和資料快取之間的資料同步,說的再直接一點,就是資料庫和快取之間的資料同步。

這裡,我們可以採取三種方案來解決資料同步的問題,如下圖所示

超時機制

這個比較好理解,就是在向快取寫入資料的時候,給一個超時時間,當快取超時後,快取的資料會自動從快取中移除,此時程式再次訪問快取時,由於快取中不存在相應的資料,查詢資料庫得到資料後,再將資料寫入快取。

定時更新快取

這種方案是超時機制的增強版,在向快取中寫入資料的時候,同樣給一個超時時間。與超時機制不同的是,在程式後臺單獨啟動一個執行緒,定時查詢資料庫中的資料,然後將資料寫入快取中,這樣能夠在一定程度上避免快取的穿透問題。

實時更新快取

這種方案能夠做到資料庫中的資料與快取的資料是實時同步的,可以使用阿里開源的Canal框架實現MySQL資料庫與快取資料的實時同步。 也可以使用我個人開源的mykit-data框架哦(推薦使用)~~

mykit-data開源地址:

好了,今天就到這兒吧,我是冰河,我們下期見~~