3.商城业务-分布式锁Redisson

229 阅读7分钟

本文将介绍一下在项目中使用到的 Redisson

Redisson学习网址:github.com/redisson/re…

1.Redisson简介&整合

Redis官网说明:分布式锁在很多场景中是非常有用的原语【原语:指由若干条指令组成的程序段,用来实现某个特定功能,在执行过程中不可被中断】, 不同的进程必须以独占资源的方式实现资源共享就是一个典型的例子。有很多分布式锁的库和描述怎么实现分布式锁管理器(DLM)的博客,但是每个库的实现方式都不太一样,很多库的实现方式为了简单降低了可靠性,而有的使用了稍微复杂的设计。

Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。充分的利用了Redis键值数据库提供的一系列优势,基于Java实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。

  • 因此基于Redis提出来了一种算法--Redlock(Distributed Locks with Redis)

以下是基于各个语言实现的实现库,而我们主要讲解基于Java的Redisson

image-20230105212759822.png

1.1.整合Redisson

  • 引入相应依赖

    <!-- 以后使用redisson作为所有分布式锁,分布式对象等功能框架 -->
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>3.17.7</version>
    </dependency>
    
  • 配置方法

    配置方法分为程序化配置方法文件配置方法;而配置的模式分为:云托管模式、单Redis节点模式、哨兵模式、集群模式、主从模式;而我们选择单Redis节点模式的程序化配置方法

    @Configuration
    public class MyRedissonConfig {
        @Bean(destroyMethod="shutdown")
        public RedissonClient redisson() throws IOException {
            // 1.创建配置
            Config config = new Config();
            // 报错Redis url should start with redis:// or rediss:// (for SSL connection)
            config.useSingleServer().setAddress("redis://服务器地址:6379");
            // 如果设置了密码,需要配置上
            config.useSingleServer().setPassword("123456");
            // 2.根据config创建出RedissonClient实例
            RedissonClient redissonClient = Redisson.create(config);
            return redissonClient;
        }
    }
    
  • 测试一下

    如果出现报错Redis url should start with redis:// or rediss:// (for SSL connection),需要在配置的时候加上前缀redis://来启用SSL连接

    @Autowired
    RedissonClient redissonClient;
    
    @Test
    public void testRedisson() {
        System.out.println(redissonClient);
    }
    

2.分布式锁和同步器

2.1.可重入锁(Reentrant Lock)

基于Redis的Redisson分布式可重入锁RLock Java对象实现了java.util.concurrent.locks.Lock接口。同时还提供了异步(Async)反射式(Reactive)RxJava2标准的接口。

  • 注意锁的名字要保证颗粒度,以访出现不相关的业务模块起相同的锁名称

    如product-11-lock、product-12-lock、product-lock(不合适,不具有特殊性,颗粒度大)

  • lock.lock()是阻塞式等待(源码中是while自旋),默认加锁是30s时间

演示代码

RLock lock = redisson.getLock("锁的名字");
// 最常见的使用方法
lock.lock();

解决死锁的方法

大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。

  • 锁的自动续期,如果业务超长,运行期间自动给锁续上新的30s。不用担心业务时间长,锁自动过期被删掉

    private long lockWatchdogTimeout = 30 * 1000;
    
  • 只要占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是看门狗的默认时间】,每隔10s(internalLockLeaseTime / 3)就会自动再次续期,20s->30s

    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
                @Override
                public void run(Timeout timeout) throws Exception {
                    ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
                    if (ent == null) {
                        return;
                    }
                    Long threadId = ent.getFirstThreadId();
                    if (threadId == null) {
                        return;
                    }
                    
                    CompletionStage<Boolean> future = renewExpirationAsync(threadId);
                    future.whenComplete((res, e) -> {
                        if (e != null) {
                            log.error("Can't update lock " + getRawName() + " expiration", e);
                            EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                            return;
                        }
                        
                        if (res) {
                            // reschedule itself
                            renewExpiration();
                        } else {
                            cancelExpirationRenewal(null);
                        }
                    });
                }
            }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    

注意: Redisson还提供了leaseTime的参数来指定加锁的时间。

  • 超过leaseTime这个时间后锁便自动解开了。不再自动续期。可看源码中就是将传入的时间作为lua脚本执行的参数,因此注意自动解锁时间一定要大于业务的执行时间。

    <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
            return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
                    "if (redis.call('exists', KEYS[1]) == 0) then " +
                            "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                            "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                            "return nil; " +
                            "end; " +
                            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                            "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                            "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                            "return nil; " +
                            "end; " +
                            "return redis.call('pttl', KEYS[1]);",
                    Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
        }
    

演示代码

// 加锁以后10秒钟自动解锁
// 无需调用unlock方法手动解锁
lock.lock(10, TimeUnit.SECONDS);

// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
   try {
     // 业务逻辑
       Thread.sleep(30000);
   } finally {
       lock.unlock();
   }
}

2.2.读写锁(ReadWriteLock)

读写锁核心:读读共享,读写/写读/写写互斥,适合读多写少;

写锁是一个排他锁(互斥锁);读锁是一个共享锁;

基于Redis的Redisson分布式可重入读写锁RReadWriteLock Java对象实现了java.util.concurrent.locks.ReadWriteLock接口。其中读锁和写锁都继承了RLock接口。

分布式可重入读写锁允许同时有多个读锁一个写锁处于加锁状态。

代码演示

@GetMapping("/write")
@ResponseBody
public String writeValue() {
    RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
    String s = "";
    RLock rLock = readWriteLock.writeLock();
    rLock.lock();
    try {
        System.out.println("写锁加锁成功....");
        s = UUID.randomUUID().toString();
        Thread.sleep(30000);
        redisTemplate.opsForValue().set("writevalue", s);
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
        System.out.println("写锁释放成功....");
    }
    return s;
}

@GetMapping("/read")
@ResponseBody
public String readValue() {
    RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
    RLock rLock = readWriteLock.readLock();
    rLock.lock();
    String s = "";
    try {
        System.out.println("读锁加锁成功....");
        //Thread.sleep(30000);
        s = (String) redisTemplate.opsForValue().get("writevalue");
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
        System.out.println("读锁释放成功....");
    }
    return s;
}

2.3. 信号量(Semaphore)

基于Redis的Redisson的分布式信号量(Semaphore)Java对象RSemaphore采用了与java.util.concurrent.Semaphore相似的接口和用法。

  • tryAcquire():非阻塞式获取1个许可,可以使线程不至于在同步处一直持续等待的状态。

代码演示

/**
  * 车库停车
  * 3车位
  * 信号量也可以用作分布式限流
*/
@GetMapping("/park")
@ResponseBody
public String park() throws InterruptedException {
    RSemaphore semaphore = redisson.getSemaphore("park");
    //semaphore.acquire();
    boolean flag = semaphore.tryAcquire();
    if(flag) {
        // 执行业务
    } else {
        return "error";
    }
    return "park " + flag;
}

@GetMapping("/go")
@ResponseBody
public String go() {
    RSemaphore semaphore = redisson.getSemaphore("park");
    semaphore.release();// 释放一个车位
    return "release ok!";
}

2.4.闭锁(CountDownLatch)

基于Redisson的Redisson分布式闭锁(CountDownLatch)Java对象RCountDownLatch采用了与java.util.concurrent.CountDownLatch相似的接口和用法。

  • CountDownLatch主要有两个方法:countDown()和await()。

    countDown()方法用于使计数器减一,其一般是执行任务的线程调用

    await()方法则使调用该方法的线程处于等待状态,其一般是主线程调用。

/**
  * 门卫大爷锁门
*/
@GetMapping("/door")
@ResponseBody
public String lockdoor() throws InterruptedException {
    RCountDownLatch countDownLatch = redisson.getCountDownLatch("door");
    countDownLatch.trySetCount(5);
    countDownLatch.await();
    return "可以锁门了";
}

@GetMapping("/goHome/{id}")
@ResponseBody
public String goHome(@PathVariable("id") Long id) {
    RCountDownLatch countDownLatch = redisson.getCountDownLatch("door");
    countDownLatch.countDown();
    return id + "班已经锁门了";
}

3.缓存一致性

两种解决方法

  • 双写模式
  • 失效模式

无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?

  • 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可;
  • 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式;
  • 缓存数据+过期时间也足够解决大部分业务对于缓存的要求;
  • 通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心脏数据,允许临时脏数据可忽略);

总结:

  • 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上 过期时间,保 证每天拿到当前最新数据即可。
  • 我们不应该过度设计,增加系统的复杂性
  • 遇到实时性、一致性要求 的数据,就应该查数据库,即使慢点。