1. 程式人生 > 實用技巧 >[Redis]Redis實現分散式鎖

[Redis]Redis實現分散式鎖

分散式鎖

為了防止分散式系統中的多個執行緒之間相互干擾,需要一種分散式協調技術來對這些程序進行排程,這個技術的核心就是分散式鎖。比如在如下場景中,就需要用到分散式鎖,現有某個服務有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()的解釋:

Redis.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釋放成功