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. 什么是讀寫鎖?微服務注冊中心是如何進行讀寫鎖優化的?


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM