【技术派后端篇】Redis分布式锁:原理、实践与应用

在当今的高并发系统中,分布式锁是保障数据一致性和系统稳定性的重要手段。今天,我们就来深入探讨一下Redis分布式锁,揭开它神秘的面纱。

1 本地锁与分布式锁的区别

在Java开发的早期阶段,我们接触过synchronizedLock锁,这些都属于本地锁。本地锁的特点是仅对当前节点有效,举个例子:假设我们有两个节点node A和node B,当node A获取了本地锁时,node B依然可以获取相同的锁。
在这里插入图片描述

如果我们的服务仅部署了一个节点,本地锁是完全能够满足需求的。但随着业务的发展,为了应对高并发、实现高可用和高性能,很多系统会采用多节点(集群部署)的方式。此时,本地锁就显得力不从心了,分布式锁便应运而生。

为什么本地锁锁不住?

  1. 锁范围有限:有多个服务实例时,本地锁只在自己的实例里起作用。像synchronized ,不同实例的锁对象(字节码 )不一样。大量用户查文章详情,缓存没数据时,不同实例的线程都能去连MySQL,本地锁拦不住。
  2. 跨进程管不了:服务分布式部署,在不同JVM进程里。本地锁只能管自己进程内的线程,别的进程线程来访问MySQL ,它管不了。
  3. 高并发顶不住:高并发时很多请求同时来,本地锁在单个JVM里抢锁很厉害,而且它也没法阻止其他JVM进程的线程访问MySQL ,数据库还是可能被大量请求弄“挂” 。

分布式锁的锁对象不在服务实例中,而是在服务实例的外部。分布式锁的核心思想是,当一个节点获取到锁后,其他节点无法获取该锁,从而保证了在分布式环境下的资源同步访问。

分布式锁的多种实现方式:

  • Redis分布式锁;
  • Zookeeper分布式锁;
  • MySQL分布式锁。

2 Redis分布式锁、Zookeeper分布式锁与MySQL分布式锁的差异

谈到分布式锁,就不得不提到CAP理论,即强一致性(Consistency)、可用性(Availability)和分区容错性(Partition Tolerance),三者只能选其二。

  1. Redis分布式锁 :它追求的是高可用性和分区容错性。Redis在写入主节点数据后,会立即返回成功,而不关心异步主节点同步从节点数据是否成功。由于Redis是基于内存的,其性能极高,官方给出的指标是每秒可达到10W的吞吐量。适用于对性能要求较高、允许一定数据延迟一致性的场景,比如一般的电商商品浏览、秒杀活动中的部分非核心数据校验等场景。
  2. Zookeeper分布式锁 :Zookeeper更侧重于强一致性和分区容错性。在写入主节点数据后,Zookeeper会等待从节点同步完数据后才返回成功,这在一定程度上牺牲了可用性。常用于对数据一致性要求极高的场景,例如金融行业的账务处理等场景。
  3. MySQL分布式锁
    • 实现原理:通常利用数据库自身的事务机制和行锁、表锁等特性来实现。比如通过在特定表中插入或更新特定记录,并利用事务的原子性来获取锁。若插入或更新成功则表示获取锁成功,否则获取失败。
    • 性能方面:相比Redis基于内存的操作,MySQL分布式锁由于涉及磁盘I/O(即使有缓存机制 ),在高并发场景下性能相对较低。例如在大量短时间内的锁竞争场景中,Redis可能轻松应对每秒数万甚至数十万的请求,而MySQL可能每秒只能处理数千请求。
    • 一致性与可用性:它在一定程度上能保证数据的强一致性,因为基于事务机制,数据操作要么全部成功,要么全部失败。但在可用性方面,当数据库出现故障(如主库宕机 )时,可能导致锁服务不可用,且恢复时间相对较长。而且,若锁表等关键资源出现瓶颈,会严重影响整个系统的可用性。
    • 适用场景:适用于对一致性要求较高,并发量不是特别极端高,且业务逻辑相对复杂,需要借助数据库事务特性来保证数据完整性的场景,比如一些企业内部的业务流程审批系统,涉及多步骤数据更新和状态转换,利用MySQL分布式锁可以结合事务更好地控制流程顺序和数据一致性。

综合考虑,为了追求更好的用户体验度,在高并发且对性能要求较高、对数据一致性有一定容忍度的场景下,很多时候会选择Redis分布式锁来实现;而在对一致性要求极高,对性能要求相对没那么苛刻的场景下,可能会选择Zookeeper分布式锁或MySQL分布式锁。

3 使用Redis分布式锁的背景

以查询文章详情为例:
在这里插入图片描述

用户根据articleId查询文章详情时,正常流程是先查询缓存,如果缓存中有数据,直接返回;如果缓存中没有数据,则需要到MySQL中查询。在并发量不高的情况下,这个流程没有问题。但当并发量很高时,就会出现问题。假设缓存中没有数据,大量用户会同时访问DB层的MySQL。而MySQL的资源相对珍贵,且性能不如Redis,很容易导致MySQL被打宕机,进而影响整个服务。

为了解决这个问题,当大量用户同时访问同一篇文章时,我们只允许一个用户去MySQL中获取数据。由于服务是集群化部署的,所以需要用到Redis分布式锁。通过加锁的方式,可以有效地保护DB层数据库,保证系统的高可用性。
在这里插入图片描述

4 Redis分布式锁的实现方式

4.1 Redis实现分布式锁

  • 项目仓库(GitHub):https://github.com/itwanger/paicoding
  • 项目仓库(码云):https://gitee.com/itwanger/paicoding
  • 分支:origin/feature/redis_distributed_lock_20230531

4.1.1 第一种方式:setIfAbsent(key,value,time)

使用redisTemplate.opsForValue().setIfAbsent(key, value, time, TimeUnit.SECONDS),对应的Redis命令是set key value EX time NX。这是一个复合操作,由setNx + setEx组成,底层采用lua脚本来保证原子性,要么全部成功,否则加锁失败。其含义是:如果key不存在,则加锁成功,返回true;否则加锁失败,返回false 。
在这里插入图片描述
代码实现:

/**
 * Redis分布式锁第一种方法
 *
 * @param articleId
 * @return ArticleDTO
 */
private ArticleDTO checkArticleByDBOne(Long articleId) {

    String redisLockKey =
            RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;

    ArticleDTO article = null;
    // 加分布式锁:此时value为null,时间为90s(结合自己场景设置合适过期时间)
    Boolean isLockSuccess = redisUtil.setIfAbsent(redisLockKey, null, 90L);

    if (isLockSuccess) {
        // 加锁成功可以访问数据库
        article = articleDao.queryArticleDetail(articleId);
    } else {
        try {
            // 短暂睡眠,为了让拿到锁的线程有时间访问数据库拿到数据后set进缓存,
            // 这样在自旋时就能够从缓存中拿到数据;注意时间依旧结合自己实际情况
            Thread.sleep(200);
        } catch (InterruptedException e) {
            
            e.printStackTrace();
        }
        // 加锁失败采用自旋方式重新拿取数据
        this.queryDetailArticleInfo(articleId);
    }

    return article;
}

这种方式的主要逻辑是:当缓存中没有数据时,开始加锁,加锁成功则允许访问数据库,加锁失败则自旋重新访问。但它存在一个缺点,虽然在setIfAbsent中设置了过期时间,但可能会出现业务执行完之后,锁还被持有的情况。虽然Redis有淘汰策略,但这种情况还是不建议出现,因为Redis缓存资源非常重要,正确的做法应该是业务执行完后直接释放锁。

4.1.2 第二种方式:setIfAbsent(key,value,time)的优化

为了解决第一种方式中锁不能及时释放的问题,我们在业务执行完毕之后(增加finally块)立即删除key值。
代码实现:

/**
 * Redis分布式锁第二种方法
 *
 * @param articleId
 * @return ArticleDTO
 */
private ArticleDTO checkArticleByDBTwo(Long articleId) {

    String redisLockKey =
            RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;

    ArticleDTO article = null;

    Boolean isLockSuccess = redisUtil.setIfAbsent(redisLockKey, null, 90L);
    try {
        if (isLockSuccess) {
            article = articleDao.queryArticleDetail(articleId);
        } else {
            Thread.sleep(200);
            this.queryDetailArticleInfo(articleId);
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        // 和第一种方式相比增加了finally中删除key
        RedisClient.del(redisLockKey);
    }

    return article;

}

但这种方式也存在问题,比如线程A获取到锁并正在执行业务,还未执行完成时,锁的过期时间到了,该锁被释放。此时线程B可以获取该锁并执行业务逻辑,而当线程A执行完成后,它释放的将是线程B的锁,即释放了别人的锁。
在这里插入图片描述

4.1.3 第三种方式:改进误释放锁的问题

为了解决误释放他人锁的情况,我们在加锁时设置一个value值,然后在释放锁前判断给key的value是否和前面设置的value值相等,相等则说明是自己的锁,可以删除;否则是别人的锁,不能删除。
在这里插入图片描述
代码实现:

/**
 * Redis分布式锁第三种方法
 *
 * @param articleId
 * @return ArticleDTO
 */
private ArticleDTO checkArticleByDBThree(Long articleId) {

    String redisLockKey =
            RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;

    // 设置value值,保证不误删除他人锁
    String value = RandomUtil.randomString(6);
    Boolean isLockSuccess = redisUtil.setIfAbsent(redisLockKey, value, 90L);
    ArticleDTO article = null;
    try {
        if (isLockSuccess) {
            article = articleDao.queryArticleDetail(articleId);
        } else {
            Thread.sleep(200);
            this.queryDetailArticleInfo(articleId);
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        
        // 这种先get出value,然后再比较删除;这无法保证原子性,为了保证原子性,采用了lua脚本
        /*
        String redisLockValue = RedisClient.getStr(redisLockKey);
        if (!ObjectUtils.isEmpty(redisLockValue) && StringUtils.equals(value, redisLockValue)) {
            RedisClient.del(redisLockKey);
        }
        */
        // 采用lua脚本来进行先判断,再删除;和上面的这种方式相比保证了原子性
        Long cad = redisLuaUtil.cad("pai_" + redisLockKey, value);
        log.info("lua 脚本删除结果:" + cad);

    }

    return article;

}

不过,这种方式又带来了一个新问题,那就是过期时间的值该如何设置呢?

  • 如果时间设置过短,可能业务还未执行完毕,锁就已经过期被释放,其他线程可以拿到锁去访问DB,这就违背了我们加锁的初衷;
  • 如果时间设置过长,可能在加锁成功后还未执行到释放锁时,节点宕机了,那么在锁未过期的这段时间,其他线程无法获取锁。
  • 针对这个问题,我们可以写一个守护线程,每隔固定时间查看业务是否执行完毕,如果没有执行完毕,则延长其过期时间,即为锁续期

4.2 Redission实现分布式锁

Redission实现分布式锁的流程是:首先获取锁(get lock()),然后尝试加锁,加锁成功后执行下面的业务逻辑,执行完毕之后释放该分布式锁。
在这里插入图片描述

代码实现:

/**
 * Redis分布式锁第四种方法
 *
 * @param articleId
 * @return ArticleDTO
 */
private ArticleDTO checkArticleByDBFour(Long articleId) {

    ArticleDTO article = null;
    String redisLockKey =
            RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;
    RLock lock = redissonClient.getLock(redisLockKey);
    //lock.lock();

    try {
        //尝试加锁,最大等待时间3秒,上锁30秒自动解锁
        if (lock.tryLock(3, 30, TimeUnit.SECONDS)) {
            article = articleDao.queryArticleDetail(articleId);
        } else {
            // 未获得分布式锁线程睡眠一下;然后再去获取数据
            Thread.sleep(200);
            this.queryDetailArticleInfo(articleId);
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        //判断该lock是否已经锁 并且 锁是否是自己的
        if (lock.isLocked() && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }

    }
    return article;
}

Redission解决了Redis实现分布式锁中出现的锁过期问题和释放他人锁的问题。它还是可重入锁,内部机制是默认锁过期时间是30s,然后会有一个定时任务每10s去扫描一下该锁是否被释放,如果没有释放则延长至30s,这就是看门狗机制。如果请求没有获取到锁,那么它将通过while循环继续尝试加锁。

5 总结

通过本文,我们从原理到实践,详细介绍了Redis分布式锁的相关知识。我们了解了本地锁与分布式锁的区别,Redis分布式锁、Zookeeper分布式锁、MySQL分布式锁的差异,以及Redis分布式锁的几种实现方式。虽然Redission实现分布式锁基本解决了大部分问题,但当Redis是主从架构时,它也存在一些问题,比如线程A在master节点加锁后还未同步到slave节点,此时master节点挂了,线程B仍可以加锁,这涉及到高一致性问题,Redission无法解决。

如果想要解决高一致性问题,可以使用红锁或者zk锁,它们保证了高一致性,但不建议使用,因为为了保证高一致性,它们丢失了高可用性,对用户体验感不好,而且上述问题出现的几率不大,我们不能因为很小的问题而舍弃其高可用性。

希望本文能帮助大家更好地理解和应用Redis分布式锁,在实际项目中根据具体需求选择合适的分布式锁实现方式。

6 参考链接

  1. 技术派Redis分布式锁
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值