[分布式锁]:Redis与Redisson

1 分布式锁

1.1 为什么需要分布式锁?

1.1.1 引入业务场景

业务场景:在物流系统中,快递员提交支付请求,由于网络等原因一直没有返回二维码,此时快递员在发起一次请求,这样就会生成2个订单,这就重复了不符合业务场景,因此我们就要在生成交易订单时加锁,若获取到锁就执行,否则就抛出异常。

1.1.2 不添加锁时出现的业务错误

//业务方法,创建订单
    @Resource(name = "aliNativePayHandler")
    private NativePayHandler nativePayHandler;

    /**
     * 创建交易单示例代码
     *
     * @param productOrderNo 订单号
     * @return 交易单对象
     */
    public TradingEntity createDownLineTrading(Long productOrderNo) {
        TradingEntity tradingEntity = new TradingEntity(); //交易订单实体
        tradingEntity.setProductOrderNo(productOrderNo);

        //基于订单创建交易单
        tradingEntity.setTradingOrderNo(IdUtil.getSnowflakeNextId());
        tradingEntity.setCreated(LocalDateTime.now());
        tradingEntity.setTradingAmount(BigDecimal.valueOf(1));
        tradingEntity.setMemo("运费");

        //调用第三方支付创建交易
        this.nativePayHandler.createDownLineTrading(tradingEntity);
        return tradingEntity;
    }

//测试方法,模拟多线程创建订单
 @Resource
    NativePayService nativePayService;

    @Test
    public void createDownLineTrading() throws Exception {
        Long productOrderNo = 1122334455L;

        //多线程模拟并发
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {

                //使用加锁的方式添加订单
                TradingEntity tradingEntity = nativePayService.createDownLineTrading(productOrderNo);
                System.out.println("交易单" + tradingEntity + ",线程id= " + Thread.currentThread().getId());
            }).start();
        }

        //睡眠20秒,等待所有子线程完成
        Thread.sleep(20000);
    }

注意:可以发现对于一个订单号创建了多个交易对象,这就是并发常见数据重复问题
在这里插入图片描述

1.1.3 本地锁与分布式锁

锁的定义:用于保障并发访问时数据的一致性、可见性和安全性。在多线程中为避免数据竞争常使用锁来限制对共享资源的访问。

本地锁:诸如针对Java多线程并发控制的synchronized、Lock本地锁,针对数据库并发访问控制的乐观锁、悲观锁等本地锁,只适用于单体项目,在多个节点的分布式项目中不适用。

分布式锁:诸如Redisson、ZooKeeper、数据库(通过数据库可以实现分布式锁,但是高并发情况下对数据库压力较大,由于数据库存储和管理重要数据,造成软件开发的瓶颈集中在数据库上,所以很少使用)等,适用于分布式多节点锁定。

1.2 分布式锁核心思想

实现分布式锁,可以借助Redis的setnx命令实现,使用该命令时,若key存在,则返回1,若key不存在,则返回0,也就意味着key只能设置一次,假设有多个线程同时设置值,只有一个能设置成功,这样就得到互斥效果,即达到锁的效果。

setnx命令演示:
在这里插入图片描述

1.3 基于Redis实现分布式锁

1.3.1 自定义ILock接口

public interface ILock {

    /**
     * 尝试获取锁
     *
     * @param name 锁名称
     * @param timeoutSec 锁持有超时时间,过期后自动释放
     * @return true表示获取锁成功, false表示获取锁失败
     */
    boolean tryLock(String name, Long timeoutSec);

    /**
     * 释放锁
     * @param name 锁名称
     */
    void unlock(String name);
}

1.3.2 实现ILock接口方法

@Component
public class SimpleRedisILock implements ILock {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    private static final String KEY_PREFIX = "Lock:";

    /**
     * 获取锁
     *
     * @param name       锁名称
     * @param timeoutSec 锁持有超时时间,过期后自动释放
     * @return 是否获取锁成功
     */
    @Override
    public boolean tryLock(String name, Long timeoutSec) {
        //获取线程标识
        String threadId = Thread.currentThread().getId() + "";
        //获取锁 setIfAbsent() 是SETNX命令在java的体现
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    /**
     * 释放锁
     *
     * @param name 锁名称
     */
    @Override
    public void unlock(String name) {
        //获取线程标识
        String threadId = Thread.currentThread().getId() + "";

        //获取锁的标识
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        //判断标示是否一致,即当前线程与拥有锁的线程相同时,才会释放锁
        if (threadId.equals(id)) {
            //通过del删除锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

1.3.3 业务方法添加锁

@Service
public class NativePayService {
    @Resource(name = "aliNativePayHandler")
    private NativePayHandler nativePayHandler;

    @Resource
    private SimpleRedisILock simpleRedisLock;

    /**
     * 创建交易单示例代码
     *
     * @param productOrderNo 订单号
     * @return 交易单对象
     */
    public TradingEntity createDownLineTrading(Long productOrderNo) {
        TradingEntity tradingEntity = new TradingEntity(); //交易订单实体
        tradingEntity.setProductOrderNo(productOrderNo);

        //基于订单创建交易单
        tradingEntity.setTradingOrderNo(IdUtil.getSnowflakeNextId());
        tradingEntity.setCreated(LocalDateTime.now());
        tradingEntity.setTradingAmount(BigDecimal.valueOf(1));
        tradingEntity.setMemo("运费");

        //调用第三方支付创建交易
        this.nativePayHandler.createDownLineTrading(tradingEntity);
        return tradingEntity;
    }

    /**
     * 加锁方式创建交易单示例方法
     *
     * @param productOrderNo 订单号
     * @return 交易单对象
     */
    public TradingEntity createDownLineTradingLock(Long productOrderNo) {

        //获取锁
        String lockName = Convert.toStr(productOrderNo);//hutool字符转换器,转换为字符串
        boolean lock = this.simpleRedisLock.tryLock(lockName, 5L); //拥有锁时间,在此时间内,拥有锁的线程执行
        if (!lock) {
            System.out.println("没有获取到锁,线程id=" + Thread.currentThread().getId());
            return null;
        }
        System.out.println("获取到锁,线程id=" + Thread.currentThread().getId());

        //获取到锁后,在创建订单
        TradingEntity tradingEntity = createDownLineTrading(productOrderNo);

        //释放锁
        this.simpleRedisLock.unlock(lockName);
        return tradingEntity;
    }
}

1.3.4 测试方法

 @Resource
    NativePayService nativePayService;

    @Test
    public void createDownLineTrading() throws Exception {
        Long productOrderNo = 1122334455L;

        //多线程模拟并发
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {

                //使用加锁的方式添加订单
                TradingEntity tradingEntity = nativePayService.createDownLineTradingLock(productOrderNo);
                System.out.println("交易单" + tradingEntity + ",线程id= " + Thread.currentThread().getId());
            }).start();
        }

        //睡眠20秒,等待所有子线程完成
        Thread.sleep(20000);
    }

注意:如下对于一个订单只会生成一个交易单,这才符合业务员需求。
在这里插入图片描述

1.3.5 基于Redis自定义实现分布式锁问题

问题:

  • ① 由于每个线程持有锁时间被设置为5秒,此时,需要思考一个问题,当程序运行时间大于5s时,程序还没结束锁已经释放,其他线程就会获得锁,最终会导致脏数据情况发生。

1.3.6 自定义锁脏数据问题时间线分析

在这里插入图片描述

1.3.7 自定义Redis锁其余问题

① 线程拥有锁时间问题:如1.3.6产生的脏数据情况。
不是原子性(要么全成功,要么全失败)操作:在高并发情况下,多个线程可能同时执行tryLock()方法,并在执行结果返回前都认为自己已获得锁,从而导致分布式锁失效(解决方法使用WATCH key [key ...]命令检查锁是否已经被占用,并在获取锁之后理解将锁的状态设置为“已占用”,获取锁说之后通过使用EVAL命令执行Lua脚本,来确保释放锁原子性)。
不可重入:同一把锁无法多次获取同一把锁,在上方1.3.6时间线中,同一线程只能获得锁一次。
不可重试:获取锁只尝试一次就返回false,无重试机制,在上方1.3.6时间线中,同一线程只能得到一次结果。
超时释放:锁超时释放虽然可避免死锁,若业务执行时间较长,会导致锁释放,存在安全隐患。
主从一致性:在Redis集群中,若主从存在延迟,会导致线程在主节点上成功获取到锁,然而从节点键值未被同步,出现数据不一致情况(解决方法:哨兵等组间通过Redis集群高可用性)。

2 对于自定义锁问题解决——Redisson入门

Redis提供的分布式多有:可重入锁、公平锁、联锁、红锁、读写锁、信号量、可过期性信号量、闭锁。
官网:https://redisson.org/

2.1 Redisson快速实现

2.1.1 导入maven坐标

<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson</artifactId>
</dependency>

2.1.2 Redisson配置

@Data
@EnableConfigurationProperties(RedisProperties.class)
@Configuration
public class RedissonConfiguration {

    @Resource
    private RedisProperties redisProperties;

    @Bean
    public RedissonClient redisSingle() {
        Config config = new Config();
        SingleServerConfig serverConfig = config.useSingleServer().setAddress("redis://" + redisProperties.getHost() + ":" + redisProperties.getPort());
        if (null != (redisProperties.getTimeout())) {
            //设置超时时间
            serverConfig.setTimeout(1000 + Convert.toInt(redisProperties.getTimeout().getSeconds()));
        }
        if (StrUtil.isNotEmpty(redisProperties.getPassword())){
            //设置密码
            serverConfig.setPassword(redisProperties.getPassword());
        }

        //创建RedissonClient
        return Redisson.create(config);
    }
}

2.1.3 测试方法

    @Test
    public void createDownLineTrading() throws Exception {
        Long productOrderNo = 1122334455L;

        //多线程模拟并发
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {

                //使用加锁的方式添加订单
                TradingEntity tradingEntity = nativePayService.createDownLineTradingRedissonLock(productOrderNo);
                System.out.println("交易单" + tradingEntity + ",线程id= " + Thread.currentThread().getId());
            }).start();
        }

        //睡眠20秒,等待所有子线程完成
        Thread.sleep(20000);
    }

与我们自定义实现锁效果一样
在这里插入图片描述

2.2 开门狗机制

2.2.1 问题引入

在我们使用Redisson分布式锁时,没有指定存储到Redis中锁的有效时间? 那Redis如何处理这个时间问题,什么原理?

说明

  • 在程序中没有指定存储到Redis中锁的有效时间,而是,由Redisson的默认时间30s,其次,当程序执行时间超过30s,锁仍不会自动删除,是因为,Redisson一旦加锁成功会启动“看门狗”(watch dog)机制,当时间每过期1/3时,就检查下,若当前线程还继续持有锁,就会重新刷新到30s,直到最后锁释放。

可以看出Redisson的看门狗机制确保锁不会在业务执行完之前过期。
在这里插入图片描述

注意:若获取锁时指定leaseTime,看门狗程序不会生效。
在这里插入图片描述

2.2.2 加锁的实现原理

Redisson实现分布式锁原理并非直接采用SETNX命令实现,而是采用Lua脚本实现,主要确保操作原子性。

  • 原理:获取自定义名为myLock的锁,其存储的是hash结构数据,大KEY是myLock,小key是< uuid >:< threadId >,value是获取锁的次数,类似结构如下:
    在这里插入图片描述

2.2.3 Redisson加锁的核心Lua脚本

    <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    //key[1]:为锁名。即redissonClient.getFairLock("myLock")中的myLock
    //ARGV[1]:锁时间爱你,即lock.tryLock(5L,10l,TimeUnit.SECONDS),10*60s,默认internalLockLeaseTime=30s
     //ARGV[2]:锁的唯一标识,<uuid>:<threadId>
            return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
            //1.若key不存在,则进行加锁,返回nil,即null
                "if (redis.call('exists', KEYS[1]) == 0) then " +
                //1.1 对key加锁,并设置加锁次数为1
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        //1.2 设置key的过期时间,单位为毫秒
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +
                        //2.通过 大KEY+小key的方式判断锁是否存在,如果存在获取锁的次数+1,重新设置时间,返回n
                        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                        //2.1 加锁次数+1
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        //2.2 重置过期时间
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +

                        //3.兜底处理,锁获取不成功,若锁被其他程序已占用,返回锁剩余时间
                        "return redis.call('pttl', KEYS[1]);",
                Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
    }
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值