關(guān)于如何實現(xiàn)分布式鎖,大家可能對基于redis?實現(xiàn)比較熟悉,但是往往很多情況是一些并發(fā)量不大的項目用不上Redis,Redis往往適用于并發(fā)量比較大的場景。但是MySQL基本都是有的,所以今天我來談?wù)勅绾位贛ySQL實現(xiàn)我們的分布式鎖。
?前言
分布式鎖想必大家都不陌生,可以用來解決在分布式環(huán)境下,多個用戶在同一時間讀取/更新相同的資源帶來的問題。比如秒殺場景下的庫存問題、redis key失效情況下請求直接打到MySQL中造成MySQL負(fù)載過大的問題,這些問題都可以通過分布式鎖來解決。
關(guān)于如何實現(xiàn)分布式鎖,大家可能對基于Redis?實現(xiàn)比較熟悉,但是往往很多情況是一些并發(fā)量不大的項目用不上Redis,Redis往往適用于并發(fā)量比較大的場景。但是MySQL基本都是有的,所以今天我來談?wù)勅绾位贛ySQL實現(xiàn)我們的分布式鎖。
設(shè)計目標(biāo)
- 互斥。不同機器上許多進程/線程中只有一個可以訪問特定資源,其他進程/線程應(yīng)該等到鎖被釋放才可以用。
- TTL。從CAP理論我們知道,網(wǎng)絡(luò)總是不可靠的,任何一臺服務(wù)器都有可能宕機一段時間。所以我們在設(shè)計分布式鎖服務(wù)的時候,需要考慮到可能有一個持有鎖的客戶端宕機,無法釋放鎖,從而阻塞所有等待獲取同一個鎖的客戶端。所以我們需要一種機制,可以在這種情況下自動釋放鎖來解鎖其他客戶端。
- 相關(guān)API
- lock():獲取鎖
- unlock():釋放鎖
- tryLock(): 可選,更高級的API,例如:客戶端可以指定獲取鎖的最大等待時間。如果不能在窗口內(nèi)獲得鎖,則錯誤返回而不是繼續(xù)等待。
- 高性能
- 低延遲:在正常情況下,鎖定和解鎖應(yīng)該非常快。比如實際的業(yè)務(wù)邏輯處理只需要1ms,而單純的獲取和釋放鎖,處理一個請求又需要100ms,那么最大QPS只能達(dá)到10,這對于現(xiàn)在的很多服務(wù)來說已經(jīng)很低了。在這種情況下,服務(wù)器可以處理的最大 QPS 受到鎖性能的限制。
- 通知機制:分布式鎖理想情況下應(yīng)該提供通知機制。如果服務(wù)器進程A由于被另一個服務(wù)器進程B持有而無法獲得鎖,那么A不應(yīng)該一直等待并占用CPU。相反,A 應(yīng)該空閑以避免浪費 CPU資源 。然后當(dāng)鎖可用時,鎖服務(wù)通知A,A將獲得CPU資源并恢復(fù)運行。
- 避免驚群效應(yīng)。假設(shè)有 100 個進程想要獲取同一個鎖,當(dāng)鎖可用時,理想情況下應(yīng)該只通知隊列中的“下一個”進程,而不是突然調(diào)用所有 100 個進程來競爭鎖。
- 公平。先到先得。等待時間最長的人應(yīng)該下一個獲得鎖。如果是這樣,則該鎖被認(rèn)為是公平鎖。否則就是非公平鎖。這兩種鎖在現(xiàn)實中都有實際使用。
- 重入鎖。 想象一下,一個節(jié)點或服務(wù)器進程獲取了一個鎖,開始處理業(yè)務(wù)邏輯,然后遇到一個代碼片段要求再次獲取同一個鎖,在這種情況下,節(jié)點或進程不應(yīng)死鎖,相反,它應(yīng)該能夠再次獲取相同的鎖,因為它已經(jīng)持有鎖。
MySQL如何實現(xiàn)分布式鎖?
1. 唯一鍵約束
我們可以使用MySQL的唯一性約束來實現(xiàn)分布式鎖,整體的思路如下:
- 客戶端 A 正在嘗試獲取鎖。此時沒有其他客戶端持有鎖,所以客戶端A成功獲取到了鎖,并向MySQL表中插入一行數(shù)據(jù)。
- 現(xiàn)在客戶端 B 想要獲取相同的鎖,先查詢DB,發(fā)現(xiàn)客戶端A插入的行已經(jīng)存在。在這種情況下,客戶端B無法獲取到鎖。然后客戶端 B 將等待一段時間后重試。客戶端 B 會在指定的 TTL 窗口內(nèi)不斷重試幾次,最終要么在客戶端 A 釋放鎖后成功獲取鎖,要么因為 TTL 而失敗。
- 一旦客戶端 A 完成其任務(wù),它將通過簡單地刪除 DB 表中的行來釋放鎖lock。現(xiàn)在其他客戶端能夠獲取鎖。
現(xiàn)在我們來簡單實現(xiàn)下,創(chuàng)建一個lock?表,其中l(wèi)ock_key字段有唯一性約束。
CREATE TABLE `lock` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`lock_key` varchar(256) NOT NULL,
`holder` varchar(256) NOT NULL,
`creation_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_lock_key` (`lock_key`)
);
- lock_key? 是鎖的唯一名稱。我們可以使用 project_name + resource_id 作為鎖的名稱,表明要搶的資源是什么,具備唯一性。
- holder?是當(dāng)前持有鎖的客戶端ID。我們可以使用service_name +IP 地址 + thread_id 來標(biāo)識分布式環(huán)境中的客戶端。
獲取鎖:
INSERT INTO `lock`(`lock_key`, `holder`) VALUES ('project1_uid1', 'server1_ip1_tid1');
釋放鎖:
DELETE FROM `lock` WHERE `lock_key` = 'project1_uid1';
上面的方案已經(jīng)基本滿足通過MySQL實現(xiàn)分布式鎖的基本要求。現(xiàn)在讓我們考慮一些特殊情況,看看它是否對分布式系統(tǒng)中的常見故障具有魯棒性。
如果客戶端 A 獲取了鎖,向 DB 中插入了一行,但后來客戶端 A 崩潰了,或者網(wǎng)絡(luò)分區(qū)和客戶端 A 無法訪問 DB 怎么辦?在這種情況下,該行將保留在數(shù)據(jù)庫中,不會被刪除。換句話說,對于其他客戶端來說,就好像客戶端 A 仍然持有鎖(即使 A 已經(jīng)崩潰了!)。其他客戶端將無法獲取鎖,并返回錯誤。
一種常用的方法是為每個鎖分配一個 TTL。這個想法很簡單:如果客戶端 A 崩潰并且無法釋放鎖,那么其他人應(yīng)該執(zhí)行刪除 DB 中的行從而釋放鎖的工作。假設(shè)通常客戶端 A 需要 3 分鐘才能完成任務(wù)。我們可以將 TTL 設(shè)置為 5 分鐘。然后我們需要構(gòu)建另一個服務(wù)來不斷掃描lock表,并刪除超過 5 分鐘前創(chuàng)建的任何行。但是,還有其他問題:
- 如果 A 沒有崩潰,它只需要比平時多一點時間來完成任務(wù)怎么辦?
- 如果我們?yōu)閽呙鑜ock表而構(gòu)建的這項新服務(wù)本身崩潰了怎么辦?
第一個問題用MySQL很難完全解決。我們可以考慮A在獲取到分布式鎖后,新起個線程去檢查鎖是否快要過期了,比如發(fā)現(xiàn)TTL還剩下1/3時間,但是A還沒有結(jié)束,這時候去擴大TTL時間,這就是鎖的續(xù)簽機制。但是在現(xiàn)實中,對于大部分的業(yè)務(wù)案例,我們總是可以設(shè)置一個足夠大的TTL,使得這種情況很少發(fā)生,以至于對公司業(yè)務(wù)的影響幾乎察覺不到。
現(xiàn)在讓我們看看第2個問題怎么解決?
2. 使用時間戳+唯一鍵約束
我們可以在lock?表中添加一列來存儲上次獲取鎖的時間戳last_lock_time。
CREATE TABLE `lock` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`lock_key` varchar(128) NOT NULL,
`holder` varchar(128) NOT NULL DEFAULT '',
`version` int(11) not null,
`creation_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_lock_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_lock_key` (`lock_key`)
);
現(xiàn)在我們用${timeout}表示分布式鎖的TTL。
獲取鎖:
當(dāng)客戶端 B 試圖獲取鎖時,我們可以添加`last_lock_time` < ${now} - ${timeout}?作為where條件的一部分。
UPDATE `lock` SET `holder` = 'server1_ip1_tid1', `last_lock_time` = ${now} WHERE `lock_key` = 'project1_uid1' and `last_lock_time` < ${now} - ${timeout};
在這種情況下,只有當(dāng)`last_lock_time` < ${now} - ${timeout}?客戶端 B 可以獲取鎖、將 holder? 更改為其 ID 并將其重置last_lock_time?為當(dāng)前時間戳?xí)r。假設(shè)后面客戶端 B 掛了,不能釋放鎖,最壞的情況是等待${timeout}TTL時間以后,其他客戶端就能拿到鎖。
釋放鎖:
我們可以把last_lock_time?更新為一個很小時間戳,例如‘1970–01–01 00:00:01’。
UPDATE `lock` SET `holder` = '', `last_lock_time` = ${min_timestamp} WHERE `lock_key` = 'project1_uid1' and `holder` = 'server1_ip1_tid1';
在WHERE語句中,我們添加了`holder` = ‘server1_ip1_tid1’,這是為了避免其他客戶端不小心釋放了當(dāng)前客戶端持有的鎖。
成功釋放鎖后,holder?將其設(shè)置為空,并將last_lock_time設(shè)置為最小時間戳,以便其他客戶端可以輕松獲取鎖。
現(xiàn)在我們解決了TTL問題,但是在上面的實現(xiàn)中,如果持有鎖,其他客戶端將需要一直循環(huán)重試,等待鎖釋放后再獲取鎖。如果分布式鎖服務(wù)可以通知等待的客戶端鎖可用,那就更好了,我們思考下在MySQL中該如何實現(xiàn)。
3.使用FOR UPDATE?實現(xiàn)鎖釋放通知
MySQL具有行級鎖功能,在RC隔離級別下,當(dāng)我們使用FOR UPDATE?時,MySQL會為所有符合過濾條件的行加行級鎖。當(dāng)一個客戶端會話獲得鎖時,所有其他客戶端都將等待鎖。此外,等待客戶端喚醒并獲取鎖的順序與它們首次嘗試獲取鎖時的順序相同。只要持有鎖的客戶端在 SQL 事務(wù)內(nèi)執(zhí)行邏輯,F(xiàn)OR UPDATE 就可以執(zhí)行多次。換句話說,鎖是重入鎖。
另外,針對FOR UPDATE?,MySQL還支持兩種模式:NOWAIT? 和 SKIP LOCKED。
- NOWAIT:不等待鎖的釋放。如果鎖被其他客戶端持有,無法獲取,則立即返回鎖沖突消息。
- SKIP LOCKED:讀取數(shù)據(jù)時,跳過行級鎖被其他客戶端持有的行。
通過這兩個選項,我們可以實現(xiàn)tryLock行為,即客戶端嘗試獲取鎖,獲取不到鎖則立即返回,而不是等待。
我們可以簡化我們的lock表以僅包含兩個字段:
CREATE TABLE `lock` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`lock_key` varchar(128) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_lock_key` (`lock_key`)
);
獲取鎖:
BEGIN;
SELECT * FROM `demo`.`lock` WHERE `lock_key` = 'project1_uid1' FOR UPDATE;
這里關(guān)于啟動新事務(wù)BEGIN? 做一個說明,只有在第一次獲取鎖時才需要它。后續(xù)重入時,不要執(zhí)行BEGIN,否則會啟動一個新的事務(wù),現(xiàn)有的事務(wù)結(jié)束,實際上是在事務(wù)結(jié)束時釋放鎖。
非阻塞嘗試鎖tryLock():
BEGIN;
SELECT * FROM `demo`.`lock` WHERE `lock_key` = 'project1_uid1' FOR UPDATE NOWAIT;
釋放鎖:
COMMIT;
提交事務(wù)就可以釋放鎖。
總結(jié)
我們現(xiàn)在回頭來看看基于MySQL實現(xiàn)分布式鎖,是否滿足我們一開始定下的設(shè)計目標(biāo):
- 互斥,最基本的功能,肯定是可以的。
- TTL 機制,MySQL 本地管理客戶端會話。如果客戶端由于機器故障或網(wǎng)絡(luò)故障而斷開連接,MySQL 將自動釋放行級鎖。
- 支持所有 3 個 API:獲取/嘗試/釋放鎖。
- 高性能:釋放鎖時,MySQL只會通知隊列中等待的下一個客戶端,而不是一次性通知所有客戶端,避免雷群問題。
- 公平。MySQL 行鎖本身支持。
- 重入。MySQL 行鎖本身也支持。記住第一次獲取鎖就開始事務(wù),以后再入時不要再開始新的事務(wù)。
看來基本上是沒什么問題的,但是還有一點,我們需要提前向lock表中插入資源鎖的數(shù)據(jù),然后獲取/嘗試/釋放鎖的 API 才能按預(yù)期工作。
參考:https://medium.com/@bb8s/design-distributed-lock-with-mysql-9bc28ac59629