為什么要使用鎖
業(yè)務在同一時刻只能有一個實例在運行,比如活動開獎、耗時數據庫操作等場景玖喘,通常都以保證業(yè)務執(zhí)行正常、節(jié)約服務器資源或者提高程序健壯性為目的曹鸠。
引子 —— 文件鎖實現鎖
借助文件系統(tǒng)自帶的鎖機制 —— 排它鎖
來實現煌茬,即一個進程在啟動時獲得一個文件的排它鎖,并在自己的整個運行期間都保留句柄資源而不釋放鎖彻桃,使得另一個進程實例在啟動時想要獲得同一文件時失敗坛善,從而保證在 單臺機器上同一時刻至多只有一個實例 在運行
PHP代碼實現如下:
<?php
$filename = __FILE__ . '.lock';
$handle = fopen($filename, 'w');
if (!flock($handle, LOCK_EX | LOCK_NB)) {
// 獲取排它鎖不成功,說明已經有其他進程獲取到文件鎖邻眷,視作已有實例在執(zhí)行眠屎,當前進程退出
echo "當前已經有進程在執(zhí)行,本進程不執(zhí)行", PHP_EOL;
exit();
}
/* 業(yè)務邏輯 */
在這里講一個本人在實踐中遇到的一個小坑:
上述示例代碼中沒有采用面向對象的程序設計肆饶,習慣面向對象編碼的同學喜歡將上述程序過程整合到一個類里頭改衩,注意這個時候需要保證 $handle
在你的方法結束之后還沒有被釋放,如下面這樣的寫法就是有問題的:
<?php
/* 這段示例代碼是有問題的驯镊,是個錯誤示范葫督,可不要在自己的項目中使用噢 */
class Locker {
public static function doLock() {
$filename = __FILE__ . '.lock';
$handle = fopen($filename, 'w');
if (!flock($handle, LOCK_EX | LOCK_NB)) {
// 獲取排它鎖不成功,說明已經有其他進程獲取到文件鎖板惑,視作已有實例在執(zhí)行橄镜,當前進程退出
echo "當前已經有進程在執(zhí)行,本進程不執(zhí)行", PHP_EOL;
exit();
}
}
}
Locker::doLocker();
/* 業(yè)務邏輯 */
因為 doLocker()
方法執(zhí)行完畢之后冯乘,句柄 $handle
作為局部變量會被立即會收掉洽胶,所以排它鎖也會被釋放掉,進程鎖就直接失效了裆馒。
優(yōu)點
穩(wěn)定妖异!本人在不計其數的業(yè)務中使用過文件鎖,兩年多程序員職業(yè)生涯還沒有在此方面翻過車领追。只要磁盤不出問題他膳,文件鎖還是很給力的;缺點
從上述示例可見绒窑,文件鎖依賴本次文件系統(tǒng)棕孙,只能在單個操作系統(tǒng)中產生作用。
由于業(yè)務的橫向擴展些膨,通常情況下一套業(yè)務需要部署在多臺服務器上蟀俊,此時文件鎖便不能滿足需求了。
如果要實現跨越操作系統(tǒng)的鎖限制订雾,則必須引入 外部存儲方案;
幾個分布式鎖實現方案
從理論上來說肢预,任何第三方的存儲都能夠實現本地文件系統(tǒng)功能。只不過各種操作需要走網絡IO洼哎,在穩(wěn)定性烫映、速率上同本地文件系統(tǒng)會存在一定的差距沼本。下面來研究一下幾個比較常用的外部存儲方案來實現分布式鎖。
MySQL
論及最常用的外部存儲方案锭沟,MySQL作為從大學時期就接觸的數據庫選型抽兆,必然首當其沖。使用數據庫實現分布式鎖也有兩種方式:僅將數據庫作為存儲 和 數據庫排它鎖族淮;
僅將數據庫作為存儲
-
基本思路
- 定義鎖的標識符辫红,這個標識符作為數據庫表中的表征字段(主鍵),以此字段查詢表中是否存在對應記錄祝辣,若存在贴妻,則說明存在鎖,則稍后再來檢查蝙斜;否則執(zhí)行下一步揍瑟;
- 執(zhí)行
INSERT
語句插入鎖信息,此時可能有好幾個進程同時執(zhí)行插入語句乍炉,但是由于插入字段中會含有主鍵,所以只有會一條INSERT
語句執(zhí)行成功滤馍,執(zhí)行成功的實例獲得排它鎖岛琼,則開始執(zhí)行自己的業(yè)務邏輯;其他執(zhí)行失敗的實例則稍后再來檢查巢株,從第一步重新開始槐瑞; - 業(yè)務邏輯執(zhí)行完畢之后,執(zhí)行
DELETE
語句刪除掉數據庫中的鎖記錄從而釋放鎖阁苞; - 另外起一個維護鎖的業(yè)務來定期刪除掉數據庫中過期的鎖記錄困檩,防止因為程序意外退出而沒有刪除掉鎖記錄造成死鎖;
-
存在問題
上述業(yè)務雖然能夠滿足簡單的一些需求那槽,但是還是存在問題:- 需要保證MySQL服務的高可用悼沿;
- 必須使用輪詢的方式去檢查和獲得鎖,輪詢間隔時間長了骚灸,業(yè)務執(zhí)行中斷到重新啟動業(yè)務之間存在空檔期變長了糟趾,不能忍受中斷時間過長的業(yè)務不適用;輪詢間隔短了甚牲,MySQL操作將會變得頻繁义郑,一旦業(yè)務增多,將會出現性能問題丈钙;
- 最后一步中的死鎖清理存在誤判風險非驮,存在業(yè)務還未執(zhí)行完畢鎖就被清除的情況,從而導致同一時刻運行多個實例雏赦;
利用數據庫的排它鎖
采用數據庫排它鎖必須滿足兩個條件:
- 數據庫表格使用
InnoDB
引擎劫笙; - 必須使用到表格主鍵芙扎,否則會將整個表格都鎖住邀摆;
- 表格設計:
字段 | 類型 | 鍵 | 注釋 |
---|---|---|---|
name | VARCHAR(100) | Primary Key | 名稱 |
info | VARCHAR(100) | - | 名稱 |
created_at | INT(10) UNSIGNED | - | 創(chuàng)建時間 |
構造語句如下:
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è)務鎖'
ENGINE=InnoDB
;
-
基本思路
- 假定鎖名稱為
lock
纵顾,則先到數據庫中查詢是否存在name = 'lock'
的記錄,不存在則INSERT
一條栋盹,再向下執(zhí)行施逾;否則直接向下執(zhí)行; - 執(zhí)行數據庫語句:
執(zhí)行之后例获,由于數據行鎖的作用汉额,只有有一個實例會直接返回記錄信息,其他的實例都會進入阻塞狀態(tài)榨汤;START TRANSACTION; SELECT * FROM `business_lock` WHERE `name` = 'lock' FOR UPDATE;
- 執(zhí)行業(yè)務邏輯蠕搜,執(zhí)行完畢之后,只用
COMMIT
語句釋放行鎖收壕;
- 假定鎖名稱為
-
優(yōu)點
- 使用了MySQL行鎖帶來的阻塞特性妓灌,使得實例A掛掉,實例B馬上可以接替A的工作蜜宪,期間空檔期會縮短虫埂;
- 不用輪詢MySQL;
-
缺點
- 業(yè)務增多并且MySQL的排它鎖長期不釋放圃验,會導致MySQL的連接變多掉伏,占據大量的MySQL連接池資源;
Redis
除了MySQL這樣的關系型數據庫之外澳窑,我們用得最多的就是 Redis
斧散,Redis
作為非關系型的內存數據庫,在執(zhí)行速度上比MySQL要快上不少摊聋。Redis實現分布式鎖本質上和上面介紹的第一種MySQL方案是一樣的鸡捐。
單點 Redis
-
基本思路
- 定義鎖的標識符,并生成
token
麻裁; - 以標識符作為 key 執(zhí)行
setnx
設置值為token
和expire
語句 (用LUA封裝闯参,保證原子性),若數據庫中無記錄悲立,則會執(zhí)行成功鹿寨,表示實例獲得鎖;否則執(zhí)行失敗表示鎖已經被其他實例已經獲得鎖薪夕,不繼續(xù)向下執(zhí)行脚草; - 執(zhí)行業(yè)務邏輯,實例結束之前執(zhí)行獲取 key 對應的內容原献,如果內容和
token
相同馏慨,則執(zhí)行刪除(get 和 del 操作用LUA進行封裝來保證原子性)埂淮;
- 定義鎖的標識符,并生成
優(yōu)點
- 執(zhí)行效率高,而且自帶過期操作写隶,開發(fā)友好倔撞;
- 缺點
- 強依賴Redis,單點Redis風險高慕趴,掛掉之后造成實例都不會進行痪蝇;
- 存在 key 過期之后實例還沒執(zhí)行完畢的情況,有概率在同一時刻會執(zhí)行多個實例冕房;
RedLock
Zookeeper
ZooKeeper是一個高可用的分布式數據管理與系統(tǒng)協(xié)調框架躏啰,在Paxos算法的加持之下,該框架在分布式的環(huán)境中可以保持非常強的數據一致性耙册,從而可以幫助解決很多分布式問題给僵。
我們可以簡單地將它看成是一個遠程的小文件服務,而每個小文件又支持狀態(tài)變化的監(jiān)聽和通知详拙,它的數據模型如下:
/-
|--- locks/
|--- mylock0000001
|--- mylock0000002
|--- service/
|--- users/
上面根路徑下的各種路徑和節(jié)點都是由我們自己手動創(chuàng)建的帝际。
在上面的每個節(jié)點上,我們都可以新增監(jiān)聽器饶辙,當zookeeper發(fā)現節(jié)點發(fā)生變化時(增蹲诀、改、刪)畸悬,都會通知到監(jiān)聽它的客戶端。
如何使用Zookeeper實現分布式鎖
利用Zookeeper實現分布式鎖也有兩種基本思路:
- 利用節(jié)點名稱唯一性實現共享所珊佣,和文件鎖有些類似蹋宦;
由于和文件鎖比較類似,原理不再贅述咒锻。不過要提的是冷冗,當節(jié)點(鎖)被釋放時,zookeeper會通知到所有監(jiān)聽這個節(jié)點的客戶端惑艇,從而各個客戶端開始競爭蒿辙,最終只有一個客戶端獲得鎖。雖然實現簡單滨巴,但鎖釋放時喚醒了所有的客戶端思灌,產生了「驚群效應」,故在性能上不是很客觀恭取。 - 利用臨時順序節(jié)點實現共享鎖泰偿;
Zookeeper 還支持一個很厲害的特性:臨時節(jié)點和順序節(jié)點。
臨時節(jié)點顧名思義蜈垮,就是臨時創(chuàng)建的節(jié)點耗跛,客戶端創(chuàng)建的該類節(jié)點裕照,在客戶端和服務連接斷開時就會刪除;
而順序節(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é)點的變化唉擂。
當1號實例釋放了鎖,那么2號實例就會得到通知檀葛,再掃描一下所有節(jié)點玩祟,判斷到自己是最小的節(jié)點了,于是便獲得了鎖屿聋,后續(xù)的節(jié)點按照這個邏輯類推空扎;
如果在1號實例釋放前3號實例突然意外掛了,4號節(jié)點得到通知润讥,掃描一下所有節(jié)點發(fā)現自己并不是最小的转锈,于是開始監(jiān)聽2號節(jié)點的變化,所以整個監(jiān)聽鏈路是穩(wěn)健的楚殿。
該方法每次鎖釋放時只會通知到一個客戶端撮慨,所以不會有「驚群效應」。
總結
總之脆粥,設計分布式鎖無非就是處理如下這三個方面的問題:
- 獲得鎖砌溺;
- 數據庫用行鎖或者用唯一約束字段實現;
- Redis用
setnx
實現变隔; - Zookeeper用
最小節(jié)點
實現规伐;
- 釋放鎖;
- 正常情況下客戶端會主動釋放匣缘,如刪掉數據庫的某條數據猖闪,釋放行鎖等;
- 異常情況下肌厨,需要借助過期鎖清理機制釋放鎖培慌,而zookeeper和客戶端之間就存在心跳,如果客戶端意外退出柑爸,心跳檢測可以立即發(fā)現检柬,從而服務端主動清鎖;
- 釋放鎖通知:分為客戶端主動獲取 和 服務端主動告知,前者需要客戶端做輪詢操作何址,在時效性上不如后者里逆;
從這幾點看,zookeeper在實現分布式鎖最為簡單用爪。
后續(xù)我會再研究一下zookeeper的性能原押。