【架构实战】缓存一致性:Cache Aside与双写问题的破解

【架构实战】缓存一致性:Cache Aside与双写问题的破解 【架构实战】缓存一致性Cache Aside与双写问题的破解一、背景缓存里的商品价格是错的2021年双11运营急call“前台显示99元用户下单页面显示129元用户截图投诉了”查了半天商品价格从129元改成了99元数据库更新成功但Redis里的缓存还是129元。缓存没失效用户看到的是旧价格直到10分钟后缓存过期才恢复正常。更糟的是那10分钟里有200多个用户以错误的价格看到了商品半数直接走了。这不是Bug是缓存一致性的经典问题数据库更新了缓存没更新。团队复盘时发现大家都听过先更新数据库再删缓存但很少有人真正理解为什么要这么做以及这么做就万无一失了吗二、缓存一致性的核心矛盾2.1 问题的本质┌──────────┐ 读取 │ Redis │ 读取 (快但可能不是最新) 用户 ──┤ 缓存 ├── 应用 ── MySQL (慢但是最新) 写入 │ │ 写入 └──────────┘核心矛盾数据库和缓存是两个独立的数据源更新操作无法原子性地同时更新两个存储。2.2 不一致的场景所有不一致都源于同一类并发时序问题时间线 线程A读 线程B写 T1 更新数据库 price99 T2 读缓存 → 命中 → 返回129 T3 删除缓存 → 用户看到了129旧数据而数据库已经是99 或者反过来 T1 缓存过期读数据库129 T2 更新数据库 price99 T3 删除缓存但此时缓存已经过期了 T4 把129写入缓存 ← 脏数据 → 缓存里是129数据库是99三、缓存更新策略对比3.1 四种策略策略操作顺序优点缺点一致性Cache Aside读: 先读缓存miss再读DB并回填写: 先更新DB再删缓存实现简单需要处理并发最终一致Read/Write Through缓存层代理读写业务无感知业务代码干净缓存层复杂强一致(同事务)Write Behind先写缓存异步写DB写入快数据可能丢失弱一致双写同时更新DB和缓存实时性好并发时易出错最终一致3.2 Cache Aside旁路缓存——最推荐的方案读流程读请求 → 查缓存 ├── 命中 → 直接返回 └── 未命中 → 查数据库 → 写入缓存 → 返回写流程写请求 → 更新数据库 → 删除缓存不是更新缓存为什么是删除缓存而不是更新缓存更新缓存的陷阱 线程A: 更新数据库 price99 线程B: 更新数据库 price129 线程B: 更新缓存 price129 ← B的缓存更新先到达 线程A: 更新缓存 price99 ← A的缓存更新后到达 结果数据库里是129缓存里却是99 → 价格显示错误 删除缓存不会有这个问题 线程A: 更新数据库 price99 线程B: 更新数据库 price129 线程A: 删除缓存 ✓ 线程B: 删除缓存 ✓ 结果缓存被删除下次读取时从DB加载最新数据3.3 核心实现ServicepublicclassProductService{AutowiredprivateProductMapperproductMapper;AutowiredprivateStringRedisTemplateredisTemplate;privatestaticfinalStringCACHE_PREFIXproduct:;privatestaticfinallongCACHE_TTL3600;// 1小时// 读Cache Aside模式publicProductgetProduct(LongproductId){StringcacheKeyCACHE_PREFIXproductId;// 1. 查缓存StringjsonredisTemplate.opsForValue().get(cacheKey);if(json!null){returnJSON.parseObject(json,Product.class);}// 2. 缓存miss查数据库ProductproductproductMapper.findById(productId);if(productnull){// 缓存空值防止穿透后面文章会讲redisTemplate.opsForValue().set(cacheKey,NULL,60,TimeUnit.SECONDS);returnnull;}// 3. 写入缓存redisTemplate.opsForValue().set(cacheKey,JSON.toJSONString(product),CACHE_TTL,TimeUnit.SECONDS);returnproduct;}// 写先更新DB再删除缓存TransactionalpublicvoidupdateProduct(Productproduct){// 1. 先更新数据库productMapper.update(product);// 2. 再删除缓存在事务提交后执行StringcacheKeyCACHE_PREFIXproduct.getId();TransactionSynchronizationManager.registerSynchronization(newTransactionSynchronization(){OverridepublicvoidafterCommit(){redisTemplate.delete(cacheKey);// 延迟双删可选进一步降低不一致概率// delayDelete(cacheKey, 500); // 500ms后再次删除}});}}四、Cache Aside 的并发问题4.1 极端情况下的不一致即使使用了先更新DB再删缓存极端并发下仍可能不一致时间线 线程A读 线程B写 T1 缓存未命中准备查数据库 T2 更新数据库 price129 T3 删除缓存这是空操作因为T1时缓存已不存在 T4 从数据库读出 price129 T5 写入缓存 price129 T6 更新数据库 price99 T7 删除缓存 ← 这一步删的是T5写入的129 T8 缓存被删除下次读会加载price99 ✓ 最终一致这个场景下最终还是一致的。真正有问题的是时间线 线程A读 线程B写 T1 缓存未命中准备查数据库 T2 更新数据库 price129 T3 删除缓存 T4 从数据库读出 price129 T5 写入缓存 price129 T6 用户看到了price129B的更新已生效 → 这个场景下数据是一致的不一致发生的概率极低。4.2 延迟双删Double Delete为了进一步降低不一致概率可以采用延迟双删TransactionalpublicvoidupdateProduct(Productproduct){// 1. 第一次删缓存redisTemplate.delete(CACHE_PREFIXproduct.getId());// 2. 更新数据库productMapper.update(product);// 3. 事务提交后延迟第二次删缓存TransactionSynchronizationManager.registerSynchronization(newTransactionSynchronization(){OverridepublicvoidafterCommit(){// 延迟500ms等待其他线程的读操作完成CompletableFuture.runAsync(()-{try{Thread.sleep(500);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}redisTemplate.delete(CACHE_PREFIXproduct.getId());});}});}原理第二次删除可以清理读线程在写操作期间写入缓存的旧数据。适用场景对一致性的容忍度极低的业务如价格、库存。五、读写穿透策略5.1 订阅Binlog异步更新缓存这是目前大厂最主流的方案监听MySQL的Binlog异步更新缓存。架构 MySQL → Canal监听Binlog → Kafka → 缓存更新服务 → Redis ┌─────────┐ ┌─────────┐ ┌────────┐ ┌─────────┐ │ MySQL │────│ Canal │────│ Kafka │────│ 缓存更新 │ │ (Binlog)│ │ (解析) │ │(消息) │ │ 服务 │ └─────────┘ └─────────┘ └────────┘ └────┬────┘ │ ▼ ┌─────────┐ │ Redis │ └─────────┘// Canal监听Binlog的简化实现ComponentpublicclassBinlogCacheSync{AutowiredprivateStringRedisTemplateredisTemplate;/** * 处理Binlog事件 */publicvoidhandleBinlogEvent(CanalEntryentry){// 解析Binlog获取变更的表和数据Stringtableentry.getHeader().getTableName();StringeventTypeentry.getHeader().getEventType().name();if(product.equals(table)){for(CanalEntry.RowDatarowData:entry.getRowChange().getRowDatasList()){if(UPDATE.equals(eventType)||DELETE.equals(eventType)){// 解析变更后的数据LongproductIdparseProductId(rowData);if(DELETE.equals(eventType)){// 删除缓存redisTemplate.delete(product:productId);}else{// 更新缓存如果有的话或者直接删除redisTemplate.delete(product:productId);// 或者从DB重新加载后更新缓存}}}}}}优点完全解耦业务代码不需要关心缓存只管写数据库一致性高只要Binlog不丢缓存最终一定一致可观测Kafka消息可追踪方便排查缺点引入Canal Kafka架构复杂度增加有延迟通常100ms-500ms不适用强一致场景5.2 缓存一致性最佳实践总结一致性保证的层次 Level 1: 基础方案 写更新DB → 删除缓存 读查缓存 → miss → 查DB → 回填缓存 Level 2: 增强方案 写删缓存 → 更新DB → 延迟双删 读同上 Level 3: 企业方案 写只更新DB → Canal监听Binlog → 异步更新/删缓存 读同上 Level 4: 终极方案 Level 3 定时对账 不一致自动修复六、常见坑点坑1在事务中操作缓存// ❌ 错误在事务中操作缓存TransactionalpublicvoidupdateProduct(Productproduct){productMapper.update(product);// DB操作redisTemplate.delete(cacheKey);// 缓存操作}// 问题如果DB事务回滚了缓存已经删了导致缓存中数据丢失// ✅ 正确缓存操作在事务提交后TransactionalpublicvoidupdateProduct(Productproduct){productMapper.update(product);TransactionSynchronizationManager.registerSynchronization(newTransactionSynchronization(){OverridepublicvoidafterCommit(){redisTemplate.delete(cacheKey);// 只在DB提交成功后删缓存}});}坑2更新缓存而不是删除缓存前面已经详细解释了永远优先选择删缓存而不是更新缓存。更新缓存的并发问题无解。坑3高并发下缓存频繁失效热点数据频繁更新刚删除缓存就有大量请求涌入全部打到DB。解决热点数据不强求实时一致用本地缓存短TTL1-3秒做缓冲。七、总结缓存一致性的核心原则Cache Aside是基础先更新DB再删除缓存——最简单且有效的方案删除优于更新永远不要并发更新缓存只删除事务提交后才操作缓存避免DB回滚但缓存已变更延迟双删做兜底对一致性要求高的场景加延迟双删Binlog异步是最佳实践业务代码零侵入一致性有保障做不到强一致就接受最终一致缓存一致性没有完美的实时方案。如果在业务上可以接受几百毫秒的不一致那Cache Aside足够了。如果不能接受那就别用缓存直接读数据库。缓存一致性的终极答案不是技术是业务妥协。接受最终一致性用对账系统兜底比追求完美的一致性要靠谱得多。个人观点仅供参考