在現在編程語言中,接觸過多線程的人多多少少都對鎖有一定的瞭解。簡單來說,多線程中的鎖就是在多線程運行的環境下,多個線程共享同一個資源,當對資源進行變更的時候,能保證資源的一致性機制。在分佈式環境下,原來簡單的多線程鎖就不管用了,也就是需要分佈式鎖來保證多個服務共享的資源的一致性。
接下來就簡單討論下基於java通過redis實現分佈式鎖,實現分佈式鎖需要滿足以下的要求:
- 支持立即獲取鎖方式,如果獲取到返回true,獲取不到返回false
- 支持等待獲取鎖方式,如果獲取到,直接返回true,獲取不到等待一段時間,在這段時間內重複嘗試獲取,如果嘗試獲取成功,返回true,等待時間過後還獲取不到,返回false
- 不能產生死鎖的情況
- 不能釋放非自己加的鎖
加鎖
通過redis來實現分佈式鎖的加鎖邏輯如下圖所示
根據以上邏輯,實現上鎖的核心代碼如下:
key = KEY_PRE + key;
String value = this.fetchLockValue();
if (SET_SUCCESS.equals(jedis.set(key, value, "NX", "EX", 5000))) {
return value;
}
要在分佈式環境中正確的實現加鎖操作,“判斷 key 是否存在”、“保存 key-value”、“設置key過期時間”這三個操作必須是原子操作,如果不是原子操作,則可能會出現以下兩種情況:
- 在 “判斷 key 是否存在” 得出key不存在的結果步驟後,“保存 key-value” 步驟前,另一個客戶端執行同樣的邏輯,並且執行到了 “判斷 key 是否存在”步驟, 同樣得出了 key 不存在的結果。這樣會導致多個客戶端獲得到了同一把鎖;
- 在客戶端執行完 “保存 key-value” 步驟後,需要設置一個 key 的過期時間,以防止客戶端因爲代碼質量未解鎖,再或者進程崩潰等情況未解鎖導致的死鎖情況。在 “保存 key-value” 步驟之後,“設置 key 的過期時間” 步驟之前,可能進程崩潰,導致 “設置 key 的過期時間” 步驟失敗;
解鎖
解鎖的基本流程如下:
根據以上邏輯,在代碼中解鎖的核心代碼如下:
key = KEY_PRE + key;
String command = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
if (RELEASE_SUCCESS.equals(jedis.eval(command, Collections.singletonList(key), Collections.singletonList(value)))) {
return true;
}
解鎖和加鎖的時候一樣,“key 是否存在”、“判斷是否自己是否持有鎖”、“刪除 key-value” 這三步操作需要是原子操作,否則當一個客戶端執行完 “判斷是否自己持有鎖” 步驟後,得出自己有鎖的結論,此時鎖的過期時間到了,自動被redis釋放了,同時另一個客戶端有基於這個 key 加鎖成功,如果第一個客戶端還繼續執行 “刪除 key-value” 步驟,就會將不屬於自己的鎖給釋放了。
在這裏我們利用以上代碼中 redis 執行 Lua 腳本的能力來解決原子操作的問題。
另外,判斷是否自己持有鎖的機制是用加鎖的時候的 key-value 來判斷當前的 key 的值是否等於自己持有鎖時獲得的值。所以加鎖的時候的 value 必須是一個全局唯一的字符串。
完整的實現代碼如下:
public class LockRedisUtil {
private static final Logger logger = LoggerFactory.getLogger(LockRedisUtil.class);
private static String SET_SUCCESS = "OK";
private static String KEY_PRE = "REDIS_LOCK_";
private static Long LOCK_EXPIRSE_TIME = 5000L;
private static Long TRY_EXPIRSE_TIME = 3000L;
private DateFormat df = new SimpleDateFormat("yyyyMMddHHmmssSSS");
public String lock(String key) {
Jedis jedis = null;
try {
jedis = new Jedis();
key = KEY_PRE + key;
String value = this.fetchLockValue();
if (SET_SUCCESS.equals(jedis.set(key, value, "NX", "EX", LOCK_EXPIRSE_TIME))) {
return value;
}
} catch (Exception e) {
logger.info("{}", e);
} finally {
jedis.close();
}
return null;
}
public String tryLock(String key) {
Jedis jedis = null;
try {
jedis = new Jedis();
key = KEY_PRE + key;
String value = this.fetchLockValue();
Long firstTryTime = System.currentTimeMillis();
// 如果沒獲取到鎖,則在 TRY_EXPIRSE_TIME 這短時間內 每過100毫秒 嘗試獲取一次
do {
if (SET_SUCCESS.equals(jedis.set(key, value, "NX", "EX", LOCK_EXPIRSE_TIME))) {
return value;
}
TimeUnit.MILLISECONDS.sleep(100);
} while ((System.currentTimeMillis() - TRY_EXPIRSE_TIME) < firstTryTime);
} catch (Exception e) {
logger.info("{}", e);
} finally {
jedis.close();
}
return null;
}
public boolean unLock(String key, String value) {
Long RELEASE_SUCCESS = 1L;
Jedis jedis = null;
try {
jedis = new Jedis();
key = KEY_PRE + key;
String command = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
if (RELEASE_SUCCESS.equals(jedis.eval(command, Collections.singletonList(key), Collections.singletonList(value)))) {
return true;
}
} catch (Exception e) {
logger.info("{}", e);
} finally {
jedis.close();
}
return false;
}
/**
* 生成加鎖的唯一字符串
*
* @return 唯一字符串
*/
private String fetchLockValue() {
return UUID.randomUUID().toString() + "_" + df.format(new Date());
}
}
測試代碼
通過創建一個線程池來模擬併發場景下的調用變更共享資源的狀況,其中 getNumUseLock 方法裏使用了分佈式鎖,而 getNumNoLock 方法則是正常的處理,未使用相關鎖機制,方法裏獲取數據的方式是從 redis 裏獲取一個數,然後給這個數自加 1 ,再存回 redis 。
public class ThreadDemoA {
private static int POOL_NUM = 10;
public static void main(String[] args) {
HandleData handleData = new HandleData();
handleData.delNum();
ExecutorService executorService = newFixedThreadPool(5);
List<RunnableThread> threads = Lists.newArrayList();
for (int i = 0; i < POOL_NUM; i++) {
RunnableThread thread = new RunnableThread("a" + i);
threads.add(thread);
}
threads.forEach(item -> executorService.execute(item));
executorService.shutdown();
}
static class RunnableThread implements Runnable {
private String name;
private HandleData handleData = new HandleData();
public RunnableThread(String name) {
this.name = name;
}
@Override
public void run() {
int num = handleData.getNumUseLock(); // 使用了分佈式鎖處理
// int num = handleData.getNumNoLock(); // 未使用相關鎖機制
System.out.println("thread " + name + " : " + num);
}
}
static class HandleData {
private final String KEY = "demo_key_01";
private final String LOCK_KEY = "20200509";
RedisUtilNonConfig redisUtil = new RedisUtilNonConfig();
LockRedisUtil lockRedisUtil = new LockRedisUtil();
public Integer getNumUseLock() {
String value = lockRedisUtil.lock(LOCK_KEY);
if (value == null) {
value = lockRedisUtil.tryLock(LOCK_KEY);
}
Integer num = this.getNumNoLock();
lockRedisUtil.unLock(LOCK_KEY, value);
return num;
}
public Integer getNumNoLock() {
if (!redisUtil.hasKey(KEY)) {
redisUtil.setValue(KEY, "1");
} else {
Integer num = Integer.valueOf(redisUtil.getValue(KEY));
num++;
redisUtil.setValue(KEY, num.toString());
}
return Integer.valueOf(redisUtil.getValue(KEY));
}
public Boolean delNum() {
redisUtil.delValue(KEY);
return true;
}
}
}
測試結果
使用了分佈式鎖處理的結果輸出如下:
thread a4 : 1
thread a3 : 2
thread a6 : 3
thread a7 : 4
thread a8 : 5
thread a9 : 6
thread a1 : 7
thread a0 : 8
thread a5 : 9
thread a2 : 10
未使用相關鎖機制處理的結果輸出如下:
thread a0 : 1
thread a5 : 2
thread a1 : 3
thread a6 : 3
thread a8 : 4
thread a7 : 4
thread a9 : 5
thread a3 : 5
thread a2 : 6
thread a4 : 6
從測試結果可以看到,使用分佈式鎖可以有效的避免多線程環境下對同一個資源進行調用變更無法同步的問題,即在分佈式環境下,多個服務對同一個資源進行處理變更,就可以使用類似的分佈式鎖來鎖定當前資源,只有獲取到鎖的服務纔可以進行相關處理變更資源,其它服務只能等待重新嘗試。