現在的業務場景越來越復雜,使用的架構也就越來越復雜,分布式、高並發已經是業務要求的常態。像騰訊系的不少服務,還有CDN優化、異地多備份等處理。
說到分布式,就必然涉及到分布式鎖的概念,如何保證不同機器不同線程的分布式鎖同步呢?
實現要點
- 互斥性,同一時刻,只能有一個客戶端持有鎖。
- 防止死鎖發生,如果持有鎖的客戶端因崩潰而沒有主動釋放鎖,也要保證鎖可以釋放並且其他客戶端可以正常加鎖。
- 加鎖和釋放鎖必須是同一個客戶端。
- 容錯性,只要redis還有節點存活,就可以進行正常的加鎖解鎖操作。
正確的redis分布式鎖實現
錯誤加鎖方式一
保證互斥和防止死鎖,首先想到的使用redis的setnx命令保證互斥,為了防止死鎖,鎖需要設置一個超時時間。
public static void wrongLock(Jedis jedis, String key, String uniqueId, int expireTime) {
Long result = jedis.setnx(key, uniqueId);
if (1 == result) {
//如果該redis實例崩潰,那就無法設置過期時間了
jedis.expire(key, expireTime);
}
}
在多線程並發環境下,任何非原子性的操作,都可能導致問題。這段代碼中,如果設置過期時間時,redis實例崩潰,就無法設置過期時間。如果客戶端沒有正確的釋放鎖,那么該鎖(永遠不會過期),就永遠不會被釋放。
錯誤加鎖方式二
比較容易想到的就是設置值和超時時間為原子原子操作就可以解決問題。那使用setnx命令,將value設置為過期時間不就ok了嗎?
public static boolean wrongLock(Jedis jedis, String key, int expireTime) {
long expireTs = System.currentTimeMillis() + expireTime;
// 鎖不存在,當前線程加鎖成果
if (jedis.setnx(key, String.valueOf(expireTs)) == 1) {
return true;
}
String value = jedis.get(key);
//如果當前鎖存在,且鎖已過期
if (value != null && NumberUtils.toLong(value) < System.currentTimeMillis()) {
//鎖過期,設置新的過期時間
String oldValue = jedis.getSet(key, String.valueOf(expireTs));
if (oldValue != null && oldValue.equals(value)) {
// 多線程並發下,只有一個線程會設置成功
// 設置成功的這個線程,key的舊值一定和設置之前的key的值一致
return true;
}
}
// 其他情況,加鎖失敗
return true;
}
乍看之下,沒有什么問題。但仔細分析,有如下問題:
- value設置為過期時間,就要求各個客戶端嚴格的時鍾同步,這就需要使用到同步時鍾。即使有同步時鍾,分布式的服務器一般來說時間肯定是存在少許誤差的。
- 鎖過期時,使用 jedis.getSet雖然可以保證只有一個線程設置成功,但是不能保證加鎖和解鎖為同一個客戶端,因為沒有標志鎖是哪個客戶端設置的嘛。
錯誤解鎖方式一
直接刪除key
public static void wrongReleaseLock(Jedis jedis, String key) {
//不是自己加鎖的key,也會被釋放
jedis.del(key);
}
簡單粗暴,直接解鎖,但是不是自己加鎖的,也會被刪除,這好像有點太隨意了吧!
錯誤解鎖方式二
判斷自己是不是鎖的持有者,如果是,則只有持有者才可以釋放鎖。
public static void wrongReleaseLock(Jedis jedis, String key, String uniqueId) {
if (uniqueId.equals(jedis.get(key))) {
// 如果這時鎖過期自動釋放,又被其他線程加鎖,該線程就會釋放不屬於自己的鎖
jedis.del(key);
}
}
看起來很完美啊,但是如果你判斷的時候鎖是自己持有的,這時鎖超時自動釋放了。然后又被其他客戶端重新上鎖,然后當前線程執行到jedis.del(key),這樣這個線程不就刪除了其他線程上的鎖嘛,好像有點亂套了哦!
正確的加解鎖方式
基本上避免了以上幾種錯誤方式之外,就是正確的方式了。要滿足以下幾個條件:
- 命令必須保證互斥
- 設置的 key必須要有過期時間,防止崩潰時鎖無法釋放
- value使用唯一id標志每個客戶端,保證只有鎖的持有者才可以釋放鎖
加鎖直接使用set命令同時設置唯一id和過期時間;其中解鎖稍微復雜些,加鎖之后可以返回唯一id,標志此鎖是該客戶端鎖擁有;釋放鎖時要先判斷擁有者是否是自己,然后刪除,這個需要redis的lua腳本保證兩個命令的原子性執行。
下面是具體的加鎖和釋放鎖的代碼:
@Slf4j
public class RedisDistributedLock {
private static final String LOCK_SUCCESS = "OK";
private static final Long RELEASE_SUCCESS = 1L;
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
// 鎖的超時時間
private static int EXPIRE_TIME = 5 * 1000;
// 鎖等待時間
private static int WAIT_TIME = 1 * 1000;
private Jedis jedis;
private String key;
public RedisDistributedLock(Jedis jedis, String key) {
this.jedis = jedis;
this.key = key;
}
// 不斷嘗試加鎖
public String lock() {
try {
// 超過等待時間,加鎖失敗
long waitEnd = System.currentTimeMillis() + WAIT_TIME;
String value = UUID.randomUUID().toString();
while (System.currentTimeMillis() < waitEnd) {
String result = jedis.set(key, value, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, EXPIRE_TIME);
if (LOCK_SUCCESS.equals(result)) {
return value;
}
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} catch (Exception ex) {
log.error("lock error", ex);
}
return null;
}
public boolean release(String value) {
if (value == null) {
return false;
}
// 判斷key存在並且刪除key必須是一個原子操作
// 且誰擁有鎖,誰釋放
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = new Object();
try {
result = jedis.eval(script, Collections.singletonList(key),
Collections.singletonList(value));
if (RELEASE_SUCCESS.equals(result)) {
log.info("release lock success, value:{}", value);
return true;
}
} catch (Exception e) {
log.error("release lock error", e);
} finally {
if (jedis != null) {
jedis.close();
}
}
log.info("release lock failed, value:{}, result:{}", value, result);
return false;
}
}