RedisDistributedLock

分布式锁

CAP理论:任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。

特点

  • 互斥性: 同一时刻只能有一个线程持有锁;
  • 可重入性: 同一节点上的同一个线程如果获取了锁之后能够再次获取锁;
  • 锁超时:和J.U.C中的锁一样支持锁超时,防止死锁;
  • 高性能和高可用: 加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效;
  • 具备阻塞和非阻塞性:能够及时从阻塞状态中被唤醒;

实现方式

  • 基于数据库
  • 基于Redis
  • 基于zookeeper

基于Redis实现的分布式锁

利用 setnx + expire 命令(错误的做法)

SETNX key value

SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。

只在键 key 不存在的情况下, 将键 key 的值设置为 value;若键 key 已经存在, 则 SETNX 命令不做任何动作。

返回值:命令在设置成功时返回 1 , 设置失败时返回 0 。

因为分布式锁还需要超时机制,所以我们利用expire命令来设置,所以利用 SETNX + expire 命令的核心代码如下:

public boolean tryLock(String key,String requset,int timeout) {
Long result = jedis.setnx(key, requset);
// result = 1时,设置成功,否则设置失败
if (result == 1L) {
return jedis.expire(key, timeout) == 1L;
} else {
return false;
}
}

存在问题:SETNX 和 expire 是分开的两步操作,不具有原子性,如果执行完第一条指令应用异常或者重启了,锁将无法过期。

使用 SET key value [EX seconds] [PX milliseconds] [NX | XX] 命令(正确做法)

如果 key 已经持有其他值,SET 就覆写旧值, 无视类型;当 SET 命令对一个带有生存时间(TTL)的键进行设置之后,该键原有的 TTL 将被清除。

从 Redis 2.6.12 版本开始, SET 命令的行为可以通过一系列参数来修改:

  • EX seconds :将键的过期时间设置为 seconds 秒。执行 SET key value EX seconds 的效果等同于执行 SETEX key seconds value ;
  • PX milliseconds :将键的过期时间设置为 milliseconds 毫秒。 执行 SET key value PX milliseconds 的效果等同于执行 PSETEX key milliseconds value;
  • NX :只在键不存在时, 才对键进行设置操作。 执行 SET key value NX 的效果等同于执行 SETNX key value;
  • XX :只在键已经存在时, 才对键进行设置操作;
public boolean tryLock_with_set(String key, String UniqueId, int seconds) {
return "OK".equals(jedis.set(key, UniqueId, "NX", "EX", seconds));
}

value必须要具有唯一性,我们可以用UUID来做,设置随机字符串保证唯一性,至于为什么要保证唯一性?假如value不是随机字符串,而是一个固定值,那么就可能存在下面的问题:

  • 客户端1获取锁成功;
  • 客户端1在某个操作上阻塞了太长时间;
  • 设置的key过期了,锁自动释放了;
  • 客户端2获取到了对应同一个资源的锁;
  • 客户端1从阻塞中恢复过来,因为value值一样,所以执行释放锁操作时就会释放掉客户端2持有的锁,这样就会造成问题;

所以通常来说,在释放锁时,我们需要对value进行验证。

释放锁的实现

释放锁时需要验证value值,也就是说我们在获取锁的时候需要设置一个value,不能直接用del key这种粗暴的方式,因为直接del key任何客户端都可以进行解锁了,所以解锁时,我们需要判断锁是否是自己的,基于value值来判断,代码如下:

public boolean releaseLock_with_lua(String key,String value) {
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then " +
"return redis.call('del',KEYS[1]) else return 0 end";
return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(value)).equals(1L);
}

这里使用Lua脚本的方式,尽量保证原子性。
使用 set key value [EX seconds][PX milliseconds][NX|XX] 命令 看上去很OK,实际上在Redis集群的时候也会出现问题,比如说A客户端在Redis的master节点上拿到了锁,但是这个加锁的key还没有同步到slave节点,master故障,发生故障转移,一个slave节点升级为master节点,B客户端也可以获取同个key的锁,但客户端A也已经拿到锁了,这就导致多个客户端都拿到锁。
所以针对Redis集群这种情况,还有其他方案。

Redlock算法 与 Redisson 实现

  • Redlock 算法

  • Redisson 实现

    Jedis 是阻塞式I/O,而 Redisson 底层使用Netty可以实现非阻塞I/O,该客户端封装了锁的,继承了 J.U.C 的Lock接口,所以我们可以像使用 ReentrantLock 一样使用 Redisson ,具体使用过程如下。

    <dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.10.6</version>
    </dependency>
    // 1. 配置文件
    Config config = new Config();
    config.useSingleServer()
    .setAddress("redis://127.0.0.1:6379")
    .setPassword(RedisConfig.PASSWORD)
    .setDatabase(0);
    //2. 构造RedissonClient
    RedissonClient redissonClient = Redisson.create(config);

    //3. 设置锁定资源名称
    RLock lock = redissonClient.getLock("redlock");
    lock.lock();
    try {
    System.out.println("获取锁成功,实现业务逻辑");
    Thread.sleep(10000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    } finally {
    lock.unlock();
    }

Redis 实现的分布式锁轮子

自定义注解

被注解的方法会执行获取分布式锁的逻辑

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RedisLock {
/**
* 业务键
*
* @return
*/
String key();
/**
* 锁的过期秒数,默认是5秒
*
* @return
*/
int expire() default 5;

/**
* 尝试加锁,最多等待时间
*
* @return
*/
long waitTime() default Long.MIN_VALUE;
/**
* 锁的超时时间单位
*
* @return
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
}

AOP拦截器实现

在AOP中我们去执行获取分布式锁和释放分布式锁的逻辑

@Aspect
@Component
public class LockMethodAspect {
@Autowired
private RedisLockHelper redisLockHelper;
@Autowired
private JedisUtil jedisUtil;
private Logger logger = LoggerFactory.getLogger(LockMethodAspect.class);

@Around("@annotation(com.redis.lock.annotation.RedisLock)")
public Object around(ProceedingJoinPoint joinPoint) {
Jedis jedis = jedisUtil.getJedis();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();

RedisLock redisLock = method.getAnnotation(RedisLock.class);
String value = UUID.randomUUID().toString();
String key = redisLock.key();
try {
final boolean islock = redisLockHelper.lock(jedis,key, value, redisLock.expire(), redisLock.timeUnit());
logger.info("isLock : {}",islock);
if (!islock) {
logger.error("获取锁失败");
throw new RuntimeException("获取锁失败");
}
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
throw new RuntimeException("系统异常");
}
} finally {
logger.info("释放锁");
redisLockHelper.unlock(jedis,key, value);
jedis.close();
}
}
}

Redis实现分布式锁核心类

@Component
public class RedisLockHelper {

private long sleepTime = 100;

/**
* 直接使用setnx + expire方式获取分布式锁
* 非原子性
*
* @param key
* @param value
* @param timeout
* @return
*/
public boolean lock_setnx(Jedis jedis,String key, String value, int timeout) {
Long result = jedis.setnx(key, value);
// result = 1时,设置成功,否则设置失败
if (result == 1L) {
return jedis.expire(key, timeout) == 1L;
} else {
return false;
}
}

/**
* 使用Lua脚本,脚本中使用setnex+expire命令进行加锁操作
*
* @param jedis
* @param key
* @param UniqueId
* @param seconds
* @return
*/
public boolean Lock_with_lua(Jedis jedis,String key, String UniqueId, int seconds) {
String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
"redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
List<String> keys = new ArrayList<>();
List<String> values = new ArrayList<>();
keys.add(key);
values.add(UniqueId);
values.add(String.valueOf(seconds));
Object result = jedis.eval(lua_scripts, keys, values);
//判断是否成功
return result.equals(1L);
}

/**
* 在Redis的2.6.12及以后中,使用 set key value [NX] [EX] 命令
*
* @param key
* @param value
* @param timeout
* @return
*/
public boolean lock(Jedis jedis,String key, String value, int timeout, TimeUnit timeUnit) {
long seconds = timeUnit.toSeconds(timeout);
return "OK".equals(jedis.set(key, value, "NX", "EX", seconds));
}

/**
* 自定义获取锁的超时时间
*
* @param jedis
* @param key
* @param value
* @param timeout
* @param waitTime
* @param timeUnit
* @return
* @throws InterruptedException
*/
public boolean lock_with_waitTime(Jedis jedis,String key, String value, int timeout, long waitTime,TimeUnit timeUnit) throws InterruptedException {
long seconds = timeUnit.toSeconds(timeout);
while (waitTime >= 0) {
String result = jedis.set(key, value, "nx", "ex", seconds);
if ("OK".equals(result)) {
return true;
}
waitTime -= sleepTime;
Thread.sleep(sleepTime);
}
return false;
}

/**
* 错误的解锁方法—直接删除key
*
* @param key
*/
public void unlock_with_del(Jedis jedis,String key) {
jedis.del(key);
}

/**
* 使用Lua脚本进行解锁操纵,解锁的时候验证value值
*
* @param jedis
* @param key
* @param value
* @return
*/
public boolean unlock(Jedis jedis,String key,String value) {
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then " +
"return redis.call('del',KEYS[1]) else return 0 end";
return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(value)).equals(1L);
}
}

Controller层控制

定义一个TestController来测试我们实现的分布式锁

@RestController
public class TestController {
@RedisLock(key = "redis_lock")
@GetMapping("/index")
public String index() {
return "index";
}
}

锁续命

Author: Jiayi Yang
Link: https://jiayiy.github.io/2020/06/25/RedisDistributedLock/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.