Redis高级(七)、为何要使用分布式锁+大量实操(一套看懂)

120 阅读5分钟

觉得对你有益的小伙伴记得点个赞+关注

后续完整内容持续更新中

希望一起交流的欢迎发邮件至javalyhn@163.com

1. 什么是分布式锁

满足分布式系统或者集群模式下多线程可见并且互斥的锁

分布式锁的核心思路就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路

2. 一个靠谱分布式锁需要具备的条件和刚需

2.1 独占性

任何时刻,只能有且仅有一个线程持有

2.2 高可用

如果Redis集群环境下,不能因为某一个节点挂了而出现获取锁和释放锁失败的情况

2.3 防死锁

杜绝死锁,必须有超时控制机制或者撤销操作,有个兜底的中介跳出方案

2.4 不乱抢

防止张冠李戴,不能私下unlock不属于自己的锁,只能释放属于自己的锁

2.5 重入性

同一个节点的同一个线程如果获取到了锁之后,它也可以再次获取这个锁

3. 常见的分布式锁

3.1 mysql

mysql本身就带有锁机制,但是由于mysql的性能一般,所以采用mysql作为分布式锁情况很少见,在这只是简单提一下

3.2 Redis

Redis是最常用的作为分布式锁的方式,现在企业级开发中基本都是使用Redis或者Zookeeper作为分布式锁,利用setnx这个方法,如果插入key成功,则表示获取到了锁,如果有人插入成功,其他人插入失败则表示没有获取到锁。 有人会问,在高并发情况下,瞬间大量请求想要获取锁会不会有问题?

答案是否定的,这基于Redis强大的设计架构,Reids对于核心操作(服务端的数据处理阶段,不牵扯网络连接)是单线程的

3.3 Zookeeper

zookeeper也是企业级开发中较好的一个实现分布式锁的方案,由于本章讲解Redis,所以放在有关Zookeeper详解文章中,大家敬请期待

MysqlRedisZookeeper
互斥利用Mysql本身的互斥锁机制利用setnx命令利用节点的唯一性和有序性实现互斥
高可用
高性能一般一般
安全性断开连接,自动释放锁利用锁超时时间,到期释放临时节点,断开连接自动释放

4. 分布式锁案例

4.1使用场景

多个服务间保证同一时刻同一时间段内同一用户只能有一个请求(防止关键业务出现并发攻击)

4.2 建module

4.3 改pom


<properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <!--guava-->
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>23.0</version>
        </dependency>
        <!--web+actuator-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <!--SpringBoot与Redis整合依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <!-- jedis -->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>3.1.0</version>
        </dependency>
        <!-- springboot-aop 技术-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <!-- redisson -->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.13.4</version>
        </dependency>
        <!--一般通用基础配置-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

4.4 写配置文件

# 端口号

server.port=1111

# ========================redis相关配置=====================
# Redis数据库索引(默认为0)
spring.redis.database=0  
# Redis服务器地址
spring.redis.host=
# Redis服务器连接端口
spring.redis.port=  
# Redis服务器连接密码(默认为空)
spring.redis.password=
# 连接池最大连接数(使用负值表示没有限制) 默认 8
spring.redis.lettuce.pool.max-active=8
# 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1
spring.redis.lettuce.pool.max-wait=-1
# 连接池中的最大空闲连接 默认 8
spring.redis.lettuce.pool.max-idle=8
# 连接池中的最小空闲连接 默认 0
spring.redis.lettuce.pool.min-idle=0

4.5 主启动类

@SpringBootApplication
public class BootRedis01Application
{

    public static void main(String[] args)
    {
        SpringApplication.run(BootRedis01Application.class, args);
    }

}

4.6 RedisConfig

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Serializable> redisTemplate(LettuceConnectionFactory connectionFactory)
    {
        RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();

        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.setConnectionFactory(connectionFactory);

        return redisTemplate;
    }
}

4.7 业务类

@RestController
public class GoodController
{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Value("${server.port}")
    private String serverPort;

    @GetMapping("/buy_goods")
    public String buy_Goods()
    {
        String result = stringRedisTemplate.opsForValue().get("goods:001");
        int goodsNumber = result == null ? 0 : Integer.parseInt(result);

        if(goodsNumber > 0)
        {
            int realNumber = goodsNumber - 1;
            stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
            System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
            return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
        }else{
            System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
        }
        
        return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
    }
}

4.8 测试

image.png

5. 列举上述案例出现的问题以及解决方案

5.1 单机版没加锁

没有枷锁,就会出现我们熟知的超卖问题。

那么是加Synchronized还是ReentrantLock还是都可以呢?

@GetMapping("/buy_goods")
    public String buyGoods() throws InterruptedException
    {
        /*synchronized (this)
        {
            String number = stringRedisTemplate.opsForValue().get("goods:001");

            int realNumber = number == null ? 0 : Integer.parseInt(number);

            if(realNumber > 0)
            {
                realNumber = realNumber - 1;
                stringRedisTemplate.opsForValue().set("goods:001",String.valueOf(realNumber));
                return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件";
            }
        }*/

        //if (lock.tryLock(2L,TimeUnit.SECONDS))
        if (lock.tryLock())
        {
            try
            {
                String number = stringRedisTemplate.opsForValue().get("goods:001");

                int realNumber = number == null ? 0 : Integer.parseInt(number);

                if(realNumber > 0)
                {
                    realNumber = realNumber - 1;
                    stringRedisTemplate.opsForValue().set("goods:001",String.valueOf(realNumber));
                    return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件";
                }
            }finally {
                lock.unlock();
            }
        }
        return "商品售罄/活动结束,欢迎下次光临";
    }
  • 其实ReentrantLock和Synchronized最核心的区别就在于Synchronized适合于并发竞争低的情况,因为Synchronized的锁升级如果最终升级为重量级锁在使用的过程中是没有办法消除的,意味着每次都要和cpu去请求锁资源,而ReentrantLock主要是提供了阻塞的能力,通过在高并发下线程的挂起,来减少竞争,提高并发能力

  • synchronized是一个关键字,是由jvm层面去实现的,而ReentrantLock是由java api去实现的。

  • synchronized是隐式锁,可以自动释放锁,ReentrantLock是显式锁,需要手动释放锁

  • ReentrantLock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断。

  • ReentrantLock可以获取锁状态,而synchronized不能。

所以单机版没加锁这个问题只要加个锁就可以了,代码就不展示了,相信大家都会。

5.2 为什么synchronized或Lock不能实现分布式锁

在单机环境下,可以使用synchronized或Lock来实现。

但是在分布式系统中,因为竞争的线程可能不在同一个节点上(同一个jvm中), 所以需要一个让所有进程都能访问到的锁来实现(比如redis或者zookeeper来构建)

不同进程jvm层面的锁就不管用了,那么可以利用第三方的一个组件,来获取锁,未获取到锁,则阻塞当前想要运行的线程

5.3 如果仍使用单机版锁,但是采用Nginx分布式微服务架构

分布式部署后,单机锁还是出现超卖现象,需要分布式锁

image.png

有关Nginx配置负载均衡的内容可以取楼主专门文章查看,在此就不再叙述。

在配置完以后,启动两个微服务1111,2222,我们通过访问Nginx(反向代理+负载均衡)http://nginx所设置的路径/buy_goods 可以看到效果就是 1111完成操作、2222完成操作,默认是轮询

下面利用Jmeter模拟高并发

image.png

image.png

image.png

image.png

可见单机锁是不行的,于是有了第三种方案加分布式锁,代码如下

@RestController
public class GoodController
{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Value("${server.port}")
    private String serverPort;

    @GetMapping("/buy_goods")
    public String buy_Goods()
    {
        String key = "zzyyRedisLock";
        String value = UUID.randomUUID().toString()+Thread.currentThread().getName();

        Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key, value);
        if(!flagLock)
        {
            return "抢夺锁失败,o(╥﹏╥)o";
        }

        String result = stringRedisTemplate.opsForValue().get("goods:001");
        int goodsNumber = result == null ? 0 : Integer.parseInt(result);

        if(goodsNumber > 0)
        {
            int realNumber = goodsNumber - 1;
            stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
            stringRedisTemplate.delete(key);
            System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
            return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
        }else{
            System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
        }

        return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;


    }
}

5.4 在出现异常时可能无法释放锁

finally必须关闭锁资源,出异常的话,可能无法释放锁,必须要在代码层面finally释放锁。

加锁解决,lock/unlock必须同时出现并保证调用,代码修改如下

@RestController
public class GoodController
{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Value("${server.port}")
    private String serverPort;

    @GetMapping("/buy_goods")
    public String buy_Goods()
    {
        String key = "zzyyRedisLock";
        String value = UUID.randomUUID().toString()+Thread.currentThread().getName();

        try {
            Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key, value);
            if(!flagLock)
            {
                return "抢锁失败,o(╥﹏╥)o";
            }

            String result = stringRedisTemplate.opsForValue().get("goods:001");
            int goodsNumber = result == null ? 0 : Integer.parseInt(result);

            if(goodsNumber > 0)
            {
                int realNumber = goodsNumber - 1;
                stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
                System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
                return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
            }else{
                System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
            }
            return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
        } finally {
            stringRedisTemplate.delete(key);
        }
    }
}

5.5 宕机导致没有走到finally代码块

部署了微服务jar包的机器挂了,代码层面根本没有走到finally这块, 没办法保证解锁,这个key没有被删除,需要加入一个过期时间限定key

修改代码如下

Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key, value);

stringRedisTemplate.expire(key,10L,TimeUnit.SECONDS);

5.6 设置key和过期时间不是原子性的

设置key+过期时间分开了,必须要合并成一行具备原子性

修改代码如下

Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key,value,10L,TimeUnit.SECONDS);

5.7 张冠李戴,删除了别人的锁

image.png

图的意思就是

进程A获得锁后,进程A执行自己的业务,而这个业务很耗时,由于进程A设置的所过期时间到了,自动释放了锁,但是词汇进程A还在执行业务,也就是说最终finally代码块中的解锁key代码还未执行,此时进程B来抢锁,因为进程A释放了锁,所以进程B抢锁成功,此时进程A业务完成,执行finally块代码,删除了锁。大家知道者删除的锁实际上是B的,B一脸懵。

只能自己删除自己的,不许动别人的,解决方案就是给锁的value设置一个uuid或者其他具有唯一性的值,并且在释放锁的时候将要解锁的锁的value值与当前线程的uuid匹配,成功就删除锁

修改代码如下

public String buy_Goods()
    {
        String key = "zzyyRedisLock";
        String value = UUID.randomUUID().toString()+Thread.currentThread().getName();

        try {
            Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key,value,10L,TimeUnit.SECONDS);

            if(!flagLock)
            {
                return "抢锁失败,o(╥﹏╥)o";
            }

            String result = stringRedisTemplate.opsForValue().get("goods:001");
            int goodsNumber = result == null ? 0 : Integer.parseInt(result);

            if(goodsNumber > 0)
            {
                int realNumber = goodsNumber - 1;
                stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
                System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
                return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
            }else{
                System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
            }
            return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
        } finally {
            if (stringRedisTemplate.opsForValue().get(key).equals(value)) {
                stringRedisTemplate.delete(key);
            }
        }
    }

5.8 finally块的判断和del删除操作不是原子性的

image.png

此时就需要lua脚本来解决了,Redis调用Lua脚本通过eval命令保证代码执行的原子性,代码如下

RedisUtils

public class RedisUtils
{
    private static JedisPool jedisPool;

    static {
        JedisPoolConfig jedisPoolConfig=new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(20);
        jedisPoolConfig.setMaxIdle(10);
        jedisPool=new JedisPool(jedisPoolConfig,"192.168.111.147",6379);
    }

    public static Jedis getJedis() throws Exception {
        if(null!=jedisPool){
            return jedisPool.getResource();
        }
        throw new Exception("Jedispool was not init");
    }

}
finally {
            Jedis jedis = RedisUtils.getJedis();

            String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
                    "then " +
                    "return redis.call('del', KEYS[1]) " +
                    "else " +
                    "   return 0 " +
                    "end";

            try {
                Object result = jedis.eval(script, Collections.singletonList(REDIS_LOCK_KEY), Collections.singletonList(value));
                if ("1".equals(result.toString())) {
                    System.out.println("------del REDIS_LOCK_KEY success");
                }else{
                    System.out.println("------del REDIS_LOCK_KEY error");
                }
            } finally {
                if(null != jedis) {
                    jedis.close();
                }
            }

5.9 确保redisLock过期时间大于业务执行时间的问题

Redis分布式如何续期? 这个问题等过一会我们分析RedLock的看门狗机制时讨论。

5.10 集群+CAP Redis对比Zookeeper对比Eureka

在此先聊一下 集群+CAP Redis对比Zookeeper

5.10.1 什么是CAP

CAP原则又称CAP定理,指的是在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)。CAP 原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾。

image.png

5.10.2 Redis集群是CP 还是 AP

AP

Redis异步复制造成锁的丢失,比如主节点没来的及把刚刚set进来这条数据给从节点,master就挂了,从机上位但从机上无该数据

5.10.3 Zookeeper集群是CP 还是 AP

CP

image.png

5.10.4 Zookeeper集群故障

image.png

5.10.5 Eureka集群注册中心是CP 还是 AP

AP

image.png

5.11 由5.10可知Redis集群环境下,我们自己写的也不OK

那就使出终极绝招了 RedLock之Redisson落地实现 官网说明请参照

redis.io/topics/dist…

github.com/redisson/re…

redisson.org/

github.com/redisson/re…

代码如下(这里我们基于单节点演示)

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Serializable> redisTemplate(LettuceConnectionFactory connectionFactory)
    {
        RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();

        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.setConnectionFactory(connectionFactory);

        return redisTemplate;
    }

下面这个配置很重要

    @Bean
    public Redisson redisson()
    {
        Config config = new Config();

        config.useSingleServer().setAddress("redis://192.168.111.140:6379").setDatabase(0);

        return (Redisson) Redisson.create(config);
    }
}

主业务类修改

@RestController
public class GoodController
{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Value("${server.port}")
    private String serverPort;
    @Autowired
    private Redisson redisson;

    @GetMapping("/buy_goods")
    public String buy_Goods(){
        String key = "zzyyRedisLock";
        RLock redissonLock = redisson.getLock(key);
        redissonLock.lock();
        try{
            String result = stringRedisTemplate.opsForValue().get("goods:001");
            int goodsNumber = result == null ? 0 : Integer.parseInt(result);

            if(goodsNumber > 0)
            {
                int realNumber = goodsNumber - 1;
                stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
                System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
                return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
            }else{
                System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
            }
            return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
        }finally {
            redissonLock.unlock();
        }
    }
}

可见就这几行是Redisson的核心

RLock redissonLock = redisson.getLock(key);
       redissonLock.lock();
       
finally {
           redissonLock.unlock();
       }

这也太省事了吧,这不就和那个lock.lock、lock.unlock一样嘛,以后不管怎么样,就这样写就完事了,我啥也不管。

真的可以吗?

其实这么写有一个bug,哇,我服了,程序员真开心,天天写bug。

什么bug?????????

image.png

Redisson的分布式锁只能通过创建锁的线程进行解锁,正所谓解铃还须系铃人,不是同一个线程解锁会报异常,也就是我们之前说的张冠李戴问题

解决代码如下

finally {
            if(redissonLock.isLocked() && redissonLock.isHeldByCurrentThread())
            {
              redissonLock.unlock();
            }
        }

5.12 流程差不多走完了,现在梳理总结一下

graph TD
synchronized单机版OK,上分布式 --> nginx分布式微服务,单机锁不行 --> 取消单机锁,上redis分布式锁setnx --> 只加了锁,没有释放锁,出异常的话,可能无法释放锁,必须要在代码层面finally释放锁 --> 宕机了,部署了微服务代码层面根本没有走到finally这块,没办法保证解锁,这个key没有被删除,需要有lockKey的过期时间设定 --> 为redis的分布式锁key,增加过期时间此外,还必须要setnx+过期时间必须同一行 --> 必须规定只能自己删除自己的锁,你不能把别人的锁删除了,防止张冠李戴,1删2,2删3 --> finally块的判断操作与del删除操作不满足原子性,用lua脚本实现 --> redis集群环境下,我们自己写的也不OK直接上RedLock之Redisson落地实现

6. Redis分布式锁-Redlock算法(Distributed locks with Redis)

这一章内容就在楼主写的下一篇文章,由于内容很多,知识点很密,就不放在这里讲了,小伙伴们感兴趣的就快去看吧!!

如果这篇文章对你有帮助,别忘了点赞加关注哟~