一、基於數據庫實現分佈式鎖
大致思路:需要在數據庫中建一張鎖表,當我們需要鎖住某個資源或者方法時,就向該表中添加一條數據,如果需要釋放鎖則刪除數據即可。
以方法鎖爲例
create table method_lock
(
id int not null auto_increment,
method_name varchar(100) not null comment '鎖定的方法名',
desc varchar(100) comment '描述',
update_time datetime not null default CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
holder varchar(50) comment '鎖持有者',
primary key (id),
unique key `idx_method_name` (`method_name`) using BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 comment='方法鎖表';
如果需要鎖定某個方法,則向method_lock表中插一條數據
insert into method_lock(method_name, desc, holder) values ('function1', 'desc1', 'holder1');
如果需要釋放鎖,則從method_lock表中刪除數據
delete from method_lock where method_name = 'function1';
由於method_name列有唯一索引,若多個應用同時獲取function1的鎖,數據庫會保證只有一條數據插入成功,我們可以認爲插入成功的那個應用獲得了該方法鎖,就可以執行方法體。
以上的實現有如下的問題:
這把鎖強依賴於數據庫,數據庫單點,一旦數據庫掛掉,會導致所有需要獲取方法鎖的業務不可用;
鎖沒有加有效時間,一旦釋放鎖失敗了,則導致鎖數據一直存在數據庫中,其他應用無法再獲得該鎖;
這把鎖是非阻塞式的,一旦insert失敗就會直接報錯,嘗試獲得鎖的線程無法進入排隊隊列,如果想重新獲取鎖,只能再次觸發獲得鎖操作。
針對以上問題,有如下的解決方案:
數據庫單點?搞兩個數據庫,主主同步,掛了一個另一個也可以頂上;
鎖沒有有效時間?搞一個定時任務,隔一段時間就掃描一次表,將超時的數據刪除(需要設置一個合理的超時時間);
鎖非阻塞?如果希望獲取鎖失敗的應用進入排隊隊列,搞一個while循環反覆嘗試insert,直到insert成功(但大部分的業務場景是不需要這樣的)。
二、基於緩存實現分佈式鎖
相較於基於數據庫實現分佈式鎖,基於緩存實現分佈式鎖的性能會好一些(畢竟操作緩存的開銷小於操作數據庫的開銷),而且緩存可以實現分佈式部署,解決了基於數據庫實現分佈式鎖的單點問題。
常用的分佈式緩存有memcached和redis,下面分開講一下。
memcached:
可以利用memcached的add命令,這個命令在key不存在的時候纔會執行成功,併發下多個應用同時add同一個key,只有一個可以add成功,add成功的那個應用就獲取了鎖。處理完業務後需要將鎖釋放掉,用delete命令將key刪除掉即可(有些文章會先判斷一下緩存是否失效,沒失效的情況下刪除,失效了就不管它了,我個人覺得多此一舉,直接刪除沒那麼多麻煩事)
僞碼如下:
String key = 'method_lock_' + methodName;
try {
String value = holder;//鎖持有者的信息
long expireTime = xxx;//鎖的有效時長
if(mc.add(key, value, expireTime)) {
//do business things
//業務處理結束後,將緩存刪除
mc.delete(key);
}
} catch(Exception e) {
mc.delete(key);
}
redis:
redis沒有add這個命令,但它有一個SETNX(SET IF NOT EXISTS)可以實現一樣的效果,只有當key不存在時纔會設置成功並返回1,設置不成功時返回0。
但遺憾的是,SETNX這個命令不能像mc.add那樣直接設置失效時間,需要在設置成功後再通過EXPIRE命令追加設置失效時間。
僞碼如下:
String key = 'method_lock_' + methodName;
String value = holder;//鎖持有者的信息
long expireTime = xxx;//鎖的有效時長
int lockedRes = redis.SETNX(key,value);
if(lockedRes == 1) {
//[1]獲取鎖成功
//[2]追加設置失效時間
redis.EXPIRE(key, expireTime);
try {
//[3]do business things
//[4]業務處理結束後,將緩存刪除
redis.DEL(key);
} catch(Exception e) {
//業務處理過程中發生了異常,也需要將緩存刪除
redis.DEL(key);
}
}
以上僞碼基本上實現了分佈式鎖的功能,但是有一個問題,在[1]和[2]之間(即已經成功獲取了鎖但是還沒來得及設置失效時間時)應用掛掉了,那麼這個緩存將永久存在,其他應用再也無法獲取這個鎖。雖說這種場景是很少見的,但是也得處理啊,改進思路如下:
1.把value從holder變成expiredDate,即令value=expiredDate.toString(),嘗試通過SETNX(key,value)獲取鎖。若返回1,獲取鎖成功;返回0,獲取鎖失敗,進入下一步;
2.通過GET(key)命令,拿到已有的value(即expiredDate),判斷是否已經超時,若未超時,說明某個應用正在持有鎖;若已經超時,則進入下一步;
3.通過GETSET(key,newValue)命令(將給定key的值設爲newValue ,並返回key的舊值(oldValue)),拿到oldValue,再次解析出oldValue中包含的失效時間,判斷是否已經超時,若已經超時,說明獲取鎖成功。
爲什麼第三步中要再一次判斷是否超時呢?第二步裏不是判斷過了嗎?
原因是,可能有多個應用併發執行到第三步,比如應用A和應用B都執行到第三步了,應用A早於應用B一點點的時間執行了GETSET方法,對應用A來說,拿到的oldValue是第二步中的value,肯定是超時的,那麼應用A實際上獲得了這個鎖;對應用B來說,因爲晚了一步,GETSET拿到的oldValue實際上是應用A設置的newValue,理論上它此刻是不會超時的,那麼應用B獲取鎖就失敗了。
有人要問了,應用B通過GETSET再次更新了失效時間(覆蓋了已經獲取鎖的應用A設置的失效時間),有影響嗎?不影響,因爲無論是應用A還是應用B,它們的失效時長都是固定的,應用B的expiredDate會晚於應用A的expiredDate,應用B的GETSET不會影響應用A已經獲取鎖的這個事實,它只是將失效時間延長了一點點。
僞碼如下:
long expireTime = 300 * 1000; //失效時長(單位ms) 300秒即5分鐘
String expiredDateStr = //當前時間加上expireTime時長計算得到的時間並轉成String類型;
String key = 'method_lock_' + methodName;
int lockResult = redis.SETNX(key, expiredDateStr);
bool getLock = false;
if (lockResult == 1) {
//得到鎖
getLock = true;
} else {
String oldExpiredDateStr = redis.GET(key);
//檢查鎖是否超時
if (CheckedLockTimeOut(oldExpiredDateStr)) {
String newExpiredDateStr = //當前時間加上expireTime時長計算得到的時間並轉成String類型;
string oldValue = redis.GETSET(key, newExpiredDateStr);
if (CheckedLockTimeOut(oldValue)) {
//得到鎖
getLock = true;
}
}
}
//[1]獲得鎖
if (getLock)
{
try {
//[2]do business function
//[3]釋放鎖
redis.DEL(key);
} catch (Exception e) {
//業務處理過程中發生了異常,需要釋放鎖
redis.DEL(key);
}
}
改進後的代碼解決了死鎖的問題,但是,這種方式實現的鎖無法重入,即獲得鎖的應用再重新進入的時候,無法判斷出鎖的持有者是自己。改進前的代碼是可以的,因爲改進前存儲的value是holder,可以GET(key)後判斷holder是不是自己。有人要說了,是不是可以把改進後代碼中的value中加進holder信息?答案是,不可以。因爲改進後的代碼用到了GETSET,如果應用A和應用B幾乎同時進入第三步,A先GETSET,B後GETSET,最終結果是A拿到了鎖B沒拿到,但是存儲的信息卻被B更新了(鎖的holder變成了B但實際上A拿到了鎖),這就尷尬了。
所以,改進後的方法也不是完美的。
三、基於zookeeper實現分佈式鎖
zookeeper臨時有序節點可以實現分佈式鎖
大致思路:每個客戶端嘗試獲取某個方法鎖時,就在zookeeper的該方法對應的節點目錄下,生成一個臨時有序節點。如果生成的有序節點是該目錄下最小的,即認爲獲得了該方法鎖,可以執行方法體。如果需要釋放某個方法鎖,則只要刪除之前生成的節點就可以。
這種實現方式的優點:
如果某個應用掛掉,與zookeeper斷連,則該應用生成的臨時節點全部自動刪除掉,即它所擁有的鎖會自動釋放。這樣避免了基於數據庫和基於緩存實現分佈式鎖時,單個應用掛掉無法自動釋放其持有鎖的問題;
可以實現阻塞式的鎖(如果有這個需要的話)。具體實現方式是:如果某個客戶端嘗試獲取鎖失敗,那麼它可以在當前目錄下的最小節點上綁定一個監聽器,如果該節點發生了變化(被刪除),監聽器可以通知到之前嘗試獲取鎖的客戶端,該客戶端可以再次檢查自己生成的節點是否是當前最小的節點,如果是的話,即獲取了方法鎖;
不存在單點問題。zookeeper是集羣部署的,只要集羣中有半數以上機器存活,就可以對外提供服務;
生成節點的時候,將鎖持有者的信息保存下來,同一客戶端重入時,判斷持有者是否是自己,即可重入。
基於zookeeper臨時有序節點的方式實現分佈式鎖優點很多,缺點只有一個,效率不如基於緩存實現分佈式鎖,因爲zookeeper在生成節點、銷燬節點時的消耗大於緩存操作的消耗。
四、三種方式的比較
理解難易程度(從易到難):
數據庫>緩存>zookeeper
實現難易程度(從易到難):
zookeeper≈緩存>數據庫(數據庫需要建表,其他兩個相對簡單)
性能(從高到低):
緩存>zookeeper>數據庫(數據庫最低毫無疑問,zookeeper居中,緩存最高)
可靠性(從高到低):
zookeeper>緩存>數據庫
本文參考了http://www.hollischuang.com/archives/1716,在原文基礎上理解並重組,其中基於緩存實現的分佈式鎖有較大改變。