微信公眾號:房東的小黑黑
路途隨遙遠,將來更美好
學海無涯,大家一起加油!
為什麼要設計分散式鎖
在簡單的單機系統中,當存在多個執行緒同時要修改某個共享變數時,為了資料的操作安全,往往需要通過加鎖的方法,在同一時刻同一程式碼塊只能有一個程式執行操作,存在很多加鎖的方式,比如在java中有synchronize或Lock子類等。
但是在分散式中,會存在多個主機,即會存在多個jvm, 在jvm之間資料是不能共享的,上面的方法只能在一個jvm中執行有效,在多個jvm中同一變數可能會有不同的值。所以我們要設計一種跨jvm的共享互斥機制來控制共享變數資源的訪問,這也是提出分散式鎖的初衷。
需要解決的問題
為了將分散式鎖實現較好的效能,我們需要解決下面幾個重要的問題:
- 一個方法或程式碼片段在同一時刻只能被一個程式所執行。
- 高可用的獲取鎖與釋放鎖功能。
- 避免死鎖
- 鎖只能被持有該鎖的客戶端刪除或者釋放。
- 容錯,在伺服器當機時,鎖依然能得到釋放或者其他伺服器可以進行加鎖。
下面分別利用redis和zookeeper來實現加鎖和解鎖機制。
基於Redis的加鎖第一版
本版本通過變數sign設定鎖的唯一標識,確保只有擁有該鎖的客戶端才能刪除它,其他客戶端不能刪除。
利用阻塞鎖的思想, 通過while(System.currentTimeMillis() < endTime)
和Thread.sleep()
相結合,在設定的規定時間內進行多次嘗試。
但是setnx
操作和expire
分割開了,不具有原子性,可能會出現問題。
比如說,在執行到jedis.expire
時,可能系統發生了崩潰,導致鎖沒有設定過期時間,導致發生死鎖。
public String addLockVersion1(String key, int blockTime, int expireTime) {
if (blockTime <=0 || expireTime <= 0)
return null;
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
String sign = UUID.randomUUID().toString();
String token = null;
//設定阻塞嘗試時間
long endTime = System.currentTimeMillis() + blockTime;
while (System.currentTimeMillis() < endTime) {
if (jedis.setnx(key, sign) == 1) {
// 新增成功,設定鎖的過期時間,防止死鎖
jedis.expire(key, expireTime);
// 在釋放鎖時用於驗證
token = sign;
return token;
}
//加鎖失敗,休眠一段時間,再進行嘗試。
try {
Thread.sleep(DEFAULT_SLEEP_TIME);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} catch (JedisException e) {
e.printStackTrace();
} finally {
if (jedis != null)
jedis.close();
}
return null;
}
複製程式碼
基於Redis的加鎖第二版
通過設定key對應的value值為鎖的過期時間,當遇到系統崩潰,致使利用expire
設定鎖過期時間失敗時,通過獲取value值,來判斷當前鎖是否過期,如果該鎖已經過期了,則進行重新獲取。
但是它也存在一些問題。當鎖過期時,如果多個程式同時執行jedis.getSet
方法,雖然只有一個程式可以獲得該鎖,但是這個程式的鎖的過期時間可能被其他程式的鎖所覆蓋。
該鎖沒有設定唯一標識,也會被其他客戶端鎖釋放,不滿足只能被鎖的擁有者鎖釋放的條件。
public boolean addLockVersion2(String key, int blockTime, int expireTime) {
if (blockTime <=0 || expireTime <= 0)
return false;
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
long endTime = System.currentTimeMillis() + blockTime;
while (System.currentTimeMillis() < endTime) {
long redisExpierTime = System.currentTimeMillis() + expireTime;
if (jedis.setnx(key, redisExpierTime + "") == 1) {
jedis.expire(key, expireTime);
return true;
} else {
String oldRedisExpierTime = jedis.get(key);
// 當鎖設定成功,但是沒有通過expire成功設定過期時間,但是根據存的值判斷出它實際上已經過期了
if (oldRedisExpierTime != null && Long.parseLong(oldRedisExpierTime) < System.currentTimeMillis()) {
String lastRedisExpierTime = jedis.getSet(key, System.currentTimeMillis() + blockTime + "");
//獲取到該鎖,沒有被其他執行緒所修改
if (lastRedisExpierTime.equals(oldRedisExpierTime)) {
jedis.expire(key, expireTime);
return true;
}
}
}
//加鎖失敗,休眠一段時間,再進行嘗試。
try {
Thread.sleep(DEFAULT_SLEEP_TIME);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} catch (JedisException e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
return false;
}
複製程式碼
基於Redis的加鎖第三版
具體通過set
方法來實現setnx
和expire
的相加功能,實現了原子操作。
如果key不存在時,就進行加鎖操作,並對鎖設定一個有效期,同時uniqueId表示加鎖的客戶端;如果key存在,不做任何操作。
public boolean addLockVersion3(String key, String uniqueId, int blockTime, int expireTime) {
Jedis jedis = null;
try {
long endTime = System.currentTimeMillis() + blockTime;
while (System.currentTimeMillis() < endTime) {
jedis = jedisPool.getResource();
String result = jedis.set(key, uniqueId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_STATE.equals(result))
return true;
try {
Thread.sleep(DEFAULT_SLEEP_TIME);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return false;
} catch (JedisException e) {
e.printStackTrace();
} finally {
if (jedis != null)
jedis.close();
}
return false;
}
複製程式碼
基於Redis的加鎖第四版
為了使對同一個物件新增多次鎖,並且不發生阻塞,即實現類似可重入鎖,我們借鑑了ReetrantLock
的思想,新增了變數states
來控制。
public boolean addLockVersion4(String key, String uniqueId, int expireTime) {
int state = states.get();
if (state > 1) {
states.set(state+1);
return true;
}
return doLock(key, uniqueId, expireTime);
}
private boolean doLock(String key, String uniqueId, int expireTime) {
Jedis jedis = null;
if (expireTime <= 0)
return false;
try {
jedis = jedisPool.getResource();
String result = jedis.set(key, uniqueId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_STATE.equals(result))
states.set(states.get() + 1);
return true;
} catch (JedisException e) {
e.printStackTrace();
} finally {
if (jedis != null)
jedis.close();
}
return false;
}
複製程式碼
基於Redis的加鎖第五版
從上面可知,利用setnx
和expire
實現加鎖機制時因為不是原子操作,會產生一些問題,我們可用lua指令碼來實現。
public boolean addLockVersion5(String key, String uniqueId, int expireTime) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
String luaScript = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
"redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
List<String> keys = new ArrayList<>();
List<String> values = new ArrayList<>();
keys.add(key);
values.add(uniqueId);
values.add(String.valueOf(expireTime));
Object result = jedis.eval(luaScript, keys, values);
if ((Long)result == 1L)
return true;
} catch (JedisException e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
return false;
}
複製程式碼
基於Redis的釋放鎖第一版
在解鎖時首先判斷加速與解鎖是否是同一個客戶端,然後利用del
方法進行刪除。
但是會出現一些問題。
當方法執行到判斷內部時,即將要執行del
方法時,該鎖已經過期了,並被其他的客戶端所請求應有,此時執行del
會造成鎖的誤刪。
public boolean releaseLockVersion1(String key, String uniqueId) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
//加鎖與解鎖是否是同一個客戶端
String lockId = jedis.get(key);
if (lockId != null && lockId.equals(uniqueId)) {
jedis.del(key);
return true;
}
} catch (JedisException e) {
e.printStackTrace();
} finally {
if (jedis != null)
jedis.close();
}
return false;
}
複製程式碼
基於Redis的釋放鎖第二版
從上面的分析來看,我們要確保刪除的原子性,利用lua指令碼可以保證一點。
在指令碼語言裡,KEYS[1]和ARGV[1]分別表示傳入的key名和唯一識別符號。
public boolean releaseLockVersion2(String key, String uniqueId) {
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Jedis jedis = null;
Object result = null;
try{
jedis = jedisPool.getResource();
result = jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(uniqueId));
if ((Long)result == 1)
return true;
} catch (JedisException e) {
e.printStackTrace();
} finally {
if (jedis != null)
jedis.close();
}
return false;
}
複製程式碼
基於Redis的釋放鎖第三版
在利用可重入鎖思想時,只有當states=1
時才能被釋放,大於0時,只能進行減1操作。
public boolean releaseLockVersion3(String key, String uniqueId) {
int state = states.get();
if (state > 1) {
states.set(states.get() - 1);
return false;
}
return this.doRelease(key, uniqueId);
}
private boolean doRelease(String key, String uniqueId) {
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Jedis jedis = null;
Object result = null;
try{
jedis = jedisPool.getResource();
result = jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(uniqueId));
if ((Long)result == 1)
return true;
} catch (JedisException e) {
e.printStackTrace();
} finally {
states.set(0);
if (jedis != null)
jedis.close();
}
return false;
}
複製程式碼
利用Zookeeper實現分散式鎖
Zookeeper提供一個多層次的節點名稱空間,每個節點都用一個以斜槓(/)分割的路徑表示,
而且每個節點都有父節點(根節點除外),非常類似於檔案系統。
基本思想流程
- 在某父節點下新增建立一個節點,
- 獲取該父節點下的所有子節點,並進行排序,獲得有個有序序列
- 如果當前新增的節點是序列中序號最小的節點,表示獲取鎖成功
- 如果不是最小的節點,則對在有序列表中的它的前一個節點進行監聽,當被監聽的節點被刪除後,會通知該節點獲取鎖。
- 解鎖的時候刪除當前節點。
實現程式碼
public class zklock {
private ZkClient zkClient;
private String name;
private String currentLockPath;
private CountDownLatch countDownLatch;
private static final String PATENT_LOCK_PATH = "distribute_lock";
private static final int MAX_RETEY_TIMES = 3;
private static final int DEFAULT_WAIT_TIME = 3;
public zklock(ZkClient zkClient, String name) {
this.zkClient = zkClient;
this.name = name;
}
public void addLock() {
if (!zkClient.exists(PATENT_LOCK_PATH)) {
zkClient.createPersistent(PATENT_LOCK_PATH);
}
int count = 0;
boolean iscompleted = false;
while (!iscompleted) {
iscompleted = true;
try {
//建立當前目錄下的臨時有序節點
currentLockPath = zkClient.createEphemeralSequential(PATENT_LOCK_PATH + "/", System.currentTimeMillis());
} catch (Exception e) {
if (count++ < MAX_RETEY_TIMES) {
iscompleted = false;
} else
throw e;
}
}
}
public void releaseLock() {
zkClient.delete(currentLockPath);
}
//檢查是否是最小的節點
private boolean checkMinNode(String localPath) {
List<String> children = zkClient.getChildren(PATENT_LOCK_PATH);
Collections.sort(children);
int index = children.indexOf(localPath.substring(PATENT_LOCK_PATH.length()+1));
if (index == 0) {
if (countDownLatch != null) {
countDownLatch.countDown();
}
return true;
} else {
String waitPath = PATENT_LOCK_PATH + "/" + children.get(index-1);
waitForLock(waitPath, false);
return false;
}
}
//監聽有序序列中的前一個節點
private void waitForLock(String waitPath, boolean useTime) {
countDownLatch = new CountDownLatch(1);
zkClient.subscribeDataChanges(waitPath, new IZkDataListener() {
@Override
public void handleDataChange(String s, Object o) throws Exception {
}
@Override
public void handleDataDeleted(String s) throws Exception {
checkMinNode(currentLockPath);
}
});
if (!zkClient.exists(waitPath)) {
return;
}
try {
if (useTime == true)
countDownLatch.await(DEFAULT_WAIT_TIME, TimeUnit.SECONDS);
else
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
countDownLatch = null;
}
}
複製程式碼
基於Redis和Zookeeper的分散式鎖的優劣
- Redis是nosql資料庫,主要特點是快取;
- Zookeeper是分散式協調工具,主要用於分散式解決方案。
加鎖機制
- Redis: 通過
set
方法建立key, 因為Redis的key是唯一的,誰先建立成功,誰能夠先獲得鎖。 - Zookeeper: 會在Zookeeper上建立一個臨時節點,因為Zookeeper節點命名路徑保證唯一,只要誰先建立成功,誰能夠獲取到鎖。
釋放鎖
- Redis: 為了確保鎖的一致性問題,在刪除的redis的key時,需要判斷是否是之前擁有該鎖的客戶端;通過設定有效期解決死鎖。
- Zookeeper: 直接關閉臨時節點session會話連線,因為臨時節點生命週期與session會話繫結在一塊,如果session會話連線關閉的話,該臨時節點也會被刪除。
效能
redis分散式鎖,其實需要自己不斷去嘗試獲取鎖,比較消耗效能。
zk分散式鎖,獲取不到鎖,註冊個監聽器即可,不需要不斷主動嘗試獲取鎖,效能開銷較小。
另外一點就是,如果是redis獲取鎖的那個客戶端bug了或者掛了,那麼只能等待超時時間之後才能釋放鎖;而zk的話,因為建立的是臨時znode,只要客戶端掛了,znode就沒了,此時就自動釋放鎖。