1. 缓存到底在解决什么问题
我当年第一次正经接触 Cache,是在接手一个慢得让人抓狂的报表系统时。数据库每秒扛着几千次重复查询,同样的聚合结果被不同的用户翻来覆去地算,CPU 都快烧红了。那时候才真正意识到,缓存不是一种“性能优化技巧”,而是一种用空间换时间、用分层换效率的基础架构思想。这个章节标题叫“Cache 摘录”,其实就是把缓存体系里那些最核心、最容易被忽略、但又决定系统上限的东西集中捋一遍。
先给缓存下个最朴素的定义:把需要反复读取的数据,放到一个比原始存储更快的介质里,下次访问时直接从快介质返回,省掉重复计算或重复 IO。这句话听起来简单,但落到工程上,难点几乎全在“放哪里、放多久、失效了怎么办、多个副本怎么保持一致”这些细节上。很多团队一开始只是给数据库加了个 Redis 就以为万事大吉,结果遇到缓存穿透、缓存雪崩、缓存与数据库不一致这些问题时,才发现缓存的水远比想象中深。
在深入拆解之前,我建议你先建立起一个分层缓存的全局视图。一般业务系统里会同时存在好几层缓存:浏览器本地缓存、CDN 边缘缓存、服务端进程内缓存(如 Caffeine、Guava Cache)、分布式缓存(如 Redis、Memcached)、以及数据库自带的 Buffer Pool。每层的访问延迟相差一到两个数量级,每一层能承载的数据量和一致性要求也完全不同。搞懂某一层的原理固然重要,但真正体现架构能力的,是知道一个请求应该优先命中哪一层,每一层又该配置多大的容量和多长的过期时间。
打个生活化的比方:你家里做饭,冰箱是“本地缓存”,楼下的菜市场是“主数据库”,而网上下单次日达的电商是“远端冷存储”。做菜前先开冰箱翻一翻,有就直接用,没有再去市场买;市场也没有那就等快递。冰箱里放什么东西、放多久会坏、买菜回来要不要先塞冰箱,这些日常决策,就是缓存设计里命中率、过期策略、回源逻辑的缩影。
我写这章摘录,目标是帮你把缓存这个“工具箱”里的核心工具都认识一遍,并且搞清楚每个工具适合什么场景、会踩什么坑。后面所有内容都是我真实项目里验证过的东西,不是教科书式的理论堆砌。无论你是后端开发、系统架构师,还是正在准备技术面试,这章都会让你对缓存有一个系统性的、能落地的理解。
2. 缓存的核心机制与关键决策点
2.1 命中率:缓存价值的唯一度量衡
缓存有没有用,不看代码写得多漂亮,就看一个指标:命中率。命中率是指请求在缓存中直接找到结果的占比,计算公式很简单:
命中率 = 缓存命中的请求数 ÷ 总请求数举个例子,一个接口每天被调用 100 万次,缓存命中 80 万次,那么命中率就是 80%,另外 20 万次会穿透到数据库。一次数据库查询假设耗时 50 毫秒,缓存查询假设耗时 1 毫秒,那么平均响应时间可以这样估算:
平均响应时间 = 80% × 1ms + 20% × 50ms = 0.8ms + 10ms = 10.8ms如果命中率降到 50%,平均响应时间就会飙升到 25.5 毫秒,翻了一倍不止。如果命中率是 0,那平均响应时间就是 50 毫秒,缓存完全白加了。所以做缓存优化的第一件事永远不是调参数,而是观察命中率,找到导致命中率低的根因。
不同场景对命中率的“及格线”也不一样。热点数据集中、读多写少的场景通常能轻松做到 95% 以上;而数据量大且访问分散的场景,可能 70% 就算不错了。我自己实践中的一个经验值是:命中率低于 80% 的缓存方案,大概率是设计有问题,要么缓存的数据粒度不对,要么过期时间设置不合理,要么 key 的设计没有考虑局部性原理。
提升命中率最有效的手段之一,是引入多级缓存。比如先查进程内缓存(最快,纳秒级),没命中再查 Redis(毫秒级),再没命中才查数据库。每往下一层,命中的概率都会增加,最终穿透到数据库的流量会被逐层削峰。这里要特别注意的是,多级缓存会带来一致性问题——因为同一份数据在多个层里都有副本,某层更新了而另一层没更新,就会出现“脏读”。后面专门讲一致性的时候我会细说。
2.2 过期策略与惰性删除
缓存里的数据不能永远不变地躺着。因为底层数据随时可能变化,而缓存中的数据只是那个变化在某个时间点的快照。所以每份缓存数据都要有过期时间(TTL),这是缓存设计中最基础也最容易被设错的参数。
TTL 常见的三种设定方式:
- 固定 TTL:所有缓存数据统一过期时间,比如 10 分钟。优点是实现简单,缺点是无法适应不同数据的不同变化频率。
- 随机 TTL:在基础 TTL 上增加一个随机偏移,比如 600 秒正负 60 秒。目的是避免大量 key 在同一时刻集中过期,防止瞬时穿透压力波峰。
- 业务自定义 TTL:根据数据的实时性要求分别设定,比如配置信息缓存 1 小时,用户会话缓存 30 分钟,库存数据只缓存 5 秒。
在 Redis 里,过期策略又分两种:惰性删除和定期删除。惰性删除是指当某个 key 被访问时才检查它是否过期,过期了就删掉;定期删除则是后台线程每隔一段时间扫描一批 key,删除其中过期的。两者配合使用:惰性删除保证了“过期了就不会被读出来”,定期删除则保证了“过期的 key 不会长期占着内存”。这个机制我实测下来有个特点:如果过期时间设得过长,内存中会堆积大量等待定期扫描删除的过期 key,内存碎片会变多;如果设得过短,又会导致频繁回源。所以 TTL 不是拍脑袋定的,要结合数据变化频率和访问热度的统计数据来定。
另外还有一类“逻辑过期”的方案,很多高并发场景会用它规避缓存击穿。做法是:缓存里存的不是业务数据,而是一个包装对象,对象里带着业务数据的值和逻辑过期时间。当请求发现逻辑上已经过期时,并不会立即删除缓存,而是先返回旧数据,同时异步触发一个线程去刷新缓存。这样就把“一个 key 瞬间失效导致大量请求打到数据库”的击穿风险,变成了“一个请求触发刷新,其他请求继续用旧值”的平滑过渡。代价是极端情况下会短暂读到稍旧的数据,换来的却是稳定性和吞吐。
2.3 缓存穿透、击穿、雪崩三兄弟
这哥仨是缓存面试和实战里出场率最高的话题,排着队讲一遍。
缓存穿透:请求查询的 key 在缓存和数据库中都不存在,每一次查询都会直接打到数据库。比如恶意攻击者不断请求一个不存在的商品 ID,缓存里没有,数据库里也没有,所有请求都穿透了。防穿透有三板斧:布隆过滤器(Bloom Filter)在缓存前面挡一道,把所有可能存在的 key 在初始化时加入,查不到直接返回空;空值缓存——把数据库查询结果为空的 key 也写进缓存,TTL 设短一点比如 5 分钟,防止同一批非法 key 反复穿透;参数校验——把明显非法的请求在入口直接拦截掉。布隆过滤器有一个特性:它说“不存在”就肯定不存在,说“存在”则不一定真的存在(有误判率)。所以它能拦截掉绝大部分穿透请求,但无法 100% 拦截,还需要空值缓存兜底。
缓存击穿:某个热点 key 在缓存过期的那一刻,恰好有大量并发请求同时访问它,这些请求全部穿透到数据库。和穿透不同的是,击穿时数据在数据库中是真实存在的,只是缓存刚好过期了。高并发下要防止击穿,核心思路是让“重建缓存”这个动作只让一个线程做。实现方式有互斥锁(Mutex Key):发现缓存没命中时,先去抢锁,抢到锁的线程查数据库并回填缓存,其他线程自旋等待一段时间后再尝试读缓存;前面提过的逻辑过期也是一种温和的击穿解决方案;还有多级缓存,比如热点 key 在进程内缓存里有一份副本,即使 Redis 里的 key 过期了,进程内的副本仍能扛住一部分请求。实际项目中,互斥锁方案最通用,逻辑过期方案最适合读多写少且可以容忍数据短暂不一致的场景。
缓存雪崩:大量 key 在同一时间段集中过期,或者缓存节点宕机,导致大量请求同时落到数据库。雪崩的杀伤力比击穿更大,因为它不是针对某一个 key,而是整体性的。预防手段包括:随机化 TTL,避免同一时刻的过期风暴;缓存高可用部署,Redis 主从 + 哨兵或集群模式,避免单点故障;多级缓存做最后屏障,即使分布式缓存全部失效,进程内缓存和数据库连接池限流依然能扛住一部分压力;接口层做熔断降级,当检测到数据库压力超过阈值时,直接返回降级数据或友好提示。我的经验是,架构设计上要默认“缓存会死”,把所有降级路径提前想好,而不是等雪崩发生时才手忙脚乱地查原因。
3. 缓存淘汰算法到底在淘汰什么
3.1 为什么必须淘汰
缓存的空间是有限的。一台服务器的内存只有那么大,Redis 部署实例的内存也有上限。当新数据要写入缓存而空间已满时,就必须按照某种规则选出一部分老数据淘汰掉,把位置腾出来。淘汰算法的目标很明确:用有限的容量尽可能提高命中率。
但这里有个容易混淆的点:淘汰策略(Eviction Policy)和过期策略(Expiration Policy)是两回事。过期策略是“数据到点了就主动失效”,淘汰策略是“缓存满了,按规则踢掉一些数据”。两者既独立又互补:过期的数据只是逻辑上失效了,物理上还占着内存,要等删除机制清走;淘汰则是物理腾空间的即时手段。Redis 如果设置了maxmemory,那么当内存达到上限时,就会根据配置的淘汰算法开始干活。
3.2 三种经典算法的原理与选型
FIFO(先进先出):谁先来谁先走。这种算法最简单,队列即可实现,但完全不考虑数据的访问频率,很可能把一个高频访问但恰好早进来一步的数据踢掉,命中率很难看。现在生产环境用得很少,一般只用在规格极小且数据访问无规律的临时场景。
LRU(Least Recently Used,最近最少使用):淘汰最长时间没有被访问的数据。它的核心假设是:如果一个数据最近被访问过,那么未来一段时间内再被访问的概率也比较高。用双向链表 + 哈希表实现,访问一个数据时把它挪到链表头部,淘汰时从链表尾部操作,时间复杂度是 O(1)。这个算法在绝大多数业务场景下表现良好,是默认的选择。
LFU(Least Frequently Used,最不经常使用):淘汰被访问次数最少的数据。它的核心假设是:访问频率高的数据未来还会频繁访问。如果某个数据在某一小段时间内被狂刷,但之后就再也不用了,LFU 反而可能让这个历史热点长期占着位置,把真正的新热点挤掉。所以 LFU 通常需要引入时间衰减窗口,让访问频率随时间推移逐步衰减。
选型时的判断依据,主要看数据访问的分布形态。以电商场景为例,爆款商品的访问频率集中在少数热门商品上且持续稳定,LFU 表现更好;而资讯类内容,热点话题层出不穷、热度快速迁移,LRU 更贴合数据访问模式。综合来看,大多数通用场景选 LRU,明确有长期高频稳定热点的场景选 LFU,FIFO 慎用。
3.3 Redis 的 maxmemory 策略与配置建议
Redis 中实际用的是一种近似 LRU 算法——approx_lru,它不维护全局的双向链表,而是基于采样来近似判断哪些 key 长时间没被访问。这样做是为了省内存,因为真正的 LRU 需要额外的指针维护顺序,key 数量很大时这个开销不可忽略。
redis.conf中相关配置如下:
maxmemory 4gb maxmemory-policy allkeys-lrumaxmemory-policy常用的几个值:
noeviction:内存满时不再接受写入,直接报错。适合不允许丢失写操作的场景,但生产上一般不用。allkeys-lru:从所有 key 中按 LRU 淘汰,通用性最强。volatile-lru:只从设置了过期时间的 key 中按 LRU 淘汰。适合某些需要长期保留、不希望被淘汰的 key 存在的场景。allkeys-random/volatile-random:随机淘汰,简单粗暴,命中率不可控,基本只在缓存数据无差别时使用。
实际踩过的坑:曾经给一个 Redis 实例设了volatile-lru,但业务中有一部分 key 根本没设过期时间。结果内存满时,那些设置了 TTL 的 key 被大量淘汰,引发了一轮命中率骤降和数据库压力飙升。后来改成allkeys-lru,把所有 key 一视同仁,问题立刻解决。所以配置淘汰策略前,先想清楚这个 Redis 实例里是不是所有 key 都允许被淘汰。
4. 缓存与数据的一致性怎么保证
4.1 先更新数据库还是先删缓存
这是缓存领域最经典的灵魂拷问。主流的方案有两个:**Cache Aside(旁路缓存)模式和Write Behind(写回)**模式。Cache Aside 是现在互联网应用最普遍的一种,它的操作序列是:读的时候先读缓存,不中则读数据库,然后回填缓存;写的时候先更新数据库,再删除缓存(而不是更新缓存)。
为什么写的时候是“删除缓存”而不是“更新缓存”?因为更新缓存是一个成本更高且更易出错的方案:如果多个线程并发写,后写的线程可能把先写的值覆盖了,缓存里最终是哪个值完全取决于线程执行顺序;而删除缓存则没有这个问题,下一次读取时发现没命中,自然会把最新数据回填进去。删除比更新更安全。
在 Cache Aside 模式下,典型的风险窗口是:线程 A 更新数据库成功,准备删除缓存;线程 B 此时读到旧缓存并返回;线程 A 删除缓存。这一小段时间内,客户端拿到的是旧数据。要彻底根治这个问题,最实用的手段是给缓存设置一个较短的 TTL,保证即使出现短暂的不一致,也会在 TTL 内自愈。极端情况下的最终一致性方案是引入消息队列,数据库变更后发一条消息,消费者异步删除缓存并做重试机制。
4.2 双删策略与延迟双删的细节
双删策略是为了解决“先删缓存,再更新数据库”方案下的并发问题。它的操作序列是:
- 第一次删除缓存。
- 更新数据库。
- 短暂休眠(比如几百毫秒)。
- 第二次删除缓存。
为什么要休眠几百毫秒再删一次?因为可能出现这样的情况:线程 A 第一次删缓存后,线程 B 读缓存没命中,于是从数据库读取旧值并回填缓存;此时线程 A 更新数据库完成,但缓存里已经是线程 B 写入的旧值了。如果不做第二次删除,这个旧值就会一直存在缓存里。休眠的作用是等线程 B 的回填动作完成,然后第二次删除把它清掉,下一次读就会拿到新值。
这个方案的问题在于“休眠多久”很难精确控制。如果并发请求量极大,回填动作可能在一个较长的时间窗口内反复发生,休眠时间不够的话还是会残留脏数据。所以延迟双删不是万能的,它降低了不一致的概率,但没有从原理上消除。更彻底的做法是订阅数据库的 binlog,通过一个独立服务进行缓存删除——数据库一有变更,binlog 就产生一条记录,消费端拿到后精准删除对应缓存。这样即使业务代码里各种并发顺序乱成一锅粥,最终也会被 binlog 驱动的删除动作兜底纠正。这也是很多大型团队采用的“最终一致”方案。
4.3 高并发下的强一致诉求怎么取舍
需要承认一个现实:在分布式系统里,缓存和数据库做到强一致,代价极高,很多时候根本不划算。如果你能接受在极小的时间窗口内读到旧数据,那么 TTL 自愈 + 删除缓存就是性价比最高的方案。如果业务真的对一致性极其敏感——比如库存扣减、订单金额展示——那么正确做法不是花大力气去给缓存做一致性保障,而是干脆不要让这些数据进缓存,让请求直接打到数据库。缓存解决的是读多写少、一致性要求不高的数据;属于强一致范畴的数据,不应该为了性能而牺牲正确性。
我在项目中见过不少反面案例:团队非要给库存数据加缓存,为了处理超卖问题引入分布式锁、事务消息、binlog 同步,搞了一大堆基础设施,最后发现数据库本身的能力完全够用,加缓存纯属自找麻烦。所以作为架构师或开发,做技术决策时要先问一句:这个数据真的有缓存的必要吗?命中率能到多少?一致性要求是什么?回答完这三个问题,就知道该不该上缓存了。
5. 缓存工具选型与生产实践
5.1 本地缓存选型对比
本地缓存是指运行在应用进程内部、由应用自身管理的缓存。它最大的优势是快——不经过网络 IO,数据就在当前进程的内存里,访问时间可以在纳秒级别。代价是缓存数据与应用程序的生命周期绑定,应用重启缓存即丢失,且多实例部署时每台机器各自维护一份,无法做到全局一致。
Java 生态中常用的本地缓存框架有 Caffeine、Guava Cache 和 Ehcache。Caffeine 是 Guava Cache 的现代替代品,内部实现了基于频率的淘汰算法(TinyLFU),在读写性能上比 Guava 高一个量级。它的 API 设计得很顺手,支持异步加载、基于容量的淘汰、基于时间的过期,还能通过recordStats打开命中率统计。一段最简单的用法示例:
Cache<String, Order> cache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .recordStats() .build(); Order order = cache.get(key, k -> orderDao.queryById(k));get方法的第二个参数是一个加载函数:缓存未命中时自动执行加载逻辑并把结果写入缓存。这个 API 非常适合“读多写少、单次查询成本高”的场景。需要强调一点:本地缓存的容量上限一定要根据实际内存情况谨慎设置,默认情况下如果配置了很大的maximumSize,在流量峰值时可能会占用数百 MB 甚至几 GB 的内存,引发 GC 压力甚至 OOM。我习惯在本地缓存之外再配合 JVM 参数-Xmx做总内存预算,确保缓存不会把堆挤爆。
Go 生态中的本地缓存库有bigcache、gcache、freecache等。其中bigcache的设计思路很特别:它用字节数组而不是map来存储,极大减少了 GC 压力,适合高并发写入、超大缓存量的场景。demo 级代码量很小,但每个库的适用场景差异不小,选型时重点要看内存分配方式和并发读写模型。
5.2 Redis 与 Memcached 怎么选
分布式缓存场景下,Redis 和 Memcached 曾经是两大主力,但现在 Redis 几乎是绝对主流。很多团队从 Memcached 迁到 Redis,原因主要有几个:
- Redis 支持丰富的数据结构(String、Hash、List、Set、ZSet、BitMap、HyperLogLog),不仅是 KV 缓存,还能做排行榜、计数器、分布式锁、延迟队列。
- Redis 支持持久化(RDB 和 AOF),虽然通常不建议把 Redis 当作可靠存储,但有了持久化,重启后可以更快地恢复缓存预热,降低冷启动对数据库的冲击。
- Redis 原生支持主从复制、哨兵模式和集群模式,高可用方案成熟;Memcached 的多节点扩展则主要靠客户端一致性哈希,运维复杂度更高。
- Redis 的
SCAN命令可以非阻塞地遍历大 key 集合,方便做缓存统计和清理;Memcached 在这一点上比较弱。
Memcached 也有它的优势:纯内存实现、内存分配采用 Slab Allocator 机制,内存碎片控制得很好,单线程模型的性能在纯 KV 场景下非常稳定,且在极大并发下 CPU 开销比 Redis 更低。如果你只需要一个极其简单的 KV 缓存,不需要复杂数据结构,Memcached 仍然是一个值得考虑的轻量方案。
5.3 缓存 Key 的设计规范
很多性能问题其实从 Key 设计那一步就埋下了。常见的设计不佳的情况:key 过长(把整个对象序列化后塞进 key)、key 无前缀无命名空间、key 不包含版本号、热点 key 无分片。这些看似细枝末节的东西,在千万级 key 的场景下会被无限放大。
我在团队里推行的 Key 设计规范大致如下:
- 命名结构:
业务:模块:ID:属性,比如order:detail:123456:v1。这样一眼就能看出 key 属于哪个业务域,排查问题也方便。 - 控制长度:key 越短越好,因为 Redis 中 key 本身也占内存。能用数字 ID 就不要拼接长字符串。
- 加版本号:如果业务数据的结构发生过升级,通过 key 中携带版本号可以平滑迁移,否则新旧数据混在一起很难区分。
- 热点 key 分片:单个 key 的 QPS 过高时,把一个逻辑 key 拆成多个物理 key(如
hot:1到hot:100),读请求先随机选一个分片去读。代价是更新时要把所有分片更新一遍,所以在“极高热点 + 读多极少写”的场景下才值得这么做。
我还有一个习惯:把所有 key 的生成逻辑收口到一个专门的 KeyBuilder 类或函数里,禁止在业务代码里随手拼接 key。这样做的好处是,哪天要统一改 key 前缀或者调整命名规则,只需要改一处。这个习惯帮我在多次大规模缓存治理中节省了无数赶工时间。
6. 高并发场景的缓存实战与排障
6.1 缓存预热与冷启动处理
系统刚上线、服务重启、缓存集群扩容后,缓存里是空的,而流量却不会等你慢慢把缓存填满。如果没有任何预热方案,冷启动后的第一波流量会全部穿透到数据库,轻则响应变慢,重则直接打垮数据库。缓存预热的核心思路是:在正式流量到达之前,把热点数据主动加载到缓存中。
一个可落地的预热方案是:线上保存最近一段时间内的热点 key 清单(可以从日志或监控系统中统计),服务启动后异步并发地查询这些 key 对应的数据并回填缓存。比如 Java 中使用线程池并发执行预热任务:
List<String> hotKeys = loadHotKeysFromLog(); ExecutorService executor = Executors.newFixedThreadPool(16); for (String key : hotKeys) { executor.submit(() -> { Object value = orderDao.queryById(key); redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(10)); }); }预热脚本要控制并发度,避免回填请求把数据库冲爆。更好的做法是在流量低峰期预热,或者按批次小流量放行。还有一个细节:预热时给每个 key 设置一个随机 TTL,防止同一时间点整体过期后再次雪崩。
如果是 Redis 持久化配置了 RDB 或 AOF,那么重启后可以从持久化文件中恢复一部分数据,但这取决于最后一条持久化记录的时间点,无法保证与故障前状态完全一致。所以不能依赖持久化来做预热,还是要有一个外部的、独立于缓存节点的热点清单来源。
6.2 缓存监控指标体系怎么搭
不监控的缓存等于裸奔。我见过很多系统上线后从不看缓存指标,直到某天 MySQL CPU 报警才发现 Redis 命中率已经掉到了 40%。建立缓存监控体系的成本并不高,关键是把指标定义清楚。
核心监控指标至少要有这么几项:
- 命中率(hitRate):按分钟粒度统计,低于阈值时报警。
- QPS 与延迟:缓存读写请求量及 P99 延迟,判断缓存是否是瓶颈。
- 内存使用率:used_memory / maxmemory,接近 90% 时就要关注淘汰率。
- 淘汰 key 数量:如果
evicted_keys持续增长,说明容量不足,需要考虑扩容或优化 key 设计。 - 慢查询:Redis 中执行时间超过阈值(比如 100ms)的命令日志,通常与 bigkey 操作有关。
实现上,Spring Boot Actuator + Micrometer 可以比较方便地暴露缓存指标到 Prometheus,再配合 Grafana 展示。如果用的是 Redis 自带的INFO命令,也能拿到最关键的基础指标。我常用的做法是写一个定时任务,每 10 秒跑一次INFO stats,把keyspace_hits和keyspace_misses两个计数器拉出来算命中率,存到监控库里。这样最轻量,不需要引入任何额外的探针。
注意:分析命中率时,注意 Redis 的
keyspace_hits与keyspace_misses是累计值,要先取出差值再计算,否则直接把累计值相除会发现命中率越来越失真。
6.3 常见故障排查手册
故障排查靠的是经验和工具的结合。下面几条是我实际排查中最高频遇到的问题,整理成一个速查式的清单:
问题现象:缓存命中率突然骤降
排查步骤一:确认缓存节点是否发生主从切换,新主节点数据是否为空。第二步:确认是否有批量任务在近期修改了大量 key 的过期时间。第三步:查看监控里淘汰数是否激增,判断是容量问题还是 TTL 问题。
问题现象:Redis 内存暴涨
先看INFO memory的used_memory和used_memory_rss是否差距过大,如果 RSS 明显大于 used_memory,说明有内存碎片率高的问题,可能需要执行MEMORY PURGE或考虑重启主节点。再看 key 数量是否暴涨,是不是有大 key 写入或者缓存 value 过大。
排查命令示例:
# 查看大 key redis-cli --bigkeys # 查看内存碎片率 redis-cli INFO memory | grep mem_fragmentation_ratio # 查看淘汰情况 redis-cli INFO stats | grep evicted_keys问题现象:某个 key 的响应延迟异常高
大概率是 bigkey 问题。一个包含几万条数据的 Hash 或 ZSet,执行一次HGETALL或ZRANGE很容易达到秒级。解决方法是拆分大 key 为多个小 key,或者把大对象拆成多个字段分别缓存。线上排查时--bigkeys是首选工具。
问题现象:缓存产生脏数据,读到的值不是最新值
优先检查写路径——是不是用了“更新缓存”而不是“删缓存”;再看延迟双删的休眠时间是否足够;如果用了消息队列异步删除,检查消费者是否积压,以及删除失败后有没有重试机制。脏数据问题十有八九是写路径设计的问题,不是 Redis 本身的问题。
7. 一个完整缓存方案的设计复盘
7.1 业务场景与性能指标
拿我之前做过的某个商品详情页系统来做个完整的复盘。这个系统的特点是:商品数量约 200 万,单日详情页请求量约 1 亿次,数据库 MySQL 单机 QPS 上限大概 2000 左右,商品信息变更频率中等(编辑一次后短时间内不会频繁变动),用户对数据的强一致要求不高,要求展示结果在 30 秒内追上最新变更即可。
初期方案没上缓存时,数据库 QPS 峰值达到 5000 以上,MySQL 的 CPU 长期 90% 以上,慢查询日志刷屏。团队当时加了一个 Redis 缓存,缓存了完整的商品详情 JSON,TTL 设为 24 小时,结果发现商品价格变了之后,用户端最迟要一天才能看到新价,运营那边炸锅了。这是一个典型的不理解“缓存一致性”而导致的失败案例。
7.2 目标方案设计
重新设计后的方案如下:
第一层:CDN 缓存静态资源,html、css、js、图片都由 CDN 加速,这一层几乎不占用后端资源。
第二层:服务端本地缓存(Caffeine),缓存商品基础信息,容量 10 万条,TTL 60 秒。为什么放一层本地缓存?因为商品详情页的 QPS 高达几千到几万,热点商品的访问集中度非常高,本地缓存能扛掉很大一部分请求,降低 Redis 压力。
第三层:Redis 缓存,缓存商品详情的完整聚合数据,TTL 15 分钟,使用allkeys-lru淘汰策略。这里把 TTL 从 24 小时改成 15 分钟,是为了让缓存数据在可接受的延迟内自愈,舍掉了一部分“绝对命中率”,换来了远端缓存与数据库的一致性。
第四层:MySQL 数据库,承接所有缓存未命中的查询。
写路径:运营后台编辑商品后,先更新 MySQL,然后通过 binlog 订阅服务异步删除 Redis 缓存。由于本地缓存 TTL 只有 60 秒,即使本地缓存短时间内未失效,也能在 1 分钟内追上最新数据。
这套方案上线后,数据库 QPS 从 5000+ 降到 300 左右,接口 P99 延迟从 400ms 降到 45ms,命中率整体在 92% 以上,运营的修改在 1 分钟内全部生效。整体思路其实并不复杂,但每一步的 TTL、淘汰策略、一致性方案都是根据真实访问数据调出来的,而不是套模板。
7.3 容量与成本估算
设计任何缓存方案时,都要先拍一下容量账。商品详情 JSON 平均大小约 5KB,Redis 中缓存 15 万个热点商品(覆盖 90% 以上的请求),需要的空间是:
150,000 × 5KB × 1.5(Redis 内部对象开销和碎片率) ≈ 1.1GB再加上 20% 的预留空间,Redis 实例设置maxmemory 1.5gb足够。本地缓存 10 万条,每个对象平均 3KB,加上 Caffeine 内部的结构开销,堆内占用大约 500MB,需要给 JVM 多留出这部分堆内存预算。记得把本地缓存从堆内存中隔离计算,或者用堆外缓存方案,避免影响正常业务对象的 GC 表现。
这些计算看起来很简单,但在真实项目里,很多团队就是没提前算,等到内存溢出了才开始逐个排查是哪份缓存吃掉了内存。提前把容量账算清楚,能省掉后面很多运维救火的痛苦。
8. 我对缓存设计的一些总结与心得
回到章节标题“Cache 摘录”,我写的这些不是缓存技术的全部,但它们是缓存工程实践中最常被用到、也最容易被搞砸的部分。缓存不是一个可以“加了就完事”的组件,它是一整套需要持续关注命中率、延迟、内存、一致性、故障恢复的生命周期管理体系。
几个我反复验证过的心得,最后再强调一遍:
第一个,缓存设计从“数据该不该缓存”开始,而不是从“用 Redis 还是 Caffeine”开始。考虑缓存之前先想清楚问题是什么:是数据库压力大?还是接口响应慢?如果是数据计算链路过长导致的慢,也许应该优化计算逻辑而不是加缓存。
第二个,一切缓存参数都要用数据驱动。TTL 设多长、容量开多大、淘汰算法选哪个,全部要有监控数据支撑。不要凭感觉拍脑袋配置,然后出了问题再反复调。
第三个,缓存是系统的加速器,不是系统的存储层。任何放进缓存的数据都要做好“随时消失也影响不大”的心理准备。如果一份缓存数据的丢失会导致系统功能不可用,那这份数据本来就不该只放在缓存里。
第四个,技术方案越复杂,越容易在生产环境翻车。延迟双删、binlog 订阅、分布式锁、多级缓存,这些都是解决特定问题的工具,不是必须全部上的配置。如果一个简单的 TTL 自愈方案能满足业务需求,就不要为了让架构“看起来高端”而引入额外的复杂度。我见过太多过度设计的缓存方案,最后反而是最简单的方案跑得最稳。
另外分享一个小技巧:排查缓存问题时,先看监控数据,再动手改代码。很多缓存问题的表象在应用层,根因却在容量规划或 key 设计上。比如接口突然变慢,新手第一反应是去优化代码,老手则会先看一眼命中率和 Redis 慢查询,很多时候问题就已经水落石出了。这个习惯比记住任何具体技术点都值钱。