这个系列写到第八篇今天想认真聊一个后端开发绕不开、但又特别容易在流量面前翻车的知识点——缓存。做全栈偏后端必备知识点扫盲这几年我发现一个现象很多同学对缓存的认知停留在“Redis存一下数据查询快一点”的层面可真到线上数据不一致、缓存穿透把数据库打穿、大促前压测发现一清缓存服务就告警的时候才发现这里的细节远比自己想象的多。这篇文章打算按照我个人的项目经验把缓存从收益边界、选型、异常场景到一致性方案完整捋一遍。适合工作过一段时间、用过Redis但还没系统梳理过缓存体系的后端同学参考也适合准备系统设计面试的人把它当一份复习提纲来读。1. 先从“缓存到底解决什么问题”说起延迟与吞吐的权衡1.1 为什么你的服务慢往往不是代码慢而是IO慢一个后端请求从进来到最后返回真正花在计算上的时间通常很少大头都在等IO。数据库查询、外部接口调用、文件读写每一个环节都是毫秒甚至几十毫秒级别的延迟。数据库自己虽然也有缓冲池但在高并发场景下大量重复查询依然会形成巨大的压力。我压测过一个模拟项目单条用户信息查询在数据库里的耗时大概是5毫秒每秒几百次请求时数据库就开始出现慢查询。加了一层缓存之后热点查询的耗时就降到了0.5毫秒以内数据库的压力瞬间小了一个量级。这个对比很直观缓存的本质就是“用昂贵的内存换取廉价的IO”把重复计算和重复查询的结果临时保存下来让下一次请求直接拿答案。理解了这一点你就能明白为什么缓存不是万能的它只对特定模式的数据有效。1.2 缓存的收益有边界读多写少、热点集中、时效可控才值得上不是所有数据都适合丢进缓存这点很多人容易忽略。适合缓存的数据有三个特征第一读多写少比如用户资料、商品详情、系统配置读的频率远高于写第二热点集中也就是大部分请求都落在少量数据上二八法则非常明显第三时效性要求可控短暂的不一致不会造成严重后果。反过来库存、余额这类强一致数据就不适合简单粗暴地加缓存。我自己的习惯是在设计缓存之前先做一个简单的“缓存收益率”判断预估一下查询QPS、单次查询耗时、重复查询比例。如果重复查询比例超过30%这个数据就值得缓存。这个方法不严谨但非常好用能帮你快速过滤掉一批不值得加缓存的场景。1.3 缓存失效瞬间的“体验崩盘”才是设计缓存真正要面对的缓存能扛住正常流量但真正考验设计的是失效瞬间。如果没有任何保护缓存miss之后所有请求会同时打到数据库短时间内连接池被打满、慢查询堆积服务很容易整体雪崩。我参与过好几次压测预热状态下命中率99%压测数据非常漂亮一旦清空缓存做冷启动测试数据库马上告警。所以请记住缓存设计的第一原则不是“命中后有多快”而是“失效瞬间数据库能不能扛得住”。后面要讲的穿透、击穿、雪崩本质上都是围绕这个失效瞬间展开的。2. 本地缓存还是Redis各有各的坑选型不能凭感觉2.1 本地缓存快是真的快但“各自为政”也是真的麻烦本地缓存指的是放在进程内的缓存Java生态里常用的有Caffeine、Guava CachePython里也有functools.lru_cache这类内置工具。它的最大优势是极快零网络开销查询就是一次内存访问。但多实例部署时问题就来了每个实例各有一份数据数据更新的通知无法跨实例传递很容易出现各实例状态不一致。更危险的是热点场景如果某个key在某一个实例上失效这个实例的请求会直接回源数据库而其他实例不受影响但数据库可能被这一个实例的回源流量打挂。所以我的使用原则很明确本地缓存只放两类数据。一类是几乎不变、各实例短暂不一致也完全能接受的配置类数据另一类是访问频率极高、允许短暂过期的临时结果。同时容量要严格控制启动时就要设置好最大条数和淘汰策略不然JVM堆内存分分钟被吃掉。2.2 Redis分布式缓存的绝对主角但开销比你想的大Redis能成为分布式缓存的主角靠的是共享存储、丰富的数据结构、成熟的过期淘汰策略。但你也要清楚它的成本每一次查询都有网络RTT一个get可能就消耗0.1到0.5毫秒批量操作必须用pipeline或者mget合并而不是在循环里一个个get。还有一个非常隐蔽的容量问题Redis内存比磁盘贵得多key和value的编码方式直接决定成本。一个Java对象转成JSON后动辄几百字节如果里面有几十个字段缓存几百万元素就吃掉几十GB内存。同样的数据用hash结构存或者把字段压缩成短键名体积能缩小一半以上。我估算容量的公式是单条数据实际大小乘期望条目数再乘一个1.2到1.3的冗余系数留给过期策略和临时数据的波动。这个公式很粗糙但能让你在申请资源的时候心里有数。2.3 两级缓存架构怎么搭才合理本地挡热点Redis挡回源高并发项目里我经常用“本地缓存Redis”的两级架构逻辑是请求先查本地缓存miss了再查RedisRedis也miss才回源数据库。回源成功后先写Redis再写本地缓存。这里有一个关键细节两级缓存的过期时间要拉开差距。本地缓存时间必须明显短于Redis比如本地5秒、Redis 10分钟。这样做的原因是本地缓存无法收到其他节点的更新通知时间长了数据新鲜度会变差。另一个细节是本地缓存不能对所有key一视同仁地缓存成本太高应该用热点统计或者白名单机制只让大热点key进入本地缓存。我在代码里通常会做一个简单工具类内部维护一个访问计数超过阈值才把key升级到本地缓存。// 两级缓存读取示例 public Object get(String key) { Object value localCache.getIfPresent(key); if (value ! null) { return value; } value redis.get(key); if (value ! null) { localCache.put(key, value, LOCAL_TTL); return value; } value loadFromDb(key); redis.set(key, value, REDIS_TTL); localCache.put(key, value, LOCAL_TTL); return value; }3. 缓存穿透、击穿、雪崩背下名词没用要能对症处理3.1 缓存穿透查一个根本不存在的数据缓存永远帮不了你缓存穿透最常见的场景是请求一条数据库中不存在的记录。因为缓存里没有所以每次请求都会穿过缓存直达数据库哪怕是完全无效的ID也会消耗数据库连接。攻击者可以轻易用大量随机ID绕过缓存把数据库打满。解决穿透有两个常用方案。第一是缓存空值查询结果为null时也写缓存设置一个短过期时间比如60秒。这个方案实现简单可以解决绝大多数穿透问题但要注意空值膨胀如果非法ID非常多缓存里会堆满无意义的空key所以空值过期时间要短同时要做总量上限控制。第二是布隆过滤器在缓存前加一道过滤器判断ID是否存在不存在直接拒绝。这个方案要求ID集合相对稳定如果ID是随机生成的分布式ID布隆过滤器的误判率和内存消耗也值得权衡。我的习惯是两者结合普通业务用空值缓存核心入口加布隆过滤器做前置拦截。3.2 缓存击穿单个热点key失效瞬间流量全部压在数据库上击穿和穿透的区别在于“点”穿透说的是数据本身不存在击穿说的是一个访问量极高的热点key突然过期缓存失效的瞬间所有请求同时回源数据库数据库连接池在被击穿的几十毫秒内被打满。解决击穿有两个主流思路。第一个是互斥锁重建发现缓存miss后不是直接查数据库而是先获取一个分布式锁拿到锁的线程才去查数据库并回填缓存其他线程等待锁释放后直接读缓存。这个方案代码清晰但会增加一次锁的等待时间。第二个是逻辑过期缓存不设置物理过期时间而是把过期时间作为字段存在value里读请求永远能读到缓存发现逻辑过期后由异步线程去重建缓存。这个方案读请求体验最好但实现复杂度明显更高。我的经验是对访问量非常集中的大热点key比如大促商品详情用逻辑过期更稳对普通热点key互斥锁已经完全够用。不要一上来就上最复杂的方案先评估这个key的QPS到底有多高再决定。# 互斥锁重建缓存示例 def get_with_lock(key): value redis.get(key) if value is not None: return value if redis.set(flock:{key}, 1, nxTrue, ex1): try: value load_from_db(key) redis.set(key, value, ex300) return value finally: redis.delete(flock:{key}) time.sleep(0.02) return redis.get(key)3.3 缓存雪崩大量key同时失效数据库被“面”级打穿雪崩和击穿的区别在“面”与“点”击穿是一个热点key失效雪崩是大量key在同一时刻集体失效。典型场景是给所有key设置了相同的过期时间比如统一10分钟时间一到成千上万个key一起过期数据库要承受整个缓存层的回源压力。解决雪崩主要有三种手段。第一是过期时间加随机抖动这是最常用也最有效的办法基础时间加上一个随机偏移量把失效时间打散。第二是多级缓存即使Redis里的key大面积失效本地缓存还能先吸收一部分流量给数据库争取重建时间。第三是熔断降级当数据库压力超过阈值时直接返回默认值或者旧缓存不要死扛。我会在封装缓存工具类时直接把抖动逻辑内置所有的key默认都经过统一的过期时间配置这样开发同学就不容易在某处写死一个相同的过期时间。3.4 加锁和降级的参数不是玄学压测数据说了算互斥锁的超时时间、重试等待、降级阈值这些参数在面试里常被问但很多人回答得都很虚。我的参考值是这样来的锁超时一般500毫秒到1秒重试等待20到50毫秒最多重试3次。如果数据库查询本身就很快锁的意义就很小如果回源查询要200毫秒锁的价值就非常大。降级阈值要用数据库的承受能力反推。假设数据库连接池有200个连接单条查询耗时10毫秒那么数据库能安全承受的QPS大约是20000我们就应该在QPS到16000左右开始触发降级而不是等数据库已经被打满才介入。这些参数必须在压测环境里验证不能拍脑袋写一个数字就上线。4. 缓存与数据库一致性别再迷信“删缓存就好了”4.1 先更新数据库还是先删缓存这个顺序决定了你踩不踩坑做缓存更新时最常见的操作是“先更新数据库再删除缓存”。这里存在一个极小的竞态窗口线程A更新数据库成功但还没删缓存时线程B读到了旧缓存并返回随后线程A才把缓存删掉。这个窗口非常短对大多数业务来说可以接受。另一种做法是“先删缓存再更新数据库”问题就大多了。删除缓存之后到数据库更新完成之前其他线程会读到数据库的旧值并写回缓存脏数据可能长期存在很难自动恢复。所以我的默认实践非常明确先更新数据库后删缓存。并且要保证删除这一步足够可靠——如果删缓存失败必须有重试机制否则下次查询会把旧值重新回填脏数据就赖着不走了。4.2 延迟双删理论完美实际操作很难受延迟双删的思路是更新数据库后先删一次缓存然后等一小段时间比如500毫秒再删一次。目的是清掉第一次删除后到数据库更新完成前并发线程写回缓存里的旧值。听起来很严谨但实际用起来很别扭。问题在于延迟时间很难定。太短了并发的写回还没结束第二次删除删了寂寞太长了接口响应时间被拖长或者你得把这步放到异步线程里去。就算延迟时间设得合适它也只在特定并发窗口内有效不能根治不一致。所以我的结论是延迟双删只能降低出问题的概率不能根除问题。真正对一致性要求高的场景我不会依赖它而是用下面这种更解耦的做法。4.3 更务实的方案订阅数据库日志让缓存更新变成异步任务订阅数据库binlog实现缓存同步是当前比较成熟的做法。核心思路是用类似Canal的组件订阅数据库变更日志解析出变更的主键然后异步去更新或者删除缓存。这样业务代码里不需要手动管理缓存一致性更新数据库和删缓存被天然解耦成两个阶段脏数据的窗口被大幅压缩。这个方案的代价是引入了一条新的数据管道需要处理数据延迟、消息重复、消费失败等问题。我的落地经验是binlog方案适合一致性要求较高、变更频繁的核心链路比如用户资料、订单状态字段对于低频更新、可容忍短暂不一致的页面用“先更新库再删缓存”的简单方案已经足够。架构上不要过度设计每引入一个组件都要想清楚它到底解决了什么痛。4.4 一致性分级先想清楚你接受多大程度的“脏读”踩过几次一致性坑之后我总结出一个原则先给数据做一致性分级再决定用什么方案。对用户可见的核心数据比如支付状态、账户余额尽量做到秒级最终一致对列表页、聚合页、非关键配置短暂不一致通常不会引起投诉。架构评审时我会反复追问几个问题这个数据读错了会影响钱吗会影响用户体验吗会影响多少用户如果答案都是“影响很小”那就不需要花高成本去追求强一致。缓存本来就是一种取舍搞清楚自己愿意接受多少不一致很多方案选型都会变得简单。5. 缓存实战监控指标、容量评估与上线前的自查清单5.1 命中率不是唯一指标要拆开看缓存命中率是最常见的监控项一般从Redis的INFO stats或者代理层指标里取。但只看整体命中率会掩盖很多问题某个核心key命中率掉到0但其他key命中率都在90%以上整体数字依然好看线上已经出事故了。因此我要求给重要业务单独设置key维度的命中率告警或者按前缀分组统计。除了命中率还要关注淘汰数和慢命令如果evicted_keys数量不断增加说明内存容量不够或者过期策略不合理如果出现大量慢查询命令就要排查是不是有同学用O(N)命令处理了大数据集。这些指标组合起来才能比较真实地反映缓存健康状况。5.2 容量评估和过期时间怎么定有没有可量化的方法容量评估要结合业务量级公式大概是内存占用等于key平均大小乘key数量加上value平均大小乘value数量集群的话再乘副本数最后加上20%到30%的冗余。我见过很多兄弟申请Redis资源时完全靠感觉结果大促前才突然发现内存要到上限非常被动。过期时间的设定要综合考虑数据新鲜度与回源压力。对基本不变的数据可以设到24小时对价格、库存这类高频变化的数据要么设几秒要么干脆不缓存。这里有个容易忽略的点所有key必须带业务前缀和环境前缀比如order:pay:prod:这种格式。别小看这个习惯将来清理数据、灰度上线、按维度排查问题前缀能帮你省下无数时间。5.3 上线前可以照着过一遍的自查清单最后分享一个我每次接入缓存都会过一遍的检查清单每一行都对应过一次线上事故教训是否存在缓存穿透风险查询不存在的key时会怎么处理有没有热点key热点key失效瞬间数据库回源压力能扛住吗所有key的过期时间是否做了随机化处理删除缓存失败怎么办有没有重试或者订阅日志兜底缓存容量是否做过量化估算内存上限和淘汰策略是什么是否配置了命中率、内存、慢查询指标的告警这份清单看起来简单但真要把每一项都做到位其实需要不少准备工作。你可以在项目上线前对着过一遍能提前发现很多隐患。缓存这个组件用的时候很爽出问题的时候确实很惨。它不像业务代码那样可以靠修逻辑来补救一旦在流量面前失守数据库被击穿就是系统性风险。我个人的体会是做缓存设计时不要追求“最好的方案”而要把“失效瞬间的兜底”和“数据不一致的容忍边界”想清楚再根据业务场景选择那个足够简单、足够可靠的方案。希望这篇文章能帮你少走一些弯路至少在下一次被问到“缓存和数据库怎么保持一致性”的时候你能给出自己的判断而不是背一个标准答案。