分佈式鎖
爲了防止分佈式系統中的多個線程之間相互干擾,需要一種分佈式協調技術來對這些進程進行調度,這個技術的核心就是分佈式鎖。比如在如下場景中,就需要用到分佈式鎖,現有某個服務有ABC三個實例,部署在三臺服務器上,成員變量var在三個實例中都存在,此時三個請求經過nginx同時對var操作,顯然結果不是對的,而倘若不同時對A進行操作,而A是不共享的,也不具有可見性,所以處理的結果也是不對的。這時候就需要分佈式鎖了。
分佈式鎖的條件
- 分佈式環境下,一個方法同一時間只能有一個機器的線程執行。
- 高可用地獲取和釋放
- 高性能地獲取和釋放
- 可重入
- 具有鎖失效機制,防止死鎖
- 具有非阻塞鎖特性,即沒有獲取到鎖將直接返回獲取鎖失敗。
用Redis實現分佈式鎖
加鎖
加鎖過程簡單拆分爲兩步,第一步是檢查key是否存在,也就是檢查目前有沒有別的客戶端已經上鎖了,如果有人上鎖了,那就直接返回失敗。第二步就是如果沒有上鎖,也就是key不存在,那麼就設置key,那麼就設置一個過期時間,之所以設置過期時間,是因爲萬一客戶端發生意外沒有來解鎖,redis也可以自己來解,代碼比較簡單。
/**
* 嘗試獲取分佈式鎖
* @param lockKey 鍵
* @param reqId 值,此處設置爲requestID可以在解鎖的時候有依據知道是哪個請求加的鎖
* @param expire 過期時間
* */
public static boolean tryGetLock(Jedis jedis,String lockKey,String reqId,int expire){
//SET_IF_NOT_EXIST 不存在的時候設置,存在的時候不操作
//SET_WITH_EXPIRE_TIME 設置過期時間
String result = jedis.set(lockKey,
reqId,
SET_IF_NOT_EXIST,
SET_WITH_EXPIRE_TIME,
expire);
if(LOCK_SUCCESS.equals(result)){
return true;
}
return false;
}
那麼問題來了,如果將這兩個步驟分開可以不呢,例如像下面這樣:
public static boolean tryGetLock(Jedis jedis,String lockKey,String reqId,int expire){
long result = jedis.setnx(lockKey,reqId);
if(result==1){
jedis.expire(lockKey,expire);
}
}
自然是不行的,因爲這段代碼不能保證原子性,倘若某一個客戶端執行完setnx之後就因爲某些原因沒有繼續往下面執行了,那麼這個key就一直設置在這裏而不能自動過期,這個時候就沒有別的客戶端能夠獲取到這個鎖了。
第二個問題是我在網上看到的,我初一看還沒想明白爲什麼是錯誤的加鎖代碼:
public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {
long expires = System.currentTimeMillis() + expireTime;
String expiresStr = String.valueOf(expires);
// 如果當前鎖不存在,返回加鎖成功
if (jedis.setnx(lockKey, expiresStr) == 1) {
return true;
}
// 如果鎖存在,獲取鎖的過期時間
String currentValueStr = jedis.get(lockKey);
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// 鎖已過期,獲取上一個鎖的過期時間,並設置現在鎖的過期時間
String oldValueStr = jedis.getSet(lockKey, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考慮多線程併發的情況,只有一個線程的設置值和當前值相同,它纔有權利加鎖
return true;
}
}
// 其他情況,一律返回加鎖失敗
return false;
}
這段問題在於,1,使用System.currentTimeMillis()這個系統函數,那麼就可能存在多個客戶端上時間並不一致的問題。2,多線程的情況下,設置過期時間還是不線程安全。3,俗話說,解鈴還須繫鈴人,這段代碼缺少一個能表示客戶端的值來用作key的值。所以無論是哪個客戶端都能來解鎖。
釋放鎖(lua腳本)
看過不少網上博客的會發現在使用Redis實現分佈式鎖的時候使用了lua腳本,那麼爲什麼需要lua腳本呢,難道就不能手動通過Redis的原生API實現麼,如果用了會有什麼問題呢,首先看lua腳本是怎麼寫的。
if redis.call('get', KEYS[1]) == ARGV[1]
then return redis.call('del', KEYS[1])
else return 0 end
這段代碼作用挺好理解,就是獲取鎖對應的值,如果和傳來的ARGV[1]即RequestID相等,那麼就刪除,這是解鎖中用到的,現在拋開這個lua腳本不談,用jedis的api來解鎖。
第一種:
public static void releaseLock(Jedis jedis,String key){
jedis.del(key);
}
第二種:
public static void releaseLock(Jedis jedis,String key,String reqId){
if(jedis.get(key).equals(reqId)){
jedis.del(key);
}
}
第一種的問題在於,當一個線程到達解鎖方法的時候,沒有判別是否這個線程就是給這個key上鎖的線程,也就是鎖不認主人了,誰都能解開。當然在某些場景下是允許這樣的,但是在分佈式鎖中這樣自然不行。而第二個問題看似滴水不漏實際上這是兩條命令,而兩個命令無法保證原子性,也就是說,判斷if之後執行del之前,中間的是有可能其他線程再上鎖的,但是這個時候被當前線程解鎖了。說到這裏,lua腳本的作用自然就出來了,請出官方對於jedis,eval()的解釋:
然後中間有一段這個:
Atomicity of scripts
Redis uses the same Lua interpreter to run all the commands. Also Redis guarantees that a script is executed in an atomic way: no other script or Redis command will be executed while a script is being executed. This semantic is similar to the one of MULTI / EXEC. From the point of view of all the other clients the effects of a script are either still not visible or already completed.
......
Atomicity,也就是說,使用lua腳本,能夠保證腳本的執行具有原子性,不會因爲多個進程競爭而被細分爲更小的過程。
實測
寫一個簡單的Controller,然後用JMeter來模擬多個客戶端進行請求的情景。
@Slf4j
@RestController
public class RedisController {
@Autowired
private JedisPool jedisPool;
@GetMapping("/lock")
public String lock(){
String reqId = UUID.randomUUID().toString();
boolean locked = false;
Jedis jedis = jedisPool.getResource();
try {
locked = RedisUtil.tryGetLock(jedis,"lock1",reqId,2000);
if (locked){
log.info(reqId+"獲取成功\n");
}else{
log.info(reqId+"獲取失敗\n");
}
Thread.sleep(1000);
}catch (Exception e){
e.printStackTrace();
//回收jedis實例,不回收jedis實例會pool中的jedis資源越來越少,從而導致獲取不到可以用的jedis實例,報異常。
if(jedis != null ) {
jedisPool.returnResource(jedis);
}
}finally {
if(locked) {
boolean released = RedisUtil.releaseLock(jedis, "lock1",reqId);
if(released){
log.info(reqId+"釋放成功\n");
}else{
log.info(reqId+"釋放失敗\n");
}
}
if(jedis != null ) {
jedisPool.returnResource(jedis);
}
}
return "ok";
}
}
邏輯很簡單,設置key,這裏設置過期時間爲兩秒,實際上這裏也可以通過請求傳入過期時間,這樣每個客戶端都可以設置自己的時間,然後用sleep(1000)來模擬業務處理。最後釋放鎖。
- JMeter測試
用JMeter開啓一個線程組,設置五個線程,循環五次,然後用http請求去訪問這個接口。
- 測試結果
上面五次中,每一個都應該是一個線程獲取成功,其他四個獲取失敗。下面查看一下控制檯,截取兩次的日誌進行查看,可以發現符合預期。
2020-10-25 13:32:39.274 INFO 13012 --- [nio-9001-exec-2] c.i.r.controller.RedisController : a230e2cb-c87c-4abf-a17e-19ea8d3affc5獲取成功
2020-10-25 13:32:39.274 INFO 13012 --- [nio-9001-exec-1] c.i.r.controller.RedisController : 77ce1001-9621-42bb-bd2a-15bce0f83e25獲取失敗
2020-10-25 13:32:39.684 INFO 13012 --- [nio-9001-exec-3] c.i.r.controller.RedisController : d9a3d30b-8aa1-4481-b82e-d1a3b347267e獲取失敗
2020-10-25 13:32:39.684 INFO 13012 --- [nio-9001-exec-4] c.i.r.controller.RedisController : 48709ff6-5b84-42ca-8b00-83ee64ef756a獲取失敗
2020-10-25 13:32:39.783 INFO 13012 --- [nio-9001-exec-5] c.i.r.controller.RedisController : e54a38a1-3c3f-4e3b-99a9-6766b04bafa5獲取失敗
2020-10-25 13:32:40.296 INFO 13012 --- [nio-9001-exec-2] c.i.r.controller.RedisController : a230e2cb-c87c-4abf-a17e-19ea8d3affc5釋放成功
2020-10-25 13:32:40.393 INFO 13012 --- [nio-9001-exec-2] c.i.r.controller.RedisController : 740adf9e-bb28-445f-82e4-cf7e8f1bda99獲取成功
2020-10-25 13:32:40.394 INFO 13012 --- [nio-9001-exec-1] c.i.r.controller.RedisController : f5bbe368-e016-4fed-ba05-32700e908404獲取失敗
2020-10-25 13:32:40.698 INFO 13012 --- [nio-9001-exec-4] c.i.r.controller.RedisController : cf393325-868f-4137-806e-c52f6d5e2968獲取失敗
2020-10-25 13:32:40.698 INFO 13012 --- [nio-9001-exec-3] c.i.r.controller.RedisController : d914d620-2ce6-46df-a002-c92b546978c1獲取失敗
2020-10-25 13:32:40.792 INFO 13012 --- [nio-9001-exec-6] c.i.r.controller.RedisController : 7356a83c-30a8-492f-b7a0-179d4ecd27a2獲取失敗
2020-10-25 13:32:41.396 INFO 13012 --- [nio-9001-exec-2] c.i.r.controller.RedisController : 740adf9e-bb28-445f-82e4-cf7e8f1bda99釋放成功