我见过不少系统加缓存,动机主要有两个:"别人都在加"和"它能让我系统变快"。
缓存的价值我不否认:避免重复操作,节省带宽和计算资源。缓存有很多层------本地内存缓存、分布式缓存(Redis)、文件缓存、CDN------每层解决的问题不同,付出的代价也不同。本地内存快,但每个实例各持一份;Redis 共享,但多一次网络 IO;CDN 扛静态流量,但更新滞后。
但每一层都有标价。我说说几个真实场景里看到的代价,以及每个代价对应的解法------还有解法本身引入的新问题。
一个真实的场景
需求是用户请求执行指定版本的代码。系统负责下载对应版本的代码包,然后执行。
完整链路:用户请求 → 查询版本号列表(缓存)→ 如果版本不在 NFS,从 OSS 下载 → 存入 NFS → 解压 → 执行。
这里有三个角色,容易混:
- OSS:云对象存储,代码归档包由业务方上传,只做存放大文件,节点无法直接执行。
- NFS:共享文件存储系统,代码下载后放这里,本地节点可以直接读取执行。这是存储,不是缓存。
- 缓存:版本号列表,记录哪些版本已经下载到 NFS。
用户请求 v1.0,先查版本号列表。命中,说明文件已在 NFS,直接加载执行;未命中,从 OSS 下载。
关键的约束是:版本不可预知,我不能保留所有版本。缓存的价值是避免每次都查 NFS 确认版本是否存在------省的是那一次查询的开销。

代价一:内存成本
缓存最直接的代价是内存。
版本文件场景里,缓存的是版本号而不是文件内容。版本号是一个字符串,1000 个版本号的内存占用可以忽略------这反过来说明,这个场景里"内存成本"根本不是缓存的主要开销。
如果换成缓存文件内容,成本立刻上来。比如每个版本的 meta 文件(SHA256、文件大小、发布时间),一个几 KB,1000 个也就几 MB。再比如配置内容,JSON 格式一个几百 KB,几千个就是几 GB。内存成本随数据类型变化,差一个量级都不止。
内存成本还不只是存储空间:
- GC 压力。Java 堆里放大量缓存对象,Full GC 一次几百毫秒到秒级,用户请求卡那一瞬。Go 和 Rust 没有 GC,但有大内存分配、锁竞争、内存分配器扩展性的开销。
- 淘汰策略的 CPU 开销。LRU 维护访问顺序,LFU 维护计数,每次访问都有额外开销,高 QPS 下不可忽视。
- 大 key 的代价。一个大 key 导致内存碎片化;嵌套 JSON、大 key 让内存使用超出预期。
内存成本这块,真正的决策点不是"1GB 多少钱",是"缓存的值体积和访问频率,配得上这块内存吗"。
代价二:网络成本
网络成本来自每次缓存访问的网络 round trip,以及序列化/反序列化的 CPU 开销。
版本文件场景:不做缓存,每次要先查 NFS 上有没有这个版本的文件。NFS 是网络存储,一次查询就是一次网络 IO。版本不在 NFS 时还要从 OSS 下载+解压,延迟更高。
用了缓存(版本号列表),查询命中时省掉 NFS 的 IO。但引出一个新问题:并发下载。多个请求同时发现版本不存在,都去 OSS 下载,问题有两个:一是把 OSS 打挂,二是多个节点同时下载同一个版本文件,写 NFS 时出现写冲突------文件写一半被另一个节点覆盖,或者各自写出的内容不一致。
解法是分布式锁------只放一个请求去下载,其余等它完成。但锁本身有代价:
- 锁服务要保证高可用,它自己成了新单点
- 下载期间的等待,请求全部阻塞在锁上
- 锁异常(没释放、死锁)时,整个下载链路卡死
这就是"解法引入新问题"的典型:用锁解决并发下载,代价是引入一个需要额外运维的分布式组件。
网络成本的反面案例:一次请求查 10 个缓存 key,每个都是小对象,10 次 round trip 加起来比一次数据库批量查询还慢。缓存不是加了就快,命中率低、key 拆分过细,反而更慢。
代价三:一致性成本
这是最贵的一层,也是扣分最多的地方。
版本文件场景的一致性,先分清楚是哪种一致性:版本号列表的一致性,不是版本文件内容的一致性。 每个版本对应唯一文件,版本号即文件标识------v1.0 就是 v1.0,内容不会变。没有"改了昵称要刷新缓存"这类内容更新产生的不一致------这一层天然规避了。
但一致性问题是存在的,来源是定时任务清理过期版本。NFS 容量有限,过期版本会被后台任务定期删掉。删除动作发生在缓存之外:清理任务删了 NFS 上的文件,版本号列表里却还记着"v1.0 存在"。
于是出现经典的视图不一致:
- 本地缓存说 v1.0 存在(TTL 还没到)
- Redis 说 v1.0 存在
- NFS 上 v1.0 文件已经被清理任务删了
- 用户请求 v1.0:查本地缓存命中 → 从 NFS 加载 → 文件不存在 → 加载失败
缓存告诉请求方"有",实际没有。这比"缓存没命中"更糟------命中是错的。
解法是多级缓存一致:清理任务删除 NFS 文件时,同步清掉 Redis 和本地缓存里的版本号;或者退而求其次,靠 TTL 收敛------各层缓存设短 TTL,过期后重新探测 NFS,用时间换一致。前者实时但要把清理任务和缓存打通,后者简单但窗口期内会返回错误命中。
真正有一致性代价的可变数据------以"用户信息"为例,缓存了一份用户资料,用户改了昵称,缓存怎么办?------走下面的模式。
Cache Aside 是底线
实际工程里默认的模式是 Cache Aside:
- 读:先查缓存,命中返回;未命中查数据库,写回缓存
- 写:更新数据库,然后删除缓存(不是更新缓存)
为什么删除而不是更新缓存?因为更新缓存要承担"缓存值怎么算"的逻辑,比如用户信息带了个关联字段,直接更新容易算错;删除则简单,下次读自然重建。这是删比更新稳的第一层原因。
删除也有竞态
删除缓存不是免费的,有一个经典的并发竞态:
- 线程 A 读缓存,未命中
- 线程 B 更新数据库,值从 V1 变成 V2
- 线程 B 删除缓存
- 线程 A 此时才去读数据库------读到的是 V1(B 还没提交或者按旧值查),写回缓存
- 从此缓存里躺着 V1,直到 TTL 过期
数据库更新和缓存删除之间没有原子性,这个窗口就漏了。延迟双删是兜底:删除缓存 → 更新数据库 → 等一小段(比如 500ms)→ 再删一次。第二次删除把竞态窗口里写回的旧值清掉。代价是那 500ms 内的请求读到旧数据,以及多一次删除操作。
一个常见误解:"先删缓存再更新数据库"是安全的------不对。先删缓存、再更新数据库,中间有窗口,读请求查库读到旧值写回缓存,数据库更新完成后缓存是脏的,TTL 不失效就一直脏。这是三种顺序里最危险的一种。删缓存要放在更新数据库之后,再配延迟双删兜底。
更强的写模式,更大的代价
Cache Aside 之外还有两个写模式:
- Write-Through(同步写):写请求同时更新数据库和缓存,缓存永远和数据库同步,读永远命中。代价是每次写多一次同步调用,写路径延迟上升。适合写少读多、一致性要求高的数据(用户余额、库存)。
- Write-Behind(异步写):先写缓存,后台异步批量写回数据库。写入极快,但缓存挂掉时会丢未落库的数据。适合允许最终一致的高写入场景(阅读计数、点赞数)。
三个模式的取舍不是"哪个好",是"你愿意付哪种代价":
| 模式 | 读的代价 | 写的代价 | 风险 |
|---|---|---|---|
| Cache Aside | 略慢(可能 miss 重建) | 更新数据库 + 删缓存(delete O(1)),比 Write-Through 快 | 竞态窗口,需延迟双删 |
| Write-Through | 最快(永远命中) | 慢,更新数据库 + 更新缓存(序列化 + 网络写入) | 同步调用失败缓存脏 |
| Write-Behind | 快(永远命中) | 最快(只写缓存) | 缓存丢数据,最终一致 |
多层缓存失效:最难的一层
再加一层本地缓存,问题升级。多实例本地缓存怎么同步失效?这是分布式缓存里最难的工程问题之一,方案有三个:
- Redis pub/sub 广播:更新后发消息,各实例收到后清本地缓存。延迟低(毫秒级),但消息可能丢------订阅断线、Redis 用内存列表存消息有截断风险。可靠性弱。
- MQ 广播:走可靠消息队列,消息不丢,但延迟高(秒级是常态),且多了一整个 MQ 依赖。
- TTL 兜底:本地缓存设短 TTL,比如 60 秒,不主动清,靠过期自愈。零额外依赖,但最坏 60 秒不一致窗口。
实际系统的做法是组合:核心数据用 pub/sub 主动清,TTL 作最后防线,清理失败靠 TTL 收敛。没有银弹,每一层都要回答"清不了怎么办"。
一致性这段的结论:一致性没有免费的模式,付什么代价得自己选。 Cache Aside 是底线,延迟双删是补丁;Write-Through 用写延迟换读一致性;Write-Behind 用丢数据风险换写吞吐。选哪个,取决于业务接受哪种风险。
代价四:故障成本
加缓存的系统,故障点多了缓存本身。缓存挂了,系统能不能活,是加缓存前就要想清楚的问题。
先想高可用这件事。Redis 挂了,还能不能读到版本号?两条路:
- 有本地缓存兜底:Redis 挂了还能从本地读到,系统降级但不崩
- 没有本地缓存:请求打到 NFS 或 OSS,系统承压,NFS 扛不住就变慢
反过来,如果不做缓存,每次都查 NFS,NFS 挂了系统直接不可用------因为 OSS 上的文件无法直接执行。缓存和高可用是配套的:加缓存就要想缓存挂了怎么办,要么本地兜底,要么降级直查存储。
缓存自身的故障有三类经典问题,每类都有标准解法,解法又各有代价:
穿透------查一个不存在的 key,缓存每次查都不命中,请求穿透到数据库。恶意构造不存在的 key(比如 id=-1)洪水攻击,数据库直接被漏空。两个解法:
- 缓存空值:查不到也缓存一个 null,设短 TTL(比如 5 分钟)。代价:恶意攻击者可以构造海量不同 key,空值缓存把内存打爆,需要配合基数上限控制。
- 布隆过滤器:请求进来先过过滤器,key 不存在直接返回,不碰缓存和数据库。代价:布隆过滤器本身占用内存,且存在误判(可能放行不存在的 key,但不会拦下存在的),且 key 集合变化时要重建。
两者常配合用:布隆过滤器挡大头,空值缓存兜小尾巴。
击穿------热点 key 过期的一瞬间,大量请求同时打到数据源。版本文件场景:某个版本刚发布成为热点,版本号列表刷新,所有请求都去探测,探测量骤增。两个解法:
- 互斥锁:只放一个请求去重建缓存,其余请求等待结果。代价:引入锁竞争,热点下请求排队;锁实现不好会死锁,需要超时机制。
- 逻辑过期:不设 TTL,缓存永不主动过期,后台异步任务定期刷新。代价:后台刷新要额外开发;刷新失败时请求还会拿到旧值(对某些业务不可接受)。
雪崩------大量 key 同一时刻过期,数据库瞬间被打穿。根因通常是 TTL 设成一样的,比如统一 30 分钟,凌晨一起过期。三个解法:
- 随机 TTL:过期时间 = 基础值 + 随机偏移,错开集中过期。零代价,只是不要设成整齐的整数。
- 热点 key 永不过期 + 后台刷新。代价同击穿的逻辑过期。
- 熔断降级:数据库压力升高时,熔断器打开,直接返回降级响应,宁可旧数据不可无数据。代价:降级期间用户看到旧数据或兜底响应。
故障排查的成本也常被低估。缓存故障的症状是"数据库负载突然升高""接口响应变慢",根因在缓存不在数据库,排查要穿透好几层才能定位。这也是为什么缓存层要提前埋监控。
代价五:运维成本
缓存上线只是开始,维护它要持续花钱。
监控指标的解读是个技术活。缓存命中率、响应时间、内存使用是基础三件套,但指标本身会骗人:
- 命中率 99% 是好吗?可能是缓存太大根本没怎么淘汰,或者缓存的都是冷数据------浪费内存换虚高的命中率
- 命中率 50% 是差吗?如果命中的都是热点数据,50% 已经省掉了大部分数据库查询,比命中率 99% 但全是冷数据更有价值
监控要会"解读"------区分数据冷热,比看数字本身重要。
容量规划。数据量增长趋势、扩容节奏、淘汰策略都要设计。版本文件场景:缓存版本号,1000 个的内存占用可忽略;换成 Session、热点商品详情,一个 key 几百 KB,几万个就是几 GB。淘汰哪个?LRU 还是 LFU?LFU 还要处理访问频率的时效性------一个旧热点长期占着高频,新热点上不来。
故障演练。定期演练缓存故障,看系统表现。多数团队不做,因为"没时间",结果真出故障时才发现兜底路径从来没跑通过。
代价六:机会成本
最隐蔽但可能最贵的一层:选了一条路,放弃了另一条。
复杂度。加一层缓存,系统就多一层要维护、要监控、要排查的东西。有些场景不缓存反而更简单------直接查数据库,逻辑清晰,故障好排查。
架构绑定。选了 Redis,就绑定了它的技术栈和数据模型,将来迁移改造成本不低。
认知负荷。团队里多少人真正理解缓存层的逻辑?新成员上手多久?出问题谁能排?
回到版本文件场景,把六个代价套一遍:
- 缓存的是版本号,内存成本可忽略 ✅
- 网络成本:省的是每次查 NFS 的 round trip ✅
- 一致性:版本号列表和 NFS 实际存储之间的一致性(版本文件本身不可变,没有内容一致性问题)
- 故障:多了一层要兜底(Redis 挂了怎么办、并发下载怎么锁)
- 运维:版本号列表的容量、TTL、淘汰策略
- 机会:多维护一个缓存组件和它的锁、监控、故障路径
那么问题来了:这个场景到底该不该加缓存? 我的判断是:该加,但要说清楚为什么。
几个要权衡的点:
- NFS 查询是有成本的。 每次查 NFS 确认版本是否存在,都是一次网络 IO。版本请求量大时,这些查询累积成可观的 IO 压力,直接打在共享存储上。缓存版本号列表,把这些查询拦在 NFS 之前,是真实省下的开销。
- 不加缓存,并发下载的问题依然要面对。 多个请求同时发现版本不存在,都去下载,写 NFS 冲突、打挂 OSS------这个问题不因"不缓存"而消失,它一直都在。
- 版本不可变这特性,把一致性问题收窄成了"存在性"一类。 没有内容更新,就不需要延迟双删那套;要处理的只有"NFS 上版本被清理任务删了,缓存还留着"这一种。解法就是前面说的:清理时同步清缓存,或靠 TTL 收敛。
所以这个场景的最终形态是多级缓存,而不是"加不加"的二选一:
- 第一级:本地缓存(进程内)。版本号列表放内存,查询是零 IO。多实例各持一份,靠 TTL 自愈,这个场景可接受。
- 第二级:分布式缓存(Redis)。多实例共享版本号列表,新下载的版本立刻全局可见,避免其他实例重复探测。
- 第三级:NFS 本身。它是最终事实源,也是兜底------缓存全挂,请求还可以直接查 NFS。

每一级都在回答不同的问题:本地缓存答延迟,Redis 答多实例一致,NFS 兜底保正确。层数定了之后,剩下的就是运维负担------监控命中率、容量、TTL------这些是加缓存必然伴随的代价,用省下的 NFS IO 抵消。
顺带说,这也解释了为什么很多系统加了缓存没变快:它们缓存的是一查数据库就能拿到的廉价数据,省下的开销还没开销掉的复杂度多。缓存价值取决于被缓存操作有多昂贵,而这个场景里 NFS 查询的多实例并发放大,正好是贵的。
总结
缓存不是白用的,每一层都有标价。
我加缓存之前会按这几个问题过一遍:这一层解决什么问题、省下什么开销、付什么代价、代价引出的新问题有没有解法、比不加缓存更简单吗。
缓存用对了确实省成本,但代价不清楚就加,有时候会适得其反------尤其是一致性和故障这两层,解法比问题本身更复杂。