【📕分布式锁通关指南 02】基于Redis实现的分布式锁

简介: 本文介绍了从单机锁到分布式锁的演变,重点探讨了使用Redis实现分布式锁的方法。分布式锁用于控制分布式系统中多个实例对共享资源的同步访问,需满足互斥性、可重入性、锁超时防死锁和锁释放正确防误删等特性。文章通过具体示例展示了如何利用Redis的`setnx`命令实现加锁,并分析了简化版分布式锁存在的问题,如锁超时和误删。为了解决这些问题,文中提出了设置锁过期时间和在解锁前验证持有锁的线程身份的优化方案。最后指出,尽管当前设计已解决部分问题,但仍存在进一步优化的空间,将在后续章节继续探讨。

引言

在01篇文章中,我们深入探讨了单机锁的多种实现方式,并相信各位读者已经对它们有了较为全面的了解。然而,随着我们对单机锁的深入了解,不难发现它们所固有的一些局限性。因此,从本篇开始,我们将开始探讨分布式锁的相关内容。

认识分布式锁

首先,先来看它的概念-控制分布式系统之间同步访问共享资源的一种方式。所以,它需要满足以下四个特性:互斥性可重入性锁超时防死锁锁释放正确防误删。而01篇中提到的JVM锁在分布式场景中就会存在问题,比如,我们当前有两个服务实例,它们都访问商品库存表进行扣减库存,如果使用JVM锁,其实并没有效果,如图:

1.png

JVM锁只能锁所在服务的实例,所以在分布式场景下,有多少个服务实例自然也会存在多少个JVM锁。那么有解决办法吗?当然是有的。没有什么是加一层解决不了的,我们只需要在服务实例和数据库之间再加一层作为分布式锁即可,如图:

2.png

我们可以依靠中间件来实现加的这一层,常见的有reidsZookeeperEtcd等,本篇我们将以redis分布式锁的实现展开讲解,其他实现也会在后续篇中陆续讲解。

redis实现分布式锁的思路

在开始实现前,我们先来聊聊为什么选择redis来实现分布式锁。这里做技术选型,自然离不开对中间件本身的特点进行分析,redis的以下特点足够支持它来实现分布式锁:

  • 1.Redis是高性能的内存数据库,满足高并发的需求;
  • 2.Redis支持原子性操作,保证操作的原子性和一致性;
  • 3.Redis支持分布式部署,支持多节点间的数据同步和复制,从而满足高可用性和容错性。

除了上述特性,redis客户端提供的一个命令让我们设置锁也变得更为简单,即setnx,区别于set命令,使用它来设置键值对,如果键已存在,就不会设置成功。所以使用这个命令来获取锁的话,我们可以省去很多判断逻辑。

redis实现简化版分布式锁

有了思路,我们可以尝试用代码来实现下。首先,使用redisTemplate来实现下加锁和解锁的方法。加锁就是用setnx命令设置个键值对,key根据业务场景设置,value随意;解锁就是根据key删除指定的键值对,如下:

@Override
public void lock() {
   
    //1.使用setnx指令进行加锁
    while (true) {
   
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockName, "1");
        if (result != null && result) {
   
          break;
        }
    }
}

@Override
public void unlock() {
   
    stringRedisTemplate.delete(this.lockName);
}
AI 代码解读

接着我们继续以扣减库存为例,大致逻辑应该是先获取锁,锁的key就是商品id,拿到锁之后先判断库存数量是否足够,如果足够,则去扣减库存。如下:

public String deductStockRedisLock(Long goodsId,Integer count) {
   

    AbstractLock lock = null;
    try {
   
        lock = new RedisLock(template, "stock" + goodsId);
        lock.lock();
        //1.查询商品库存数量
        String stock = template.opsForValue().get("stock" + goodsId);
        if (StringUtil.isNullOrEmpty(stock)) {
   
            return "商品不存在!";
        }
        int lastStock = Integer.parseInt(stock);
        //2.判断库存数量是否足够
        if (lastStock < count) {
   
            return "库存不足!";
        }
        //3.如果库存数量足够,则去扣减库存
        template.opsForValue().set("stock" + goodsId, String.valueOf(lastStock - count));
        return "扣减库存成功";
    } finally {
   
        if (lock != null) {
   
            lock.unlock();
        }
    }
}
AI 代码解读

接着我们启动熟悉的JMeter来进行测试,在开始前,我们先往redis里set一个key为stock1,value为6000的键值对来表示id为1的商品有6000库存,如下:

3.png

启动JMeter观察执行报告,会发现吞吐量很低,这里读者可以自行对比01篇中的数据。最直接的体现就是这里的扣减库存执行了差不多20s左右才完成,如下:

4.png

这个执行效率如果放到线上肯定是不行的,前面也讲过我们选择redis是奔着高性能去的,可是为什么表现却这么差呢?我们看下加锁的逻辑,如下:

public void lock() {
   
    //1.使用setnx指令进行加锁
    while (true) {
   
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockName, "1");
        if (result != null && result) {
   
          break;
        }
    }
}
AI 代码解读

我们这里的加锁逻辑是只要没获取到锁就去重试,而redis的写命令执行的也比较快,所有这里在高并发场景下就变成了低效重试,那么有没有解决办法呢?当然是有的,很简单,我们只需要在获取失败后,让当前线程先停一下即可,如下:

public void lock() {
   
    //1.使用setnx指令进行加锁
    while (true) {
   
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockName, "1");
        if (result != null && result) {
   
          break;
        }
        try {
   
            Thread.sleep(50);
        } catch (InterruptedException e) {
   
            throw new RuntimeException(e);
        }
    }
}
AI 代码解读

简化版分布式锁存在的问题

在上面的代码里,我们基于redis手撸了一个简化版的分布式锁,那么它是否就满足日常业务使用了呢?当然不行,既然是简化版的自然就存在问题。我们先来分析一下前文中提到的四个特性中的其中两个-锁超时防死锁锁释放正确防误删,那么我们的简化版能否满足呢?显然是不行的,因此就需要我们继续迭代了。

1.锁超时怎么办?

锁超时的情况可能有很多,比如扣减库存获取锁之后代码执行到一半服务挂掉了,由于是异常关闭,所以finally中释放锁的逻辑也没来得及执行,这个时候锁就被永久的持有了。所以为了解决这个问题,我们就需要为锁加上过期时间,这样可以保证无论业务或者服务是否出现异常,最终都可以保证锁的释放,代码如下:


private final long defaultExpireTime = 30000;


@Override
public void lock() {
   
    lock(TimeUnit.MILLISECONDS, defaultExpireTime);
}

@Override
public void lock(TimeUnit timeUnit, Long expireTime) {
   
    //1.使用setnx指令进行加锁
    while (true) {
   
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockName, "1", expireTime, timeUnit);
        if (result != null && result) {
   
            break;
        }

        try {
   
            Thread.sleep(100);
        } catch (InterruptedException e) {
   
            throw new RuntimeException(e);
        }
    }
}
AI 代码解读

所以其实很简单,只需要给锁加个过期时间就可以了,这个时间根据自己的业务场景定。因为如果你定的少了,假如我们定的过期时间是500毫秒,但是相应的业务逻辑执行完成需要800毫秒,那么就会造成业务逻辑还没执行完成,锁就被释放了,这锁就是加了个寂寞。

2.锁被误删了怎么办?

首先,我们来定义下什么叫锁误删,即某个线程持有的锁被别的线程删了。那么这里肯定就有同学疑惑了,按照我们上面的代码逻辑,假设现在有个A线程获取到锁了,在它没释放的情况下,其他线程应该是一直循环获取才对,也就是说这个时候其他线程根本就拿不到这把锁,又怎么能给它释放了呢。

5.png

其实问题就出在我们上面为了解决锁超时问题而给锁加了过期时间,我们假设A线程的业务逻辑处理的时间超过了锁超时释放的时间,就造成了A线程还没执行完,锁就自己释放了,这个时候B线程获取到了锁开始执行,而A线程继续执行到了释放锁的逻辑。注意:此时按照我们的设计,锁的key是商品id,也就是说A、B两线程拿到的是同一把锁,那么这个时候A线程的释放锁反而把B线程拿到的给释放了,最终肯定会造成并发问题的。那么知道了问题所在,我们怎么解决呢?很简单,只需要在释放锁之前判断下当前释放锁的线程是否是拿到锁的线程不就好了,只有一致的情况下才可以释放锁,代码如下:

@Override
public void lock(TimeUnit timeUnit, Long expireTime) {
   
    //1.使用setnx指令进行加锁
    while (true) {
   
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockName, uuid, expireTime, timeUnit);
        if (result != null && result) {
   
            break;
        }

        try {
   
            Thread.sleep(100);
        } catch (InterruptedException e) {
   
            throw new RuntimeException(e);
        }
    }
}

@Override
public void unlock() {
   
    //1.判断当前持有锁线程是否等于本线程
    String result = stringRedisTemplate.opsForValue().get(this.lockName);
    if (this.uuid.equals(result)) {
   
        stringRedisTemplate.delete(this.lockName);
    }
}
AI 代码解读

我们这里的做法是在获取锁的时候给value设置一个uuid,并在删除之前先判断当前线程的uuid和锁对应的uuid是否一致。

小结

本章节通过redis实现了一套简易的分布式锁,看似我们现在的设计已经非常完美,解决了锁超时和锁误删的问题,但实际上还有一些问题没有解决,比如释放锁那里,如果线程A执行过判断后刚好到了锁自然释放的时间,于是释放掉了,而正要执行删除锁的时候,线程B已经拿到锁了,但此时线程A肯定也不知道uuid已经发生变化了,于是执行删除顺利地把线程B刚拿到的锁给释放了,顺利地造成了后续的并发问题。因此,我们将在下一章解决这样的问题。

目录
打赏
0
130
131
12
152
分享
相关文章
Redis分布式锁如何实现 ?
Redis分布式锁主要依靠一个SETNX指令实现的 , 这条命令的含义就是“SET if Not Exists”,即不存在的时候才会设置值。 只有在key不存在的情况下,将键key的值设置为value。如果key已经存在,则SETNX命令不做任何操作。 这个命令的返回值如下。 ● 命令在设置成功时返回1。 ● 命令在设置失败时返回0。 假设此时有线程A和线程B同时访问临界区代码,假设线程A首先执行了SETNX命令,并返回结果1,继续向下执行。而此时线程B再次执行SETNX命令时,返回的结果为0,则线程B不能继续向下执行。只有当线程A执行DELETE命令将设置的锁状态删除时,线程B才会成功执行S
【📕分布式锁通关指南 03】通过Lua脚本保证redis操作的原子性
本文介绍了如何通过Lua脚本在Redis中实现分布式锁的原子性操作,避免并发问题。首先讲解了Lua脚本的基本概念及其在Redis中的使用方法,包括通过`eval`指令执行Lua脚本和通过`script load`指令缓存脚本。接着详细展示了如何用Lua脚本实现加锁、解锁及可重入锁的功能,确保同一线程可以多次获取锁而不发生死锁。最后,通过代码示例演示了如何在实际业务中调用这些Lua脚本,确保锁操作的原子性和安全性。
64 6
【📕分布式锁通关指南 03】通过Lua脚本保证redis操作的原子性
|
24天前
|
【📕分布式锁通关指南 04】redis分布式锁的细节问题以及RedLock算法原理
本文深入探讨了基于Redis实现分布式锁时遇到的细节问题及解决方案。首先,针对锁续期问题,提出了通过独立服务、获取锁进程自己续期和异步线程三种方式,并详细介绍了如何利用Lua脚本和守护线程实现自动续期。接着,解决了锁阻塞问题,引入了带超时时间的`tryLock`机制,确保在高并发场景下不会无限等待锁。最后,作为知识扩展,讲解了RedLock算法原理及其在实际业务中的局限性。文章强调,在并发量不高的场景中手写分布式锁可行,但推荐使用更成熟的Redisson框架来实现分布式锁,以保证系统的稳定性和可靠性。
46 0
【📕分布式锁通关指南 04】redis分布式锁的细节问题以及RedLock算法原理
|
1月前
|
Springboot使用Redis实现分布式锁
通过这些步骤和示例,您可以系统地了解如何在Spring Boot中使用Redis实现分布式锁,并在实际项目中应用。希望这些内容对您的学习和工作有所帮助。
177 83
太惨痛: Redis 分布式锁 5个大坑,又大又深, 如何才能 避开 ?
Redis分布式锁在高并发场景下是重要的技术手段,但其实现过程中常遇到五大深坑:**原子性问题**、**连接耗尽问题**、**锁过期问题**、**锁失效问题**以及**锁分段问题**。这些问题不仅影响系统的稳定性和性能,还可能导致数据不一致。尼恩在实际项目中总结了这些坑,并提供了详细的解决方案,包括使用Lua脚本保证原子性、设置合理的锁过期时间和使用看门狗机制、以及通过锁分段提升性能。这些经验和技巧对面试和实际开发都有很大帮助,值得深入学习和实践。
太惨痛: Redis 分布式锁 5个大坑,又大又深, 如何才能 避开 ?
Redis,分布式缓存演化之路
本文介绍了基于Redis的分布式缓存演化,探讨了分布式锁和缓存一致性问题及其解决方案。首先分析了本地缓存和分布式缓存的区别与优劣,接着深入讲解了分布式远程缓存带来的并发、缓存失效(穿透、雪崩、击穿)等问题及应对策略。文章还详细描述了如何使用Redis实现分布式锁,确保高并发场景下的数据一致性和系统稳定性。最后,通过双写模式和失效模式讨论了缓存一致性问题,并提出了多种解决方案,如引入Canal中间件等。希望这些内容能为读者在设计分布式缓存系统时提供有价值的参考。感谢您的阅读!
134 6
Redis,分布式缓存演化之路
|
3月前
|
使用lock4j-redis-template-spring-boot-starter实现redis分布式锁
通过使用 `lock4j-redis-template-spring-boot-starter`,我们可以轻松实现 Redis 分布式锁,从而解决分布式系统中多个实例并发访问共享资源的问题。合理配置和使用分布式锁,可以有效提高系统的稳定性和数据的一致性。希望本文对你在实际项目中使用 Redis 分布式锁有所帮助。
283 5
基于Redis海量数据场景分布式ID架构实践
【11月更文挑战第30天】在现代分布式系统中,生成全局唯一的ID是一个常见且重要的需求。在微服务架构中,各个服务可能需要生成唯一标识符,如用户ID、订单ID等。传统的自增ID已经无法满足在集群环境下保持唯一性的要求,而分布式ID解决方案能够确保即使在多个实例间也能生成全局唯一的标识符。本文将深入探讨如何利用Redis实现分布式ID生成,并通过Java语言展示多个示例,同时分析每个实践方案的优缺点。
141 8
|
4月前
|
Redis分布式锁如何实现 ?
Redis分布式锁通过SETNX指令实现,确保仅在键不存在时设置值。此机制用于控制多个线程对共享资源的访问,避免并发冲突。然而,实际应用中需解决死锁、锁超时、归一化、可重入及阻塞等问题,以确保系统的稳定性和可靠性。解决方案包括设置锁超时、引入Watch Dog机制、使用ThreadLocal绑定加解锁操作、实现计数器支持可重入锁以及采用自旋锁思想处理阻塞请求。
104 16
|
4月前
|
Redis作为PHP缓存解决方案的优势、实现方式及注意事项。Redis凭借其高性能、丰富的数据结构、数据持久化和分布式支持等特点,在提升应用响应速度和处理能力方面表现突出
本文深入探讨了Redis作为PHP缓存解决方案的优势、实现方式及注意事项。Redis凭借其高性能、丰富的数据结构、数据持久化和分布式支持等特点,在提升应用响应速度和处理能力方面表现突出。文章还介绍了Redis在页面缓存、数据缓存和会话缓存等应用场景中的使用,并强调了缓存数据一致性、过期时间设置、容量控制和安全问题的重要性。
88 5
AI助理

你好,我是AI助理

可以解答问题、推荐解决方案等