最经典、最常用的缓存设计模式业务代码自己维护缓存缓存组件不感知数据库缓存和 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可以用互斥锁和这个脏数据场景是两个问题不要混淆。延迟双删伪代码// 更新 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。伪代码业务逻辑// 查询流程 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 结构{ data: 业务数据, expireTime: 1798888888000 // 逻辑过期时间戳 }流程请求先读缓存拿到对象判断当前时间 expireTime逻辑过期没过期直接返回缓存数据逻辑过期返回旧缓存数据同时单独开一个异步线程去更新 DB 更新缓存里的 data 和逻辑过期时间⚠️ 重点不会阻塞用户请求用户拿到的是旧数据异步后台更新伪代码示意//约定这个方法只给热点 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 AsideRedis 缓存允许短暂不一致✅ 下单扣减真实库存不读缓存做扣减库存扣减以数据库为准缓存只做查询展示核心原则扣减动作不走缓存缓存只用来抗查询流量库存扣减的判断必须落到数据库或者分布式锁 Redis 预扣两种路线路线一数据库扣减中小流量简单稳妥推荐优先1查询展示库存Cache Aside读流程查 Redis 展示库存缓存命中直接返回给前端商品页miss → 查询 DB 库存 → 写入 Redis返回写流程库存变更下单、取消订单、补货数据库执行库存扣减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 AsideRedis 不再只是旁路缓存变成库存计数器。流程系统预热把 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 消费、重试、故障。
阅读完成 · 觉得有帮助?