淺談分布式鎖的實現(xiàn)

為什么要使用鎖

業(yè)務(wù)在同一時刻只能有一個實例在運行,比如活動開獎、耗時數(shù)據(jù)庫操作等場景,通常都以保證業(yè)務(wù)執(zhí)行正常、節(jié)約服務(wù)器資源或者提高程序健壯性為目的。

引子 —— 文件鎖實現(xiàn)鎖

借助文件系統(tǒng)自帶的鎖機制 —— 排它鎖 來實現(xiàn),即一個進程在啟動時獲得一個文件的排它鎖,并在自己的整個運行期間都保留句柄資源而不釋放鎖,使得另一個進程實例在啟動時想要獲得同一文件時失敗,從而保證在 單臺機器上同一時刻至多只有一個實例 在運行
PHP代碼實現(xiàn)如下:

<?php
$filename = __FILE__ . '.lock';
$handle = fopen($filename, 'w');
if (!flock($handle, LOCK_EX | LOCK_NB)) {
    // 獲取排它鎖不成功,說明已經(jīng)有其他進程獲取到文件鎖,視作已有實例在執(zhí)行,當(dāng)前進程退出
    echo "當(dāng)前已經(jīng)有進程在執(zhí)行,本進程不執(zhí)行", PHP_EOL;
    exit();
}
/* 業(yè)務(wù)邏輯 */

在這里講一個本人在實踐中遇到的一個小坑
上述示例代碼中沒有采用面向?qū)ο蟮某绦蛟O(shè)計,習(xí)慣面向?qū)ο缶幋a的同學(xué)喜歡將上述程序過程整合到一個類里頭,注意這個時候需要保證 $handle 在你的方法結(jié)束之后還沒有被釋放,如下面這樣的寫法就是有問題的:

<?php
/* 這段示例代碼是有問題的,是個錯誤示范,可不要在自己的項目中使用噢 */
class Locker {
    public static function doLock() {
        $filename = __FILE__ . '.lock';
        $handle = fopen($filename, 'w');
        if (!flock($handle, LOCK_EX | LOCK_NB)) {
            // 獲取排它鎖不成功,說明已經(jīng)有其他進程獲取到文件鎖,視作已有實例在執(zhí)行,當(dāng)前進程退出
            echo "當(dāng)前已經(jīng)有進程在執(zhí)行,本進程不執(zhí)行", PHP_EOL;
            exit();
        }
    }
}

Locker::doLocker();
/* 業(yè)務(wù)邏輯 */

因為 doLocker() 方法執(zhí)行完畢之后,句柄 $handle 作為局部變量會被立即會收掉,所以排它鎖也會被釋放掉,進程鎖就直接失效了。

  • 優(yōu)點
    穩(wěn)定!本人在不計其數(shù)的業(yè)務(wù)中使用過文件鎖,兩年多程序員職業(yè)生涯還沒有在此方面翻過車。只要磁盤不出問題,文件鎖還是很給力的;

  • 缺點
    從上述示例可見,文件鎖依賴本次文件系統(tǒng),只能在單個操作系統(tǒng)中產(chǎn)生作用。
    由于業(yè)務(wù)的橫向擴展,通常情況下一套業(yè)務(wù)需要部署在多臺服務(wù)器上,此時文件鎖便不能滿足需求了。
    如果要實現(xiàn)跨越操作系統(tǒng)的鎖限制,則必須引入 外部存儲方案;

幾個分布式鎖實現(xiàn)方案

從理論上來說,任何第三方的存儲都能夠?qū)崿F(xiàn)本地文件系統(tǒng)功能。只不過各種操作需要走網(wǎng)絡(luò)IO,在穩(wěn)定性、速率上同本地文件系統(tǒng)會存在一定的差距。下面來研究一下幾個比較常用的外部存儲方案來實現(xiàn)分布式鎖。

MySQL

論及最常用的外部存儲方案,MySQL作為從大學(xué)時期就接觸的數(shù)據(jù)庫選型,必然首當(dāng)其沖。使用數(shù)據(jù)庫實現(xiàn)分布式鎖也有兩種方式:僅將數(shù)據(jù)庫作為存儲數(shù)據(jù)庫排它鎖;

僅將數(shù)據(jù)庫作為存儲

  • 基本思路

    1. 定義鎖的標(biāo)識符,這個標(biāo)識符作為數(shù)據(jù)庫表中的表征字段(主鍵),以此字段查詢表中是否存在對應(yīng)記錄,若存在,則說明存在鎖,則稍后再來檢查;否則執(zhí)行下一步;
    2. 執(zhí)行 INSERT 語句插入鎖信息,此時可能有好幾個進程同時執(zhí)行插入語句,但是由于插入字段中會含有主鍵,所以只有會一條 INSERT語句執(zhí)行成功,執(zhí)行成功的實例獲得排它鎖,則開始執(zhí)行自己的業(yè)務(wù)邏輯;其他執(zhí)行失敗的實例則稍后再來檢查,從第一步重新開始;
    3. 業(yè)務(wù)邏輯執(zhí)行完畢之后,執(zhí)行 DELETE 語句刪除掉數(shù)據(jù)庫中的鎖記錄從而釋放鎖;
    4. 另外起一個維護鎖的業(yè)務(wù)來定期刪除掉數(shù)據(jù)庫中過期的鎖記錄,防止因為程序意外退出而沒有刪除掉鎖記錄造成死鎖;
  • 存在問題
    上述業(yè)務(wù)雖然能夠滿足簡單的一些需求,但是還是存在問題:

    1. 需要保證MySQL服務(wù)的高可用;
    2. 必須使用輪詢的方式去檢查和獲得鎖,輪詢間隔時間長了,業(yè)務(wù)執(zhí)行中斷到重新啟動業(yè)務(wù)之間存在空檔期變長了,不能忍受中斷時間過長的業(yè)務(wù)不適用;輪詢間隔短了,MySQL操作將會變得頻繁,一旦業(yè)務(wù)增多,將會出現(xiàn)性能問題;
    3. 最后一步中的死鎖清理存在誤判風(fēng)險,存在業(yè)務(wù)還未執(zhí)行完畢鎖就被清除的情況,從而導(dǎo)致同一時刻運行多個實例;

利用數(shù)據(jù)庫的排它鎖

采用數(shù)據(jù)庫排它鎖必須滿足兩個條件:

  1. 數(shù)據(jù)庫表格使用 InnoDB 引擎;
  2. 必須使用到表格主鍵,否則會將整個表格都鎖??;
  • 表格設(shè)計:
字段 類型 注釋
name VARCHAR(100) Primary Key 名稱
info VARCHAR(100) - 名稱
created_at INT(10) UNSIGNED - 創(chuàng)建時間

構(gòu)造語句如下:

CREATE TABLE `business_lock` (
    `name` VARCHAR(100) NOT NULL COMMENT '名稱',
    `info` VARCHAR(100) NOT NULL COMMENT '信息',
    `created_at` INT(10) UNSIGNED NOT NULL COMMENT '創(chuàng)建時間',
    PRIMARY KEY (`name`)
)
COMMENT='業(yè)務(wù)鎖'
ENGINE=InnoDB
;
  • 基本思路

    1. 假定鎖名稱為 lock ,則先到數(shù)據(jù)庫中查詢是否存在 name = 'lock' 的記錄,不存在則 INSERT 一條,再向下執(zhí)行;否則直接向下執(zhí)行;
    2. 執(zhí)行數(shù)據(jù)庫語句:
      START TRANSACTION; SELECT * FROM `business_lock` WHERE `name` = 'lock' FOR UPDATE;
      
      執(zhí)行之后,由于數(shù)據(jù)行鎖的作用,只有有一個實例會直接返回記錄信息,其他的實例都會進入阻塞狀態(tài);
    3. 執(zhí)行業(yè)務(wù)邏輯,執(zhí)行完畢之后,只用 COMMIT 語句釋放行鎖;
  • 優(yōu)點

    1. 使用了MySQL行鎖帶來的阻塞特性,使得實例A掛掉,實例B馬上可以接替A的工作,期間空檔期會縮短;
    2. 不用輪詢MySQL;
  • 缺點

    1. 業(yè)務(wù)增多并且MySQL的排它鎖長期不釋放,會導(dǎo)致MySQL的連接變多,占據(jù)大量的MySQL連接池資源;

Redis

除了MySQL這樣的關(guān)系型數(shù)據(jù)庫之外,我們用得最多的就是 Redis ,Redis 作為非關(guān)系型的內(nèi)存數(shù)據(jù)庫,在執(zhí)行速度上比MySQL要快上不少。Redis實現(xiàn)分布式鎖本質(zhì)上和上面介紹的第一種MySQL方案是一樣的。

單點 Redis

  • 基本思路

    1. 定義鎖的標(biāo)識符,并生成 token;
    2. 以標(biāo)識符作為 key 執(zhí)行 setnx 設(shè)置值為 tokenexpire語句 (用LUA封裝,保證原子性),若數(shù)據(jù)庫中無記錄,則會執(zhí)行成功,表示實例獲得鎖;否則執(zhí)行失敗表示鎖已經(jīng)被其他實例已經(jīng)獲得鎖,不繼續(xù)向下執(zhí)行;
    3. 執(zhí)行業(yè)務(wù)邏輯,實例結(jié)束之前執(zhí)行獲取 key 對應(yīng)的內(nèi)容,如果內(nèi)容和 token 相同,則執(zhí)行刪除(get 和 del 操作用LUA進行封裝來保證原子性);
  • 優(yōu)點

  1. 執(zhí)行效率高,而且自帶過期操作,開發(fā)友好;
  • 缺點
  1. 強依賴Redis,單點Redis風(fēng)險高,掛掉之后造成實例都不會進行;
  2. 存在 key 過期之后實例還沒執(zhí)行完畢的情況,有概率在同一時刻會執(zhí)行多個實例;

RedLock

Redis Distlock

Zookeeper

ZooKeeper是一個高可用的分布式數(shù)據(jù)管理與系統(tǒng)協(xié)調(diào)框架,在Paxos算法的加持之下,該框架在分布式的環(huán)境中可以保持非常強的數(shù)據(jù)一致性,從而可以幫助解決很多分布式問題。
我們可以簡單地將它看成是一個遠程的小文件服務(wù),而每個小文件又支持狀態(tài)變化的監(jiān)聽和通知,它的數(shù)據(jù)模型如下:

/-
 |--- locks/
      |--- mylock0000001
      |--- mylock0000002
 |--- service/
 |--- users/

上面根路徑下的各種路徑和節(jié)點都是由我們自己手動創(chuàng)建的。
在上面的每個節(jié)點上,我們都可以新增監(jiān)聽器,當(dāng)zookeeper發(fā)現(xiàn)節(jié)點發(fā)生變化時(增、改、刪),都會通知到監(jiān)聽它的客戶端。

如何使用Zookeeper實現(xiàn)分布式鎖

利用Zookeeper實現(xiàn)分布式鎖也有兩種基本思路:

  1. 利用節(jié)點名稱唯一性實現(xiàn)共享所,和文件鎖有些類似;
    由于和文件鎖比較類似,原理不再贅述。不過要提的是,當(dāng)節(jié)點(鎖)被釋放時,zookeeper會通知到所有監(jiān)聽這個節(jié)點的客戶端,從而各個客戶端開始競爭,最終只有一個客戶端獲得鎖。雖然實現(xiàn)簡單,但鎖釋放時喚醒了所有的客戶端,產(chǎn)生了「驚群效應(yīng)」,故在性能上不是很客觀。
  2. 利用臨時順序節(jié)點實現(xiàn)共享鎖;
    Zookeeper 還支持一個很厲害的特性:臨時節(jié)點和順序節(jié)點。
    臨時節(jié)點顧名思義,就是臨時創(chuàng)建的節(jié)點,客戶端創(chuàng)建的該類節(jié)點,在客戶端和服務(wù)連接斷開時就會刪除;
    而順序節(jié)點就是在節(jié)點名稱最后自動加上后綴,這個后綴在節(jié)點所在路徑中時自增的。
    依靠這兩種特性,聰明而偉大的開發(fā)者就想到了一種比較好的監(jiān)聽流程
 ↑:表示監(jiān)聽


Instance1 -> /locks/0000001
 ↑
Instance2 -> /locks/0000002
 ↑
Instance3 -> /locks/0000003
 ↑
Instance4 -> /locks/0000005
 ↑
......

上面這段示例中InstanceX表示運行實例,/locks/000000X為獲得的節(jié)點路徑,節(jié)點路徑后綴最小的節(jié)點獲得鎖,其他的每一個實例都去監(jiān)聽所有節(jié)點中比自己次小(比自己小的節(jié)點中最大的節(jié)點)的節(jié)點的變化。
當(dāng)1號實例釋放了鎖,那么2號實例就會得到通知,再掃描一下所有節(jié)點,判斷到自己是最小的節(jié)點了,于是便獲得了鎖,后續(xù)的節(jié)點按照這個邏輯類推;
如果在1號實例釋放前3號實例突然意外掛了,4號節(jié)點得到通知,掃描一下所有節(jié)點發(fā)現(xiàn)自己并不是最小的,于是開始監(jiān)聽2號節(jié)點的變化,所以整個監(jiān)聽鏈路是穩(wěn)健的。
該方法每次鎖釋放時只會通知到一個客戶端,所以不會有「驚群效應(yīng)」。

總結(jié)

總之,設(shè)計分布式鎖無非就是處理如下這三個方面的問題:

  1. 獲得鎖;
    • 數(shù)據(jù)庫用行鎖或者用唯一約束字段實現(xiàn);
    • Redis用 setnx 實現(xiàn);
    • Zookeeper用 最小節(jié)點 實現(xiàn);
  2. 釋放鎖;
    • 正常情況下客戶端會主動釋放,如刪掉數(shù)據(jù)庫的某條數(shù)據(jù),釋放行鎖等;
    • 異常情況下,需要借助過期鎖清理機制釋放鎖,而zookeeper和客戶端之間就存在心跳,如果客戶端意外退出,心跳檢測可以立即發(fā)現(xiàn),從而服務(wù)端主動清鎖;
  3. 釋放鎖通知:分為客戶端主動獲取 和 服務(wù)端主動告知,前者需要客戶端做輪詢操作,在時效性上不如后者;

從這幾點看,zookeeper在實現(xiàn)分布式鎖最為簡單。
后續(xù)我會再研究一下zookeeper的性能。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容