天天看點

基于Redis腳本的分布式鎖

Redis腳本的基本認識

EVAL和EVALSHA

Redis從2.6.0版本開始,内置了lua腳本的解析執行器EVAL和EVALSHA,為避免終端相同腳本的頻繁傳輸,Redis提供了腳本緩存的機制,緩存腳本的解析對應于EVALSHA,使用SCRIPT LOAD将腳本緩存到Redis并擷取SHA1值備用。

EVAL script numkeys key [key ...] arg [arg ...] 
EVALSHA sha1 numkeys key [key ...] arg [arg ...]      

EVAL參數說明:

  • script: 參數是一段 Lua 5.1 腳本程式。腳本不必(也不應該)定義為一個 Lua 函數。
  • numkeys: 用于指定鍵名參數的個數。
  • key [key ...]: 從 EVAL 的第三個參數開始算起,表示在腳本中所用到的那些 Redis 鍵(key),這些鍵名參數可以在 Lua 中通過全局變量 KEYS 數組,用 1 為基址的形式通路( KEYS[1] , KEYS[2] ,以此類推)。
  • arg [arg ...]: 附加參數,在 Lua 中通過全局變量 ARGV 數組通路,通路的形式和 KEYS 變量類似( ARGV[1] 、 ARGV[2] ,諸如此類)。

EVALSHA參數說明:

  • sha1: 通過 SCRIPT LOAD 生成的 sha1 校驗碼。
120.78.72.23:0>eval "if redis.call('setex',KEYS[1],100,ARGV[1]) then return 1 else return 0 end" 1 liming-key liming-arg
120.78.72.23:0>script load "if redis.call('setex',KEYS[1],100,ARGV[1]) then return 1 else return 0 end"
"7a5b676ca41522553a5d587787d56e810465380b"
120.78.72.23:0>evalsha 7a5b676ca41522553a5d587787d56e810465380b 1 liming 1
"1"      

CALL和PCALL

Redis再内置的lua中提供了redis.call與redis.pcall兩個lua函數來通路redis的指令,兩者唯一的不同的地方是面對異常的處理方式,當redis指令執行出現異常時,redis.call将會抛出lua錯誤并強制eval指令傳回一個錯誤給調用者,而redis.pcall将會把錯誤資訊封裝作為結果傳回。現用redis中不存在的指令x做異常測試,表現如下:

120.78.72.23:0>eval "return redis.call('x','liming')" 0
"ERR Error running script (call to f_3e6a9c0be3d9d53955c4478529aac907cb317793): @user_script:1: @user_script: 1: Unknown Redis command called from Lua script"
120.78.72.23:0>eval "return redis.pcall('x','liming')" 0
"@user_script: 1: Unknown Redis command called from Lua script"      

腳本的原子性

Redis使用同一個Lua解釋器來執行一個腳本中的全部指令,且當一個腳本在執行時,不會有其他腳本或Redis指令同時執行,是以Redis能確定以原子方式執行腳本。從另一個角度來看,執行一個非常耗時的腳本不是一個很好的實踐,因為它會阻塞其他腳本或指令執行。當Redis中的記憶體使用超過maxmemory限制,且腳本執行過程中遇到第一個需要使用額外記憶體空間的寫指令時,會導緻腳本終止(除非使用redis.pcall),若不滿足上面條件,那麼即使後面的寫指令需要額外的記憶體空間,redis也能通過允許記憶體使用超過maxmemory限制,保證腳本的原子性。

Java實作

擷取分布式鎖

if redis.call('exists', KEYS[1]) == 0 or redis.call('get', KEYS[1]) == ARGV[1] then 
  if redis.call('setex', KEYS[1], tonumber(ARGV[2]), ARGV[1]) then 
    return 1 
  end 
end 
return 0      

腳本分析

  • 1:不存在指定鍵的緩存,或者指定鍵的緩存值與傳入值是一緻的(支援相同所有者的重入);
  • 2:設定鍵值,并且設定過期時間,避免異常導緻鎖長期得不到釋放;

釋放分布式鎖

if redis.pcall('exists', KEYS[1]) == 1 and redis.pcall('get', KEYS[1]) == ARGV[1] then 
  return redis.pcall('del', KEYS[1])
end
return 0      

  • 1:鎖存在性校驗與擁有者校驗,遵循誰申請誰釋放原則;
  • 2:删除鎖;

完整代碼

import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import java.io.Serializable;
import java.text.MessageFormat;
import java.util.Collections;
/**
 * 基于redis腳本實作的分布式鎖
 */
@Slf4j
public class DistributionLock {
    /**
     * 分布式鎖鍵的字首
     */
    public static final String PREFIX_LOCK = "distribution:lock:{0}";
    /**
     * 申請鎖的腳本
     */
    private static final String SCRIPT_LOCK = "if redis.call('exists', KEYS[1]) == 0 or redis.call('get', KEYS[1]) == ARGV[1] then if redis.call('setex', KEYS[1], tonumber(ARGV[2]), ARGV[1]) then return 1 end end return 0";
    /**
     * 釋放鎖的腳本
     */
    private static final String RELEASE_LOCK = "if redis.call('exists', KEYS[1]) == 1 and redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) end return 0";
    /**
     * Redis用戶端
     */
    private RedisTemplate<String, String> redisTemplate;
    public DistributionLock(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    /**
     * 申請鎖
     *
     * @param key     鍵
     * @param owner   所有者
     * @param timeout 實作時間(秒)
     * @return 成功傳回1,其他傳回0
     */
    public boolean lock(String key, String owner, Integer timeout) {
        log.info("distribution lock get : key:{} , owner:{} , timeout:{}", key, owner, timeout);
        String redisKey = MessageFormat.format(PREFIX_LOCK, key);
        return ((long) redisTemplate.execute(RedisScript.of(SCRIPT_LOCK, Long.class), Collections.singletonList(redisKey), owner, timeout)) == 1L;
    }
    /**
     * 釋放鎖
     *
     * @param key   鍵
     * @param owner 所有者
     * @return 成功傳回1,其他傳回0
     */
    public boolean release(Serializable key, Serializable owner) {
        log.info("distribution lock release : key:{} , owner:{}", key, owner);
        String redisKey = MessageFormat.format(PREFIX_LOCK, key);
        return ((long) redisTemplate.execute(RedisScript.of(RELEASE_LOCK, Long.class), Collections.singletonList(redisKey), owner)) == 1L;
    }
}      
  • lock(Serializable key, Serializable owner, Long timeout)中,key應該是業務相關,owner應該與鎖的所有者相關,在單執行個體的實體環境下,owner可以是線程名稱(不建議);
  • lock(Serializable key, Serializable owner, Long timeout)中,timeout定義了持有鎖的失效時長,避免程式異常導緻持有鎖不能釋放;
  • release(Serializable key, Serializable owner)中,輸入key與owner確定誰申請誰釋放;

示例代碼

try {
 if (distributionLock.lock(key, Thread.currentThread().getName(), 60L)) {
    //todo
 }
} catch (Exception e) {
 log.error("process error : {}", e.getMessage(), e);
} finally {
 distributionLock.release(key, Thread.currentThread().getName());
}      

若有收獲,就點個贊吧!

若有錯誤,歡迎留言指正~