介绍Redis的缓存读写策略

前一阵子,我写了个熟练使用 Redis,但是我一想,缓存常用的 3 种读写策略,我却不怎么知道

所以说,今天来系统的介绍一下这些内容,Redis有三种读写策略分别是:

  • 旁路缓存策略
  • 读写穿透策略
  • 异步缓存写入策略

三种缓存读写策略各有优势,需要我们根据实际的业务场景选择最合适的

Cache Aside Pattern 旁路缓存模式

Redis 的 旁路缓存模式(Cache-Aside Pattern),也被称为 懒加载缓存(Lazy Loading)或 旁观者缓存模式,是目前最常用、最经典的缓存使用策略之一,比较适合读请求比较多的场景

旁路缓存模式中服务端需要同时维护DBCache,并且是以DB的结果为准。

也就是

缓存是“旁路”的,不是主数据源;数据库才是权威数据源。

那么,整个读写操作的流程大概如下

读:

  • 应用程序在读取数据时,先查 Redis 缓存

    • 如果缓存命中(cache hit),直接返回;
    • 如果未命中(cache miss),则去查数据库,并将结果回填(write-through)到缓存中,供后续请求使用。
    img

写:

  • 在写入数据时,先更新数据库再删除缓存,而非更新缓存。(如果你先删缓存再更新DB会导致脏数据回填)

    img

所以说,这种读写模式下,缓存不参与业务逻辑的核心流程,只是作为数据库的加速副本存在,因此缓存是旁路。

而且对于写操作,它是直接删除缓存,等到后面用的时候再回填,而不是更新缓存,这是在考虑什么?

  • 并发安全:若多个线程同时更新同一数据,直接 SET 缓存可能导致旧值覆盖新值,很容易时空混乱,不知道真正被需要的是哪个进程写入的内容
  • 简单且保证数据安全:这样实现不仅实现简单,而且能保证数据安全,天然保证一致性。而且如果更新后无人读取,就不需要缓存,节省内存

那么,上面说到。先删除cache,再更新DB,会造成读写竞争导致的数据不一致问题,那么先写BD,再删除cache就不会造成数据不一致了吗?emmmm,我想了一下,好像也不是一定的。

理论上来说还是会出现数据不一致的问题(更新DB前被读了),不过概率很小,因为缓存的写入速度是比数据库写入速度快很多的。

但是这个问题本质上是一个并发时序问题:只要“读 DB → 写 Cache”这段时间窗口内,恰好有写请求完成了 DB 更新,就有可能产生不一致。在大多数业务里,这个窗口时间相对较短,而且还需要与写请求并发“撞车”,所以发生概率不算高,但绝不是不可能。

那么,旁路缓存模式存在的缺点就是很明显了:

  • 首次请求的数据一定不在cache的,当然,你可以完全可以将热点数据提前写入cache中。
  • 但是如果,写操作比较频繁的话导致cache中的数据会被频繁的删除,这样会影响缓存命中率。
    • 数据库和缓存数据强一致场景:更新 DB 的时候同样更新 Cache,不过我们需要加一个锁/分布式锁来保证更新 Cache 的时候不存在线程安全问题。
    • 可以短暂地允许数据库和缓存数据不一致的场景:更新 DB 的时候同样更新 Cache,但是给缓存加一个比较短的过期时间(如 1 分钟),这样的话就可以保证即使数据不一致的话影响也比较小

Read/Write Through Pattern 读写穿透

在这种模式下,应用程序将Cache 视为唯一的、主要的存储。所有的读写请求都直接打向 Cache,而 Cache 服务负责将此数据读取和写入DB,从而减轻应用程序的职责。

这种缓存读写策略小伙伴们应该也发现了在平时在开发过程中非常少见。抛去写回 DB 等位置产生的性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 本身并没有提供 Cache 将数据写入 DB 的功能,需要我们在业务侧或中间件里自己实现。

该模式在开源 Redis 生态中 极少直接使用,因为 Redis 定位为“缓存”,而非“带持久化代理的数据存储” 。

在这个模式下,读写步骤如下:

读:

  • 先从cache中读取数据,读取到直接返回。
  • cache中读取不到,则先从DB加载写入到cache后返回响应。
img

写:

  • 先查cachecache中不存在,直接更新DB
  • cache中存在,则先更新cache,然后cache服务自己更新去更新DB(同时更新DBcache
img

从实现角度看,Read-Through 本质上是把 Cache-Aside 中“读 Miss → 读 DB → 回填 Cache”的逻辑,下沉到了缓存服务内部,对客户端透明。

什么意思,也就是这里面涉及到了一个代理模式,应用程序认为自己在操作一个 完整的数据存储系统,而实际上这个系统由缓存 + 数据库组成,所有读写都必须经过缓存

这种模式实现了逻辑解耦,业务代码无需关心数据库,只需操作缓存即可 。

这种模式的缺点也很明显,除了需要自己实现写回DB的功能,而且还存在:

  • 和旁路缓存一样,读写穿透也存在首次请求数据一定不在cache中的问题,对于热点数据可以提前写入缓存中。
  • 所有读写流量都经过缓存服务,若其性能不足,会拖垮整个系统
  • 每次写操作都要等待 DB 确认,无法像 Write-Behind 那样异步提交 。

Write Behind Pattern 异步缓存写入

Write Behind Pattern(异步缓存写入),又称 Write-Back(写回模式),是一种以极致写性能为目标的缓存策略。

读写传统和异步缓存写入很相似,两者都是由 Cache 服务来负责 Cache 和 DB 的读写。

但是,两个又有很大的不同:读写穿透是同步更新 Cache 和 DB,而 异步缓存 则是只更新缓存,不直接更新 DB,改为异步批量的方式来更新 DB。

先写缓存,后异步刷盘

应用程序只与缓存交互,写操作立即在缓存中完成并返回成功,而数据库的更新则由缓存服务在后台异步、批量地执行

对于写回模式,读写的操作如下

读:

  • 与 Read-Through 相同:直接从缓存读取,因为两种模式下,缓存都是最新数据源

写:

  • 应用将数据写入 Cache,然后立即返回
  • Cache 服务将这个写操作放入一个队列中。
  • 通过一个独立的异步线程/任务,将队列中的写操作批量地、合并地写入 DB。

很明显,数据库不是实时更新的!数据持久化存在延迟,甚至可能丢失,这种模式对数据一致性带来了挑战(例如:Cache 中的数据还没来得及写回 DB,系统就挂了),因此不适用于对强一致性高的场景(如交易、库存)。

Write-Behind 是 Write-Through 的异步优化版,用 一致性换性能。

但是,它的异步和批量特性,带来了无与伦比的写性能。它在很多高性能系统中都有广泛应用:

  • MySQL 的 InnoDB Buffer Pool 机制: 数据修改先在内存 Buffer Pool 中完成,然后由后台线程异步刷写到磁盘。
  • 操作系统的页缓存(Page Cache): 文件写入也是先写到内存,再由操作系统异步刷盘。
  • 高频计数场景: 对于文章浏览量、帖子点赞数这类允许短暂数据不一致、但写入极其频繁的场景,可以先在 Redis 中快速累加,再通过定时任务异步同步回数据库

emmm,这种情况用的其实也不多,因为和读写穿透一样,Redis 本身无“异步写回 DB”功能,需自行开发中间件或使用消息队列桥接。

Redis的三种缓存问题

那么,既然刚刚介绍了三种缓存读写的策略,那么就来说一下缓存会遇到的三大问题

在现代高并发分布式系统中,Redis作为高性能缓存层被广泛应用,但不当的设计会引发三大经典问题:缓存击穿、缓存穿透和缓存雪崩。这些问题在不同场景下对后端数据库构成致命威胁,需要针对性策略进行防护。

缓存穿透

恶意请求大量查询 id=-1这种根本不存在的数据,导致请求每次都穿透到 DB。

那么此时大量的请求都落在数据库中,导致数据库压力骤增,这就是缓存穿透问题。

img

对于这种形式的解决方式

  • 布隆过滤器:

    • 最经典的方式,快速判断 key 是否可能存在,如果不存在 那这个请求直接无效,如果存在那就在缓存中查找对应的数据。这样当出现大量请求的时候,会先查询布隆过滤器和Redis,不会对数据库造成压力。

      别忘了,布隆过滤器说数据不存在,那么数据库中一定不会有这个数据。如果说数据存在,并不一定证明数据库中存在这个数据,有误判的几率,只不过几率非常小。

    • 布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。

      布隆过滤器会通过 3 个操作完成标记:

      • 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
      • 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
      • 第三步,将每个哈希值在位图数组的对应位置的值设置为 1;

      举个例子,假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器。

      img
  • 空值缓存或者限制非法请求

    • 这种方式的根本逻辑是保证不合法的参数请求不到达DB,直接抛出特定内容返回给客户端。

    • 对于非法请求不再多说,对于空值缓存:

      • 即使数据库返回空结果,也将一个特殊标记的空值写入缓存,并设置较短 TTL(如 1~5 分钟)。后续相同请求直接命中缓存,不再访问数据库。

        graph LR
            A[请求 Key=X] --> B{Redis 有 X?}
            B -- 是 --> C{值是否为空标记?}
            C -- 是 --> D[返回 null/错误]
            C -- 否 --> E[返回数据]
            B -- 否 --> F[查询数据库]
            F -- 有数据 --> G[写入缓存 + 返回]
            F -- 无数据 --> H[写入空值缓存 + 返回 null]

        最近的我找到了这样的内容,我是这样实现的

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        @Service
        public class UserService {
        @Autowired
        private RedisTemplate<String, Object> redisTemplate;
        @Autowired
        private UserMapper userMapper;

        // 自定义空值标记类
        static class NullValue implements Serializable {}

        public User getUserById(Long id) {
        String key = "user:" + id;

        // 1. 查缓存
        Object cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
        return cached instanceof NullValue ? null : (User) cached;
        }

        // 2. 缓存未命中,查数据库
        User user = userMapper.selectById(id);
        if (user == null) {
        // 3. 数据库无结果:缓存空值(TTL=5分钟)
        redisTemplate.opsForValue()
        .set(key, new NullValue(), Duration.ofMinutes(5));
        return null;
        }

        // 4. 有结果:缓存真实数据(TTL=30分钟)
        redisTemplate.opsForValue()
        .set(key, user, Duration.ofMinutes(30));
        return user;
        }
        }

缓存击穿

热门数据的缓存过期,大量请求同时查 DB。这是由热点 Key 失效引起的瞬间高并发

其根本原因是热点数据在缓存过期的瞬间,海量请求同时到达,缓存层无法拦截,形成”击穿”效应 。例如秒杀商品、热门新闻等场景,单个Key的失效可能引发数据库连接池瞬间耗尽 。

img

所以说,缓存击穿的发生需满足三要素:热点数据、缓存过期、瞬时高并发。

当热点Key TTL归零被清除后,第一个请求发现缓存缺失,触发数据库查询。在数据回写缓存前,后续请求全部穿透到数据库,形成”请求风暴” 。在Java应用中,若未加保护,1000 个线程完全可能在 10ms 内生成 1000 次数据库查询。

检测缓存击穿可通过以下指标检查和监控:

  • 缓存命中率骤降:热点Key所在命名空间命中率从95%跌至10%以下
  • 数据库QPS激增:监控慢查询日志,观察特定SQL执行频率异常上升
  • Redis连接数异常:客户端连接数激增但缓存GET操作减少

对于解决方案,通常是

  • 互斥锁(分布式锁)

    • 当缓存失效时,仅允许一个线程获取锁并查询数据库,根据上面讲的缓存读写策略,那么接下来缓存从数据库被更新同步之后再释放掉锁,其余线程等待锁释放后重试缓存读取,实现请求串行化

    • 而且分布式锁能够强一致,保安全

    • 实现方式:就看大概的逻辑吧,这是我从别的地方摘来的实现

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      @Service
      public class ProductService {

      @Autowired
      private RedisTemplate<String, Object> redisTemplate;
      @Autowired
      private ProductMapper productMapper;

      private static final String LOCK_PREFIX = "lock:product:";
      private static final long LOCK_EXPIRE = 5000; // 5秒
      private static final long RETRY_DELAY = 100; // 重试间隔

      public Product getProduct(Long id) {
      String cacheKey = "product:" + id;
      // 1. 先查缓存
      Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
      if (product != null) return product;

      // 2. 缓存失效,尝试加锁
      String lockKey = LOCK_PREFIX + id;
      Boolean locked = redisTemplate.opsForValue()
      .setIfAbsent(lockKey, "1", Duration.ofMillis(LOCK_EXPIRE));

      if (Boolean.TRUE.equals(locked)) {
      try {
      // 双重检查:防止锁等待期间已被其他线程重建
      product = (Product) redisTemplate.opsForValue().get(cacheKey);
      if (product != null) return product;

      // 查数据库
      product = productMapper.selectById(id);
      if (product != null) {
      // 重建后的缓存设置随机TTL防雪崩:30~55分钟
      int ttl = 30 + new Random().nextInt(25);
      redisTemplate.opsForValue().set(cacheKey, product, Duration.ofMinutes(ttl));
      } else {
      // 防穿透:缓存空值
      redisTemplate.opsForValue().set(cacheKey, "", Duration.ofSeconds(30));
      }
      return product;
      } finally {
      // 安全释放锁(Lua脚本保证原子性)
      String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
      redisTemplate.execute(
      new DefaultRedisScript<>(script, Long.class),
      Collections.singletonList(lockKey),
      "1"
      );
      }
      } else {
      // 未获取锁,短暂等待后重试
      try {
      Thread.sleep(RETRY_DELAY);
      return getProduct(id); // 递归重试
      } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
      return null;
      }
      }
      }
      }
  • 逻辑过期高性能,最终一致

    • 热点数据不存在真正的过期时间,是有一个逻辑上的过期时间,此时间到达前会提前通知后台线程,引发由后台异步更新缓存来更新热点数据,然后时间重置

    • 那么整体的实现流程大概是

      graph LR
          A[请求到达] --> B{缓存存在?}
          B -- 否 --> C[返回默认值/降级]
          B -- 是 --> D{逻辑过期?}
          D -- 否 --> E[返回缓存数据]
          D -- 是 --> F[启动异步更新任务]
          F --> G[返回旧数据(不阻塞)]

      所以一般用在,对实时性要求不高但 QPS 极高的数据(如热门榜单、首页推荐)

    • 实现方式

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      public class HotCacheService {
      private final ExecutorService refreshPool = Executors.newFixedThreadPool(5);

      public String getHotData(String key) {
      String json = redisTemplate.opsForValue().get(key);
      if (json == null) {
      return getDefaultData(); // 降级处理
      }

      JSONObject obj = JSON.parseObject(json);
      long expireTime = obj.getLong("expireTime");
      String data = obj.getString("data");

      // 未过期,直接返回
      if (System.currentTimeMillis() < expireTime) {
      return data;
      }

      // 已过期,异步刷新(仅第一个请求触发)
      String refreshLock = "refresh:" + key;
      Boolean needRefresh = redisTemplate.opsForValue()
      .setIfAbsent(refreshLock, "1", Duration.ofSeconds(10));

      if (Boolean.TRUE.equals(needRefresh)) {
      refreshPool.submit(() -> {
      try {
      // 从DB加载新数据
      String newData = loadFromDB(key);
      long newExpire = System.currentTimeMillis() + 3600_000; // 1小时
      JSONObject updated = new JSONObject();
      updated.put("data", newData);
      updated.put("expireTime", newExpire);
      redisTemplate.opsForValue().set(key, updated.toJSONString());
      } finally {
      redisTemplate.delete(refreshLock); // 释放刷新锁
      }
      });
      }

      return data; // 返回旧数据,保证响应速度
      }
      }
    • 类似逻辑过期还有一种方案,就是热点数据永不过期,然后以固定逻辑(定时任务或事件驱动)来主动的更新缓存,这种方式也很常用,但是这种方式相对复杂了就

缓存雪崩

缓存雪崩是大量Key在同一时间段集体失效,或Redis服务宕机,导致所有请求转发至数据库。

其破坏力最大,可能引发整个微服务架构的级联故障。典型场景包括批量导入数据时设置相同TTL、Redis集群节点故障等。

img

导致原因

  • 大量数据同时过期;
  • Redis 故障宕机;

针对大量数据同时过期而引发的缓存雪崩问题,常见的应对方法有下面这几种

  • 设置过期时间
    • 大量的数据设置不同的过期时间,避免同时过期。也可以选择给过期时间加上一个随机数,这样就可以不会在同一时间过期。
  • 互斥锁
    • 发现访问的数据不在Redis中,加上互斥锁,保证同一时间内只有一个请求来构建缓存( 从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
    • 对了,实现互斥锁的时候,最好设置超时时间,差不多两分钟就够了,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时 其他请求也一直拿不到锁,整个系统就会出现无响应的现象。

当然,对于服务治理的角度,缓存雪崩一旦发生,也可以这样处理

这两个实现方法和缓存击穿差不多,但是出现缓存雪崩的情况,建议启动 服务熔断 机制,暂停业务应用对缓存服务的访问,直接返回错误,然后根据服务的情况判断对数据库的使用情况,等Redis恢复正常后,在允许业务应用缓存服务。

这时候一般会结合启动 请求限流 机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务。

然后就是构建 Redis 集群,就是主从节点的方式构建 Redis 缓存高可靠集群。不多说了

Redis内存碎片

什么是内存碎片

你可以将内存碎片简单地理解为那些不可用的空闲内存。

就假如,操作系统为你分配了 32 字节的连续内存空间,而你存储数据实际只需要使用 24 字节内存空间,那这多余出来的 8 字节内存空间如果后续没办法再被分配存储其他数据的话,就可以被称为内存碎片。

Redis 内存碎片虽然不会影响 Redis 性能,但是会增加内存消耗,内存是很珍贵的

为什么会有 Redis 内存碎片

Redis 内存碎片产生比较常见的 2 个原因:

Redis 存储数据的时候向操作系统申请的内存空间可能会大于数据实际需要的存储空间。

Redis 实际使用的内存大小(RSS)远大于它真正存储数据所需的内存大小(Used Memory)

  • Used Memory:Redis 认为“自己用了多少内存”(通过 INFO memory 查看)
  • RSS(Resident Set Size):操作系统实际分配给 Redis 的物理内存(可通过 ps aux | grep redis 查看)

To store user keys, Redis allocates at most as much memory as the maxmemory setting enables (however there are small extra allocations possible).


来自,Redis 官方

Redis 使用 zmalloc 方法(Redis 自己实现的内存分配方法)进行内存分配的时候,除了要分配 size 大小的内存之外,还会多分配 PREFIX_SIZE 大小的内存,用于冗余,这样保证高可用。

zmalloc 方法源码地址:https://github.com/antirez/redis-tools/blob/master/zmalloc.c):

另外,Redis 可以使用多种内存分配器来分配内存( libc、jemalloc、tcmalloc),默认使用 jemalloc,而 jemalloc 按照一系列固定的大小(8 字节、16 字节、32 字节……)来分配内存的。jemalloc 划分的内存单元如下图所示:

jemalloc 内存单元示意图

那么,按照这种情况,按固定大小块分配内存,肯定会出现内存碎片的情况,而且出现内存碎片的关键是,只分配预定义的、固定大小的内存块,而不是任意大小,它会选择比你请求大的最小可用块

但是,兄弟没办法,这种浪费是不可避免的,因为 jemalloc 的设计就是如此,这是高可用的重要保障

频繁修改 Redis 中的数据也会产生内存碎片。

当 Redis 中的某个数据删除时,Redis 通常不会轻易释放内存给操作系统。

这个在 Redis 官方文档中也有对应的原话:

文档地址:https://redis.io/topics/memory-optimization

这两个一搞,肯定会产生一些内存碎片,所以我们需要收回

如何查看 Redis 内存碎片的信息?

使用 info memory 命令即可查看 Redis 内存相关的信息

1
info memory
image-20260203173156967

重点关注:

1
2
3
used_memory:1073741824      # Redis 实际使用内存(1GB)
used_memory_rss:1610612736 # OS 分配内存(1.5GB)
mem_fragmentation_ratio:1.5 # 碎片率

如下命令查看是否正在整理

1
info stats
image-20260203173226408

输出包含:

1
2
3
4
5
active_defrag_running:1     # 1=正在整理,0=未运行
active_defrag_hits:12345 # 成功整理的 key 数量
active_defrag_misses:6789 # 无法整理的 key 数量
active_defrag_key_hits:100 # 移动的 key 总数
active_defrag_key_misses:20 # 未移动的 key 数

如何清理 Redis 内存碎片?

Redis4.0-RC3 版本以后自带了内存整理,可以避免内存碎片率过大的问题。好消息,这样不用自己清理了

如下启用主动碎片整理,直接通过 config set 命令将 activedefrag 配置项设置为 yes 即可。

1
2
3
4
5
# 动态开启(无需重启)
config set activedefrag yes

# 永久生效:在 redis.conf 中添加
activedefrag yes

Redis 主动碎片清理的原理是这样的,和大部分内存碎片清理算法差不多,简单说一下吧

当 Redis 发现内存碎片过多时,它会在后台线程中:

  1. 找到那些占用连续物理内存但逻辑上分散的数据
  2. 将它们复制到新的连续内存区域
  3. 释放原来的碎片空间
  4. 最终使空闲内存变成大块连续区域,提高内存利用率

这个过程是渐进式的,不会一次性阻塞主线程。

具体什么时候清理需要通过下面两个参数控制:

1
2
3
4
# 内存碎片占用空间达到 500mb 的时候开始清理
config set active-defrag-ignore-bytes 500mb
# 内存碎片率大于 1.5 的时候开始清理
config set active-defrag-threshold-lower 50

其中关于内存相关内容的计算逻辑如下

  • 碎片空间 = used_memory_rss - used_memory
  • 碎片率 = used_memory_rss / used_memory
  • 触发条件 = (碎片空间 ≥ ignore-bytes) AND (碎片率 ≥ 1 + threshold-lower/100)

也就是说,mem_fragmentation_ratio 内存碎片率的值越大代表内存碎片率越严重。

如果想要快速查看内存碎片率的话,你还可以通过下面这个命令

1
redis-cli -p 6379 info | grep mem_fragmentation_ratio

通过 Redis 自动内存碎片清理机制可能会对 Redis 的性能产生影响,我们可以通过下面两个参数来减少对 Redis 性能的影响:

1
2
3
4
# 内存碎片清理所占用 CPU 时间的比例不低于 20%
config set active-defrag-cycle-min 20
# 内存碎片清理所占用 CPU 时间的比例不高于 50%
config set active-defrag-cycle-max 50
image-20260203173246109

另外,重启节点可以做到内存碎片重新整理。如果你采用的是高可用架构的 Redis 集群的话,你可以将碎片率过高的主节点转换为从节点,以便进行安全重启