Skip to content
raymond-zhao edited this page Aug 16, 2020 · 2 revisions

缓存

为什么要使用缓存

  • 高性能
  • 高并发

缓存的使用

缓存逻辑:先查缓存,缓存有则直接返回,缓存无则查数据库,然后将数据库的查询结果放入缓存以便下次使用。

  • 本系统中的使用场景:缓存商品首页三级分类菜单
/**
* 利用Redis进行缓存商品分类数据
*
* @return
*/
@Override
public Map<String, List<Catelog2VO>> getCatalogJson() {
    // TODO 产生堆外内存溢出 OutOfDirectMemoryError
    /**
     * 1. SpringBoot2.0之后默认使用 lettuce 作为操作 redis 的客户端,lettuce 使用 Netty 进行网络通信
     * 2. lettuce 的 bug 导致 Netty 堆外内存溢出 -Xmx300m   Netty 如果没有指定对外内存 默认使用 JVM 设置的参数
     *      可以通过 -Dio.netty.maxDirectMemory 设置堆外内存
     * 解决方案:不能仅仅使用 -Dio.netty.maxDirectMemory 去调大堆外内存
     *      1. 升级 lettuce 客户端   2. 切换使用 jedis
     *
     *      RedisTemplate 对 lettuce 与 jedis 均进行了封装 所以直接使用 详情见:RedisAutoConfiguration 类
     */
    // 给缓存中放入JSON字符串,取出JSON字符串还需要逆转为能用的对象类型

    // 1. 加入缓存逻辑, 缓存中存的数据是 JSON 字符串
    String catalogJSON = stringRedisTemplate.opsForValue().get("catalogJSON");
    if (StringUtils.isEmpty(catalogJSON)) {
        // 2 如果缓存未命中 则查询数据库
        Map<String, List<Catelog2VO>> catalogJsonFromDB = getCatalogJsonFromDB();
        // 3 查到的数据再放入缓存 将对象转为JSON放入缓存
        String cache = JSON.toJSONString(catalogJsonFromDB);
        stringRedisTemplate.opsForValue().set("catalogJSON", cache);

        // 4 返回从数据库中查询的数据
        return catalogJsonFromDB;
    }

    Map<String, List<Catelog2VO>> result = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catelog2VO>>>() {});
    return result;
}

使用缓存时可能出现的问题

推荐阅读:妈妈再也不担心我面试被 Redis 问得脸都绿了

下面材料来源于上文。

一般来说有如下几个问题,回答思路遵照 是什么为什么怎么解决

  • 缓存雪崩问题;
  • 缓存穿透问题;
  • 缓存击穿问题;
  • 缓存和数据库双写一致性问题。

缓存雪崩问题

缓存雪崩

另外对于 "Redis 挂掉了,请求全部走数据库" 这样的情况,有如下的思路:

  • 事发前:实现 Redis 的高可用(主从架构 + Sentinel 或者 Redis Cluster),尽量避免 Redis 挂掉这种情况发生。
  • 事发中:万一 Redis 真的挂了,我们可以设置本地缓存(ehcache) + 限流(hystrix),尽量避免我们的数据库被干掉(起码能保证我们的服务还是能正常工作的)
  • 事发后:Redis 持久化,重启后自动从磁盘上加载数据,快速恢复缓存数据。

缓存穿透问题

缓存穿透

缓存和数据库双写一致性问题

缓存和数据库双写一致性问题

本系统的一致性解决方案:

  • 为所有缓存数据设置过期时间,数据过期下一次查询触发主动更新。
  • 读写数据的时候,加上分布式的读写锁。(读多写少时几乎无影响)

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

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

总结:

  • 能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保

    证每天拿到当前最新数据即可。

  • 不应该过度设计,增加系统的复杂性。

  • 遇到实时性、一致性要求高的数据,就应该实时直接查数据库,即使这样速度相对于缓存会比较慢。

推荐阅读:面试前必须要知道的Redis面试题

缓存击穿

  • 是什么:在高并发的系统中,大量的请求同时查询一个 key(热点 key) 时,此时这个 key 正好失效了,就会导致大量的请求都打到数据库上面去。
  • 为什么:key 设置了过期时间,key 又为热点key
  • 怎么解决:在第一个查询数据的请求上使用一个 互斥锁(mutex) 来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。
public String get(key) {
    String value = redis.get(key);
    if (value == null) { // 代表缓存值过期
        // 设置 3min 的超时,防止 del 操作失败的时候,下次缓存过期一直不能load db
        if (redis.setnx(key_mutex, 1, 3 * 60) == 1) {  //代表设置成功
            value = db.get(key);
            redis.set(key, value, expire_secs);
            redis.del(key_mutex);
        } else {  
            // 这个时候代表同时候的其他线程已经load db并回设到缓存了
            // 这时候重试获取缓存值即可
            sleep(50);
            get(key);  //重试
        }
    } else return value;
 }

这种 互斥锁(mutex) ** 在单机情况下是有效的,但是在分布式情况下就要使用分布式锁**。

SpringBoot 所有的组件在容器中默认都是单例的,使用 synchronized (this) 可以实现加锁。

在每一个微服务中的synchronized(this)加锁的对象只是当前实例,但是并未对其他微服务的实例产生影响,即使每个微服务加锁后只允许一个请求,假如有 8 个微服务,仍然会有 8 个线程存在。

分布式锁下如何加锁

如何保证在查询缓存时只查一次DB

/**
* SpringBoot 所有的组件在容器中默认都是单例的,使用 synchronized (this) 可以实现加锁。
*
* 得到锁之后 应该再去缓存中确定一次,如果没有的话才需要继续查询。
*
* 假如有100W个并发请求,首先得到锁的请求开始查询,此时其他的请求将会排队等待锁
* 等到获得锁的时候再去执行查询,但是此时有可能前一个加锁的请求已经查询成功并且将结果添加到了缓存中
*/

将核心操作封装为原子操作,保证锁的时序性,具体地讲,就是将

  1. 确认缓存是否存在
  2. 查询数据库
  3. 将结果放入缓存

这三个操作封装为原子操作,必须当做一个事务来执行,放在同一把锁里面完成。