Loading

【缓存】缓存与分布式锁

缓存与分布式锁

缓存

缓存使用

为了系统性能的提升,一般都会将部分数据放入缓存中,加速访问。而 db 承担数据落盘工作。

哪些数据适合放入缓存?

  • 即时性、数据一致性要求不高的
  • 访问量大且更新频率不高的(读多,写少)

举例:电商类应用,商品分类,商品列表等适合缓存并加一个失效时间(根据数据更新频率来定),后台如果发布一个商品,买家需要 5 分钟才能看到新的商品一般还是可以接受的。

伪代码逻辑

data = cache.load(id);	// 从缓存加载数据
if(data == null){
    data = db.loadid);	// 从数据库加载数据
    cache.put(id,data);	// 保存到 cache 中
}
// 中间可能还涉及到格式的转换
retum data;

注意:在开发中,凡是放入缓存中的数据都应该指定过期时间,使其可以在系统即使没有主动更新数据也能自动触发数据加载进缓存的流程。避免业务崩溃导致的数据永久不一致的问题。

缓存的使用分类:

  • 本地缓存。适用于单体应用,但是在分布式模式下存在缓存一致性问题、拓展性问题和高可用问题。
  • 分布式缓存。可以很好的解决本地缓存的问题。

整合 Redis 作为缓存

Docker 安装 Redis 参考链接

SpringBoot 整合 Redis 参考链接

缓存失效问题

缓存穿透

缓存穿透:指查询一个一定不存在的数据,由于缓存不命中,将会去查询数据库,但是数据库也没有此数据,并且也没有将查询的空结果存入缓存中,这将导致这个不存在的数据每次请求都会到数据库中去查询,失去了缓存的意义。

风险:利用不存在的数据进行攻击,数据库瞬时压力增大,最终导致崩溃。

解决方案

  • 缓存空结果;
  • 布隆过滤器;
  • MVC 拦截器;

缓存雪崩

缓存雪崩:指在设置缓存时 key 采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到了数据库。

风险:数据库瞬时压力过大导致崩溃。

解决方案

  • 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生;
  • 如果缓存数据库是分布式部署,最好将热点数据均匀分布在不同缓存数据库中;

出现雪崩,常用的手段是降级熔断:

  • 事前:尽量保证整个 Redis 集群的高可用性,发现机器宕机尽快补上,并且选择合适的内存淘汰策略;
  • 事中:本地缓存 + Sentinel 降级熔断,避免数据库崩溃;
  • 事后:利用 Redis 持久化机制,将保存的数据恢复缓存;

缓存击穿

缓存击穿:指一些 “热点” 数据在大量请求同时访问前刚好失效,那么就会同时去数据库中查询。

风险:数据库瞬时压力过大导致崩溃。

解决方案

  • 设置 “热点” 数据永不过期;
  • 加互斥锁(简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去数据库加载,而是先使用缓存工具的某些带成功操作返回值的操作(比如 Redis 的 SETNX 或者 Memcache 的 ADD)去 set 一个 mutex key,当操作返回成功时,再进行数据库加载的操作并回设缓存;否则,就重试整个 get 缓存的方法。);

缓存数据一致性

双写模式

缓存数据一致性-双写模式

图片参考自 CSDN [runewbie](https://blog.csdn.net/runewbie)

可以通过加锁解决缓存一致性问题。

失效模式

缓存数据一致性-失效模式

图片参考自 CSDN [runewbie](https://blog.csdn.net/runewbie)

可以通过加锁解决缓存一致性问题。

解决方案

无论是双写模式还是失效模式,都会导致缓存的不一致问题,即多个实例同时更新会出事。主要解决方案有:

  • 如果是用户维度数据(订单数据、用户数据等),这种并发几率比较小,不用考虑这个问题。缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
  • 如果是基础数据(菜单数据、商品数据等),可以使用 canal 订阅 binlog 的方式。
  • 缓存数据添加过期时间足够解决大部分业务对于缓存的要求。
  • 写写的时候按顺序排好队,读读的时候无所谓。通过加锁保证并发读写,所以适合使用读写锁。
  • 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。

解决方案-Canal

canal [kə'næl],译意为水道/管道/沟渠,主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费。它可以用来解决缓存一致性问题,它相当于创建了一个 MySQL 的副本,同步解析 MySQL 的 binlog 日志,更新 MySQL 中的数据到 Redis。使用 canal 更新缓存,也可以使用 canal 解决数据异构问题。

工作原理

  • MySQL 主备复制原理
    • MySQL master 将数据变更写入二进制日志( binary log, 其中记录叫做二进制日志事件binary log events,可以通过 show binlog events 进行查看)
    • MySQL slave 将 master 的 binary log events 拷贝到它的中继日志(relay log)
    • MySQL slave 重放 relay log 中事件,将数据变更反映它自己的数据
  • canal 工作原理
    • canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议
    • MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )
    • canal 解析 binary log 对象(原始为 byte 流)

分布式锁

分布式下如何加锁

一个本地锁的例子:假设有一个商品服务,每一个服务都部署在一个独立的 tomcat 中,每一个服务中都使用一个锁。假设目前有 8 个服务,则需要加 8 把锁,且这 8 把锁相互独立。

但是本地锁,只能锁住当前进程,所以需要分布式锁。

锁的时序问题

在加锁的时候,需要将查询缓存查询数据库这两步同时放在加锁的方法(或者加锁的代码块)中,但是这样会出现一个问题,即多次查询数据库。这是因为,第一个查询数据库时,由于设置缓存也需要时间,此时数据还没有放入缓存中。这段时间内,缓存中还没有数据,就有可能导致多次查询数据库。所以需要将设置缓存也放在锁中。

锁的时序问题

分布式锁的演进和基本原理

由于本地锁只能锁住当前进程,如果我们在进行秒杀活动或者说抢优惠券活动的时候,如果只剩了1件商品或者1张优惠券,如果使用的是本地锁,同时多个服务一块请求获取数据,就有可能产生“超卖”的现象,为了避免这种情况的发生,我们就需要使用分布式锁。

我们可以同时去一个地方“占坑(加锁)”,如果占到,就执行逻辑。否则就必须等待,直到释放锁。“占坑(加锁)”可以去 redis,也可以去数据库,可以去任何服务都能访问的地。如果没有获取到锁,则可以可以以自旋的方式进行等待。

分布式锁演进-V1

/**
     * 从数据库获取数据,使用 redis 的分布式锁 V1
     * 问题:
     *  1、setnx 占好了位,业务代码异常或者程序在页面过程中宕机。没有执行删除锁逻辑,这就造成了死锁
     * 解决:
     *  设置锁的自动过期,即使没有删除,会自动删除
     */
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLockV1() {
    // Redis 命令:set lock 1 NX
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "1");
    if ( lock ) {
        // 加锁成功,执行业务
        Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
        // 删除锁
        stringRedisTemplate.delete("lock");
        return dataFromDb;
    } else {
        // 加锁失败,重试
        // 休眠 100ms 重试
        // 自旋的方式
        return getCatalogJsonFromDbWithRedisLockV1();
    }
}

分布式锁演进-V1

图片参考自 CSDN [runewbie](https://blog.csdn.net/runewbie)

分布式锁演进-V2

/**
     * 从数据库获取数据,使用 redis 的分布式锁 V2
     * 问题:
     *  1、setnx 设置好,正要去设置过期时间,结果突然断电,服务宕机。又死锁了。
     * 解决:
     *  设置过期时间和占位必须是原子的。redis支持使用 setnx ex 命令。
     */
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLockV2() {
    // Redis 命令:set lock 1 NX
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "1");
    if ( lock ) {
        // 加锁成功,执行业务
        // 设置过期时间
        stringRedisTemplate.expire("lock", 30, TimeUnit.SECONDS);
        Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
        // 删除锁
        stringRedisTemplate.delete("lock");
        return dataFromDb;
    } else {
        // 加锁失败,重试
        // 休眠 100ms 重试
        // 自旋的方式
        return getCatalogJsonFromDbWithRedisLockV2();
    }
}

分布式锁演进-V2

图片参考自 CSDN [runewbie](https://blog.csdn.net/runewbie)

分布式锁演进-V3

/**
     * 从数据库获取数据,使用 redis 的分布式锁 V3
     * 问题:
     *  1、如果由于业务时间很长,锁自己过期了,我们直接删除,有可能把别人正在持有的锁删除了。
     * 解决:
     *  占锁的时候,值指定为 uuid,每个人匹配是自己的锁才删除。
     */
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLockV3() {
    // Redis 命令:set lock 1 EX 30 NX
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "1", 30, TimeUnit.SECONDS);
    if ( lock ) {
        // 加锁成功,执行业务
        Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
        // 删除锁
        stringRedisTemplate.delete("lock");
        return dataFromDb;
    } else {
        // 加锁失败,重试
        // 休眠 100ms 重试
        // 自旋的方式
        return getCatalogJsonFromDbWithRedisLockV3();
    }
}

分布式锁演进-V3

图片参考自 CSDN [runewbie](https://blog.csdn.net/runewbie)

分布式锁演进-V4

/**
     * 从数据库获取数据,使用 redis 的分布式锁 V4
     * 问题:
     *  1、如果正好判断是当前值,正要删除锁的时候,锁已经过期,别人已经设置到了新的值。那么我们删除的是别人的锁。
     * 解决:
     *  删除锁必须是原子性的。使用 redis+Lua脚本完成。
     */
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLockV4() {
    // Redis 命令:set lock uuid EX 30 NX
    String uuid = UUID.randomUUID().toString();
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 30, TimeUnit.SECONDS);
    if ( lock ) {
        // 加锁成功,执行业务
        Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
        // 删除锁前先进行获取,判断是不是自己的锁编号 uuid,是的话再删除
        String lockValue = stringRedisTemplate.opsForValue().get("lock");
        if (uuid.equals(lockValue)) {
            stringRedisTemplate.delete("lock");
        }
        return dataFromDb;
    } else {
        // 加锁失败,重试
        // 休眠 100ms 重试
        // 自旋的方式
        return getCatalogJsonFromDbWithRedisLockV4();
    }
}

分布式锁演进-V4

图片参考自 CSDN [runewbie](https://blog.csdn.net/runewbie)

分布式锁演进-V5

/**
     * 从数据库获取数据,使用 redis 的分布式锁 V5
     * 问题:
     *  1、锁的自动续期问题;
     *  2、操作太麻烦,加锁解锁都需要自己完成,如果有很多锁则需要写很多重复的代码。
     * 解决:
     *  使用封装好的 redis 分布式锁工具类,例如 Redisson
     */
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLockV5() {
    // Redis 命令:set lock uuid EX 30 NX
    String uuid = UUID.randomUUID().toString();
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 30, TimeUnit.SECONDS);
    if ( lock ) {
        log.debug("获取分布式锁成功....");
        Map<String, List<Catelog2Vo>> dataFromDb;
        try {
            //加锁成功,执行业务
            dataFromDb = getDataFromDb();
        } finally {
            //删除锁前先进行获取,判断是不是自己的锁编号uuid,是的话再删除
            //获取对比值+对比成功删除==原子操作  使用lua脚本解锁
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            //删除锁,删除成功返回 1,删除失败返回 0
            Long lock1 = stringRedisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
                                                     Arrays.asList("lock"), uuid);
        }
        return dataFromDb;
    } else {
        log.debug("获取分布式锁失败,等待重试....");
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 加锁失败,重试
        // 休眠 100ms 重试
        // 自旋的方式
        return getCatalogJsonFromDbWithRedisLockV5();
    }
}

分布式锁演进-V5

图片参考自 CSDN [runewbie](https://blog.csdn.net/runewbie)

Redisson 的使用和整合

概念

Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的 Java 常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson 提供了使用 Redis 的最简单和最便捷的方法。Redisson 的宗旨是促进使用者对 Redis 的 关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。

Redisson 底层采用的是 Netty 框架。支持 Redis2.8 以上版本,支持 Java1.6+ 以上版本。关于 Redisson 项目的详细介绍可以在 官方网站 找到。

整合

方式一:使用 Redisson

  1. 在 pom 文件中引入依赖

    <!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>3.15.3</version>
    </dependency>
    
  2. 使用配置类的方式配置 redisson

    @Configuration
    public class MyRedissonConfig {
    
        /**
         * 对所有的 Redisson 的使用都是通过 RedissonClient 对象
         * @return
         * @throws IOException
         */
        @Bean(destroyMethod = "shutdown")
        RedissonClient redisson() throws IOException {
    //        // 默认连接地址 127.0.0.1:6379
    //        RedissonClient redisson = Redisson.create();
    
            // 1、创建配置
            Config config = new Config();
            // Redis url should start with redis:// or rediss:// (for SSL connection)
            config.useSingleServer().setAddress("redis://192.168.56.56:6379");
    
            // 2、根据 Config 创建出 RedissonClient 实例
            RedissonClient redisson = Redisson.create(config);
    
            return redisson;
        }
    }
    
  3. 测试

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

参考文档:配置方法#26-单redis节点模式

方式二:使用 Redisson Spring Boot Starter

分布式锁和同步器

参考文档:分布式锁和同步器

可重入锁 ReentrantLock

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

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

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

@Autowired
RedissonClient redissonClient;

/**
* Redisson 可重入锁(Reentrant Lock)的简单接口测试
*/
@ResponseBody
@GetMapping("/hello/redisson")
public String helloRedisson() {
    // 获取一把锁。只要锁的名字一样,就是同一把锁
    RLock lock = redissonClient.getLock("my-lock");
    // 加锁。阻塞式等待,默认加的锁都是30s时间
    lock.lock();
    try {
        System.out.println("加锁成功,执行业务... " + Thread.currentThread().getName());
        Thread.sleep(10000); // 10s
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        // 解锁。假设当前服务执行时宕机,解锁代码没有运行
        System.out.println("释放锁... " + Thread.currentThread().getName());
        lock.unlock();
    }

    return "hello";
}

同时启动两个不同端口的相同服务,记作服务A、B。在请求A、B之后,手动关闭服务A,模拟遭遇宕机解锁代码没有执行的情况,看最后是否解锁,服务B是否可以获得锁:

# 服务 A
加锁成功,执行业务... http-nio-10000-exec-6

Process finished with exit code -1

# 服务 B
加锁成功,执行业务... http-nio-10001-exec-8
释放锁... http-nio-10001-exec-8

从上面的执行结果中,可以看到,服务宕机,Redisson 依然解锁成功。Redisson解决了两个问题:

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

2、加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认在 30s 后自动删除;

这些都是基于看门狗实现的。

看门狗原理

Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。

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

// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
   try {
     ...
   } finally {
       lock.unlock();
   }
}
/**
* Redisson 可重入锁(Reentrant Lock) 看门狗 的简单接口测试
*/
@ResponseBody
@GetMapping("/hello/redissonWatchdog")
public String helloRedissonWatchdog() {
    // 获取一把锁。只要锁的名字一样,就是同一把锁
    RLock lock = redissonClient.getLock("my-lock");
    // 加锁。加锁以后10秒钟自动解锁
    lock.lock(10, TimeUnit.SECONDS);
    try {
        System.out.println("加锁成功,执行业务... " + Thread.currentThread().getName());
        // 业务执行需要 30s
        Thread.sleep(30000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        // 解锁。假设当前服务执行时宕机,解锁代码没有运行
        System.out.println("释放锁... " + Thread.currentThread().getName());
        lock.unlock();
    }

    return "hello";
}

执行效果:

加锁成功,执行业务... http-nio-10000-exec-10
释放锁... http-nio-10000-exec-10
ERROR 8164 --- [o-10000-exec-10] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id: 7f5b74f6-c821-4129-8428-1c90beb6be8d thread-id: 118] with root cause

java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id: 7f5b74f6-c821-4129-8428-1c90beb6be8d thread-id: 118

可以看到,业务执行需要 30 s,但是业务还没执行完,已经自动释放锁。当业务执行完后尝试释放锁就会报错。

值得注意的是,RLock对象完全符合Java的Lock规范。也就是说只有拥有锁的进程才能解锁,其他进程解锁则会抛出IllegalMonitorStateException错误。但是如果遇到需要其他进程也能解锁的情况,请使用分布式信号量Semaphore 对象。

而 lock.lock(10, TimeUnit.SECONDS); 在锁时间到了以后,不会自动续期。

1、如果我们指定了锁的超时时间,就会发送给 redis 执行脚本,进行占锁,默认超时就是我们指定的时间。

2、如果我们未指定锁的超时时间,就使用 lockWatchdogTimeout = 30000L(看门狗的默认时间)。只要占锁成功,就会启动一个定时任务(重新给锁设定过期时间,新的过期时间就是看门狗的默认时间),每隔 10s 都会自动续期,续成30s,续期时间的间隔是((internalLockLeaseTime 看门狗时间) / 3L ) 10s 续期一次。

3、所以如果要指定解锁时间,一定要保证自定解锁时间要大于业务的执行时间。

源码分析

1、不设置过期时间的加锁方法:lock.lock()

public void lock() {
    try {
        //leaseTime:-1,在后边的判断会用到;TimeUnit:null;是否可中断:false
        this.lock(-1L, (TimeUnit)null, false);
    } catch (InterruptedException var2) {
        throw new IllegalStateException();
    }
}

//看一下再点击来看一下 lock(long leaseTime, TimeUnit unit, boolean interruptibly) 方法的实现
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
    //获取当前线程的id
    long threadId = Thread.currentThread().getId();
    // 尝试获取锁,这个方法是重点,下面进入这个方法中
    Long ttl = this.tryAcquire(leaseTime, unit, threadId);
    ... //略
}

// 查看 tryAcquire 方法
private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
    // 进入 尝试获取异步 tryAcquireAsync 这个方法
    return (Long)this.get(this.tryAcquireAsync(leaseTime, unit, threadId));
}

//查看 尝试获取异步 tryAcquireAsync 方法
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
    //如果leaseTime不是-1,则进入这个逻辑,根据前面的代码知道lock()默认leaseTime=-1,所以lock()方法不进这个逻辑,所以设置自动过期时间的方法 lock.lock(10, TimeUnit.SECONDS) 是会进入这个逻辑的
    if (leaseTime != -1L) {
        return this.tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    } else {
        //获取一个 RFuture,和java中的Future是类似的, 设置锁的默认过期时间this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout() 这个是设置默认锁过期时间,也就是下面Config类中的lockWatchdogTimeout
        RFuture<Long> ttlRemainingFuture = this.tryLockInnerAsync(this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        //占锁成功,进行监听
        ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
            //没有抛出异常说明,占锁成功
            if (e == null) {
                if (ttlRemaining == null) {
                    //启动一个定时任务【重新给锁设定过期时间,新的过期时间就是看门狗的默认时间】,每隔10s都会自动续期,续成30s,下面来看这个方法
                    this.scheduleExpirationRenewal(threadId);
                }

            }
        });
        return ttlRemainingFuture;
    }
}

// 对应上面 this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout() 中的时间
public Config() {
    ...
    this.lockWatchdogTimeout = 30000L;
	...
}

// 时间表到期续订方法
private void scheduleExpirationRenewal(long threadId) {
    RedissonLock.ExpirationEntry entry = new RedissonLock.ExpirationEntry();
    RedissonLock.ExpirationEntry oldEntry = (RedissonLock.ExpirationEntry)EXPIRATION_RENEWAL_MAP.putIfAbsent(this.getEntryName(), entry);
    if (oldEntry != null) {
        oldEntry.addThreadId(threadId);
    } else {
        entry.addThreadId(threadId);
        //进入续期方法
        this.renewExpiration();
    }
}

//续期方法
private void renewExpiration() {
    RedissonLock.ExpirationEntry ee = (RedissonLock.ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
    if (ee != null) {
        Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            public void run(Timeout timeout) throws Exception {
                RedissonLock.ExpirationEntry ent = (RedissonLock.ExpirationEntry)RedissonLock.EXPIRATION_RENEWAL_MAP.get(RedissonLock.this.getEntryName());
                if (ent != null) {
                    Long threadId = ent.getFirstThreadId();
                    if (threadId != null) {
                        RFuture<Boolean> future = RedissonLock.this.renewExpirationAsync(threadId);
                        future.onComplete((res, e) -> {
                            if (e != null) {
                                RedissonLock.log.error("Can't update lock " + RedissonLock.this.getName() + " expiration", e);
                            } else {
                                if (res) {
                                    RedissonLock.this.renewExpiration();
                                }

                            }
                        });
                    }
                }
            }
            // this.internalLockLeaseTime / 3L 续期时间,在RedissonLock(CommandAsyncExecutor commandExecutor, String name)方法中可以看到internalLockLeaseTime就是 lockWatchdogTimeout看门狗的默认时间30s,所以是每隔10s续期一次,续成30s
        }, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS);
        ee.setTimeout(task);
    }
}

public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
    ...
    this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
    ...
}

2、设置过期时间的加锁方法:lock.lock(10, TimeUnit.SECONDS)

public void lock(long leaseTime, TimeUnit unit) {
    try {
        this.lock(leaseTime, unit, false);
    } catch (InterruptedException var5) {
        throw new IllegalStateException();
    }
}

private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
    long threadId = Thread.currentThread().getId();
    //同样是进入tryAcquire尝试获取锁这个方法,和lock()方法一样
    Long ttl = this.tryAcquire(leaseTime, unit, threadId);
	...
}

//尝试获取锁
private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
     // 进入 尝试获取异步 tryAcquireAsync 这个方法,和lock()方法一样
    return (Long)this.get(this.tryAcquireAsync(leaseTime, unit, threadId));
}

// 尝试获取异步
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
    if (leaseTime != -1L) {
        // lock.lock(10, TimeUnit.SECONDS),进入这个逻辑
        return this.tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    } else {
        RFuture<Long> ttlRemainingFuture = this.tryLockInnerAsync(this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
            if (e == null) {
                if (ttlRemaining == null) {
                    this.scheduleExpirationRenewal(threadId);
                }

            }
        });
        return ttlRemainingFuture;
    }
}

// 尝试获取异步,得到lua脚本
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    this.internalLockLeaseTime = unit.toMillis(leaseTime);
    return this.evalWriteAsync(this.getName(), 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(this.getName()), this.internalLockLeaseTime, this.getLockName(threadId));
}

读写锁 ReadWriteLock

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

写锁是一个排它锁(互斥锁),读锁是一个共享锁。只要有写锁没释放,读写都必须等待。能保证一定能读取到最新数据。

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

RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock");
// 最常见的使用方法
rwlock.readLock().lock();
// 或
rwlock.writeLock().lock();
/**
     * Redisson 读写锁 ReadWriteLock 简单接口测试
     * 写入数据
     */
@ResponseBody
@GetMapping("/hello/redisson/writeLock")
public String helloRedissonWriteLock() {
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock");
    String s = "";
    RLock wLock = readWriteLock.writeLock();
    try {
        // 写入数据加写锁
        wLock.lock();
        s = UUID.randomUUID().toString();
        stringRedisTemplate.opsForValue().set("writeValue", s);
        Thread.sleep(10000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        wLock.unlock();
    }

    return s;
}

/**
     * Redisson 读写锁 ReadWriteLock 简单接口测试
     * 读取数据
     */
@ResponseBody
@GetMapping("/hello/redisson/readLock")
public String helloRedissonReadLock() {
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock");
    String s = "";
    RLock rLock = readWriteLock.readLock();
    try {
        // 读取数据加读锁
        rLock.lock();
        s = UUID.randomUUID().toString();
        stringRedisTemplate.opsForValue().get("writeValue");
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
    }

    return s;
}

执行结果:在加写锁的时候,获取值会被阻塞。

闭锁 CountDownLatch

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

RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
latch.trySetCount(1);
latch.await();

// 在其他线程或其他JVM里
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
latch.countDown();
/**
     * Redisson 闭锁 CountDownLatch 简单接口测试
     * 模拟一个放假锁门的场景。学校一共 5 个班,只有等 5 个班都没人了才可以锁学校大门。
     * 锁门方法
     */
@ResponseBody
@GetMapping("/hello/redisson/CDL/lockDoor")
public String helloRedissonCDLLockDoor() throws InterruptedException {
    RCountDownLatch door = redissonClient.getCountDownLatch("door");
    door.trySetCount(5);
    door.await();

    return "已锁门";
}

/**
     * Redisson 闭锁 CountDownLatch 简单接口测试
     * 模拟一个放假锁门的场景。学校一共 5 个班,只有等 5 个班都没人了才可以锁学校大门。
     * 班级全部人离开
     */
@ResponseBody
@GetMapping("/hello/redisson/CDL/go/{id}")
public String helloRedissonCDLGo(@PathVariable("id") Long id) {
    RCountDownLatch door = redissonClient.getCountDownLatch("door");
    door.countDown();

    return id + " 班级全部人离开";
}

信号量 Semaphore

基于Redis的Redisson的分布式信号量(Semaphore)Java对象RSemaphore采用了与java.util.concurrent.Semaphore相似的接口和用法。同时还提供了异步(Async)反射式(Reactive)RxJava2标准的接口。

RSemaphore semaphore = redisson.getSemaphore("semaphore");
semaphore.acquire();
//或
semaphore.acquireAsync();
semaphore.acquire(23);
semaphore.tryAcquire();
//或
semaphore.tryAcquireAsync();
semaphore.tryAcquire(23, TimeUnit.SECONDS);
//或
semaphore.tryAcquireAsync(23, TimeUnit.SECONDS);
semaphore.release(10);
semaphore.release();
//或
semaphore.releaseAsync();
/**
     * Redisson 信号量 Semaphore 简单接口测试
     * 模拟一个车库停车的场景。5 个车位,同时只能有 5 辆车停,只有有车位了才能停车。
     * 车库停车
     */
@ResponseBody
@GetMapping("/hello/redisson/Semaphore/Park")
public String helloRedissonSemaphorePark() throws InterruptedException {
    RSemaphore park = redissonClient.getSemaphore("park");
    park.acquire(5);

    return "车库停车成功";
}

/**
     * Redisson 信号量 Semaphore 简单接口测试
     * 模拟一个车库停车的场景。5 个车位,同时只能有 5 辆车停,只有有车位了才能停车。
     * 车库的车位上的车离开
     */
@ResponseBody
@GetMapping("/hello/redisson/Semaphore/Leave")
public String helloRedissonSemaphoreLeave(){
    RSemaphore park = redissonClient.getSemaphore("park");
    park.release();

    return "车库的车位上的车离开";
}

常用应用场景:用作分布式限流的场景,比如同时在线人数只允许100000人等。

SpringCache

简介

Spring 定义了org.springframework.cache.Cacheorg.springframework.cache.CacheManager 接口来统一不同的缓存技术,并支持使用 JCache (ISR-107) 注解简化我们的开发。Cache 接口为缓存的组件规范定义,包含缓存的各种操作集合,Cache 接口下 Spring 提供了各种 诸如RedisCache 的实现。

每次调用需要缓存功能的方法时, Spring 会检查检查指定参数的指定的目标方法是否已经被调用过。如果有就直接从缓存中获取方法调用后的结果;如果没有就调用方法并缓存结果后返回给用户,下次调用直接从缓存中获取。

使用 Spring 缓存抽象时需要关注以下两点:

  • 确定方法需要被缓存以及他们的缓存策略
  • 从缓存中读取之前缓存存储的数据

SpringCache 整合

  1. 引入依赖
    • spring-boot-starter-cache
    • spring-boot-starter-data-redis(使用redis作为缓存就要引入redis的依赖)
  2. 写配置
    • 自动配置的:
      • CacheAutoConfiguration 会导入 RedisCacheConfiguration
      • RedisCacheConfiguration 会自动装配好了 redis 缓存管理器 RedisCacheManager
    • 手动配置的:
      • 缓存类型
  3. 测试缓存
    • 在启动类上开启缓存功能 @EnableCaching
    • 在方法上使用 @Cacheable 注解
    • 使用注解:
      • @Cacheable:触发缓存保存。
      • @CacheEvict:触发删除缓存。
      • @CachePut:更新缓存,而不影响方法的执行。
      • @Caching:重新组合要在一个方法上应用的多个缓存操作。
      • @CacheConfig:在类级别共享一些与缓存相关的常见设置。

@Cacheable

支持缓存一致性——失效模式的注解。

代表当前方法的结果需要缓存,如果缓存中有,方法不用调用。如果缓存中没有,会调用方法,最后将方法的结果放入缓存。

@Cacheable 的默认行为:

  • 如果缓存中有,方法不用调用
  • key默认自动生成,缓存名字::SimpleKey [](自动生成的key)
  • 缓存的value值,默认使用的是jdk的序列化机制,将序列化后的值存在redis中
  • 默认时间 ttl=-1

自定义属性:

  • 指定生成的缓存使用的 key:key 属性指定,使用 SpEL 表达式
  • 指定缓存的数据的存活时间:配置文件中修改 ttl,spring.cache.redis.time-to-live=3600000
  • 将数据保存为 json 格式:自定义缓存管理器

@CacheEvict

支持缓存一致性——失效模式的注解。

代表当前方法执行就会删除缓存。

@CachePut

支持缓存一致性——双写模式的注解。

代表当前方法执行就会更新缓存。

推荐阅读

自定义缓存配置

@EnableConfigurationProperties(CacheProperties.class)
@EnableCaching
@Configuration
public class MyCacheConfig {

//    @Autowired
//    CacheProperties cacheProperties;

    @Bean
    RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
        // 链式
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();

        // 设置 key 的序列化,使用 redis string
        config = config.serializeKeysWith(RedisSerializationContext
                .SerializationPair
                .fromSerializer(new StringRedisSerializer()));

        // 设置 value 的序列化,使用 fastjson
        //  /*GenericFastJsonRedisSerializer()*/
        config = config.serializeValuesWith(RedisSerializationContext
                .SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        // 获取 redis 配置
        CacheProperties.Redis redis = cacheProperties.getRedis();

        // 使配置文件中的所有配置都生效
        if (redis.getTimeToLive() != null) {
            config = config.entryTtl(redis.getTimeToLive());
        }
        if (redis.getKeyPrefix() != null) {
            config = config.prefixKeysWith(redis.getKeyPrefix());
            config = config.prefixCacheNameWith(redis.getKeyPrefix());
        }
        if (!redis.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }
        if (!redis.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }

        return config;

    }

}

缓存穿透问题解决

在配置文件中配置允许缓存空值,解决缓存穿透问题。

spring:
  cache:
    redis:
      cache-null-values: true # 是否缓存空值,设置为 true 可以防止缓存穿透

SpringCache 的不足

SpringCache 的原理:CacheManagerRedisCacheManager) --> 创建 --> CacheRedisCache)--> Cache负责缓存的读写操作。

它的不足有:

  • 对于读模式
    • 缓存穿透:查询一个空数据。解决:缓存空数据。添加配置 spring.cache.redis.cache-null-values=true,它能很好的解决。
    • 缓存击穿:大量并发进来同时查询一个正好过期的数据。解决:加锁。它默认 put 时是不加锁的,所以没有办法解决这个问题。但是可以设置 @Cacheable(value = xxx, key = xxx, sync = true),在查缓存的时候调用使用了同步的 get 方法,获取到空数据时在 put 中放一份空的数据。它的加锁只有在读模式下有本地锁。所以这个得分场景来确定,对于常规数据它完全够用了,对于一致性要求高的数据还是得使用分布式锁。
    • 缓存雪崩:大量的key同时过期。解决:加随机时间。加上过期时间:spring.cache.redis.time-to-live=3600000,它能很好的解决。
  • 对于写模式
    • 首先明确,这个问题对于缓存和数据库是一个道理
    • 读多写少场景:读写加锁
    • 读多写多场景:直接去数据库中查询
    • 引入Canal:感知到 MySQL 的更新就去更新缓存

总结:

  • 对于读多写少、即时性、一致性要求不高的常规数据,SpringCache 完全够用了,写模式时,只要缓存设置了过期时间就足够了。
  • 对于一致性要求高的特殊数据,得考虑特殊设计,比如加上分布式锁等。

总结

posted @ 2021-04-13 18:24  Parzulpan  阅读(125)  评论(0编辑  收藏  举报