关于如何正确实现Redis分布式锁的探索

什么是分布式锁

首先介绍下什么是分布式锁,分布式锁是针对不同系统多进程之间数据同步的一种解决方案。在不同进程需要互斥地访问共享资源时,分布式锁是一种非常有用的技术手段。

安全和可靠性要求

先提出三个属性,这三个属性,是实现高效分布式锁的基础。

  • 安全属性A:互斥,不管任何时候,只有一个客户端能持有同一个锁。
  • 效率属性A:不会死锁,最终一定会得到锁,就算一个持有锁的客户端宕掉或者发生网络分区。
  • 效率属性B:容错,只要大多数Redis节点正常工作,客户端应该都能获取和释放锁。

场景

当前目标是在多个tomcat上实现关单操作。所谓关单,就是把那些超期未支付的订单给关闭掉,这里用的是spring schedule实现,关单部分不在这里详述,仅就分布式锁问题进行探索。
这里是多个tomcat共享一个redis集群。分布式锁用Redis实现。同一时间只有一个tomcat可以获取锁,进行关单。

初始版本(多重防死锁,存在瑕疵,仅此场景可用)

原理

这个初始版本理论上不是一个真正的分布式锁,但放在这里是可以实现分布式锁的功能的。所以我也放在这里做一个例子吧。为什么这个不好呢,之后再说。

几个命令:

  • setnx :set if not exists。如果存在此key,就不会set。
  • expire:expire key seconds。为某个key设定存活时间,存活时间到了就自动删除key。

首先设定一个锁名+锁过期时间的键值对到redis中,用setnx来实现锁功能。setnx,即"set if not exists",就是只有在不存在这个key的时候才进行set,契合锁的定义。

如果set成功,也就是没有这个key,那么就获取锁成功。还要再把锁expire一下,防止set之后当前系统挂了之后一直没解锁,发生死锁。在这段时间里该进程可以对共享资源进行操作,进行关单,其他进程无法插手。

如果set失败,说明redis中已经有这个key了,但是并不代表这个锁还有效,此时仍需判断,如果锁过期了,获取锁。

下面是此过程的的流程图:
在这里插入图片描述
要使用redis,需要引入redis相关组件,这里采用jedis,在pom.xml中添加:

<!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
<dependency>
  <groupId>redis.clients</groupId>
  <artifactId>jedis</artifactId>
  <version>2.6.0</version>
</dependency>

具体实现代码如下:

@Scheduled(cron = "0 */1 * * * ?")//每1分钟执行一次(每个1分钟的整数倍)
public void closeOrderTask() {
    log.info("关闭订单定时任务启动");
    long lockTimeout = Long.parseLong(PropertiesUtil.getProperty("lock.timeout", "5000"));  //分布式锁失效时间
    Long setnxResult = RedisShardedPoolUtil.setnx(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, String.valueOf(System.currentTimeMillis()+lockTimeout));
    if (setnxResult != null && setnxResult.intValue() == 1) {
        //如果key不存在,获取锁成功
        closeOrder(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
    } else {
        //key存在,未获取到锁,继续判断时间戳,看是否可以重置并获取到锁
        String lockValueStr = RedisShardedPoolUtil.get(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);//key的旧值
        if (lockValueStr != null && System.currentTimeMillis() > Long.parseLong(lockValueStr)) {
            //如果当前key存在,且根据时间戳已经已经过时
            //返回给定的key的旧值,->旧值判断,若已过时,就可以获取锁
            //当key没有旧值时,即key不存在时,返回nil -> 获取锁

            //再次用当前时间戳 getSet
            //这里设置了一个新的 value值 getSetResult,获取旧的值
            String getSetResult = RedisShardedPoolUtil.getSet(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, String.valueOf(System.currentTimeMillis() + lockTimeout));//key的旧值
            if (getSetResult == null || (getSetResult != null && StringUtils.equals(lockValueStr, getSetResult))) {
                //若key没有旧值,或存在且前后一致,获取锁
                closeOrder(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
            } else {
                log.info("没有获取到分布式锁:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
            }
        } else {
            log.info("没有获取到分布式锁:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
        }
    }
    log.info("关闭订单定时任务结束");
}

/**
 * 关闭订单,解锁
 * @param lockName :redis中存储的锁
 */
private void closeOrder(String lockName) {
    RedisShardedPoolUtil.expire(lockName, 50);  //有效期 50s,防止死锁
    log.info("获取{}, ThreadName:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
    int hour = Integer.parseInt(PropertiesUtil.getProperty("close.order.task.time.hour"));
    iOrderService.closeOrder(hour);
    //解锁
    RedisShardedPoolUtil.del(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
    log.info("获取{}, ThreadName:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
    log.info("============================");
}

这是无业务代码的简化理解版:

public static boolean getLock(Jedis jedis, String lockKey, int timeout) {

    long expireTime = System.currentTimeMillis() + timeout;
    String expiresStr = String.valueOf(expireTime);

    // 如果当前锁不存在,加锁成功
    if (jedis.setnx(lockKey, expiresStr) == 1) {
        return true;
    }

    // 如果锁存在,获取锁的过期时间
    String currentValueStr = jedis.get(lockKey);
    if (currentValueStr != null && System.currentTimeMillis() > Long.parseLong(currentValueStr)) {
        // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
        String oldValueStr = jedis.getSet(lockKey, expiresStr);
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
            // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
            return true;
        }
    }
        
    // 其他情况,一律返回加锁失败
    return false;
}
问题分析

这里会出现什么问题呢?

  1. 首先可以很清楚地看出,这种实现方式麻烦,比较难懂,出错概率不小;
  2. 如果有多个进程同时进行getset操作,可能会出现A设置的value值覆盖B的value值的情况,此时A和B同时进行了getset,尽管最终获得锁的只有一个进程,假设为B,但是如果A的getset是在B之后的话,那么就会覆盖掉B的value值。即:锁是B的,业务是B在处理,但是过期时间是A的;
    但是放在这里还是OK的,因为A、B的过期时间相差几乎没有,既然都是基于当前时间+timeout,两条指令先后处理,相差时间是微秒级的;
  3. 可以看到代码中是有给锁设置有效时间的,而且既在redis中设置了ttl,还将过期时间设为value值。但是还有一种情况:如果锁已过期失效,但进程任务仍然没有完成,此时有另一个进程获取锁,那么就有两个进程在同时执行此任务,违背了分布式锁的规则;
    要设置一个合理的时间要考虑到执行时间问题、阻塞的问题,同时要兼顾效率。在我这个情境下执行时间是可以预测,因此这个问题可以不用考虑;
  4. 对时间同步性的要求高。锁过期时间是由进程自己生成的,那么多机部署的时候就要求时间是同步的,否则会出现某个锁时间过长/过短的情况。
  5. setnx和expire是两步操作,不具有原子性。在并发中原子性是是十分重要的一个属性。

对初始版本的分析就完了,总结起来就是仅仅可以实现当前这种情景,功能简易实现麻烦…是否可以有一种完美的,适合所有情况的分布式锁呢?

第二版

原子性命令:
set key value [EX seconds] [PX milliseconds] [NX|XX]
其实前面逻辑判断那么多就为了无冲突地set,jedis自带了一个多参数set:jedis.set(String key, String value, String nxxx, String expx, long time)。还是原子性的。

这几个参数的意思是:

  • key,键,使用唯一的key来实现唯一的锁
  • value,设置有效时间
  • nxxx,可以传NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作
  • expx,可以传PX,意思是给这个key加ttl,具体时间由time决定
  • time,代表key的过期时间

第三版

Redisson框架也可以十分简单的实现分布式锁。代码如下:

@Scheduled(cron = "0 */1 * * * ?")//每1分钟执行一次(每个1分钟的整数倍)
public void closeOrderTask() {
    RLock lock = redissonManager.getRedisson().getLock(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
    boolean getLock = false;  //是否获取锁
    try {
        //tryLock参数:等待时间,锁释放时间,时间单位
        if (getLock = lock.tryLock(0, 5, TimeUnit.SECONDS)) {
            log.info("Redisson获取分布式锁:{}, ThreadName:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
            int hour = Integer.parseInt(PropertiesUtil.getProperty("close.order.task.time.hour"));
            //执行关单业务
//                iOrderService.closeOrder(hour);
        } else {
            log.info("Redisson没有获取到分布式锁:{}, ThreadName:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
        }
    } catch (InterruptedException e) {
        log.error("Redisson分布式锁获取异常");
    } finally{
        if (!getLock) {
            //如果未获得锁
            return;
        }
        //如果获得了锁,将其释放
        lock.unlock();  //释放锁
        log.info("Redisson分布式锁释放");
    }
}

有几个需要注意的点:

  1. 这里redis中存入的锁是hash类型;
  2. 注意要在finally中释放锁。

总结

目前来说最好的redis分布式方案是Redis作者antirez 的RedLock,这种方案也只是做到了更好,远远没有到完美的程度。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值