Redis 缓存 & 分布式锁

一、缓存

1、缓存使用

为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访问。而 db 承担数据落盘工作(持久化工作),数据库查询一次后将数据存入缓存,以后需要该数据直接从缓存中取。

适合放入缓存的数据:

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

缓存中存放的所有对象都应该是 JSON 字符串,JSON 跨语言、跨平台兼容。

给缓存中存放 JSON 字符串,从缓存中拿出的 JSON 字符串,还要逆转为能用的对象类型【序列化与反序列化的过程】

Demo:

@Override
public Map<String, List<Catalog2Vo>> getCatalogJson() {
    // 给缓存中存放 JSON 字符串,从缓存中拿出的 JSON 字符串,还要逆转为能用的对象类型【序列化与反序列化的过程】

    // 1、加入缓存逻辑,缓存中存放的所有对象都应该是 JSON 字符串。
    // JSON 跨语言、跨平台兼容
    String catalogJson = redisTemplate.opsForValue().get("catalogJson");
    if (StringUtils.isEmpty(catalogJson)) {
        // 2、缓存中没有该数据,就从数据库查询出来
        // getCatalogJsonFromDb() 该方法是涉及数据库操作
        Map<String, List<Catalog2Vo>> catalogJsonFromDb = getCatalogJsonFromDb();
        // 3、将从数据库中查出来的 catalogJsonFromDb 对象转成 JSON,再存放入缓存中
        // 关于 JSON 与 Object 的转换,在 Spring MVC 的笔记中也有记录
        String jsonString = JSON.toJSONString(catalogJsonFromDb);
        redisTemplate.opsForValue().set("catalogJson",jsonString);
        return catalogJsonFromDb;
    }

    // 转为指定的对象
    // protected TypeReference() {} 底层是受保护的,因此这里引用的时候使用匿名内部类的方式加以实现
    Map<String, List<Catalog2Vo>> result = JSON.parseObject(catalogJson,new TypeReference<Map<String, List<Catalog2Vo>>>(){});

    return result;
}

Redis 异常:产生堆外内存溢出

产生堆外内存溢出:OutOfDirectMemoryError
1)、Spring Boot 2.0 以后默认使用 lettuce 作为操作 redis 的客户端,它使用 netty 进行网络通信
2)、lettuce 的 bug 导致 netty 堆外内存溢出 (现在应该修复了,因为压测结果异常率为0 没有增加)
netty 如果没有指定堆外内存,默认使用 -Xmx100m 作为堆外内存,可以通过 -Dio.netty.maxDirectMemory 进行设置
解决方案:不能使用 -Dio.netty.maxDirectMemory 只去调大堆外内存,因为调大最大内存只是延缓了该异常,在长久运行累积之后堆外内存溢出还会产生,那就应该通过如下解决:
1)、升级 lettuce 客户端
2)、切换使用 jedis 客户端
另外,lettuce 和 jedis 是操作 redis 的底层客户端,本身封装了操作 redis 的一些 API。Spring 为了简化将这些进行再次封装成
RedisTemplate

高并发下缓存失效问题 - 缓存穿透(查询一个不存在的数据,缓存与数据库中都没有)

缓存穿透:

指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的 null 写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义

风险:

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

解决:

null 结果缓存,并加入短暂过期时间

高并发下缓存失效问题 - 缓存雪崩(大面积数据 key 同时失效,过期时间相同)

缓存雪崩:

指在我们设置缓存时 key 采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到 DB,DB 瞬时压力过重导致雪崩

解决:

原有的失效时间基础上增加一个随机值,比如 1~5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件

高并发下缓存失效问题 - 缓存击穿(高并发访问一个刚好失效的热点 key)

缓存击穿:

  • 对于一些设置了过期时间的 key,如果这些 key 可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据
  • 如果这个 key 在大量请求同时进来前正好失效,那么所有对这个 key 的数据查询都落到 DB,这就是缓存击穿

解决:

加锁 —— 大量并发只让一个人去查,其它人等待,查到以后释放锁,其他人获取到锁,先查缓存,就会有数据,不用再去查 DB

二、分布式锁【参考官方文档:8.分布式锁和同步器】

1、Redisson

我们可以整合 redisson 作为分布式锁等功能框架

  1. 引入依赖

    <dependency>
        <groupId>org.redissongroupId>
        <artifactId>redissonartifactId>
        <version>3.15.2version>
    dependency>
    
  2. 配置 redisson

    @Configuration
    public class MyRedissonConfig {
    
        /**
         * 所有对 Redisson 的使用都是通过 RedissonClient 对象
         * @return
         * @throws IOException
         */
        @Bean(destroyMethod="shutdown")
        public RedissonClient redisson() throws IOException {
            // 1、创建配置
            // 可以用"rediss://"来启用SSL连接
            // Redis url should start with redis:// or rediss://
            Config config = new Config();
            config.useSingleServer().setAddress("redis://192.168.56.10:6379");
    
            // 2、根据 config 创建出 RedissonClient 实例
            RedissonClient redissonClient = Redisson.create(config);
            return redissonClient;
        }
    
    }
    

2、可重入锁(Reentrant Lock)

// 1、获取一把锁,只要锁的名字一样,就是同一把锁
RLock lock = redisson.getLock("my-lock");

// 2、加锁
lock.lock(); // 阻塞式等待。默认加的锁都是 30s 时间。
// 1)、提供了锁的自动续期,如果业务超长,运行期间自动给锁续上新的 30s。不用担心业务时间长,锁自动过期被删掉
// 2)、加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认也会在 30s 以后自动删除
try {
    System.out.println("加锁成功,执行业务...." + Thread.currentThread().getId());
    Thread.sleep(3000);
} catch (Exception e) {
    e.printStackTrace();
} finally {
    // 3、解锁
    System.out.println("释放锁..." + Thread.currentThread().getId());
    lock.unlock();
}

Redisson-lock 看门狗原理,redisson 如何解决死锁?

lock.lock(10, TimeUnit.SECONDS); // 10s 自动解锁,自动解锁时间一定要大于业务的执行时间

lock.lock(10, TimeUnit.SECONDS); 在锁时间到了以后,就不会自动续期,如果业务时间过长大于自动解锁时间 如 30s,第一个线程的锁 10s 后过期解锁,下一个线程就会进来占锁 10s 后删锁,但此处第一个线程删锁会失败,因为此时删的锁其实是第二个线程的锁,并没有匹配上。

1、如果我们传递了锁的超时时间为 10s,就会发动给 redis 执行脚本,进行占锁,默认超时就是我们指定的超时时间 10s。自己设置锁的超时时间是没有看门狗的,在锁时间到了以后,就不会自动续期。

2、如果我们没有指定锁的超时时间,就使用 30 * 1000ms【LockWatchDogTimeout 看门狗机制的默认时间】;如果占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是看门狗机制的默认时间 30s】,每隔 10s 都会自动再次续期,续成 30s。

internalLockLeaseTime【看门狗时间】 / 3 【10s】,10秒之后续期

在实战的时候,推荐使用

lock.lock(30, TimeUnit.SECONDS); //指定一个超时时间,并且省掉了整个续期操作。手动解锁,最慢30秒之后还是会删除锁

3、读写锁(ReadWriteLock)

  • 读写锁保证一定能读到最新数据,修改期间,写锁是一个排它锁(互斥锁、独享锁)。读锁是一个共享锁。
    写锁没释放,读锁就必须等待
    • 读 + 读:相当于无锁,并发读,只会在 redis 中记录好,所有当前的读锁,会同时加载成功
    • 写 + 读:等待写锁释放
    • 写 + 写:阻塞方式,下一个写锁必须等待上一个写锁释放才能进行
    • 读 + 写:有读锁,写也需要等待读锁释放
      只要有写的存在,都必须等待
@ResponseBody
@RequestMapping("/write")
public String writeValue(){
    String s = "";
    RReadWriteLock lock = redisson.getReadWriteLock("rw-lock");
    RLock rLock = lock.writeLock();;
    try {
        // 1、改数据加写锁,读数据加读锁
        rLock.lock();
        System.out.println("写锁加锁成功====="+Thread.currentThread().getId());
        s = UUID.randomUUID().toString();
        Thread.sleep(30000);
        redisTemplate.opsForValue().set("writeValue",s);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
        System.out.println("写锁释放..."+Thread.currentThread().getId());
    }
    return s;
}

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

4、信号量(Semaphore)

信号量可以用作分布式限流

获取和释放共用同一个锁 redisson.getSemaphore(“park”)

如果设置 park 的值为10,每获取一次 => 值减一,每释放一次 => 值加一。

  • acquire():获取一个信号,相当于获取一个值。是一个阻塞方法,如果获取成功,就继续执行,获取失败(值为0,没有信号可以获取了)就一直等待直到获取成功(直到释放一个信号)

  • tryAcquire():有返回值,会返回 boolean 值,如果有信号就获取,并返回 true,如果没有信号可以获取那就算了,不会阻塞

  • release():释放一个信号

/**
     * 车库停车
     * 3个车位
     * 信号量可以用作分布式限流,限制每一个应用的流量
     */
    @ResponseBody
    @GetMapping("/park")
    public String park() throws InterruptedException {
        RSemaphore park = redisson.getSemaphore("park");
        // 占一个车位
        park.acquire(); // 获取一个信号,相当于获取一个值。获取成功了。也是一个阻塞方法,获取成功就返回 "ok",获取失败就一直等,直到获取成功
        boolean b = park.tryAcquire(); // 有信号我就获取,没有就算了,区别于 acquire 的阻塞(一直等待直到获取)
//        if (b) {
//            // 执行业务
//        } else {
//            return "error";
//        }
        return "ok --- " + b;
    }

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

5、闭锁(CountDownLatch)

/**
 * 放假,锁门
 * 等所有班级人走完了,才锁门
 */
@GetMapping("lockDoor")
@ResponseBody
public String lockDoor() throws InterruptedException {
    RCountDownLatch door = redisson.getCountDownLatch("door");
    door.trySetCount(5);
    door.await(); // 等待闭锁都完成

    return "放假了...";
}

@GetMapping("/gogogo/{id}")
public String gogogo(@PathVariable("id") String id) {
    RCountDownLatch door = redisson.getCountDownLatch("door");
    door.countDown(); // 计数减一

    return id + "班的人都走了";
}

你可能感兴趣的:(Redis,redis,分布式,多线程)