天天看点

基于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());
}      

若有收获,就点个赞吧!

若有错误,欢迎留言指正~