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

一枝花算不算浪漫發表於2021-06-28

前言

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

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

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

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

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

要學廢什麼?

本文只想嘮嘮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. 什麼是讀寫鎖?微服務註冊中心是如何進行讀寫鎖優化的?

相關文章