概要
Elasticsearch在文件更新時預設使用的是樂觀鎖方案,而Elasticsearch利用文件的一些create限制條件,也能達到悲觀鎖的效果,我們一起來看一看。
樂觀鎖與悲觀鎖
樂觀鎖
ES預設實現樂觀鎖,所有的資料更新預設使用樂觀鎖機制。document更新時,必須要帶上currenct version,更新時與document的version進行比較,如果相同進行更新操作,不相同表示已經被別的執行緒更新過了,此時更新失敗,並且重新獲取新的version再嘗試更新。
悲觀鎖
我們舉一個這樣的例子:Elasticsearch儲存檔案系統的目錄、檔名資訊,有多個執行緒需要對/home/workspace/ReadMe.txt進行追加修改,而且是併發執行的,有先後順序之分,跟之前的庫存更新案例有點不一樣,此時單純使用樂觀鎖,可能會出現亂序的問題。
這種場景就需要使用悲觀鎖控制,保證執行緒的執行順序,有一個執行緒在修改,其他的執行緒只能掛起等待。悲觀鎖通過/index/lock/實現,只有一個執行緒能做修改操作,其他執行緒block掉。
悲觀鎖有三種,分別對應三種粒度,由粗到細可為分:
- 全域性鎖:最粗的鎖,直接鎖整個索引
- document鎖:指定id加鎖,只鎖一條資料,類似於資料庫的行鎖
- 共享鎖和排他鎖:也叫讀寫鎖,針對一條資料分讀和寫兩種操作,一般共享鎖允許多個執行緒對同一條資料進行加鎖,排他鎖只允許一個執行緒對資料加鎖,並且排他鎖和共享鎖互斥。
鎖的基本操作步驟
我們使用鎖的基本步驟都是一樣的,無論是關係型資料庫、Redis/Memcache/Zookeeper分散式鎖,還是今天介紹的Elasticsearch實現的鎖機制,都有如下三步:
- 上鎖
- 執行事務方法
- 解鎖
全域性鎖
假定有兩個執行緒,執行緒1和執行緒2
- 執行緒1上鎖命令:
PUT /files/file/global/_create
{}
- files表示索引名稱。
- file為type,6.3.1一個索引只允許有一個type,選用file作用type名稱。
- global:即document的id,固定寫為global表示全域性鎖,或者使用專門的索引進行加鎖操作。
- _create: 強制必須是建立,如果已經存在,那麼建立失敗,報錯。
- 執行緒1執行事務方法:更新檔名
POST /files/file/global/_update
{
"doc": {
"name":"ReadMe.txt"
}
}
- 執行緒2嘗試加鎖,失敗,此時程式進行重試階段,直到執行緒1釋放鎖
# 請求:
PUT /files/file/global/_create
{}
# 響應:
{
"error": {
"root_cause": [
{
"type": "version_conflict_engine_exception",
"reason": "[file][global]: version conflict, document already exists (current version [1])",
"index_uuid": "_6E1d7BLQmy9-7gJptVp7A",
"shard": "2",
"index": "files"
}
],
"type": "version_conflict_engine_exception",
"reason": "[file][global]: version conflict, document already exists (current version [1])",
"index_uuid": "_6E1d7BLQmy9-7gJptVp7A",
"shard": "2",
"index": "files"
},
"status": 409
}
- 執行緒1釋放鎖
DELETE files/file/global
- 執行緒2加鎖
PUT /files/file/global/_create
{}
響應
{
"_index": "files",
"_type": "file",
"_id": "global",
"_version": 3,
"result": "created",
"_shards": {
"total": 2,
"successful": 1,
"failed": 0
},
"_seq_no": 2,
"_primary_term": 1
}
- 加鎖成功,然後執行事務方法。
優缺點
全域性鎖本質上是所有執行緒都用_create語法來建立id為global的文件,利用Elasticsearch對_create語法的校驗來實現鎖的目的。
-
優點:操作簡單,容易使用,成本低。
-
缺點:直接鎖住整個索引,除了加鎖的那個執行緒,其他所有對此索引的執行緒都block住了,併發量較低。
-
適用場景:讀多寫少的資料,並且加解鎖的時間非常短,類似於資料庫的表鎖。
注意事項:加鎖解鎖的控制必須嚴格在程式裡定義,因為單純基於doc的鎖控制,如果id固定使用global,在有鎖的情況,任何執行緒執行delete操作都是可以成功的,因為大家都知道id。
document level級別的鎖
document level級別的鎖是更細粒度的鎖,以文件為單位進行鎖控制。
我們新建一個索引專門用於加鎖操作:
PUT /files-lock/_mapping/lock
{
"properties": {
}
}
我們先建立一個script指令碼,ES6.0以後預設使用painless指令碼:
POST _scripts/document-lock
{
"script": {
"lang": "painless",
"source": "if ( ctx._source.process_id != params.process_id ) { Debug.explain('already locked by other thread'); } ctx.op = 'noop';"
}
}
Debug.explain表示丟擲一個異常,內容為already locked by other thread。
ctx.op = 'noop'表示不執行更新。
- 執行緒1增加行鎖,此時傳入的process_id為181ab3ee-28cc-4339-ba35-69802e06fe42
POST /files-lock/lock/1/_update
{
"upsert": { "process_id": "181ab3ee-28cc-4339-ba35-69802e06fe42" },
"script": {
"id": "document-lock",
"params": {
"process_id": "181ab3ee-28cc-4339-ba35-69802e06fe42"
}
}
}
響應結果:
{
"_index": "files-lock",
"_type": "lock",
"_id": "1",
"_version": 1,
"result": "created",
"_shards": {
"total": 2,
"successful": 1,
"failed": 0
},
"_seq_no": 0,
"_primary_term": 1
}
- 執行緒1、執行緒2查詢鎖資訊
{
"_index": "files-lock",
"_type": "lock",
"_id": "1",
"_version": 1,
"found": true,
"_source": {
"process_id": "181ab3ee-28cc-4339-ba35-69802e06fe42"
}
}
- 執行緒2傳入的process_id為181ab3ee-28cc-4339-ba35-69802e06fe42,嘗試加鎖,失敗,此時應該啟動重試機制
POST /files-lock/lock/1/_update
{
"upsert": { "process_id": "a6d13529-86c0-4422-b95a-aa0a453625d5" },
"script": {
"id": "document-lock",
"params": {
"process_id": "a6d13529-86c0-4422-b95a-aa0a453625d5"
}
}
}
提示該文件已經被別的執行緒(執行緒1)鎖住了,你不能更新了,響應報文如下:
{
"error": {
"root_cause": [
{
"type": "remote_transport_exception",
"reason": "[node-1][192.168.17.137:9300][indices:data/write/update[s]]"
}
],
"type": "illegal_argument_exception",
"reason": "failed to execute script",
"caused_by": {
"type": "script_exception",
"reason": "runtime error",
"painless_class": "java.lang.String",
"to_string": "already locked by other thread",
"java_class": "java.lang.String",
"script_stack": [
"Debug.explain('already locked by other thread'); } ",
" ^---- HERE"
],
"script": "judge-lock",
"lang": "painless",
"caused_by": {
"type": "painless_explain_error",
"reason": null
}
}
},
"status": 400
}
- 執行緒1執行事務方法
POST /files/file/1/_update
{
"doc": {
"name":"README1.txt"
}
}
- 執行緒1的事務方法執行完成,並通過刪除id為1的文件,相當於釋放鎖
DELETE /files-lock/lock/1
- 執行緒2線上程1執行事務的期間,一直在模擬掛起,重試的操作,直到執行緒1完成釋放鎖,然後執行緒2加鎖成功
POST /files-lock/lock/1/_update
{
"upsert": { "process_id": "a6d13529-86c0-4422-b95a-aa0a453625d5" },
"script": {
"id": "document-lock",
"params": {
"process_id": "a6d13529-86c0-4422-b95a-aa0a453625d5"
}
}
}
結果:
{
"_index": "files-lock",
"_type": "lock",
"_id": "1",
"_version": 3,
"found": true,
"_source": {
"process_id": "a6d13529-86c0-4422-b95a-aa0a453625d5"
}
}
此時鎖的process_id變成執行緒2傳入的"a6d13529-86c0-4422-b95a-aa0a453625d5"
{
"_index": "files-lock",
"_type": "lock",
"_id": "1",
"_version": 3,
"found": true,
"_source": {
"process_id": "a6d13529-86c0-4422-b95a-aa0a453625d5"
}
}
這樣基於ES的行鎖操作控制過程就完成了。
指令碼解釋
update+upsert操作,如果該記錄沒加鎖(此時document為空),執行upsert操作,設定process_id,如果已加鎖,執行script
script內的邏輯是:判斷傳入引數與當前doc的process_id,如果不相等,說明有別的執行緒嘗試對有鎖的doc進行加鎖操作,Debug.explain表示丟擲一個異常。
process_id可以由Java應用系統裡生成,如UUID。
如果兩個process_id相同,說明當前執行的執行緒與加鎖的執行緒是同一個,ctx.op = 'noop'表示什麼都不做,返回成功的響應,Java客戶端拿到成功響應的報文,就可以繼續下一步的操作,一般這裡的下一步就是執行事務方法。
點評
文件級別的鎖顆粒度小,併發性高,吞吐量大,類似於資料庫的行鎖。
共享鎖與排他鎖
概念
共享鎖:允許多個執行緒獲取同一條資料的共享鎖進行讀操作
排他鎖:同一條資料只能有一個執行緒獲取排他鎖,然後進行增刪改操作
互斥性:共享鎖與排他鎖是互斥的,如果這條資料有共享鎖存在,那麼排他鎖無法加上,必須得共享鎖釋放完了,排他鎖才能加上。
反之也成立,如果這條資料當前被排他鎖鎖信,那麼其他的排他鎖不能加,共享鎖也加不上。必須等這個排他鎖釋放完了,其他鎖才加得上。
有人在改資料,就不允許別人來改,也不讓別人來讀。
讀寫鎖的分離
如果只是讀資料,每個執行緒都可以加一把共享鎖,此時該資料的共享鎖數量一直遞增,如果這時有寫資料的請求(寫請求是排他鎖),由於互斥性,必須等共享鎖全部釋放完,寫鎖才加得上。
有人在讀資料,就不允許別人來改。
案例實驗
我們先建立一個共享鎖的指令碼:
# 讀操作加鎖指令碼
POST _scripts/rw-lock
{
"script": {
"lang": "painless",
"source": "if (ctx._source.lock_type == 'exclusive') { Debug.explain('one thread is writing data, the lock is exclusive now'); } ctx._source.lock_count++"
}
}
# 讀操作完畢釋放鎖指令碼
POST _scripts/rw-unlock
{
"script": {
"lang": "painless",
"source": "if ( --ctx._source.lock_count == 0) { ctx.op = 'delete' }"
}
}
- 每次有一個執行緒讀資料時,執行一次加鎖操作
POST /files-lock/lock/1/_update
{
"upsert": {
"lock_type": "shared",
"lock_count": 1
},
"script": {
"id": "rw-lock"
}
}
在多個頁面上嘗試,可以看到lock_count在逐一遞增,模擬多個執行緒同時讀一個文件的操作。
- 在有執行緒讀文件,還未釋放的情況下,嘗試對該文件加一個排他鎖
PUT /files-lock/lock/1/_create
{ "lock_type": "exclusive" }
結果肯定會報錯:
{
"error": {
"root_cause": [
{
"type": "version_conflict_engine_exception",
"reason": "[lock][1]: version conflict, document already exists (current version [8])",
"index_uuid": "XD7LFToWSKe_6f1EvLNoFw",
"shard": "3",
"index": "files-lock"
}
],
"type": "version_conflict_engine_exception",
"reason": "[lock][1]: version conflict, document already exists (current version [8])",
"index_uuid": "XD7LFToWSKe_6f1EvLNoFw",
"shard": "3",
"index": "files-lock"
},
"status": 409
}
- 執行緒讀資料完成後,對共享鎖進行釋放,執行釋放鎖的指令碼
POST /files-lock/lock/1/_update
{
"script": {
"id": "rw-unlock"
}
}
釋放1次lock_count減1,減到0時,說明所有的共享鎖已經釋放完畢,就把這個doc刪除掉
- 所有共享鎖釋放完畢,嘗試加排他鎖
PUT /files-lock/lock/1/_create
{ "lock_type": "exclusive" }
此時能夠加鎖成功,響應報文:
{
"_index": "files-lock",
"_type": "lock",
"_id": "1",
"_version": 1,
"found": true,
"_source": {
"lock_type": "exclusive"
}
}
- 有排他鎖的情況,嘗試加一個共享鎖,失敗資訊如下:
{
"error": {
"root_cause": [
{
"type": "remote_transport_exception",
"reason": "[node-1][192.168.17.137:9300][indices:data/write/update[s]]"
}
],
"type": "illegal_argument_exception",
"reason": "failed to execute script",
"caused_by": {
"type": "script_exception",
"reason": "runtime error",
"painless_class": "java.lang.String",
"to_string": "one thread is writing data, the lock is exclusive now",
"java_class": "java.lang.String",
"script_stack": [
"Debug.explain('one thread is writing data, the lock is exclusive now'); } ",
" ^---- HERE"
],
"script": "rw-lock",
"lang": "painless",
"caused_by": {
"type": "painless_explain_error",
"reason": null
}
}
},
"status": 400
}
- 排他鎖事務執行完成時,刪除文件即可對鎖進行釋放
DELETE /files-lock/lock/1
指令碼解釋
讀鎖的加鎖指令碼和釋放鎖指令碼,成對出現,用來統計執行緒的數量。
寫鎖利用_create
語法來實現,如果有執行緒對某一文件有讀取操作,那麼對這個文件執行_create操作肯定報錯。
小結
利用Elasticsearch一些語法的特性,加上painless指令碼的配合,也能完整的復現全域性鎖、行鎖、讀寫鎖的特性,實現的思路還是挺有意思的,跟使用redis、zookeeper實現分散式鎖有異曲同工之處,只是生產案例上用redis實現分散式鎖是比較成功的實踐,Elasticsearch的對這種分散式鎖的實現方式可能不是最佳實踐,但也可以瞭解一下。
專注Java高併發、分散式架構,更多技術乾貨分享與心得,請關注公眾號:Java架構社群
可以掃左邊二維碼新增好友,邀請你加入Java架構社群微信群共同探討技術