原创

Redis分布式缓存

一、关于缓存
缓存分为本地缓存和分布式缓存。以 Java 为例,使用自带的 map 或者 guava 实现的是本地缓存,最主要的特点是轻量以及快速,生命周期随着 JVM 的销毁而结束,且在多实例的情况下本地缓存不具有一致性。

而使用 Redis 或 memcached 之类的称为分布式缓存。在多实例(集群)的情况下,Redis 的事务会一次性、顺序性、排他性地执行队列中的一系列命令,各实例共用一份缓存数据,缓存具有一致性。
file

Redis 缓存处理请求
用户第一次访问数据库的数据,因为是从硬盘上读取的所以比较慢。将该用户访问的数据存在缓存中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了。如果数据库中的对应数据改变的之后,同步改变缓存中相应的数据即可,这里涉及到的数据一致性问题会在第四小节专门讲。

至于 Redis 为什么这么快,最主要有以下几个原因:

完全基于内存,绝大部分请求是纯粹的内存操作,速度非常地快;
采用单线程,避免了不必要的上下文切换和竞争条件,不存在因多线程的切换而消耗 CPU;也不存在加锁、释放锁操作,也没有因死锁而导致的性能消耗;
使用多路 I/O 复用模型(很关键),非阻塞的 IO,能让单个线程高效地处理多个连接请求,尽量减少网络 IO 的时间消耗。
file
Redis 性能
注:X 轴为客户端连接数,Y 轴是 QPS。即在近一万的客户端连接下,还能达到近十万的 QPS,这样强悍的性能是 MySQL 无法企及的。

二、基本数据结构
众所周知,直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据(热点数据、读多写少的数据)转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。

三、缓存注解
用于后端接口的数据缓存,加在接口的实现方法上,这是我在实际项目中处理高并发的基本做法之一。说到注解,那么就需要从以下几个必不可少的方面进行展开。

3.1自定义注解
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
@Inherited
public @interface Cache {
/**

 * 缓存 key 默认为空串,会根据调用的类签名自动生成
 * 如果指定 key,则使用指定的 key
 * @return redis key
 */
String key() default "";

/**
 * 超时时间,默认 3 秒
 * @return redis 的超时时间
 */
int expiryTime() default 3;

}
3.2定义切点(拦截器)
在 AOP 中,Joinpoint 代表了程序执行的某个具体位置,比如方法的调用、异常的抛出等。AOP 框架通过拦截这些 Joinpoint 来插入额外的逻辑,实现横切关注点的功能。
而实现拦截器 MethodInterceptor 接口比较特殊,它会将所有的 @AspectJ 定义的通知最终都交给 MethodInvocation(子类 ReflectiveMethodInvocation )去执行。
public class CacheAnnotationInterceptor extends CacheAop implements MethodInterceptor {
public CacheAnnotationInterceptor(ICache iCache) {
// 调用父类有参构造
super(iCache);
}
public CacheAnnotationInterceptor(RedisTemplate redisTemplate) {
// 调用父类有参构造
super(redisTemplate);
}
/**

 * 反射实现,通过拦截方法的执行来实现通知的效果
 * @param methodInvocation
 * @return
 * @throws Throwable
 */
@Override
public Object invoke(MethodInvocation methodInvocation) throws Throwable {
    // 即下面父类的具体 AOP 实现
    return super.cacheAop(methodInvocation);
}

}
3.3 AOP 实现
下面的 AOP 仅是大致过程,思路用注释写得比较清楚了,完整的代码有时间脱敏后再分享吧。

/**

  • 子类 CacheAnnotationInterceptor 重写了 MethodInterceptor 的 invoke() 方法
    /
    public class CacheAop {
    /*

    • 基于 Redis 的一些常见 API 实现
      /
      protected ICache iCache;
      protected RedisTemplate redisTemplate;
      public CacheAop(ICache iCache) {
      this.iCache = iCache;
      }
      public CacheAop(RedisTemplate redisTemplate) {
      this.redisTemplate = redisTemplate;
      }
      /*
    • 缓存切面实现
      */
      public Object cacheAop(MethodInvocation methodInvocation) throws Throwable {
      // 检查是否使用缓存
      if (this.isUseCache(methodInvocation)) {

       // 自定义缓存注解
       Cache cache = methodInvocation.getMethod().getAnnotation(Cache.class);
       // 生成缓存的 key
       String key = this.generateCacheKey(cache, methodInvocation);
       // 缓存操作
       return this.handlerCache(key, cache, methodInvocation);
      

      } else {

       // 需要执行的时候,调用.proceed()方法即可
       return methodInvocation.proceed();
      

      }
      }
      }
      3.4使用示例
      下面是一个简单示例,@Cache 注解加在需要缓存的方法上,设置过期时间为 5 秒。即 5 秒内调用该方法,返回的数据是来自缓存,过期后会再次从数据库中获取,并重新写入缓存,循环往复。

      /**

    • 根据 Id 从缓存中查询详情
    • @param id
    • @return
      */
      @Cache(expiryTime = 5)
      public Study getDetailByIdFromCache(String id) {
      return Optional.of(this.studyMapper.selectById(id)).orElse(null);
      }
      四、数据一致性
      数据库一旦引入了其它组件,必然会带来数据一致性的问题。这里不考虑强一致性,因为强一致性引发的性能问题在高并发的情景下是不可接受的。所以只考虑最终一致性。

4.1缓存更新策略
一般来说,为了保证数据一致性,会有 3 种常见的 Redis 缓存更新策略,如下表所示:

file
我自己在项目是选择超时剔除和主动更新这 2 种方法搭配使用的,在合适的时候用合适的办法。

前者适合在对实时性要求不那么高的情况下使用,后者适合在对实时性要求较高的场景使用。至于内存淘汰是不可能会用的,太傻瓜了,放到线上 100% 出问题。

超时剔除的核心逻辑:缓存来源于数据库,到过期时间后,缓存中的数据会被删除;用户再次请求的就是数据库的数据,再把数据库数据重新放入到缓存,依次反复。

主动更新的核心逻辑:缓存操作一定不能和数据库事务作为一个大事务来处理,尤其是在较复杂的业务流程中,一般都是先完成数据库的事务操作后,再去操作缓存中的数据。

4.2缓存读写过程
具体读和写分为以下两点:

对于读操作,一般都是先读取缓存,如果命中则返回;没有命中则去读数据库返回数据,这个逻辑很好理解,也没什么争议。

对于写操作,有两个需要考虑的问题:

如何更新缓存,是删除缓存还是修改缓存?

答:设置过期时间,直接删。不必要再去修改之前的缓存数据

是先更新数据库还是先更新缓存?

答:先更新数据库,再更新缓存

如果更新完了数据库,但是之前的缓存删除失败,读的依然是旧数据怎么办?

答:设置较短的缓存时间,短时间内再次删除缓存。

如果数据库是主从结构,即 master 负责事务操作,slave 只负责读,数据同步的延迟影响到缓存的更新怎么办?

答:考虑从硬件下手,提升数据库性能 + 提升网络带宽。

五、高可用
5.1缓存穿透
缓存穿透:是指客户端请求的数据在缓存中和数据库中都不存在,那么缓存永远不会生效。这样,每次针对此 key 的请求从缓存获取不到,请求都会压到数据源,从而可能压垮数据源。此时,缓存起不到保护后端持久层的意义,就像被穿透了一样。

以下是常用的缓存穿透的解决方案:

对空值进行缓存:即使一个查询返回的数据为空,仍然把这个空结果(null)进行缓存,同时还可以对空结果设置较短的过期时间。这种方法实现简单,维护方便,但是会额外的内存消耗。

采用布隆过滤器:(布隆过滤器(Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量(位图)和一系列随机映射函数(哈希函数)。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。

加强 id 复杂度(如雪花和 UUID)和参数校验,比如保证要查询的 key 不为负数或者非法字符串

加强用户权限校验:对页面操作加以限制,对接口的调用进行鉴权。

5.2缓存击穿
缓存击穿:是指某个热点 key,在缓存过期的一瞬间有大量的请求进来,由于此时缓存刚好过期,所以请求最终都会走数据库,数据库压力瞬间骤增,导致数据库存在被打挂的风险。这样的情况下,彷佛缓存被请求给击穿了。

应该这种情况主要的解决方案如下:

加互斥锁。当热 key 过期大量的请求涌入时,只有第一个请求能获取锁并阻塞,此时保证该请求查询数据库,并将查询结果写入 redis 缓存后释放锁,则后续的请求直接走缓存。
以下是一个通用解决方案的大致思路,可以兼顾处理缓存穿透和击穿问题。至于下面提到的“给 Key 设置合理的 TTL 并加上随机值”,这个也比较好实现。

protected Object handleCache(String key, Cache cache, MethodInvocation invocation) throws Throwable {
    //设置 key 到 ThreadLocal 中,方便使用
    KeyThreadLocalUtils.setValue(key);
    //双重检查,防止高并发情况下因为缓存失效导致的缓存穿透问题
    Object value = this.getValueForCache(key);
    if (Objects.isNull(value)) {
        //加锁,防止缓存击穿
        synchronized (DigestUtils.md5Hex(key).intern()) {
            logger.info("方法为:{},key为:{}", invocation.getMethod().getName(), key);
            value = this.getValueForCache(key);
            //对空值也进行缓存
            if (Objects.isNull(value)) {
                value = invocation.proceed();
                if (value instanceof Serializable) {
                    this.setValueToCache(key, value, cache.expiryTime());
                } else {
                    logger.warn("方法{}使用了缓存注解,但返回对象{}未序列化", invocation.getMethod().getName(), value);
                }
            }
        }
    }
    KeyThreadLocalUtils.removeValue();
    return value;
}

5.3缓存雪崩
缓存雪崩:是指由于大量的缓存 key 的过期时间相同,导致数据在同一时刻集体失效,或者 Redis 服务宕机,导致大量请求到达数据库,给数据库带来巨大压力。这种情况通常是由于缓存时间设置不当,或者缓存容量不足引起的。

以下是常用的缓存雪崩的解决方案:

给 Key 设置合理的 TTL 并加上随机值

增加缓存容量

给缓存业务添加降级限流策略

5.4Redis 集群
在硬件层面,通过购买云服务厂商的 Redis 集群来保证服务的高可用。以下拿阿里云云数据库 Redis 的一些基本配置来举例:
file

阿里云云数据库 Redis
六、文章小结
到这里关于使用 Redis 实现分布式缓存的全过程就分享完了,其实关于 redis 缓存的高可用部分还有许多能详细展开的地方。但是目前我对于缓存的击穿、穿透和雪崩没有太多的实际场景来分享,更多的是一种学习和储备。

正文到此结束
本文目录