
缓存策略这事我本来以为自己早就玩明白了直到有一次线上事故教做人。当时服务刚扩容完Redis 集群也搭得好好的结果某个热点商品的库存接口突然从 5ms 飙到 900ms数据库连接池被打穿订单系统跟着雪崩。事后复盘才发现问题不是 Redis 挂了而是缓存策略本身设计错了——我们从头到尾只用了一层缓存而且 key 的设计、过期时间的设置、回源逻辑全是隐患只是平时流量不够大没暴露而已。所以这篇我想把从内存缓存到 Redis 分布式缓存的演进逻辑、踩坑经历和落地经验完整梳理一遍。不整虚的核心就聚焦几个问题缓存为什么值得分层本地缓存和 Redis 各自适合什么场景缓存穿透、击穿、雪崩到底怎么治缓存和数据的一致性怎么保证是“差不多能用”而不是“早晚出事”适合正在设计缓存方案的开发者、刚接手 Redis 维护的运维以及准备做系统性能优化的朋友参考。1. 缓存的本质是在“时间局部性”上做文章Java 里的HashMap是缓存CPU 里的 L1 Cache 也是缓存Redis 更是缓存。名字不一样但底层的逻辑完全一致把访问频率高的数据挪到离计算更近的地方用空间换时间。1.1 从 CPU 多级缓存说起CPU 的缓存设计是整个缓存体系最好的教科书。L1 Cache 大概几十 KB访问延迟 1ns 左右L2 Cache 几百 KB延迟 3-4nsL3 Cache 几 MB延迟 10-15ns再到主内存就是 100ns 量级。为什么 CPU 不直接用一块超大缓存把所有数据都装下因为成本和物理限制摆在那儿SRAM 贵得离谱而且容量越大寻址延迟也越高反而不划算。这个逻辑在业务系统里完全一样。进程内缓存比如 Caffeine、Guava Cache就是业务系统的“L1 Cache”Redis 就是“L2 Cache”数据库则是最后的“主存”。你在业务代码里直接查数据库相当于 CPU 每次计算都从主内存取数——功能上没问题但性能永远上不去。理解了这一层你再看“要不要用缓存”这个问题视角就不一样了。不是“缓存好不好”而是**“你的数据访问模式有没有时间局部性”**。同一个用户的 session 短时间内会被反复读取这是时间局部性热门商品详情页在促销时段被大量请求这也是时间局部性。如果一份数据毫无热点特征、每次请求都是随机访问那缓存就没什么收益反而白白增加了一层维护成本。1.2 缓存的三种典型形态我把实际项目里遇到的缓存形态分成三类它们的定位、延迟、容量和一致性模型都不一样搞清楚这些区别选型才不会拍脑袋。本地缓存进程内数据存在应用 JVM 内存里毫秒级甚至微秒级访问性能最高但容量受限于单台机器内存而且多实例部署时每个节点各存一份天然存在数据不一致的风险。典型代表是 Caffeine。集中式缓存分布式数据存在独立缓存服务中应用通过网络访问延迟在 0.1ms-1ms 之间单机容量远大于应用内存而且所有应用实例共享同一份数据天然解决了本地缓存的一致性问题。典型代表是 Redis。数据库缓存层MySQL 的 InnoDB Buffer Pool 其实也算一层缓存但它主要是为了减少磁盘 IO对业务层透明你基本不用干预。不过它的存在提醒我们一件事如果连数据库自带的缓存都命中不了那上层缓存策略多半也有问题。多数刚起步的业务系统可能只需要本地缓存就够了。但一旦上了多实例、流量开始波动本地缓存就会出现一个致命问题每个实例缓存的数据版本可能不一样用户第一次请求打到 A 实例拿到旧值第二次打到 B 实例拿到新值体验上就是“数据怎么变来变去”。这时候就需要把缓存抽出去做成所有实例共享的 Redis 缓存层。这是我带项目踩坑之后最深的体会缓存分层不是套路是系统发展到一定规模之后的必经之路。2. 从本地缓存到 Redis一次演进中的选型逻辑“为什么从内存缓存升级到 Redis 分布式缓存”很多人理解得很粗把数据从 JVM 搬到 Redis 不就完了实际上这只是表象真正的区别在于数据的所有权模型发生了改变——数据不再属于某个进程而是成为一个独立的基础设施组件。2.1 本地缓存撑不住的三个信号我在项目里判断“该引入 Redis 了”主要看三个信号第一个信号是实例数大于等于 2。两个实例各自维护一份本地缓存如果数据只读不变比如系统配置字典那没问题一旦有写操作A 实例改了缓存B 实例还是旧值跨实例的缓存一致性立刻成为麻烦事。你要么接受短暂不一致要么自己写广播机制比如通过消息队列通知其他实例失效而这些方案都比直接用 Redis 复杂得多。第二个信号是缓存数据量开始逼近单机内存的可用上限。JVM 堆内存本来就要兼顾业务对象、线程栈、元数据你还要另外划出一块做本地缓存堆内存压力一大GC 频率就会飙升性能不升反降。我见过一个项目把热词统计结果全部缓存到本地 Ehcache堆内存配了 4G缓存占了 2.5GFull GC 动不动就上百毫秒最后迫不得已迁移到 Redis。第三个信号是热点数据的访问规模开始跨实例不均匀。如果某些 key 恰好集中请求到某个实例上该实例的 CPU 和内存就会明显高于其他实例形成“缓存热点倾斜”。Redis 集中式缓存配合数据分片可以把热点分散到多个 Redis 节点而本地缓存完全做不到这一点。2.2 演进路径不是一步到位的从本地缓存到 Redis 的正确演进路径我的经验是分三步走而不是“一刀切换掉”第一步先给本地缓存加上统一抽象接口。不管底层用的 Caffeine 还是 Guava对外只暴露get(key)、put(key, value)、delete(key)这几个方法。这是为了后续替换的时候不用改业务代码只改实现类。第二步把“写入频繁”和“一致性要求高”的数据切到 Redis。比如用户会话、库存计数、分布式锁状态这类数据对一致性敏感继续留在本地缓存就是以小博大、风险极高。第三步对“只读性强、变更极少”的数据保留本地缓存并设计好失效机制。比如商品类目树、省市区列表这类数据可以本地缓存一分钟甚至更久即使个别实例短暂不一致也问题不大。这样既能享受本地缓存的超低延迟又不会因为一致性要求把架构搞复杂。这套演进路径的核心思路是不要追求“全有或全无”而是按数据特征做局部迁移。很多团队一上来就想“把缓存放 Redis”结果过度设计反而把简单问题复杂化。2.3 为什么 Redis 能成为分布式缓存的“标配”聊到分布式缓存Redis 几乎是绕不开的名字。并不是说它是唯一选择Memcached 也活得好好的但 Redis 在数据结构和功能覆盖上的优势确实太明显了。从数据结构上看Memcached 只能存字符串而 Redis 的 String、Hash、List、Set、ZSet 几乎覆盖了业务缓存一半以上的场景Hash 天然适合存对象属性ZSet 适合做排行榜和延迟队列。从可靠性上看Redis 的持久化机制RDB 和 AOF保证了进程重启后缓存数据还能恢复配合主从复制和哨兵商业环境里的可用性是有保障的。再说一个很多人没注意到的点Redis 的原子操作让它在“缓存 计数”这种组合场景里特别省事。比如库存扣减你用本地缓存 数据库行锁去实现代码又长又容易出并发问题用 Redis 的DECR一条命令搞定而且天然原子。这其实已经超出了单纯“缓存”的范畴它更像一个轻量级的分布式数据结构服务器。所以我的观点是不要只把 Redis 当缓存用它的价值远不止命中率那点事。3. Redis 缓存落地时最容易踩的五个坑理论讲完说点实际踩坑记录。以下是 Redis 缓存上线过程中我真实遇到和复盘过的问题有些还挺隐蔽不压测根本发现不了。3.1 坑一Key 设计没有规则命名全靠缘分公司的 Redis 集群是多个团队共用的我接手时 Redis 里的 key 长什么样都有有直接存用户 ID 的10001有带项目名前缀的order:10001还有像user_info_10001这种不知道哪个团队留下的。等你想清理旧数据、排查热点 key 的时候根本不知道该对谁下手。后来我们定了统一的命名规范项目名:业务域:业务对象:标识。比如订单缓存的 key 就是shop:order:detail:10001用户会话就是shop:session:token:abc123。这个规范看着简单但能解决大量后期运维问题按前缀扫描 key、统计业务访问量、做过期清理全部变成可操作的事情。Redis 官方也支持 key 的分级用冒号分隔是最常见的约定。还有一个容易被忽略的点不要把无界信息拼进 key。比如shop:order:list:用户ID:页码:每页条数:排序方式这种设计每次查询参数一变就是一个新 key缓存数据量会随着查询组合爆炸式增长最后 Redis 内存被一堆“一次性 key”占满命中率惨不忍睹。正确的姿势是控制 key 的维度把列表缓存设计成“业务维度”的有限集合比如shop:order:list:3587:recent而不是把查询参数全塞进去。3.2 坑二过期时间设置靠拍脑袋刚用 Redis 的时候我也干过给所有 key 统一设置 30 分钟过期时间的事。看起来很省心但问题很快就来了一些 key 根本没这么长的生命周期比如验证码 5 分钟失效你缓存 30 分钟等于把败笔无限放大另一些 key 是热点数据30 分钟的过期窗口意味着一旦失效同一时刻所有请求都会回源数据库把数据库打挂这就是后面要讲的“缓存击穿”。我现在设计过期时间的基本思路验证码、短信、临时令牌绑定业务本身的过期语义5-15 分钟绝不多给。详情页、静态配置采用“基础 TTL 随机抖动”比如基础 30 分钟再随机加上 0-5 分钟避免同一批 key 在同一时刻集体过期。排行榜、统计类数据固定窗口刷新比如每 10 分钟通过定时任务重建一次而不是依赖自然过期。过期时间的设置其实没有标准答案核心原则是TTL 要与数据的真实生命周期对齐同时考虑过期时的并发压力。3.3 坑三Value 序列化方式选错内存翻倍还不自知Redis 存的是字节数组所以对象写入前必须序列化。Java 后端最常用的方案是 JSON 序列化Fastjson2、Jackson或者 JDK 原生序列化。JDK 原生序列化虽然方便但序列化后的体积大得吓人——一个简单的用户对象用 JDK 序列化可能 300 字节用 JSON 可能只要 120 字节。数据量上来之后内存占用差距是成倍的。更坑的是 JDK 序列化会有兼容性问题类结构变了反序列化直接报错。我们后来统一走 JSON 序列化并给 RedisTemplate 自定义了序列化器。这里有个细节不要用 RedisTemplate 默认的 JdkSerializationRedisSerializer改成 GenericJackson2JsonRedisSerializer。前者读出来是二进制你在 Redis 客户端里根本没法直接看内容排查问题全靠猜后者存的是可读的 JSON出问题能一眼看到 value 长什么样。3.4 坑四连接池配置跟默认参数走高并发下先崩的是你Redis 本身的性能很牛但如果客户端连接池配置不合理第一个瓶颈反而出现在你的应用进程里。很多框架默认的连接池最大连接数是 8比如 commons-pool2 的默认值压测一上来连接一不够用线程就开始排队接口延迟直线上升。我们的接入层配置大致是这样Spring Boot 场景spring: redis: timeout: 2000ms lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 1000msmax-active不是越大越好要根据 Redis 实例的 CPU 核数和业务 QPS 来估。你可以用这个公式粗算预估最大连接数 单实例 QPS × 平均单请求 Redis 操作次数 / (单连接的吞吐量)。不过实际中线压测比估算靠谱得多调完参数一定要压一把观察连接池指标和线程阻塞情况。3.5 坑五大 Key 和热 Key是缓存系统的隐形杀手单个 key 的 value 特别大比如塞了一个几十 KB 的 JSON 字符串或者某个 key 被超高频率访问这两种情况我们统称为大 Key 和热 Key。它们的危害是大 Key读写耗时明显增加删除时可能阻塞 Redis 单线程造成整个实例短暂卡顿。热 Key单个 key 的访问量过大所在 Redis 节点的 CPU 被打满而其他节点闲置形成分片倾斜。治理手段包括大 Key 要拆分——比如把大的 Hash 拆成多个小 Hash或者把 JSON 字符串拆成多个 list 分页缓存热 Key 要做本地缓存兜底——把 Redis 里的热点数据再放一份到应用本地 Caffeine 里扛掉绝大部分读压力。但这里要权衡好本地缓存和 Redis 的一致性问题一般只对“最终一致可以接受”的数据才做这层兜底。4. 缓存三座大山的根因与实战解法做缓存的人迟早会碰上三个高频问题穿透、击穿、雪崩。名字有点像但成因和解法差别很大。我用实际场景逐一拆解。4.1 缓存穿透查询一个不存在的东西每次都打到数据库根因请求的 key 在缓存中不存在数据库里也不存在。比如恶意请求一个不存在的用户 ID缓存查不到防线形同虚设所有流量直接穿透到数据库。解法一缓存空值。查不到的数据也缓存一份value 设为 nullTTL 设置短一些比如 2-5 分钟。这样短时间内不会反复穿透同一个 key。缺点是多占用一点内存但对防御恶意请求很有效。解法二布隆过滤器。在请求进入缓存之前用布隆过滤器判断 key 是否存在。布隆过滤器的特点是判断“不存在”是准确的判断“存在”可能有极小概率误判。把合法 ID 集合预加载到过滤器里非法 ID 在入口就被拦截掉根本不会进到查询链路。布隆过滤器最简单的落地方式是 Redis 的 Bitmap 操作也可以用 Guava 的本地布隆过滤器做进程内拦截。我个人更推荐先空值缓存兜底再对极端恶意流量上布隆过滤器毕竟布隆过滤器需要维护 ID 集合的增量更新是有维护成本的。4.2 缓存击穿热点 key 恰好过期瞬间流量都怼到数据库根因一个被高并发访问的热点 key 在某个时间点过期缓存里查不到大量请求同时回源数据库数据库扛不住。解法一互斥锁Mutex Key。核心逻辑是发现缓存未命中时不直接查数据库而是先尝试获取一个分布式锁。拿到锁的线程才去查数据库并回填缓存其他线程等待或者降级。Java 里可以用 Redisson 的RLock实现或直接用 Redis 的SET NX命令手写。简单示意public String getValue(String key) { String value redis.get(key); if (value null) { if (redis.setIfAbsent(lock: key, 1, 3, TimeUnit.SECONDS)) { try { value db.query(key); redis.set(key, value, 300, TimeUnit.SECONDS); redis.delete(lock: key); } finally { redis.delete(lock: key); } } else { // 没抢到锁短暂 sleep 后重试或者返回降级值 Thread.sleep(50); return getValue(key); } } return value; }这个方案能保证数据库回源只发生一次但引入了线程阻塞和重试代价。对绝大多数业务场景来说都是可接受的。解法二逻辑过期 异步刷新。给缓存值随身携带一个逻辑过期时间比如 30 分钟逻辑过期但物理 TTL 永远不设置成绝对过期。读取时发现逻辑过期后立刻返回旧值同时异步后台重新加载数据更新缓存。这种方案最关键的优势是用户永远不感知过期请求不会因为缓存失效而等待回源。它就是 Caffeine 的refreshAfterWrite机制的 Redis 版实现。4.3 缓存雪崩大量 key 同时过期或者 Redis 整个挂了根因一是大量 key 设置了同一个过期时间比如 0 点统一过期二是 Redis 集群不可用导致所有缓存请求全部回源。解法一过期时间加随机化。设置 TTL 时增加随机分散值避免同一批 key 在同一时刻集体过期。这个我在前面已经提过是成本最低、收益最明显的手段。解法二Redis 高可用部署。主从复制加哨兵或集群模式是标配。但要注意即使 Redis 挂了业务也不能直接雪崩。所以应用侧还需要降级方案比如配置中心开关Redis 不可用时直接关闭缓存逻辑、请求走数据库但数据库也要做限流保护或者服务降级返回兜底数据比如库存返回默认值、详情页返回静态版本。这些听起来麻烦但真遇到 Redis 故障时能救命的。解法三提前压测 监控告警。雪崩往往不是“突然到来”的而是慢慢逼近的。你要监控 Redis 命中率、过期 key 数量、数据库连接池占用率这些指标。命中率低于 90% 就要警惕低于 80% 基本说明缓存策略出了问题。5. 缓存与数据库的一致性别追求 100%但要控制误差缓存系统里没有一个解法是“绝对一致”的因为我们本来就是用一致性换性能。这里的核心不是追求强一致而是把不一致的时间窗口控制在业务可接受的范围内。5.1 先更新数据库再删除缓存为什么是对的我见过不少团队用的是“先删缓存再更新数据库”的顺序这其实是经典坑。设想一下线程 A 先删缓存然后去更新数据库在线程 A 更新数据库之前线程 B 来了查询缓存发现没数据就去数据库读到了旧值然后回填缓存。结果就是缓存里存的是旧值数据库已经更新成新值不一致的时间窗口被拉到很长。反过来标准做法是“先更新数据库再删除缓存”。为什么这个顺序更安全因为更新数据库之后即使删除缓存之前有一段空窗期那也只是数据库新值 缓存旧值同时存在的短暂时间而一旦成功删除缓存后续请求重新回源就能拿到新值。相比之下“先删缓存再更新库”的不一致窗口几乎无法收敛。我实际优化的时候还会给“删除缓存”这步加一个重试机制。因为删除操作也可能失败网络抖动、Redis 超时失败后缓存里永远留着一个旧值后续所有请求都命中旧数据。我们的做法是走一条可靠的失败补偿链路把删除失败的 key 丢进一个延迟队列或者消息队列由消费者重试删除。简单说核心操作是更新数据库缓存删除是可补偿的旁路操作。5.2 延迟双删能解决“先更新后删除”的极端情况吗“先更新数据库再删除缓存”还有一个潜在问题线程 A 更新数据库后还没删除缓存线程 B 在 A 删除缓存之前读到了缓存中的旧值并返回——如果应用自己有“写后读”的强一致需求比如用户刚刚改完配置立刻要能看到新值这是有问题的。延迟双删的思路是删除缓存之后等一小段时间比如 500ms再删除一次缓存。它的目的是干掉那些在第一次删除后、数据库更新完成前可能被并发请求写入的旧值缓存。这个方案在大多数并发写读穿插的场景下都能收敛但它有一个致命前提——延迟时间要大于“线程 B 从读到写缓存”的耗时而这个耗时是无法严格预估的。我的态度是延迟双删可以作为一种工程妥协手段但不要迷信它。更可靠的做法是引入 Binlog 订阅比如 Canal做最终一致应用更新数据库Canal 监听 Binlog 变化主动删除对应缓存。这个方案的优点是解耦——业务代码不用关心缓存更新逻辑只要数据库 Binlog 正常缓存最终一定一致。5.3 一致性等级怎么选取决于业务容忍度我把常见业务的缓存一致性需求分成了三个等级你完全可以对照自己的场景选择等级一最终一致即可。大部分读多写少的数据比如商品描述、文章详情允许秒级甚至分钟级旧值。方案先更新数据库再删缓存加个失败重试就行。等级二写后读一致。用户改动后下一次请求必须读取到新值。方案延迟双删 强制刷新或者按用户维度写入完成后主动刷新其缓存 key。等级三强一致。这类数据其实不适合放缓存。如果业务真的要求强一致建议直接查数据库放弃缓存这层别给自己挖坑。我个人比较坚持的一个原则任何缓存方案都要有明确的降级预期。也就是说团队里每个人都要能回答“当缓存和数据库不一致时这个业务表现出来是什么样能接受吗”如果答案模棱两可说明你在没想清楚之前就上了缓存。6. 缓存系统的观测与量化把“感觉还行”变成“数据说话”缓存做完了运行起来没问题你是不是就觉得大功告成了千万别。如果不去观测和量化缓存系统的各项指标问题只会在某个深夜集中爆发到时候你连排查入口都找不到。6.1 必须盯住的四个指标我在这里只列四个最关键的缓存命中率命中次数 / (命中次数 未命中次数)。核心业务缓存命中率应该稳定在 90% 以上。如果某个 key 的命中率持续走低要么是过期时间不合理要么是查询条件的组合太散key 设计有问题。Redis 内存使用率与淘汰策略Redis 内存快满时开始淘汰数据你要确认淘汰策略是否符合预期。maxmemory-policy默认是noeviction意味着写不进新数据线上大概率要改成allkeys-lru按 LRU 淘汰所有 key或volatile-lru仅淘汰设置了 TTL 的 key。这个配置用错了缓存形同虚设。慢查询日志Redis 的SLOWLOG GET能看到执行时间超过阈值的命令。大 key 操作、KEYS命令扫全库、SMEMBERS拉超大集合都是慢查询的常见来源。回源数据库的 QPS 波动缓存系统做得好不好直接反映在数据库的读压力上。如果数据库读 QPS 明显跟着接口 QPS 走说明缓存根本没兜住流量。6.2 监控告警的落地模板以 Prometheus Grafana 为例我会关注这样几类指标Redis 实例的内存水位、命中率、OPS、连接数、阻塞时间应用侧的缓存调用耗时、回源次数、本地缓存命中率。我之前踩过的一个教训是监控指标确实一大堆但告警阈值设得太宽结果“狼来了”效应让团队对告警麻木。后来我们改了策略核心指标宁可多页不可无页——缓存命中率低于 90% 触发预警、Redis 内存使用率超过 80% 触发预警、回源数据库的 QPS 突增 50% 触发告警三条规则优先其他指标先观察再决定要不要升级。6.3 压测才是检验缓存设计好不好的唯一标准很多缓存问题不压测是永远不会暴露的。比如热点 key 的访问倾斜、连接池参数不合适、网络带宽的占用、序列化体积过大等问题只有在模拟真实流量的时候才会现出原形。我们的压测套路一般是这样的先用单机并发拉到一个基线水位然后逐步加机器和流量观察 Redis 的 CPU、内存、命中率、回源数据库 QPS 的变化曲线。最理想的情况是加机器后应用 QPS 线性增长但数据库 QPS 几乎不动如果数据库 QPS 跟着涨说明缓存策略有缺口这时候就要回头查 key 设计、过期时间、回源逻辑了。写在最后的个人体会缓存这东西理论听起来都很简单但真正落地处处是坑。如果让我总结一条最重要的经验我会说缓存设计永远是为具体业务服务的不要背标准答案也不要过度设计。我见过一个团队把所有数据都塞进 Redis结果内存成本翻了五六倍命中率却只有 40%。也见过一个团队因为怕缓存不一致所有接口都直连数据库白白放弃了性能优化空间。两个极端都不可取。我个人比较推荐的做法是先画一张“数据访问热度”表按访问频率和一致性要求分类再决定哪些数据该进缓存、进哪一层缓存、用多长的 TTL、在什么情况下允许降级。这样既不会牺牲性能也不会把架构搞得太重。同样这篇文章里提到的所有方案落地之前都应该先做一个小的技术验证压一把测再决定要不要全量上线。技术方案的最终裁判永远是流量和数据而不是某个人的经验。