使用redis實現分佈式鎖,相對於使用數據庫鎖或者使用ZooKeeper,簡單方便,相對可靠,是最常用的方式,本文上一個實現demo。
在寫代碼之前,先拋出幾個常見問題,帶着問題去實現代碼,邏輯更清晰完整。
redis實現分佈式鎖,幾個常見經典問題:
問題一:鎖不被釋放
就是說一個服務在獲取到分佈式鎖後,在釋放鎖之前,由於某種原因比如服務掛掉了,導致鎖一直不會被釋放,那麼其他服務自然也就再也拿不到鎖了。針對這個問題。解決辦法一般都是加鎖時,同步設置鎖的過期時間。
問題二:服務A釋放了服務B的鎖,導致問題
比如,服務A在拿到鎖之後,設置過期時間1s,但是服務A由於自身某種原因,業務執行了2s才結束;那麼,在鎖過期後,1.5s的時候,服務B正好來拿鎖,並且拿到了,然後執行B的業務1s,那麼B業務還沒執行結束,A結束了,然後去釋放鎖,這個時候釋放的就是B拿到的鎖。
爲了避免這個問題,需要爲每個服務拿鎖的請求進行標記,避免分不清鎖是誰的。釋放鎖的時候,判斷此刻redis中的鎖是不是自己檔時獲取到的。
問題三:釋放鎖過程要保證原子性
針對問題二,說到釋放鎖的時候,要進行判斷是不是自己的鎖,這個判斷+釋放的過程,必須是原子性的,否則同樣會產生釋放別人鎖的問題。
比如,服務A解鎖時剛判斷鎖是自己的,於是下一步就是釋放鎖,結果釋放鎖之前,鎖正好過期,並且服務B剛好申請到了此鎖,那麼服務A接下來釋放的鎖,必然是服務B的。
問題四:多個服務同時獲取到了鎖
業務中,分佈式鎖的目的肯定是隻希望同時只有一個服務拿到鎖,不能多個服務同時拿到鎖,不然就失去了鎖的意義。
但是,有一種場景,比如A服務拿到了鎖,由於A業務執行時間過長,在解鎖之前鎖早已經被釋放,同時又被服務B獲取到,這樣實際上就是服務A和服務B都獲取到了鎖並且在執行業務邏輯,這是有問題的。
我們可能會想到,把鎖的過期時間設置的足夠長,比如1min,保證不少於服務A的業務執行時間,這樣的確可以,但是這樣又產生了別的問題,比如服務A掛掉了,那麼其他服務就需要等1min的時間才能拿到鎖,這個等待時間未免太久;
那麼,過期時間到底設置多久呢,這個不好設定,只能說設置爲服務A業務大多數執行的時長,比如服務A的業務大多數執行時間是200ms,那麼就設置爲1s,這個應該足夠了,但是萬一服務A某次業務由於特殊原因,執行了2s呢,還是會有上述問題。
那麼,我們會想,既然服務執行時間不是那麼穩定,這個鎖的過期時間是否能根據業務執行時間動態變化呢?答案是肯定的,本問Demo中,我們使用守護線程來動態延長鎖的過期時間。
問題五:redis服務宕機,如何保證鎖正常使用
此問題是針對單機版的redis做分佈式鎖,如果此單機redis服務掛掉,那麼redis鎖將會不可用。解決方式是使用redis集羣,但是,在集羣環境下,我們的分佈式鎖的加鎖策略是怎樣的呢?
原理是對redis集羣的每個節點都加鎖,然後判斷超過半數的節點返回true,表示加鎖成功。這裏推薦使用Redisson框架,它實現的RedLock就是解決這種場景的。
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同時加鎖:lock1 lock2 lock3
// 紅鎖在大部分節點上加鎖成功就算成功。
lock.lock();
...
lock.unlock();
Redissonh實現了可重入鎖,公平鎖等各種java中定義的鎖類型,可以解決上述的5個問題,相關資料可參考官方文檔:https://github.com/redisson/redisson/wiki/目錄
Demo:
以上前4個問題,在本文Demo中都有解決,並添加了註釋,下面看代碼。
先上主線程:
public class RedisLockDemo {
//隨便弄個key的名字
private static final String LOCK_KEY = "distributedLock:key";
//主線程
public static void main(String[] args) {
//獲取redis客戶端
RedisClient redisClient = RedisClient.getInstance();
//開啓兩個工作線程,模擬分佈式服務中的兩個服務
for (int i = 0; i < 1; i++) {
startAWork(redisClient, String.valueOf(i), 10);
}
}
/**
* 開啓一個工作線程,模擬分佈式中的一個服務,搶分佈式鎖
*
* @param redisClient redis客戶端
* @param threadName 線程名稱
* @param lengthOfWork 工作時長 秒
*/
public static void startAWork(RedisClient redisClient, String threadName, int lengthOfWork) {
new Thread(() -> {
try {
//生成並保存 獲取分佈式鎖的 請求id,解決問題二
String requestId = UUID.randomUUID().toString();
RedisLockThreadLocalContext.getThreadLocal().set(requestId);
//獲取分佈式鎖,設置過期時間2s,解決問題一
boolean result = RedisTool.tryGetDistributedLock(redisClient.getJedis(), LOCK_KEY, requestId, 2000);
if (result) {//如果成功獲取到鎖
//開一個守護線程延長鎖的過期時間
Thread thread = new Thread(() -> {
while (true) {
Jedis jedis = redisClient.getJedis();
try {
TimeUnit.SECONDS.sleep(1);
System.out.println("守護線程延長鎖的過期時間1s");
jedis.setex(LOCK_KEY, 1, requestId);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
}
});
thread.setDaemon(true);
thread.start();
System.out.println("線程" + threadName + "拿到鎖,乾點事情");
//睡眠一定時間,模擬業務耗時
TimeUnit.SECONDS.sleep(lengthOfWork);
} else {
System.out.println("線程" + threadName + "沒有拿到鎖");
}
} catch (Exception e) {
//
} finally {
//釋放分佈式鎖
String requestId = RedisLockThreadLocalContext.getThreadLocal().get();
boolean result = RedisTool.releaseDistributedLock(redisClient.getJedis(), LOCK_KEY, requestId);
if (result) {
System.out.println("線程" + threadName + "釋放鎖");
} else {
System.out.println("線程" + threadName + "釋放鎖失敗");
}
}
System.out.println("線程" + threadName + "結束");
}).start();
}
}
主線程說明:
- 主線程比較簡單,只開啓了兩個工作線程,模擬搶分佈式鎖的過程;
- 具體的startAWork()方法中,新建了工作線程,使用睡眠時間來模擬執行業務邏輯的耗時;
- 在 RedisTool#tryGetDistributedLock()方法中,傳入了過期時間參數,方法內容看下問代碼。這個參數解決了問題一;
- 在 RedisTool#tryGetDistributedLock()方法中,傳入了requestId參數,這個是一個隨機UUID,用來標識每一次加鎖的線程,同時這個參數保存在了線程本地變量ThreadLocal中,解決了問題二。
- 在開啓工作線程後,代碼中緊接着又開啓另外一個線程,並使用thread.setDaemon(true);標識爲守護線程;這個守護線程的任務就是死循環延長鎖的過期時間;當業務線程執行完畢後,這個守護線程會自動銷燬。注意循環的時間間隔要小於鎖的過期時間,一般設置爲過期時間的一半即可。
其他輔助類:
添加jedis依賴包:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
使用JedisPool初始化一個Jedis客戶端:
/**
* Description:Redis客戶端
*/
public class RedisClient {
private static final Logger LOGGER = LoggerFactory.getLogger(RedisClient.class);
private static RedisClient instance = new RedisClient();
private JedisPool pool;
private RedisClient() {
init();
}
public static RedisClient getInstance() {
return instance;
}
public Jedis getJedis() {
return pool.getResource();
}
/**
* 初始化redis連接池
*/
private void init() {
int maxTotal = 10;
String ip = "redis IP";
String pwd = "redis 密碼";
int port = 6379;
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(maxTotal);
jedisPoolConfig.setMaxIdle(20);
jedisPoolConfig.setMaxWaitMillis(6000);
pool = new JedisPool(jedisPoolConfig, ip, port, 5000, pwd);
LOGGER.info("連接池初始化成功 ip={}, port={}, maxTotal={}", ip, port, maxTotal);
}
}
上述代碼初始化了redis連接信息,屬於固定代碼,沒啥好解釋的,繼續往下看代碼。
/**
* Description:redis分佈式鎖訪問工具類,提供具體的獲取鎖,釋放鎖方法
*/
public class RedisTool {
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";
/**
* 嘗試獲取分佈式鎖
*
* @param jedis Redis客戶端
* @param lockKey 鎖的key
* @param requestId 鎖的Value,值是個唯一標識,用來標記加鎖的線程請求;可以使用UUID.randomUUID().toString()方法生成
* @param expireTime 過期時間 ms
* @return 是否獲取成功,成功返回true,否則false
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = null;
try {
result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
return LOCK_SUCCESS.equals(result);
}
/**
* 釋放分佈式鎖
*
* @param jedis Redis客戶端
* @param lockKey 鎖
* @param requestId 請求標識,鎖的Value
* @return 是否釋放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
Object result = null;
try {
//使用lua腳本保證原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
return RELEASE_SUCCESS.equals(result);
}
}
- RedisTool工具類,提供了加鎖和解鎖的兩個方法;
- tryGetDistributedLock()加鎖方法設置了過期時間,解決了問題一;
- releaseDistributedLock()解鎖方法中使用了lua腳本,具備原子性,解鎖時先判斷key的value值,也就是當初加鎖保存的requestId是不是和自己線程保存的一致,一致才說明是自己當初加的鎖,方可進行解鎖;不一致說明自己加鎖已經自動過期,無需解鎖;這個解決了問題二和問題三。
/**
* Description:保存redis分佈式鎖的請求id
*/
public class RedisLockThreadLocalContext {
private static ThreadLocal<String> threadLocal = new NamedThreadLocal<>("REDIS-LOCK-LOCAL-CONTEXT");
public static ThreadLocal<String> getThreadLocal() {
return threadLocal;
}
}
上述RedisLockThreadLocalContext中創建了一個threadLocal單例,用於保存加鎖時設置的requestId。當然在使用線程池時,get完數據要注意清除裏面的保存信息,這裏就不寫那麼詳細了。
以上就是本文全部內容,特別要注意本文開頭的那幾個問題。