Loading

Redis——高并发与分布式锁

解决高并发

前提:先写一个减扣数据库产品数量的一个接口作为测试。

不加锁

在数据库中将秒杀的数量设置为1,使用Jmeter工具,模拟在一秒之内发送50个请求,测试秒杀结果。

String orderId = UUID.randomUUID().toString().replace("-", "");
QueryWrapper<Product> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("pid", 2);
Product product = productMapper.selectOne(queryWrapper);

if (product.getCount() > 0){
    int val = product.getCount() - 1;
    product.setCount(val);
    System.out.println("我抢到一个,剩余 " + val + " 个");
    list.addIfAbsent(orderId);
    productMapper.updateById(product);
}else {
    System.out.println("没有抢到");
}
System.out.println("卖出数量" + list);

结果可以看到,在不加锁的情况下,50个线程同时访问资源,会出现在第一个线程减库存之前,其他线程就已经从数据库里面读取数据的情况。

乐观锁

乐观锁的原理就是在数据库中加一个验证的字段number,每次修改时带上这个number条件,而每次减少count后修改number的值(原子性)第一个请求的用户这样处理,其他同时查到这个订单的其他用户,在减少count时根据number条件却查不到这个订单了从而无法再生成订单。

String orderId = UUID.randomUUID().toString().replace("-", "");
QueryWrapper<Product> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("pid", 2);
Product product = productMapper.selectOne(queryWrapper);

if (product.getCount() > 0){
    QueryWrapper<Product> wrapper = new QueryWrapper<>();
    wrapper.eq("pid", 2);
    wrapper.eq("number", product.getNumber());
    product.setNumber(product.getNumber() + 1);
    int val = product.getCount() - 1;
    product.setCount(val);
    int update = productMapper.update(product, wrapper);
    if (update != 0){
        list.addIfAbsent(orderId);
        System.out.println("我抢到一个,剩余 " + val + " 个");
    }
}else {
    System.out.println("没有抢到");
}
System.out.println("卖出数量" + list);

这种方法确实解决了前面的问题,在不加锁的情况下,不会阻塞其他线程。但是需要增加表字段,并且由于是在数据库层面保持原子性可能导致多事务操作同一数据时导致冲突,引起数据一致性问题。(讲得好高端,不懂)

悲观锁

加 synchronized 锁虽然能够解决前面的问题,但是该方法仅仅对当前的JVM有效,对于集群模式,该方法无法有效解决这一问题。

synchronized (this){
    String orderId = UUID.randomUUID().toString().replace("-", "");
    QueryWrapper<Product> queryWrapper = new QueryWrapper<>();
    queryWrapper.eq("pid", 2);
    Product product = productMapper.selectOne(queryWrapper);

    if (product.getCount() > 0){
        int val = product.getCount() - 1;
        product.setCount(val);
        System.out.println("我抢到一个,剩余 " + val + " 个");
        list.addIfAbsent(orderId);
        productMapper.updateById(product);
    }else {
        System.out.println("没有抢到");
    }
}
System.out.println("卖出数量" + list);

在这里对同一项目,开两个JVM,可以看到每一个JVM都抢到了一个,因此集群模式下,synchronized 不可取

加分布式锁

重头戏:Redisson 分布式锁

在代码中引入Redisson

// 先配置Redisson配置类
@Configuration
public class RedissonConfig {
    /**
     * 配置并创建Redisson客户端
     * Redisson客户端用于管理和操作Redisson的连接,支持分布式锁等功能
     * 在分布式环境下,可以用于任务协调、消息队列等多种场景
     * @return RedissonClient实例,用于操作Redisson的连接和功能
     */
    @Bean
    public RedissonClient redissonClient() {
        // 创建Redisson配置对象
        Config config = new Config();
        // 配置单服务器模式,设置Redis服务器地址和密码
        config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("******");

        // 根据配置创建Redisson客户端实例
        return Redisson.create(config);
    }
}
// 自动注入Redisson客户端
@Autowired
private RedissonClient redissonClient;

// 业务实现 获取名为"myLock"的分布式锁实例
RLock lock = redissonClient.getLock("myLock");
try {
    // 加锁
    lock.lock(10, TimeUnit.SECONDS);
    String orderId = UUID.randomUUID().toString().replace("-", "");
    QueryWrapper<Product> queryWrapper = new QueryWrapper<>();
    queryWrapper.eq("pid", 2);
    Product product = productMapper.selectOne(queryWrapper);

    if (product.getCount() > 0){
        int val = product.getCount() - 1;
        product.setCount(val);
        System.out.println("我抢到一个,剩余 " + val + " 个");
        list.addIfAbsent(orderId);
        productMapper.updateById(product);
    }else {
        System.out.println("没有抢到");
    }
    System.out.println("卖出数量" + list);
}catch (Exception e){
    e.printStackTrace();
}finally {
    lock.unlock();
}

从结果中可以看到,在两个JVM中同时分别设置50个线程发出请求,最终会只有一个线程成功下单,解决上面的问题。

Redis

为什么需要分布式锁

我们在写多线程程序时,避免同时操作一个共享变量产生数据问题,通常会使用一把锁来「互斥」,以保证共享变量的正确性,其使用范围是在「同一个进程」中。

例如,现在的业务应用通常都是微服务架构,这也意味着一个应用会部署多个进程,那这多个进程如果需要修改 MySQL 中的同一行记录时,为了避免操作乱序导致数据错误,此时,我们就需要引入「分布式锁」来解决这个问题了。

想要实现分布式锁,必须借助一个外部系统,所有进程都去这个系统上申请「加锁」。

而这个外部系统,必须要实现「互斥」的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败(或等待)。

这个外部系统,可以是 MySQL,也可以是 Redis 或 Zookeeper。但为了追求更好的性能,我们通常会选择使用 Redis 或 Zookeeper 来做。

实现最初的分布式锁

要实现最简单的分布式锁,必须要求Redis有「互斥」的能力,因此可以使用SETNX命令来加锁。

如果key不存在,才会设置并获取对应的锁,如果key已经存在,则加锁失败。

这一特性保证了在同一时间只能有一个进程获取到锁。

操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。

但是,它存在一个很大的问题,当客户端 1 拿到锁后,如果发生下面的场景,就会造成「死锁」:

  1. 程序处理业务逻辑异常,没及时释放锁
  2. 进程挂了,没机会释放锁

这时,这个客户端就会一直占用这个锁,而其它客户端就「永远」拿不到这把锁了。

如何避免死锁

在申请锁时,给这把锁设置一个「租期」。

在 Redis 中实现时,就是给这个 key 设置一个「过期时间」。这样一来,无论客户端是否异常,这个锁都可以在 「过期时间」 后被「自动释放」,其它客户端依旧可以拿到锁。

但是加锁、设置过期时间这两个操作是分开的,有可能出现只执行了「第一条」,来不及执行「第二条」的情况:

  1. SETNX 执行成功,执行 EXPIRE 时由于网络问题,执行失败
  2. SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行
  3. SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行

任何两条命令如果不能保证是原子操作(一起成功),就有潜在的风险导致时间设置失败,仍然会有死锁的可能发生。

在 Redis 2.6.12 版本之前,我们需要想尽办法,保证 SETNX 和 EXPIRE 原子性执行,还要考虑各种异常情况如何处理。

但在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,使用一个set(key,1,EX,30,NX)命令就可以完成加锁和设置过期时间两个操作。保障命令的原子性和一致性。

试想这样一种场景:

  1. 客户端 1 加锁成功,开始操作共享资源
  2. 客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
  3. 客户端 2 加锁成功,开始操作共享资源
  4. 客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)

看到了么,这里存在两个严重的问题:

  1. 锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有
  2. 释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁

第一个问题,可能是我们评估操作共享资源的时间不准确导致的。

第二个问题在于,一个客户端释放了其它客户端持有的锁。

重点在于,每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险。

解决锁被别人释放

客户端在加锁时,设置一个只有自己知道的「唯一标识」进去。

由于在释放锁之前,需要先判断锁是否「归自己持有」,因此释放锁分为两个步骤:判断锁、释放锁

这里释放锁使用的是 GET + DEL 两条命令,这时,又会遇到我们前面讲的原子性问题了。

  1. 客户端 1 执行 GET,判断锁是自己的
  2. 客户端 2 执行了 SET 命令,强制获取到锁(虽然发生概率比较低,但我们需要严谨地考虑锁的安全性模型)
  3. 客户端 1 执行 DEL,却释放了客户端 2 的锁

由此可见,这两个命令还是必须要原子执行才行。

Lua脚本

我们可以把这个逻辑,写成 Lua 脚本,让 Redis 来执行。

因为 Redis 处理每一个请求是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。

无法确认锁的过期时间

上面的已经给出了一个相当严谨的分布式锁方案:

  1. 加锁:SET $lock_key $unique_id EX $expire_time NX
  2. 操作共享资源
  3. 释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁

但是有一个问题:无法确认设置多长的「锁过期时间」,即使加长锁过期的时间,也只是治标不治本,每个进程内部都是极其复杂的,无法判断进程执行的确切时间。

方案:加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。

Redisson

Redisson就实现了上述方案。

Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。

除此之外,这个 SDK 还封装了很多易用的功能:

  • 可重入锁
  • 乐观锁
  • 公平锁
  • 读写锁
  • Redlock

这个 SDK 提供的 API 非常友好,它可以像操作本地锁的方式,操作分布式锁。

@Autowired
private RedissonClient redissonClient;

// 下面几行Redisson代码就可以实现一个相当严谨的分布式锁
RLock lock = redissonClient.getLock("myLock");
lock.lock(10, TimeUnit.SECONDS);
lock.unlock();

总结

基于 Redis 实现的分布式锁,遇到的问题,以及对应的解决方案::

  1. 死锁:设置过期时间
  2. 过期时间评估不好,锁提前过期:守护线程,自动续期
  3. 锁被别人释放:锁写入唯一标识,释放锁先检查标识,再释放

参考链接

http://kaito-kidd.com/2021/06/08/is-redis-distributed-lock-really-safe/

项目里面使用redis

依赖是必须的——pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

配置也是必须的——application.yml

spring:
  redis:
    database: 0
    host: 127.0.0.1
    port: 6379
    password: ******
    timeout: 2000ms

配置文件走起——RedisConfig

@Configuration
public class RedisConfig {
    /**
     * 配置 RedisTemplate
     *
     * @param redisConnectionFactory Redis 连接工厂,用于建立 Redis 连接
     * @return 返回一个配置好的 RedisTemplate 实例
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
        // 创建 RedisTemplate 实例
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate();
        // 设置连接工厂,以便 RedisTemplate 能够使用 Redis 连接
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        // 使用更安全的方式启用默认类型信息,但避免包名被写入
//        om.activateDefaultTyping(
//                LaissezFaireSubTypeValidator.instance,  // 安全类型验证器
//                ObjectMapper.DefaultTyping.NON_FINAL,   // 对非final类型进行类型判断
//                JsonTypeInfo.As.PROPERTY                // 类型信息以属性的形式存在
//        );
        // 如果不需要类型信息(简化版,不存储类型信息)
        om.deactivateDefaultTyping();
        jacksonSerializer.setObjectMapper(om);

        // 设置键的序列化器,使用 StringRedisSerializer
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        // 设置值的序列化器,使用 Jackson2JsonRedisSerializer
        redisTemplate.setValueSerializer(jacksonSerializer);
        // 设置 Hash 键的序列化器,使用 StringRedisSerializer
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        // 设置 Hash 值的序列化器,使用 Jackson2JsonRedisSerializer
        redisTemplate.setHashValueSerializer(jacksonSerializer);
        // 返回配置好的 RedisTemplate 实例
        return redisTemplate;
    }
}

然后就可以写接口了——controller

@RestController
@RequestMapping("/test")
public class RedisController {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @RequestMapping("/redis")
    public String testRedis(){
        User user = new User();
        user.setName("lck");
        user.setAge(23);
        user.setEmail("**********");
        redisTemplate.opsForValue().set("user", user);	//把数据存到redis里面
        redisTemplate.expire("user", 20, TimeUnit.SECONDS);	//设置数据过期时间
        System.out.println("success");
        return "success";
    }
}

项目里面使用redis(进阶版)

随便写写:

redisTemplate.opsForList().leftPush("offline_messages:" + chat.getTarget(), chat);

当然,也可以删除数据,直接传入数据的key:

redisTemplate.delete("offline_messages:" + nickname);
posted @ 2025-02-13 14:15  maoxianjia  阅读(25)  评论(1编辑  收藏  举报