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