一文详解Cache Aside(旁路缓存模式)

最经典、最常用的缓存设计模式,业务代码自己维护缓存,缓存组件不感知数据库,缓存和 DB 相互独立,所以叫旁路。

适用:Redis + MySQL 这类组合,几乎所有业务系统都在用。

旁路 = 缓存不在数据库读写的主链路里面,只是一条 "旁边的辅助支路"。

请求 → 业务代码(在主路上) 业务自己做判断:

  • 查缓存,命中直接返回;
  • 缓存没命中,业务自己去查数据库 ,拿到数据之后,业务再额外往旁边的缓存里写一份。

数据库的读写主干流程本身不依赖缓存,缓存只是挂在主干旁边的一条支路。

数据库不知道缓存存在,缓存也不知道数据库。

缓存是旁路的附属,业务代码主动去旁路操作缓存。

一、核心思想

缓存不主动和数据库联动,读写逻辑全部由应用程序代码控制。 数据库是权威数据源,缓存只是副本。

二、读流程(查询数据)

  1. 先查 Cache
  2. 命中 → 返回
  3. 未命中 → 查询 DB,拿到数据,业务手动写 Cache,返回

三、写流程(更新数据,重点!两种方案对比)

Cache Aside 的坑基本都在更新策略。

✅ 方案 A:先更新数据库,再删除缓存(业界推荐)

  1. 更新数据库
  2. 删除缓存(不是更新缓存!)

为什么是删除缓存而不是更新缓存?

如果更新缓存:高并发下多次写,会出现缓存被旧值覆盖;而且很多数据不一定会被读取,更新缓存是浪费。

删除缓存:下次读的时候自动加载最新数据,更合理。

可能存在的极小概率并发问题:

线程 A(读):缓存失效 → 查 DB(拿到旧数据)

线程 B(写):更新 DB → 删除缓存

线程 A(读):把旧数据写入缓存

结果:缓存是旧数据,DB 是新数据,数据不一致。

✅ 怎么解决:

  1. 给缓存设置过期时间(兜底,就算不一致,到期自动刷新)
  2. 或者使用延迟双删:更新 DB → 删除缓存 → 等待一小段时间(几百 ms)→ 再次删除缓存
  3. Canal binlog 方案(大厂优化)

缺点:不能 100% 根除,只是降低概率;一般业务过期时间兜底足够。
1. 这个问题本质

问题发生的根本原因 :读请求的 DB 查询 RT > 写请求更新 DB + 删缓存的总 RT。

这个场景本身就属于比较罕见的,正常情况下 DB 查询很快,很难出现这种时序。

2. 方案对比:TTL / 延迟双删 / 锁

① TTL(最常用,推荐)

给缓存 key 设置过期时间。就算出现脏数据,最多等 TTL 时间,脏数据自动消失。

  • 优点:零侵入、性能几乎无损,实现最简单
  • 缺点:短暂不一致,窗口等于 TTL

绝大多数互联网业务(商品、用户信息、订单基础信息)都接受几秒~几分钟的短暂不一致。

② 延迟双删(次选,进一步缩小概率)

更新 DB → 删除缓存 → 延迟几百 ms 再删一次。

延迟时间要大于正常读请求从查 DB 到写入缓存的最大耗时。

  • 优点:大幅降低脏数据存活概率
  • 缺点:依然不是 100% 杜绝;延迟任务需要线程池 / 消息队列;依然存在极极端场景

③ 加锁(不推荐作为通用方案)

思路:对同一个业务 key(比如 user:100)加分布式锁,读写互斥。

  • 读流程:获取锁 → 查缓存,miss 则查 DB 写缓存 → 释放锁
  • 写流程:获取锁 → 更新 DB → 删除缓存 → 释放锁

锁带来的问题(很关键,面试常问)

  1. 性能断崖下跌:同一个 key 串行化,并发读写变成排队,热点 key 直接瓶颈。
  2. 死锁风险:锁超时、业务异常、节点宕机,需要锁自动过期,处理复杂。
  3. 锁本身也有失败概率:分布式锁不是绝对可靠(网络抖动、主从切换丢锁)。
  4. 收益很低:为了堵一个极低概率的时序 bug,牺牲系统吞吐,得不偿失。

❗ 注意:这里的锁是为了解决数据不一致 ,不是解决缓存击穿。 缓存击穿(大量请求同时 miss 打 DB)可以用互斥锁,和这个脏数据场景是两个问题,不要混淆。

延迟双删伪代码

java 复制代码
// 更新
public void updateUser(User user) {
    // 1. 更新数据库
    db.updateById(user);
    // 2. 删除缓存
    cache.del("user:" + user.getId());
    // 3. 延迟一段时间,再次删除
    ThreadPool.schedule(() -> {
        cache.del("user:" + user.getId());
    }, 500, TimeUnit.MILLISECONDS);
}

延迟时间设置:大于业务读取 DB + 写入缓存的耗时。

❌方案 B:先删除缓存,再更新数据库(不推荐)

四、Cache Aside 优缺点

✅ 优点

  1. 适合读多写少场景,最通用
  2. 缓存失效策略灵活,可以 TTL 过期、主动删除

❌ 缺点

  1. 存在短暂的数据不一致(最终一致性,不是强一致)
  2. 缓存失效瞬间,大量请求同时查 DB → 缓存击穿(需要互斥锁 / 热点永不过期处理)

方案 1:互斥锁

核心思路

缓存失效时,只放行一个请求去查 DB + 回写缓存;

其他请求等待,等缓存建好之后,直接读缓存,避免大量请求同时访问 DB。

伪代码(业务逻辑)

java 复制代码
// 查询流程
public Object getHotData(String key) {
    Object cacheVal = cache.get(key);
    if (cacheVal != null) {
        return cacheVal;
    }
    // 缓存为空,尝试加锁
    String lockKey = "lock:" + key;
    boolean locked = redis.tryLock(lockKey, 30, TimeUnit.SECONDS);
    //tryLock( 锁key, 锁持有过期时间, 时间单位 )
    if (locked) {
        try {
            // ✅ 抢到锁,查询DB
            Object dbVal = db.select(key);
            // 回写缓存
            cache.set(key, dbVal, 60, TimeUnit.SECONDS);
            return dbVal;
        } finally {
            // ✅ 内部自带lua校验:只有本线程持有锁才释放
            redis.unLock(lockKey);
        }
    } else {
        // ❌ 没抢到锁:短暂休眠后重试读取缓存
        Thread.sleep(50);
        return getHotData(key);
    }
}

关键点

  1. 锁粒度:按缓存 key 加锁,不是全局锁。不同 key 互不影响。
  2. 锁必须设置过期时间:防止拿到锁的服务宕机,锁永远不释放(死锁)。
  3. 重试策略:不要无限循环,设置最大重试次数,避免线程卡死。
  4. 推荐:用 Redis 分布式锁(Redisson),不要自己手写简单 SETNX,容易有释放锁的 bug。

✅优点

  • 实时性好,数据更新后,缓存过期能读到最新 DB 数据
  • 不需要提前预判热点 key,通用方案

❌缺点

  • 会有等待,并发高时请求排队,接口延迟上升
  • 锁本身有开销;如果锁设计不当会有死锁、锁失效风险
  • 大量请求等待重试,极端情况容易堆积请求
    方案 2:热点永不过期(逻辑过期,不设置 Redis 的 TTL)

重点:物理上缓存 key 不删除、不设过期时间;在 value 里面自己存一个过期时间,叫逻辑过期。

核心思路

热点 keyRedis 不自动过期。缓存永远存在。 缓存 value 结构:

java 复制代码
{
  "data": "业务数据",
  "expireTime": 1798888888000  // 逻辑过期时间戳
}

流程:

  1. 请求先读缓存拿到对象
  2. 判断当前时间 > expireTime(逻辑过期)
    • 没过期:直接返回缓存数据
    • 逻辑过期:返回旧缓存数据,同时单独开一个异步线程去更新 DB + 更新缓存里的 data 和逻辑过期时间

⚠️ 重点:不会阻塞用户请求,用户拿到的是旧数据,异步后台更新

伪代码示意

java 复制代码
//约定这个方法只给热点 key 调用
public Object getHotKey(String key) {
    CacheItem item = cache.get(key);
    if (item == null) {
        // 缓存不存在场景,才走查DB写缓存(项目预热阶段一次性加载热点)
        return loadDbAndSetCache(key);
    }
    // 判断逻辑是否过期
    if (System.currentTimeMillis() > item.expireTime) {
        // 尝试获取更新锁,只有一个异步线程去更新
        if (redis.tryLock("updateLock:"+key)) {
            // 异步线程执行更新,不阻塞当前请求
            threadPool.submit(() -> {
                Object newData = db.select(key);
                CacheItem newItem = new CacheItem(newData, System.currentTimeMillis()+30*1000);
                cache.set(key, newItem);
                redis.unLock("updateLock:"+key);
            });
        }
    }
    // 无论是否逻辑过期,都立刻返回当前缓存数据(旧数据也返回)
    return item.data;
}

关键点

  1. 热点 key 要提前预热,项目启动或者定时任务预先加载进 Redis,保证缓存永远不为空
  2. 逻辑过期更新是异步,用户请求不等待,不会击穿 DB
  3. 数据是短暂不一致的(读到旧数据,后台慢慢更新),适合允许短暂旧数据的业务
  4. 同样加分布式锁:保证同一时刻最多一个异步线程去更新缓存,防止多线程并发更新缓存

✅优点

  • 完全杜绝缓存击穿,缓存 key 永远存在,不会大量请求打 DB
  • 用户接口响应快,没有等待、无排队延迟

❌缺点

  • 数据会读到旧值,牺牲强一致性,只能最终一致
  • 只能针对提前识别出来的热点 key,不能通用所有 key
  • 需要预热;如果 DB 数据被删除,缓存里的旧数据需要额外处理清理

五、场景问题

假设说是一个涉及到查询库存和库存扣减的业务场景,通常会怎样设计?

库存扣减 + 查询场景设计(结合 Cache Aside)

库存业务特点:读多写少,但写操作强敏感;不能超卖,库存数据一致性要求远高于普通用户信息。

普通 Cache Aside(先更新 DB 再删缓存 + TTL)可以用,但不能直接裸奔,因为一旦出现脏库存,会直接超卖,造成资损。

先明确:库存分两类,设计思路不一样

  1. 实时可售库存(扣减、下单核心):强敏感,不能随便出现脏数据;
  2. 展示库存(商品详情页展示,仅供参考):可以容忍短暂不一致,普通 Cache Aside 就够用。

面试高频坑:商品页展示库存 ≠ 下单扣减的真实库存,两者要分开!


方案整体思路

✅ 商品详情页展示库存:Cache Aside(Redis 缓存,允许短暂不一致)

✅ 下单扣减真实库存:不读缓存做扣减,库存扣减以数据库为准;缓存只做查询展示

核心原则:

扣减动作不走缓存,缓存只用来抗查询流量,库存扣减的判断必须落到数据库(或者分布式锁 + Redis 预扣,两种路线)

路线一:数据库扣减(中小流量,简单稳妥,推荐优先)

1)查询展示库存(Cache Aside)

读流程:

  1. 查 Redis 展示库存缓存
  2. 命中直接返回给前端商品页
  3. miss → 查询 DB 库存 → 写入 Redis,返回

写流程(库存变更:下单、取消订单、补货):

  1. 数据库执行库存扣减(SQL 乐观锁!)
sql 复制代码
-- 乐观锁扣减,防止超卖
UPDATE stock 
SET available = available - #{num}, version = version + 1
WHERE id = #{stockId} AND available >= #{num} AND version = #{oldVersion};

看 update 影响行数,大于 0 代表扣减成功;0 = 库存不足 / 版本不对,扣减失败。

  1. DB 更新成功后,删除 Redis 里的展示库存缓存(Cache Aside:先更 DB,再删缓存)
  2. 缓存自带 TTL 兜底(比如 10s),就算极端时序脏数据,最多 10s 过期

这里的脏缓存,只会影响前端页面展示,不会导致超卖!因为下单扣减判断不走这个缓存。 就算页面显示还有 100 件,真实 DB 已经卖完,用户点下单,DB 乐观锁直接扣减失败,不会超卖。

时序风险回顾(放到库存场景理解)

极端时序:

  1. 请求 A:缓存失效 → 查询 DB 拿到旧库存(100),正在回写缓存
  2. 请求 B:下单,DB 扣减成功变成 99,删除缓存
  3. 请求 A:把旧值 100 写回 Redis 缓存

👉 结果:前端页面看到库存 100,但真实 DB 是 99。

影响:页面展示不准,不会超卖!

所以这个场景,不需要分布式锁去解决这个 Cache Aside 的脏数据问题。TTL 兜底足够。

这就是为什么前面说:普通 Cache Aside 脏数据问题,库存展示场景不用锁。

路线二:Redis 预扣库存(高并发秒杀大流量场景)

DB 扛不住大量下单请求,把库存放到 Redis 做预扣,DB 做最终落地。 这个就不再是简单的 Cache Aside,Redis 不再只是旁路缓存,变成库存计数器。

流程:

  1. 系统预热:把 DB 库存加载到 Redis(库存 key)
  2. 用户下单:
    • Redis lua 脚本原子预扣库存;预扣成功,生成订单,进入异步队列
    • 异步消费队列,在数据库里完成真实库存扣减 + 订单落库
  3. 取消订单 / 超时未支付:lua 脚本回补 Redis 库存
  4. 定时任务:Redis 库存和 DB 库存对账,修复差异

⚠️ 风险点: Redis 预扣 + 异步落库会有数据不一致风险(Redis 扣成功,DB 宕机没扣成功),需要对账补偿机制。

这种场景下,Redis 是业务计数器,不是单纯旁路缓存,已经脱离标准 Cache Aside。
扩展:Binlog 订阅异步删缓存(大厂常用,推荐)

业务代码:DB 库存扣减成功 → 主动删除缓存。

同时用 Canal 监听 stock 表 binlog:只要库存字段发生变更,消费端再次删除缓存 key。

  • 业务代码删缓存失败(网络异常):binlog 兜底删除;
  • 极端时序产生脏缓存:一旦 DB 库存发生变更,binlog 会再次触发删除;

优势:不阻塞读写,无锁,不影响接口性能;

本质:双重删除兜底,极大降低脏缓存存在的概率。

缺点:引入 Canal 组件,架构变重,需要维护 binlog 消费、重试、故障。

相关推荐
Web3&Basketball1 小时前
用FastAPI+Redis 复刻常驻Agent
redis·bootstrap·fastapi
code_slave(码畜)2 小时前
微服务架构落地:基础服务 —— 报表服务(中篇:元数据、查询引擎、缓存与权限落地)
spring boot·spring cloud·缓存·微服务·架构
ly76896 小时前
Redis 主从复制全解析:从 PSYNC 协议到复制积压缓冲区的故障切换边界
java·数据库·redis·主从复制·故障切换·psync
hweiyu006 小时前
Redis命令:HPEXPIREAT
redis·缓存
code2cat6 小时前
【随笔】MCP缓存期限与共享范围:让Agent复用资料时记住边界
java·后端·缓存·ai agent·mcp
吴声子夜歌9 小时前
Nginx应用与运维——Nginx缓存服务应用实战(一)
运维·nginx·缓存
ShineWinsu9 小时前
对于Redis:ZSet类型的解析
数据库·c++·redis·缓存·面试·有序集合·zset
新鲜势力呀10 小时前
PHP 定时任务系统实战:从 Cron 混乱执行到任务调度中心 + Redis队列 + 失败重试
android·java·redis
hweiyu0010 小时前
Redis命令:HPTTL
redis·缓存