概要
本篇主要介紹一下Elasticsearch的并發控制和樂觀鎖的實作原理,列舉常見的電商場景,關系型資料庫的并發控制、ES的并發控制實踐,
并發場景
不論是關系型資料庫的應用,還是使用Elasticsearch做搜索加速的場景,只要有資料更新,并發控制是永恒的話題,
當我們使用ES更新document的時候,先讀取原始檔案,做修改,然后把document重新索引,如果有多人同時在做相同的操作,不做并發控制的話,就極有可能會發生修改丟失的,可能有些場景,丟失一兩條資料不要緊(比如文章閱讀數量統計,評論數量統計),但有些場景對資料嚴謹性要求極高,丟失一條可能會導致很嚴重的生產問題,比如電商系統中商品的庫存數量,丟失一次更新,可能會導致超賣的現象,
我們還是以電商系統的下單環節舉例,某商品庫存100個,兩個用戶下單購買,都包含這件商品,常規下單扣庫存的實作步驟
- 客戶端完成訂單資料校驗,準備執行下單事務,
- 客戶端從ES中獲取商品的庫存數量,
- 客戶端提交訂單事務,并將庫存數量扣減,
- 客戶端將更新后的庫存數量寫回到ES,
示例流程圖如下:

如果沒有并發控制,這件商品的庫存就會更新成99(實際正確的值是98),這樣就會導致超賣現象,假定http-1比http-2先一步執行,出現這個問題的原因是http-2在獲取庫存資料時,http-1還未完成下單扣減庫存后,更新到ES的環節,導致http-2獲取的資料已經是過期資料,后續的更新肯定也是錯的,
上述的場景,如果更新操作越是頻繁,并發數越多,讀取到更新這一段的耗時越長,資料出錯的概率就越大,
常用的鎖方案
并發控制尤為重要,有兩種通用的方案可以確保資料在并發更新時的正確性,
悲觀并發控制
悲觀鎖的含義:我認為每次更新都有沖突的可能,并發更新這種操作特別不靠譜,我只相信只有嚴格按我定義的粒度進行串行更新,才是最安全的,一個執行緒更新時,其他的執行緒等著,前一個執行緒更新完成后,下一個執行緒再上,
關系型資料庫中廣泛使用該方案,常見的表鎖、行鎖、讀鎖、寫鎖,依賴redis或memcache等實作的分布式鎖,都屬于悲觀鎖的范疇,明顯的特征是后續的執行緒會被掛起等待,性能一般來說比較低,不過自行實作的分布式鎖,粒度可以自行控制(按行記錄、按客戶、按業務型別等),在資料正確性與并發性能方面也能找到很好的折衷點,
樂觀并發控制
樂觀鎖的含義:我認為沖突不經常發生,我想提高并發的性能,如果真有沖突,被沖突的執行緒重新再嘗試幾次就好了,
在使用關系型資料庫的應用,也經常會自行實作樂觀鎖的方案,有性能優勢,方案實作也不難,還是挺吸引人的,
Elasticsearch默認使用的是樂觀鎖方案,前面介紹的_version欄位,記錄的就是每次更新的版本號,只有拿到最新版本號的更新操作,才能更新成功,其他拿到過期資料的更新失敗,由客戶端程式決定失敗后的處理方案,一般是重試,
ES的樂觀鎖方案
我們還是以上面的案例為背景,若http-2向ES提交更新資料時,ES會判斷提交過來的版本號與當前document版本號,document版本號單調遞增,如果提交過來的版本號比document版本號小,則說明是過期資料,更新請求將提示錯誤,程序圖如下:

使用內置_version實戰樂觀鎖控制效果
我們在kibana平臺上模擬兩個執行緒修改同一條document資料,打開兩個瀏覽器標簽即可,我們使用原有的案例資料:
{
"_index": "music",
"_type": "children",
"_id": "2",
"_version": 2,
"found": true,
"_source": {
"name": "wake me, shark me",
"content": "don't let me sleep too late, gonna get up brightly early in the morning",
"language": "english",
"length": "55"
}
}
當前的version是2,我們使用一個瀏覽器標簽頁,發出更新請求,把當前的version帶上:
POST /music/children/2?version=2
{
"doc": {
"length": 56
}
}
此時更新成功
{
"_index": "music",
"_type": "children",
"_id": "2",
"_version": 3,
"result": "updated",
"_shards": {
"total": 2,
"successful": 1,
"failed": 0
},
"_seq_no": 2,
"_primary_term": 2
}
同時我們在另一個標簽頁上,也使用version=2進行更新,得到的錯誤結果如下:
{
"error": {
"root_cause": [
{
"type": "version_conflict_engine_exception",
"reason": "[children][2]: version conflict, current version [3] is different than the one provided [2]",
"index_uuid": "9759yb44TFuJSejo6boy4A",
"shard": "2",
"index": "music"
}
],
"type": "version_conflict_engine_exception",
"reason": "[children][2]: version conflict, current version [3] is different than the one provided [2]",
"index_uuid": "9759yb44TFuJSejo6boy4A",
"shard": "2",
"index": "music"
},
"status": 409
}
關鍵錯誤資訊:version_conflict_engine_exception,版本沖突,將version升到3,模擬失敗后重試,此時更新成功,
真實的場景,重試的次數跟執行緒并發數有關,執行緒越多,更新越頻繁,就可能需要重試多次才可能更新成功,
使用外部_version實戰樂觀鎖控制效果
ES允許不使用內置的version進行版本控制,可以自定義使用外部的version,例如常見的使用Elasticsearch做資料查詢加速的經典方案,關系型資料庫作為主資料庫,然后使用Elasticsearch做搜索資料,主資料會同步資料到Elasticsearch中,而主資料庫并發控制,本身就是使用的樂觀鎖機制,有自己的一套version生成機制,資料同步到ES那里時,直接使用更方便,
請求語法上加上version_type引數即可:
POST /music/children/2?version=2&version_type=external
{
"doc": {
"length": 56
}
}
唯一的區別
- 內置_version,只有當你提供的version與es中的_version完全一樣的時候,才可以進行更新,否則報錯;
- 外部_version,只有當你提供的version比es中的_version大的時候,才能完成修改,
Replica Shard資料同步并發控制
在Elasticsearch內部,每當primary shard收到新的資料時,都需要向replica shard進行資料同步,這種同步請求特別多,并且是異步的,如果同一個document進行了多次修改,Shard同步的請求是無序的,可能會出現"后發先至"的情況,如果沒有任何的并發控制機制,那結果將無法相像,
Shard的資料同步也是基于內置的_version進行樂觀鎖并發控制的,
例如Java客戶端向Elasticsearch某條document發起更新請求,共發出3次,Java端有嚴謹的并發請求控制,在ElasticSearch的primary shard中寫入的結果是正確的,但Elasticsearch內部資料啟動同步時,順序不能保證都是先到先得,情況可能是這樣,第三次更新請求比第二次更新請求先到,如下圖:

如果Elasticsearch內部沒有并發的控制,這個document在replica的結果可能是text2,并且與primary shard的值不一致,這樣肯定錯了,
預期的更新順序應該是text1-->text2-->text3,最終的正確結果是text3,那Elasticsearch內部是如何做的呢?
Elasticsearch內部在更新document時,會比較一下version,如果請求的version與document的version相等,就做更新,如果document的version已經大于請求的version,說明此資料已經被后到的執行緒更新過了,此時會丟棄當前的請求,最終的結果為text3,
此時的更新順序為text1-->text3,最終結果也是對的,
小結
本篇主要介紹并發場景出現資料錯亂的原因,Elasticsearch樂觀鎖的實原理,以及ES內部資料同步時的并發控制,有不正確之處或未詳盡之處請知會修改,謝謝,
專注Java高并發、分布式架構,更多技術干貨分享與心得,請關注公眾號:Java架構社區

轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/2113.html
標籤:其他
