Eureka中讀寫鎖的奇思妙想,學廢了嗎?

前言

很抱歉 好久沒有更新文章了,最近的一篇原創還是在去年十月份,這個號確實荒廢了好久,感激那些沒有把我取消關注的小夥伴。

有讀者朋友經常私信問我: ”你號賣了?“ ”文章咋不更新了?“

不更新主要的原因就是自己太懶了,也不知道要寫些什麼東西。最近一年還是在零散的學些東西,每次準備提筆寫文章都半途而廢了,到了最後就乾脆不寫了。

廢話不多說了,還是看文章吧,分享的內容是我自己思考的一些東西,並沒有標準答案,希望大家看的時候都能夠有自己的見解,有問題可以第一時間聯繫到我 一起探討。

跟着我,要麼學會,要麼學廢!

要學廢什麼?

本文只想嘮嘮EurekaServer中關於讀寫鎖的一些使用小技巧。

對於我們正常邏輯思維來說,讀鎖就是在讀的時候加鎖,寫鎖就是在寫的時候加鎖,這似乎沒有什麼技巧?

img

好像什麼也學不廢了?Oh No ~~~ 讀寫鎖只是通俗的叫法,爲何限定讀鎖只能加在讀操作寫鎖只能加在寫操作呢?

細細品下方面那句話,接下來一起看看網飛的程序員是怎麼玩的吧。

讀寫鎖回顧

JDK中常說的讀寫鎖是ReentrantReadWriteLock,我們平時工作中使用ReentrantLock會多一些,這兩把鎖都是師出同門,它們都是實現了AbstractQueuedSynchronizer中的相關邏輯

ReentrantLockAQS中的state變量“按位切割”切分成了兩個部分,高16位表示讀鎖狀態(讀鎖個數),低16位表示寫鎖狀態(寫鎖個數)。如下圖所示:

img

大家也可以看下我之前寫過的一篇詳解AQS的文章:我畫了35張圖就是爲了讓你深入 AQS

這裏就不再贅述讀寫鎖底層的實現原理了,原理都在上面文章中。我們在這裏可以把讀寫鎖理解爲和ReentrantLock一樣的鎖,只是帶了讀寫操作的區分。

讀與讀之間不互斥,讀與寫、寫與寫之間是互斥的,這樣做的目的是能夠提升讀寫操作的性能。比如我們的業務是讀多寫少,那麼使用讀寫鎖,大多數情況都是可以併發訪問的,不需要通過每次加鎖來影響系統性能。

EurekaServer如何玩讀寫鎖的?

前面鋪墊了很多,希望大家能夠知道讀寫鎖這個東西。讀寫鎖的使用很簡單,JDK中都有現成的API供我們調用。往往一些牛叉的框架也都是使用這些JDK底層的API 構建起來的,接着我們就看EurekaServer是如何玩的吧。

PS:對於SpringCloud底層源碼感興趣的可以看我之前寫的一套源碼解讀博客:https://www.cnblogs.com/wang-meng/p/12147889.html密碼:222 不要告訴別人喲o( ̄▽ ̄)d)

EurekaServer爲何需要加鎖?

我們知道EurekaServer作爲一個註冊中心,裏面是保存EurekaClient註冊表信息的,爲了能夠感知其他註冊實例的存在,每個EurekaClient都會定時去註冊中心拉取增量的註冊表信息,然而這個增量拉取很有門道的,在增量獲取的時候必須要加寫鎖來保證獲取的數據準確性,這裏先不詳細展開,後續會一點點講解

我們先看幾個常見場景:

  • 服務A啓動的時候需要向註冊中心發送regist請求,註冊表會將服務A寫入自己的花名冊中

  • 服務B發送下線請求,告知註冊中心 我要下線了,請把我從註冊表中請求,此時註冊表會把服務B從花名冊中抹掉

  • 服務C在運行過程中也需要定時拉取註冊表的最新數據,然後將數據同步到本地,這樣本地就可以通過服務名去發現其他服務了

image-20210626213448048

這裏加讀寫鎖的玄機就藏在ServiceC獲取註冊表增量信息裏面,我們先看EurekaServer讀寫鎖中的相關代碼:

public abstract class AbstractInstanceRegistry implements InstanceRegistry {
    private static final Logger logger = LoggerFactory.getLogger(AbstractInstanceRegistry.class);

    // registry就是註冊表,存儲註冊信息的集合
    private final ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>> registry
            = new ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>>();
    
    // 存放最近修改的實例信息
    private ConcurrentLinkedQueue<RecentlyChangedItem> recentlyChangedQueue = new ConcurrentLinkedQueue<RecentlyChangedItem>();
    
    // 今天的主角,讀寫鎖
    private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private final Lock read = readWriteLock.readLock();
    private final Lock write = readWriteLock.writeLock();
}

上面有三個關鍵的地方需要注意:

註冊表:ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>> registry

最近修改的實例信息隊列:ConcurrentLinkedQueue<RecentlyChangedItem> recentlyChangedQueue

讀寫鎖:ReentranteadWriteLock readWriteLock

EurekaServer讀寫鎖使用場景?

上面交代了大致背景,接下來就看看讀寫鎖在這裏是如何使用的。我們先來梳理下讀寫鎖在這裏使用的幾個場景:

image-20210626220037428

接着也看下代碼中readLockwriteLock 的使用鏈條,這裏說明下 evict操作底層走的也是cancel邏輯讓服務下線,所以調用鏈條中並沒有顯示evict的相關引用

readLock:

image-20210626220226325

writeLock:

image-20210626220209955

這裏再回過頭去回味上面的那句話:不要限定讀鎖只能加在讀操作寫鎖只能加在寫操作,現在應該能明白這句話的含義了吧?

Eureka中確實是這麼做的,讀操作加寫鎖,寫操作加讀鎖,一頓反向操作猛如虎

image-20210626233604197

再來一張圖完整總結讀寫鎖的詳細使用場景:

image-20210627134136535

深層次思考

再去深究下上面提到的讀寫互斥操作,我們這裏需要理解清楚`EurekaClient獲取註冊表信息操作是如何實現的:

(關於註冊表獲取的原理也可以參考下我之前的博文:https://www.cnblogs.com/wang-meng/p/12118203.html)

  • EurekaClient獲取全量註冊表信息實現方式:

    image-20210626223437998

這裏是EurekaClient第一次全量獲取註冊表的實現原理,從註冊中心拉取到註冊表後,EurekaClient會將註冊表信息保存在本地的list中。

這裏也要提下EurekaServer中的兩層緩存機制,我們每次從註冊中心拉取註冊表時都是直接走的緩存,緩存使用的是谷歌提供的GuavaCahe

  • EurekaClient獲取增量註冊表實現方式:

    image-20210626230424652

EurekaClient每隔30s去註冊中心拉取註冊表增量信息,拿回來後和本地緩存的註冊信息進行比對,一頓增刪改查操作後覆蓋緩存中的註冊信息數據。下面是增量獲取註冊表信息的代碼示例,這裏會從recentlyChangedQueue中獲取存在變化的實例信息,最後還會設置一個appHashCode值:

image-20210626235121440

即使是獲取增量註冊表數據,也會從註冊中心的緩存中獲取,當EurekaClient對註冊表進行register/cancel... 等操作時,會先去更新註冊表中的數據,然後將改變的實例信息存放到一個隊列中:recentlyChangedQueue ,這個隊列只會存儲最近三分鐘有變化的節點信息,最後去清除EurekaServer中的readWriteCacheMap緩存信息

image-20210626234548457

這裏有一個重要的點需要注意:EurekaClient拉取的註冊表增量信息 時還包含一個註冊表全量信息的hash值,也就是上面代碼中提到的appHashCode, 這個hash可以看做是所有註冊實例數量的status分組後構成的:hash=count+status

image-20210626234457027

爲什麼需要有這個hash校驗的操作?這裏是爲了保證EurekaClient在獲取增量更新後的數據和註冊中心的註冊表數據保持一致時做的一個校驗。我們可以想象一下,EurekaClient 在獲取到增量數據後一頓增刪改查,按理說最終修改後的數據應該和註冊表保持一致,但是由於某些原因並沒有保持一致,那麼後續再去做增量獲取就毫無意義了!

所以這裏如果判斷hash不一致,就會立即再去註冊中心獲取全量數據來覆蓋本地的髒數據。那麼既然要獲取這個hash值,此時的註冊表就不能再有"寫入"的操作了,例如register/cancel等,他們會改變註冊表中實例的數量以及狀態,所以這裏就形成了一個互斥的操作:

image-20210626235808557

這裏也就是爲何註冊表和最近更新實例隊列都是現成安全的,還要加讀寫鎖的原因了,這裏是需要有一個互斥的操作。

再來回頭思考

上面已經解釋了EurekaServer中讀寫鎖互換使用的場景了,這裏大家肯定還會有其他疑惑,那麼我們回過頭再來思考以下幾個問題:

  1. 站在作者的角度 EurekaServer爲何這樣設計讀寫鎖的使用?
  2. 站在讀者的角度 EurekaServer 增量獲取註冊表信息的性能如何?
  3. 註冊表registry本身就是Map結構內存存取, 爲何還要再使用緩存
  4. 爲何renew操作不加任何讀寫鎖?這個明明是更新註冊表的續約時間

1、EurekaServer中讀寫鎖設計的思考

看完上面的操作讀者可能和我有同樣的困惑,作者爲何要這樣設計?

首先,我們來梳理下業務場景:這是一個典型的讀多寫少的場景(EurekaClient 默認每30s拉一次註冊表增量信息):

image-20210627003825206

註冊中心的"讀操作":

讀的時候必須要加全局鎖防止新數據的寫入更新,因爲讀的時候需要獲取註冊表的hash值,這裏必須要加互斥鎖

註冊中心的"寫操作":

註冊中心的register/cancel/evict...等操作都是可以同步執行的,依託於ConcurrentLinkedQueue/ConcurrentHashMap併發容器的實現,這類更新最近更新隊列或者修改註冊表的操作都是線程安全的

image-20210627124816803

反過來,如果上述一些操作的讀寫鎖互換,等於說是在這兩個併發容器上又加了一層寫鎖的邏輯,多一層互斥的性能損耗,性能返回會更差

2、EurekaServer 增量獲取註冊表信息的性能如何?

我們可以看下EurekaClient獲取註冊表的流程操作:

image-20210626230424652

雖然我們每次增量拉取註冊表都是加的寫鎖,但是這裏藉助了緩存技術,每次增量獲取數據並不一定都會執行加鎖操作,配合緩存的時候可以減少寫鎖的使用頻率

其他的對於最近更新隊列recentlyChangedQueue或者註冊表registry的寫入更新操作都是線程安全的,他們不需要通過讀寫鎖來保證

3、註冊表registry本身就是Map結構 爲何還要再使用一層緩存?

其實答案已經在上面了,如果我們不借助於緩存,那麼每次的增量獲取操作都會針對於registry或者recentlyChangedQueue`去操作,每次都會加寫鎖,性能相對於直接讀緩存會下降很多,所以這裏藉助了緩存來解決每次都需要加鎖的問題

由此我們是否也可以想到另一個常用的框架 Spring是如何解決循環依賴問題的?答案也是使用多級緩存,到了這裏有沒有一種豁然開朗的感覺~

我們再繼續深入思考一下,看下ResponseCacheImpl的代碼實現:

image-20210627012246839

我們舉例一種場景,這裏使用的是expireAfterWrite,當我們的緩存過期後,同時有1w個客戶端來拉取註冊表增量信息,都會走到加寫鎖的邏輯,此時註冊中心的吞吐量會降低很多嗎?

這裏如果使用refreshAfterWrites會不會更好一些?因爲refreshAfterWrite是後臺異步刷新,其他線程訪問舊值,只會有一個線程在執行刷新,不會出現多個線程刷新同一個Key的緩存

當然這些可能也是多慮的,我並沒有去實際測試這種場景,我猜測在請求量很大的情況下,增量獲取註冊信息加寫鎖內部的邏輯也會執行很快,因爲都是一些內存的操作。至於使用expireAfterWrite 則是能夠節省很多內存空間,也許作者在心裏也有過這種利弊抉擇 …(⊙_⊙;)…

4、爲何renew 續約不需要加鎖?

renew不加鎖的原因很簡單,續約操作是不會向最近更新隊列中添加元素的,不會影響增量更新數據的拉取

這裏也可以回顧下renew的作用,renew默認每30秒都會像註冊中心發送一次心跳操作,註冊中心收到心跳請求後會從註冊表中拿出這個實例信息,然後更新該實例最後心跳的時間,這個心跳時間是註冊中心用來做故障剔除的,如果一個實例在指定週期內沒有發送心跳請求,則會被認爲出現了故障 從註冊中心摘除掉

但是renew操作對於實例的lastUpdateTimeBug的更新是有Bug的,我在之前的文章中也有提到過,看下源碼註釋:

image-20210627094418453

這裏是註冊中心故障感知時的一段代碼,作者也在註釋中說了:"renew()操作是有問題的,這裏多加了一個duration的時間,但是我們又不會去修復這個問題,這裏僅僅是影響故障被感知的時間而已,而我的系統就是最終一致的,所以我也不會去修復" (PS:每每看到這裏我都會忍不住吐槽,他不知道我們爲了提升故障的感知效率 做了很多努力 這或許也就是網上很多人說Eureka代碼寫的爛的原因吧??)

寫在最後

最近在幫助公司面試一些候選人,我也會問一些 SpringCloud相關的問題,但經常一些候選人的回答:

"這些框架都過時了,我們使用了最新的xxx框架"、"你問的這些東西我只需要會用 我不需要知道原理"...

諸如此類的回答很多,我平時是一個比較喜歡刨根問底的人,堅信一切問題在源碼面前都毫無祕密,學東西要知道其然也要知道其所以然。萬丈高樓平地起,框架也只不過是輔助我們工作的一種工具,裏面的實現還都是依賴於最底層的技術。

借用我老師的一句話:技術不分新舊,技術僅僅是一個載體,通過分析他們的源碼去教給你的是架構設計、思想原理、方案機制、內核機制,以及分析源碼的方法、技巧和能力。

PS:特別鳴謝及參考

以上是我閱讀源碼時的一些思考,寫出來的內容可能會存在錯誤,有寫的不對的地方還請大家跟我說明,希望能夠和大家一同提高成長,歡迎加我微信交流:W510782645

參考以下博文,感謝原作者內容分享:

  1. Eureka 源碼解析 —— Eureka源碼解析 —— 應用實例註冊發現 (九)之歲月是把萌萌的讀寫鎖
  2. 什麼是讀寫鎖?微服務註冊中心是如何進行讀寫鎖優化的?
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章