最经典、最常用的缓存设计模式,业务代码自己维护缓存,缓存组件不感知数据库,缓存和 DB 相互独立,所以叫旁路。
适用:Redis + MySQL 这类组合,几乎所有业务系统都在用。
旁路 = 缓存不在数据库读写的主链路里面,只是一条 "旁边的辅助支路"。
请求 → 业务代码(在主路上) 业务自己做判断:
- 查缓存,命中直接返回;
- 缓存没命中,业务自己去查数据库 ,拿到数据之后,业务再额外往旁边的缓存里写一份。
数据库的读写主干流程本身不依赖缓存,缓存只是挂在主干旁边的一条支路。
数据库不知道缓存存在,缓存也不知道数据库。
缓存是旁路的附属,业务代码主动去旁路操作缓存。
一、核心思想
缓存不主动和数据库联动,读写逻辑全部由应用程序代码控制。 数据库是权威数据源,缓存只是副本。
二、读流程(查询数据)
- 先查 Cache
- 命中 → 返回
- 未命中 → 查询 DB,拿到数据,业务手动写 Cache,返回
三、写流程(更新数据,重点!两种方案对比)
Cache Aside 的坑基本都在更新策略。
✅ 方案 A:先更新数据库,再删除缓存(业界推荐)
- 更新数据库
- 删除缓存(不是更新缓存!)
为什么是删除缓存而不是更新缓存?
如果更新缓存:高并发下多次写,会出现缓存被旧值覆盖;而且很多数据不一定会被读取,更新缓存是浪费。
删除缓存:下次读的时候自动加载最新数据,更合理。
可能存在的极小概率并发问题:
线程 A(读):缓存失效 → 查 DB(拿到旧数据)
线程 B(写):更新 DB → 删除缓存
线程 A(读):把旧数据写入缓存
结果:缓存是旧数据,DB 是新数据,数据不一致。
✅ 怎么解决:
- 给缓存设置过期时间(兜底,就算不一致,到期自动刷新)
- 或者使用延迟双删:更新 DB → 删除缓存 → 等待一小段时间(几百 ms)→ 再次删除缓存
- 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 → 删除缓存 → 释放锁
锁带来的问题(很关键,面试常问)
- 性能断崖下跌:同一个 key 串行化,并发读写变成排队,热点 key 直接瓶颈。
- 死锁风险:锁超时、业务异常、节点宕机,需要锁自动过期,处理复杂。
- 锁本身也有失败概率:分布式锁不是绝对可靠(网络抖动、主从切换丢锁)。
- 收益很低:为了堵一个极低概率的时序 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 优缺点
✅ 优点
- 适合读多写少场景,最通用
- 缓存失效策略灵活,可以 TTL 过期、主动删除
❌ 缺点
- 存在短暂的数据不一致(最终一致性,不是强一致)
- 缓存失效瞬间,大量请求同时查 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); } }关键点
- 锁粒度:按缓存 key 加锁,不是全局锁。不同 key 互不影响。
- 锁必须设置过期时间:防止拿到锁的服务宕机,锁永远不释放(死锁)。
- 重试策略:不要无限循环,设置最大重试次数,避免线程卡死。
- 推荐:用 Redis 分布式锁(Redisson),不要自己手写简单 SETNX,容易有释放锁的 bug。
✅优点
- 实时性好,数据更新后,缓存过期能读到最新 DB 数据
- 不需要提前预判热点 key,通用方案
❌缺点
- 会有等待,并发高时请求排队,接口延迟上升
- 锁本身有开销;如果锁设计不当会有死锁、锁失效风险
- 大量请求等待重试,极端情况容易堆积请求
方案 2:热点永不过期(逻辑过期,不设置 Redis 的 TTL)重点:物理上缓存 key 不删除、不设过期时间;在 value 里面自己存一个过期时间,叫逻辑过期。
核心思路
热点 keyRedis 不自动过期。缓存永远存在。 缓存 value 结构:
java{ "data": "业务数据", "expireTime": 1798888888000 // 逻辑过期时间戳 }流程:
- 请求先读缓存拿到对象
- 判断当前时间 > 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; }关键点
- 热点 key 要提前预热,项目启动或者定时任务预先加载进 Redis,保证缓存永远不为空
- 逻辑过期更新是异步,用户请求不等待,不会击穿 DB
- 数据是短暂不一致的(读到旧数据,后台慢慢更新),适合允许短暂旧数据的业务
- 同样加分布式锁:保证同一时刻最多一个异步线程去更新缓存,防止多线程并发更新缓存
✅优点
- 完全杜绝缓存击穿,缓存 key 永远存在,不会大量请求打 DB
- 用户接口响应快,没有等待、无排队延迟
❌缺点
- 数据会读到旧值,牺牲强一致性,只能最终一致
- 只能针对提前识别出来的热点 key,不能通用所有 key
- 需要预热;如果 DB 数据被删除,缓存里的旧数据需要额外处理清理
五、场景问题
假设说是一个涉及到查询库存和库存扣减的业务场景,通常会怎样设计?
库存扣减 + 查询场景设计(结合 Cache Aside)
库存业务特点:读多写少,但写操作强敏感;不能超卖,库存数据一致性要求远高于普通用户信息。
普通 Cache Aside(先更新 DB 再删缓存 + TTL)可以用,但不能直接裸奔,因为一旦出现脏库存,会直接超卖,造成资损。
先明确:库存分两类,设计思路不一样
- 实时可售库存(扣减、下单核心):强敏感,不能随便出现脏数据;
- 展示库存(商品详情页展示,仅供参考):可以容忍短暂不一致,普通 Cache Aside 就够用。
面试高频坑:商品页展示库存 ≠ 下单扣减的真实库存,两者要分开!
方案整体思路
✅ 商品详情页展示库存:Cache Aside(Redis 缓存,允许短暂不一致)
✅ 下单扣减真实库存:不读缓存做扣减,库存扣减以数据库为准;缓存只做查询展示
核心原则:
扣减动作不走缓存,缓存只用来抗查询流量,库存扣减的判断必须落到数据库(或者分布式锁 + Redis 预扣,两种路线)
路线一:数据库扣减(中小流量,简单稳妥,推荐优先)
1)查询展示库存(Cache Aside)
读流程:
- 查 Redis 展示库存缓存
- 命中直接返回给前端商品页
- miss → 查询 DB 库存 → 写入 Redis,返回
写流程(库存变更:下单、取消订单、补货):
- 数据库执行库存扣减(SQL 乐观锁!)
sql
-- 乐观锁扣减,防止超卖
UPDATE stock
SET available = available - #{num}, version = version + 1
WHERE id = #{stockId} AND available >= #{num} AND version = #{oldVersion};
看 update 影响行数,大于 0 代表扣减成功;0 = 库存不足 / 版本不对,扣减失败。
- DB 更新成功后,删除 Redis 里的展示库存缓存(Cache Aside:先更 DB,再删缓存)
- 缓存自带 TTL 兜底(比如 10s),就算极端时序脏数据,最多 10s 过期
这里的脏缓存,只会影响前端页面展示,不会导致超卖!因为下单扣减判断不走这个缓存。 就算页面显示还有 100 件,真实 DB 已经卖完,用户点下单,DB 乐观锁直接扣减失败,不会超卖。
时序风险回顾(放到库存场景理解)
极端时序:
- 请求 A:缓存失效 → 查询 DB 拿到旧库存(100),正在回写缓存
- 请求 B:下单,DB 扣减成功变成 99,删除缓存
- 请求 A:把旧值 100 写回 Redis 缓存
👉 结果:前端页面看到库存 100,但真实 DB 是 99。
影响:页面展示不准,不会超卖!
所以这个场景,不需要分布式锁去解决这个 Cache Aside 的脏数据问题。TTL 兜底足够。
这就是为什么前面说:普通 Cache Aside 脏数据问题,库存展示场景不用锁。
路线二:Redis 预扣库存(高并发秒杀大流量场景)
DB 扛不住大量下单请求,把库存放到 Redis 做预扣,DB 做最终落地。 这个就不再是简单的 Cache Aside,Redis 不再只是旁路缓存,变成库存计数器。
流程:
- 系统预热:把 DB 库存加载到 Redis(库存 key)
- 用户下单:
- Redis lua 脚本原子预扣库存;预扣成功,生成订单,进入异步队列
- 异步消费队列,在数据库里完成真实库存扣减 + 订单落库
- 取消订单 / 超时未支付:lua 脚本回补 Redis 库存
- 定时任务:Redis 库存和 DB 库存对账,修复差异
⚠️ 风险点: Redis 预扣 + 异步落库会有数据不一致风险(Redis 扣成功,DB 宕机没扣成功),需要对账补偿机制。
这种场景下,Redis 是业务计数器,不是单纯旁路缓存,已经脱离标准 Cache Aside。
扩展:Binlog 订阅异步删缓存(大厂常用,推荐)业务代码:DB 库存扣减成功 → 主动删除缓存。
同时用 Canal 监听 stock 表 binlog:只要库存字段发生变更,消费端再次删除缓存 key。
- 业务代码删缓存失败(网络异常):binlog 兜底删除;
- 极端时序产生脏缓存:一旦 DB 库存发生变更,binlog 会再次触发删除;
优势:不阻塞读写,无锁,不影响接口性能;
本质:双重删除兜底,极大降低脏缓存存在的概率。
缺点:引入 Canal 组件,架构变重,需要维护 binlog 消费、重试、故障。