高并发缓存一致性实战:Cache Aside、Write Through、Read Through 选型与落地
一、为什么高并发下缓存一致性这么难
- 核心矛盾在于:数据库与缓存的两次写不是原子操作,而请求执行顺序在并发场景下不可控,导致短暂甚至较长时间的数据不一致。典型时序问题包括:先删缓存后写库,期间读请求把旧值回填;先写库后删缓存,期间读请求命中旧缓存;多写并发时缓存被旧值覆盖等。即便采用“先更新数据库,再删除缓存”,在极小窗口内仍可能读到旧值,只能追求最终一致性。在读写分离架构下,主从同步时延还会放大不一致窗口。为降低风险,工程上通常配合TTL 过期兜底、延迟双删、异步失效等手段共同使用。
二、三大缓存访问模式对比与选型
| 模式 | 读流程 | 写流程 | 一致性特征 | 适用场景 | 主要代价 |
|---|---|---|---|---|---|
| Cache Aside(旁路缓存) | 命中则返回;未命中读库并回填缓存 | 建议“先更新数据库,再删除缓存” | 通常最终一致;并发读写存在短暂旧值窗口 | 读多写少、计算/聚合类缓存 | 需处理删除失败、并发回填、主从时延 |
| Read Through(读穿透) | 命中则返回;未命中由缓存层自动读库并回填 | 应用视缓存为唯一数据源,由缓存层同步写库 | 读路径简化;一致性取决于写路径实现 | 希望读逻辑透明、统一缓存策略 | 首次访问延迟;可能缓存“一次性”数据 |
| Write Through(写穿透) | 命中则返回;未命中同 Cache Aside | 应用写缓存,缓存同步写库后返回 | 写路径强一致;读路径同 Cache Aside | 写后读频繁、对一致性要求高 | 写延迟增加;缓存写放大、可能“污染” |
| 说明:Read/Write Through 常由缓存组件/代理封装实现,对应用透明;Write Behind(写回,异步批量写库)虽能提升吞吐,但一致性风险更高,不在本文三种模式之列。 |
三、高并发下的工程化落地清单
- 写策略与顺序
- 优先采用:先更新数据库,再删除缓存;为兜底设置合理 TTL。在极端并发或“删后回填”风险高的场景,增加延迟双删(如写库后延迟数百毫秒再删一次),降低脏数据驻留时间。
- 删除失败与补偿
- 写库成功后若删除缓存失败,采用异步重试(本地队列或消息队列)确保最终删除;或引入事件驱动(如订阅MySQL binlog,通过Canal/Kafka广播失效),将缓存失效与业务解耦。
- 读写分离与强一致读
- 缓存未命中且业务要求强一致时,读请求可强制走主库回填,避免从库同步时延导致回填旧值;代价是主库压力上升,需结合熔断/降级策略。
- 热点与并发控制
- 对热点 Key 的缓存重建加分布式锁/互斥锁,避免“缓存击穿”;并发重建时只允许一个线程回种,其他线程等待重试,显著降低数据库瞬时压力。
- 多级缓存一致性
- 本地缓存(如Caffeine)+ 分布式缓存(如Redis)组合时,失效需广播到所有实例(如Redis Pub/Sub),并给本地缓存设置较短 TTL与错峰过期,避免“多级不一致”和“失效风暴”。
四、模式落地代码示例
- Cache Aside(Spring Boot + Redis,含 TTL 与互斥锁)
@ServicepublicclassProductService{@AutowiredprivateProductRepositoryrepo;@AutowiredprivateStringRedisTemplateredis;privatestaticfinalDurationTTL=Duration.ofMinutes(10);privatestaticfinalDurationLOCK_TTL=Duration.ofSeconds(3);publicProductDTOget(Longid){Stringkey="product:"+id;// 1) 命中直接返回ProductDTOv=(ProductDTO)redis.opsForValue().get(key);if(v!=null)returnv;// 2) 未命中:互斥重建StringlockKey="lock:"+key;Booleanlocked=redis.opsForValue().setIfAbsent(lockKey,"1",LOCK_TTL);if(Boolean.TRUE.equals(locked)){try{v=repo.findById(id);if(v!=null)redis.opsForValue().set(key,v,TTL);returnv;}finally{redis.delete(lockKey);}}else{// 未抢到锁,短暂等待后重试一次Thread.sleep(50);returnget(id);}}publicvoidupdate(ProductDTOdto){// 1) 先更新数据库repo.save(dto);// 2) 再删除缓存(失败可入MQ/定时补偿重试)redis.delete("product:"+dto.getId());}}- Read Through(Spring Cache 抽象,缓存层自动读库回填)
@Configuration@EnableCachingpublicclassCacheCfg{@BeanpublicRedisCacheManagercacheManager(RedisConnectionFactorycf){RedisCacheConfigurationcfg=RedisCacheConfiguration.defaultCacheConfig().serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(newStringRedisSerializer())).serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(newGenericJackson2JsonRedisSerializer())).entryTtl(Duration.ofMinutes(10)).disableCachingNullValues();returnRedisCacheManager.builder(cf).cacheDefaults(cfg).build();}}@ServicepublicclassProductReadThroughService{@Cacheable(value="products",key="#id",sync=true)publicProductDTOget(Longid){// 缓存未命中时,由 Spring Cache 调用此方法读库并回填returnrepo.findById(id);}}🔥 关注公众号【云技纵横】,开始更新redis缓存进阶,包含手写缓存注解,缓存雪崩等内容哟!
提示:Write Through 通常依赖支持写穿透的缓存组件/代理(如部分企业级缓存网关),应用将写请求发给缓存,由缓存层负责同步写库,代码形态与 Read Through 类似,但写路径会“穿透”到数据库。