Elasticsearch系列---實現分散式鎖

清茶豆奶發表於2020-04-30

概要

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. 執行緒1上鎖命令:
PUT /files/file/global/_create
{}
  • files表示索引名稱。
  • file為type,6.3.1一個索引只允許有一個type,選用file作用type名稱。
  • global:即document的id,固定寫為global表示全域性鎖,或者使用專門的索引進行加鎖操作。
  • _create: 強制必須是建立,如果已經存在,那麼建立失敗,報錯。
  1. 執行緒1執行事務方法:更新檔名
POST /files/file/global/_update
{
  "doc": {
    "name":"ReadMe.txt"
  }
}
  1. 執行緒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. 執行緒1釋放鎖

DELETE files/file/global

  1. 執行緒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
}
  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. 執行緒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. 執行緒1、執行緒2查詢鎖資訊
{
  "_index": "files-lock",
  "_type": "lock",
  "_id": "1",
  "_version": 1,
  "found": true,
  "_source": {
    "process_id": "181ab3ee-28cc-4339-ba35-69802e06fe42"
  }
}
  1. 執行緒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. 執行緒1執行事務方法
POST /files/file/1/_update
{
  "doc": {
    "name":"README1.txt"
  }
}
  1. 執行緒1的事務方法執行完成,並通過刪除id為1的文件,相當於釋放鎖

DELETE /files-lock/lock/1

  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' }"
    }
}
  1. 每次有一個執行緒讀資料時,執行一次加鎖操作
POST /files-lock/lock/1/_update
{
  "upsert": { 
    "lock_type":  "shared",
    "lock_count": 1
  },
  "script": {
    "id": "rw-lock"
  }
}

在多個頁面上嘗試,可以看到lock_count在逐一遞增,模擬多個執行緒同時讀一個文件的操作。

  1. 在有執行緒讀文件,還未釋放的情況下,嘗試對該文件加一個排他鎖
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
}
  1. 執行緒讀資料完成後,對共享鎖進行釋放,執行釋放鎖的指令碼
POST /files-lock/lock/1/_update
{
  "script": {
    "id": "rw-unlock"
  }
}

釋放1次lock_count減1,減到0時,說明所有的共享鎖已經釋放完畢,就把這個doc刪除掉

  1. 所有共享鎖釋放完畢,嘗試加排他鎖
PUT /files-lock/lock/1/_create
{ "lock_type": "exclusive" }

此時能夠加鎖成功,響應報文:

{
  "_index": "files-lock",
  "_type": "lock",
  "_id": "1",
  "_version": 1,
  "found": true,
  "_source": {
    "lock_type": "exclusive"
  }
}
  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": "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
}
  1. 排他鎖事務執行完成時,刪除文件即可對鎖進行釋放

DELETE /files-lock/lock/1

指令碼解釋

讀鎖的加鎖指令碼和釋放鎖指令碼,成對出現,用來統計執行緒的數量。

寫鎖利用_create語法來實現,如果有執行緒對某一文件有讀取操作,那麼對這個文件執行_create操作肯定報錯。

小結

利用Elasticsearch一些語法的特性,加上painless指令碼的配合,也能完整的復現全域性鎖、行鎖、讀寫鎖的特性,實現的思路還是挺有意思的,跟使用redis、zookeeper實現分散式鎖有異曲同工之處,只是生產案例上用redis實現分散式鎖是比較成功的實踐,Elasticsearch的對這種分散式鎖的實現方式可能不是最佳實踐,但也可以瞭解一下。

專注Java高併發、分散式架構,更多技術乾貨分享與心得,請關注公眾號:Java架構社群
可以掃左邊二維碼新增好友,邀請你加入Java架構社群微信群共同探討技術
Java架構社群

相關文章