资讯详情

项目缓存实战:热点 Key 击穿、缓存穿透与多级缓存架构

📅 2026/10/15 3:06:36 | 华诺云谱 👁 阅读
项目缓存实战:热点 Key 击穿、缓存穿透与多级缓存架构
1. 引言文章目录1. 引言2. 缓存优化2.1 缓存击穿逻辑过期方案2.2 缓存穿透缓存空值方案2.3 布隆过滤器缓存穿透的进阶防线2.3.1 布隆过滤器的原理2.3.2 为什么存在误判如何优化2.3.3 为什么无法删除数据如何解决3. 数据一致性3.1 更新数据库后删除缓存3.2 删除失败消息队列补偿重试3.3 TTL 兜底4. 多级缓存Caffeine Redis4.1 为什么需要多级缓存4.2 二级缓存架构4.3 代码实现4.4 多级缓存注意事项5. 总结在高并发场景下缓存是提升系统性能、降低数据库压力的核心手段。然而缓存的使用并非“一放了之”如果设计不当反而会引入缓存击穿、缓存穿透、数据不一致等一系列问题。本文结合生产实践围绕以下三个主题展开缓存优化使用逻辑过期方案防止 Redis 热点 Key 的缓存击穿问题使用缓存空值方案解决 Redis Key 的缓存穿透问题。数据一致性更新数据库后删除缓存若删除失败则通过消息队列补偿重试并以 TTL 兜底共同保证数据一致性。多级缓存使用 Caffeine 本地缓存和 Redis 缓存搭建二级缓存架构提高热点数据访问速度降低 Redis 压力。2. 缓存优化2.1 缓存击穿逻辑过期方案缓存击穿指的是某个热点 Key 在缓存过期的瞬间大量并发请求同时打到数据库导致数据库压力骤增甚至宕机。常见的解决方案有互斥锁和逻辑过期。这里重点介绍逻辑过期方案。逻辑过期的核心思想是不设置真正的 TTL而是将过期时间作为业务字段写入缓存值中。读取时判断逻辑时间是否过期若过期则尝试获取互斥锁由单个线程重建缓存其他线程先返回旧数据。publicclassRedisCacheService{privatestaticfinalStringLOCK_PREFIXlock:;/** * 写入缓存逻辑过期 */publicvoidsetWithLogicalExpire(Stringkey,Objectvalue,longexpireSeconds){// 封装逻辑过期时间RedisDataredisDatanewRedisData();redisData.setData(value);redisData.setExpireTime(System.currentTimeMillis()expireSeconds*1000);stringRedisTemplate.opsForValue().set(key,JSON.toJSONString(redisData));}/** * 读取缓存逻辑过期 互斥锁重建 */publicObjectgetWithLogicalExpire(Stringkey,Class?type,IDomainServiceservice){StringjsonstringRedisTemplate.opsForValue().get(key);if(StrUtil.isBlank(json)){returnnull;}RedisDataredisDataJSON.parseObject(json,RedisData.class);ObjectvalueredisData.getData();longexpireTimeredisData.getExpireTime();// 1. 未过期直接返回if(expireTimeSystem.currentTimeMillis()){returnvalue;}// 2. 已过期尝试获取互斥锁StringlockKeyLOCK_PREFIXkey;booleanlockedtryLock(lockKey);if(locked){try{// 重建缓存ObjectnewValueservice.queryFromDatabase();setWithLogicalExpire(key,newValue,30L);returnnewValue;}finally{unlock(lockKey);}}// 3. 未获取到锁返回旧数据returnvalue;}}优点在缓存重建期间其他线程仍能读到旧数据不会直接打到数据库也不会出现“缓存雪崩”式的空窗期。2.2 缓存穿透缓存空值方案缓存穿透指的是请求查询一个根本不存在的数据缓存和数据库中都没有导致每次请求都穿透到数据库。解决方案之一是缓存空值当数据库查询结果为空时仍然将该 Key 写入缓存但 Value 为空值并设置较短的 TTL如 2~5 分钟防止恶意请求反复穿透。publicObjectqueryWithCacheThrough(Stringkey,Class?type,IDomainServiceservice){// 1. 查询缓存StringjsonstringRedisTemplate.opsForValue().get(key);if(StrUtil.isNotBlank(json)){returnJSON.parseObject(json,type);}// 2. 缓存未命中查询数据库Objectvalueservice.queryFromDatabase();// 3. 数据库为空缓存空值if(valuenull){stringRedisTemplate.opsForValue().set(key,,5L,TimeUnit.MINUTES);returnnull;}// 4. 数据库有值写入缓存stringRedisTemplate.opsForValue().set(key,JSON.toJSONString(value),30L,TimeUnit.MINUTES);returnvalue;}注意缓存空值只能缓解穿透无法完全杜绝。对于极端恶意攻击还应配合布隆过滤器在缓存之前做一层拦截。2.3 布隆过滤器缓存穿透的进阶防线缓存空值只能缓解穿透对于极端恶意攻击更推荐在缓存之前加一层布隆过滤器Bloom Filter把大量不存在的 Key 直接拦截在缓存层之外。2.3.1 布隆过滤器的原理布隆过滤器是一种空间效率极高的概率型数据结构主要用于快速判断某个元素是否属于某个集合。它的底层由两个极简组件构成二进制位数组Bit Array一个长度为 m 的位数组初始化时所有位置均置为 0。k 个相互独立的哈希函数能将任意输入元素均匀地映射到位数组的不同下标位置。工作流程如下添加元素将元素依次输入 k 个哈希函数得到 k 个数组下标并将位数组中这些位置的 Bit 值从 0 置为 1。查询元素同样使用这 k 个哈希函数计算目标元素的 k 个位置。只要这 k 个位置中有任何一个为 0就100% 确定该元素不在集合中如果这 k 个位置全部为 1则判定该元素“可能存在”于集合中。publicclassBloomFilterDemo{privatefinalBitSetbitsnewBitSet(120);// 位数组privatefinalintk3;// 哈希函数个数// 模拟 k 个哈希函数privateinthash(Stringvalue,intseed){inthvalue.hashCode()^(seed*0x9e3779b9);returnMath.abs(h)%bits.size();}// 添加元素publicvoidadd(Stringvalue){for(inti0;ik;i){bits.set(hash(value,i));}}// 查询元素任一位置为 0 则一定不存在publicbooleanmightContain(Stringvalue){for(inti0;ik;i){if(!bits.get(hash(value,i))){returnfalse;}}returntrue;}}2.3.2 为什么存在误判如何优化误判原因假阳性误判的根源在于哈希碰撞。由于位数组长度有限不同元素经过哈希计算后可能映射到相同的 Bit 位置导致一个从未录入的元素其对应的 k 个位置碰巧都被其他元素置为 1从而被误判为存在。优化/解决方案增加位数组长度m增大空间是最直接有效的方法能显著降低哈希碰撞概率。优化哈希函数数量k哈希函数并非越多越好存在理论最优值k ≈ 0.693 × (m / n)n 为元素数量此时误判率最低。使用高质量哈希函数采用分布更均匀的哈希算法如 MurmurHash3、xxHash避免位聚集。业务层二次校验回源查询布隆过滤器仅保证“一定不存在”的确定性。当它返回“可能存在”时再回源查询数据库或精确数据结构进行最终确认。动态扩展Scalable Bloom Filter当元素数量接近设计容量时创建新的一层布隆过滤器进行扩容以维持设定的误判率。2.3.3 为什么无法删除数据如何解决无法删除原因布隆过滤器的底层是位映射结构位数组中的每一个 Bit 并不是独占的而是被多个元素共享。如果尝试删除某个元素需要将其对应的 k 个 Bit 位清零但这会不可避免地清除掉其他共享这些位置的元素的存在痕迹导致后续查询其他元素时出现“假阴性”把存在的元素误判为不存在。优化/解决方案计数布隆过滤器Counting Bloom Filter将原本的 1 个 Bit 扩展为计数器通常使用 4 bits。插入时对应位置计数加 1删除时计数减 1查询时检查计数器是否均大于 0。代价是内存消耗会增加 3~4 倍。布谷鸟过滤器Cuckoo Filter基于布谷鸟哈希算法使用“指纹存储”和“两个候选桶”机制。它不仅空间利用率更高而且天然支持高效的删除操作。定期重建在业务低谷期如每天凌晨基于最新的有效数据重新构建一个全新的布隆过滤器以清除已删除元素带来的影响。结合其他数据结构将布隆过滤器作为前置的“快速否定器”配合哈希表等精确数据结构使用。布隆过滤器负责过滤大量不存在的元素精确结构负责处理实际的删除和最终判定。3. 数据一致性3.1 更新数据库后删除缓存为了保证数据库与缓存的一致性业界常用Cache Aside旁路缓存模式先更新数据库再删除缓存。这样即使删除缓存失败也只会短暂出现脏数据而不会长期不一致。3.2 删除失败消息队列补偿重试如果删除缓存失败直接忽略会导致缓存长期为脏数据。因此引入消息队列补偿重试机制更新数据库成功后发送一条“删除缓存”的消息到 MQ消费者收到消息后尝试删除缓存若删除失败则进行重试可设置最大重试次数重试仍失败则记录日志并告警人工介入。publicvoidupdateData(Longid,ObjectnewData){// 1. 更新数据库databaseMapper.update(id,newData);// 2. 发送删除缓存消息补偿机制MQMessagemessagenewMQMessage();message.setKey(cache:user:id);mqProducer.send(cache.delete.topic,message);}为啥会删除失败失败类型典型原因结果网络/调用失败应用调用 Redis 时网络抖动、超时、连接池耗尽DEL没执行或响应丢失Redis 服务异常Redis OOM、主从切换、集群故障、持久化阻塞删除指令失败或未被正确传播应用侧异常更新 DB 后进程崩溃、Pod 重启、try-catch 吞掉异常删除代码根本没执行删除成功但数据仍残留主从切换后删除未同步并发读请求把旧值重新写回缓存看起来删了但缓存还是旧数据Key 算错/漏删同一数据有多个缓存 Key只删了主键缓存没删列表、聚合缓存部分缓存仍是旧值为啥先更新数据库数据库是事实源必须先落库缓存只是副本。先保证 MySQL 提交成功再让缓存失效下一次读请求会按统一读路径重新从数据库加载。比“先删缓存再更新 DB”安全得多如果先删缓存在 DB 还没更新完时读请求会命中空缓存从数据库读到旧值并回填。结果就是DB 已更新缓存却长期保留旧值直到 TTL 过期。删除比更新缓存更不容易出错更新缓存需要重新计算读模型并发写时容易出现“谁后写谁赢”的覆盖问题。删除缓存只是让旧副本失效幂等、简单、失败后重试也安全。删除失败更容易兜底先更新 DB 后删缓存如果删除失败缓存里是旧值但可以通过 MQ 重试、Canal 监听 Binlog、TTL 兜底恢复。3.3 TTL 兜底即便消息队列补偿也失败缓存 Key 仍带有 TTL到期后自动失效最终会重新从数据库加载从而保证最终一致性。因此TTL 是数据一致性的最后一道兜底防线。4. 多级缓存Caffeine Redis4.1 为什么需要多级缓存Redis 虽然快但每次访问仍有一次网络开销。对于超高并发的热点数据可以让请求优先命中本地缓存Caffeine未命中再访问 Redis最后才回源数据库。这样既能大幅降低 Redis 压力又能显著提升响应速度。4.2 二级缓存架构未命中未命中回填回填命中命中客户端请求Caffeine 本地缓存Redis 缓存数据库返回响应4.3 代码实现ComponentpublicclassMultiLevelCacheService{AutowiredprivateStringRedisTemplatestringRedisTemplate;privatefinalCacheString,ObjectcaffeineCacheCaffeine.newBuilder().maximumSize(10_000).expireAfterWrite(5,TimeUnit.MINUTES).build();publicObjectquery(Stringkey,IDomainServiceservice){// 1. 查本地缓存ObjectlocalcaffeineCache.getIfPresent(key);if(local!null){returnlocal;}// 2. 查 RedisStringjsonstringRedisTemplate.opsForValue().get(key);if(StrUtil.isNotBlank(json)){ObjectvalueJSON.parseObject(json,Object.class);caffeineCache.put(key,value);// 回填本地缓存returnvalue;}// 3. 查数据库Objectvalueservice.queryFromDatabase();if(value!null){stringRedisTemplate.opsForValue().set(key,JSON.toJSONString(value),30L,TimeUnit.MINUTES);caffeineCache.put(key,value);}returnvalue;}}4.4 多级缓存注意事项本地缓存一致性Caffeine 是 JVM 级缓存多实例部署时各节点数据可能不一致需配合消息通知或短 TTL 控制。内存占用本地缓存不宜过大建议设置最大容量和过期时间。更新策略更新数据时需要同时删除本地缓存和 Redis 缓存避免脏读。5. 总结本文围绕项目缓存的核心痛点给出了三套可落地的实践方案问题方案关键点缓存击穿逻辑过期 互斥锁过期期间返回旧数据单线程重建缓存穿透缓存空值空值短 TTL配合布隆过滤器更佳数据一致性删缓存 MQ 补偿 TTL 兜底最终一致性高并发热点Caffeine Redis 二级缓存降低 Redis 压力提升响应速度缓存设计没有银弹需要结合业务场景、并发量、一致性要求综合权衡。希望本文的实践能为你提供参考。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑