大家好,我是冰河~~

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

本文涉及的知識點有:

文章已收錄到:

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

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

讀寫鎖

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

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

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

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

  1. public ReentrantReadWriteLock(boolean fair) {
  2. sync = fair ? new FairSync() : new NonfairSync();
  3. readerLock = new ReadLock(this);
  4. writerLock = new WriteLock(this);
  5. }

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

快取實現

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

  1. public class ReadWriteLockCache<K,V> {
  2. private final Map<K, V> m = new HashMap<>();
  3. private final ReadWriteLock rwl = new ReentrantReadWriteLock();
  4. // 讀鎖
  5. private final Lock r = rwl.readLock();
  6. // 寫鎖
  7. private final Lock w = rwl.writeLock();
  8. // 讀快取
  9. public V get(K key) {
  10. r.lock();
  11. try { return m.get(key); }
  12. finally { r.unlock(); }
  13. }
  14. // 寫快取
  15. public V put(K key, V value) {
  16. w.lock();
  17. try { return m.put(key, value); }
  18. finally { w.unlock(); }
  19. }
  20. }

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

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

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

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

全量載入快取

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

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

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

  1. public class ReadWriteLockCache<K,V> {
  2. private final Map<K, V> m = new HashMap<>();
  3. private final ReadWriteLock rwl = new ReentrantReadWriteLock();
  4. // 讀鎖
  5. private final Lock r = rwl.readLock();
  6. // 寫鎖
  7. private final Lock w = rwl.writeLock();
  8. public ReadWriteLockCache(){
  9. //查詢資料庫
  10. List<Field<K, V>> list = .....;
  11. if(!CollectionUtils.isEmpty(list)){
  12. list.parallelStream().forEach((f) ->{
  13. m.put(f.getK(), f.getV);
  14. });
  15. }
  16. }
  17. // 讀快取
  18. public V get(K key) {
  19. r.lock();
  20. try { return m.get(key); }
  21. finally { r.unlock(); }
  22. }
  23. // 寫快取
  24. public V put(K key, V value) {
  25. w.lock();
  26. try { return m.put(key, value); }
  27. finally { w.unlock(); }
  28. }
  29. }

按需載入快取

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

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

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

  1. class ReadWriteLockCache<K,V> {
  2. private final Map<K, V> m = new HashMap<>();
  3. private final ReadWriteLock rwl = new ReentrantReadWriteLock();
  4. private final Lock r = rwl.readLock();
  5. private final Lock w = rwl.writeLock();
  6. V get(K key) {
  7. V v = null;
  8. //讀快取
  9. r.lock();
  10. try {
  11. v = m.get(key);
  12. } finally{
  13. r.unlock();
  14. }
  15. //快取中存在,返回
  16. if(v != null) {
  17. return v;
  18. }
  19. //快取中不存在,查詢資料庫
  20. w.lock();
  21. try {
  22. //再次驗證快取中是否存在資料
  23. v = m.get(key);
  24. if(v == null){
  25. //查詢資料庫
  26. v=從資料庫中查詢出來的資料
  27. m.put(key, v);
  28. }
  29. } finally{
  30. w.unlock();
  31. }
  32. return v;
  33. }
  34. }

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

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

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

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

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

讀寫鎖的升降級

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

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

  1. class CachedData {
  2. Object data;
  3. volatile boolean cacheValid;
  4. final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
  5. void processCachedData() {
  6. rwl.readLock().lock();
  7. if (!cacheValid) {
  8. // Must release read lock before acquiring write lock
  9. rwl.readLock().unlock();
  10. rwl.writeLock().lock();
  11. try {
  12. // Recheck state because another thread might have
  13. // acquired write lock and changed state before we did.
  14. if (!cacheValid) {
  15. data = ...
  16. cacheValid = true;
  17. }
  18. // Downgrade by acquiring read lock before releasing write lock
  19. rwl.readLock().lock();
  20. } finally {
  21. rwl.writeLock().unlock(); // Unlock write, still hold read
  22. }
  23. }
  24. try {
  25. use(data);
  26. } finally {
  27. rwl.readLock().unlock();
  28. }
  29. }
  30. }}

資料同步問題

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

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

超時機制

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

定時更新快取

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

實時更新快取

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

mykit-data開源地址:

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