一个商品详情缓存把"完整详情 + 全部评价摘要 + 推荐列表"塞进一个 value,平均 400KB。上线后 Redis 网络出口先打满,然后是应用的反序列化 CPU 打满——而命中率一直很好,没人觉得有问题。
缓存值的大小是一个被忽略的成本维度:它决定了网络带宽、序列化开销、以及"取一次要多久"。这个"与命中率无关"是关键:命中率是"有没有拿到",不是"拿得划不划算"。所以一个命中率 99% 的缓存,仍可能因为"每次拿 400KB 只用 2KB"而成为瓶颈。
而这些成本随"值的大小 × 请求频率"增长,与"命中率"无关。
1. 四个成本项
| 成本 | 随什么增长 |
|---|---|
| 网络传输 | 值大小 × 请求数 |
| 序列化/反序列化 | 值大小 × 请求数(CPU) |
| 内存占用 | 值大小 × 条目数 |
| 淘汰成本 | 值越大,一次淘汰释放越慢 |
第一项在"高频访问大 value"时最先爆:单次 400KB × 2000 QPS = 800MB/s,这个量级足以打满万兆网卡。
第三项在"条目多"时爆:10 万个 400KB 的 value = 40GB第一项(网络)还有个"放大"因素:取缓存与回源都要走网络,而大 value 让"缓存"这条路径的网络成本接近"回源"——于是缓存的价值被抵消。
,而多数团队按"条目数"规划容量,忽略了大小。
2. 值该多大:一个参考尺度
| 量级 | 适用 |
|---|---|
| < 1KB | 适合高频、大量条目 |
| 1–10KB | 常见配置与详情 |
| 10–100KB | 需评估带宽 |
| > 100KB | 通常该拆 |
这个尺度不是硬规则:图片类 value 常是几百 KB 且必须整块用,它不属于"该拆"的范畴。所以判据是"用多少"而不是"多大"。
第四项的建议来自"一次网络往返的价值":如果单次取 400KB 只为用其中 2KB,那 99% 的带宽是浪费的——而这类"取全量用一部分"的访问模式极常见。
// 一次请求的成本拆解(400KB value,2000 QPS)
网络:400KB × 2000 = 800MB/s(可能打满网卡)
反序列化:400KB → 对象 ≈ 1.2MB 临时内存,CPU 约 1–3ms
// 如果只用到其中 2KB,成本放大了 200 倍
3. 大 value 的四种拆法
| 拆法 | 适用 |
|---|---|
| 按"访问模式"拆 | 详情 / 评价 / 推荐分开 |
| 按"变化频率"拆 | 详情稳定、评价常变 |
| 存 ID 列表 + 二次取 | 列表类数据 |
| 存压缩后的值 | 文本类、可压缩 |
第一项最有效:把"总是一起用"的部分放一起,把"有时用"的拆出去。而多数大 value 的问题是"把不相关的东西放在一起"。
第二项减少失效成本:评价变了不该让详情失效,而合并存储会导致整块重算。第二项的额外收益在"降级":评价服务挂了,详情仍可读。而合并存储时,只要其中一部分不可用,整块就不可用——这也是"耦合"的一种形态。
// 拆分示例:商品详情页
key: item:{id}:base ≈ 2KB 详情,TTL 1h
key: item:{id}:reviews ≈ 8KB 评价摘要,TTL 5m
key: item:{id}:recs ≈ 3KB ID 列表,TTL 10m
// 收益:详情命中率最高、评价失效不影响详情、推荐可单独降级
4. 压缩:什么时候值得
| 数据类型 | 压缩比 |
|---|---|
| JSON(含重复键) | 3–8 倍 |
| 文本 | 2–4 倍 |
| 已压缩二进制 | 接近 1 |
| 短字符串 | 可能变大 |
第二项与第三项的边界要注意:对已压缩的数据再压缩,通常只增加 CPU。
第二项与第三项的差别值得说清:JSON 的压缩比高是因为"键重复出现",而二进制格式已经"去掉重复",所以再压缩收益小。判断方法:先看原始格式的"冗余程度"。
第四项是"小 value 压缩"的陷阱:压缩头本身有几个字节,1KB 以下可能得不偿失。
// 压缩的取舍:先量压缩比,再看 CPU
byte[] raw = serialize(value); // 40KB
byte[] gz = gzip(raw); // 6KB(压缩比 6.7)
// 收益:网络省 34KB;成本:gzip 约 0.3ms、gunzip 约 0.2ms
// 判据:网络省下的时间 > CPU 花掉的时间(在高 QPS 下通常成立)
还有一个"批量取"的形态会放大问题:MGET 一次取 100 个 key,每个 400KB 就是 40MB 一次往返。这不仅慢,还会让 Redis 的单线程被"打包响应"占住。
// 批量取的体积估算(必须提前算)
MGET 100 key × 400KB = 40MB / 次
若 QPS 10 → 400MB/s,且 Redis 单线程要拼 40MB 响应
// 缓解:限制 batch 数量、按大小分片、或改用 pipeline + 流式消费
5. 序列化格式的影响
| 格式 | 大小 | 速度 |
|---|---|---|
| JSON | 大 | 慢 |
| Java 序列化 | 中 | 中(且不跨语言) |
| Protobuf/MessagePack | 小 | 快 |
| 紧凑二进制 | 最小 | 最快 |
第一项在调试友好性上最好,但它的体积与解析成本都是最高的——而多数团队用它只是为了"人能读"。
第三项在"大 value"上收益明显:字段名只出现一次(或编号代替),体积常能减半。第三项(Protobuf)的代价是"不可读":排障时不能直接看 value,需要工具解码。所以"人能读"这件事在缓存里往往不重要——缓存不是给人看的。
// 同一对象的体积对比(约 20 个字段)
JSON: 2.4KB Protobuf: 0.9KB // 体积差 2.6 倍
// 若 QPS 2000:省下 3GB/小时的网络流量
监控上要能"按大小分档"看缓存,否则问题不可见:
// 三个必须有的观测量
1 value 大小分布(P50/P99/max) → 发现"少数大 value 占大头"
2 命中 vs 带宽消耗 → 判断"命中是否值得"
3 序列化耗时(单次与总量) → 判断 CPU 成本
// 只监控"命中率"时,这三个都看不到
6. 排错:五种"缓存拖慢了系统"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| Redis 带宽打满 | 大 value + 高 QPS | 拆分或压缩 |
| 应用 CPU 高但 QPS 不高 | 反序列化开销 | 换格式或缩小值 |
| 内存增长快 | 条目 × 大小 | 加淘汰或缩小 |
| 取缓存比查库还慢 | 值太大 | 拆小或改直查 |
| 偶发超时 | 大 value 传输抖动 | 加超时与降级 |
第四项还有一个"冷启动"版本:大 value 在缓存刚预热时集中写入,会同时打满网络与 CPU,表现为"服务刚起来时特别慢"。
第四项是最反直觉的:缓存本应更快,但大 value 的传输 + 反序列化可能超过一次简单查询。判据是"实测两条路径的耗时",而不是"缓存一定更快"。
最后一个"演进"视角:缓存值的大小会随时间悄悄增长。今天 20KB 的详情,加了推荐、加了标签、加了 A/B 字段,半年后变成 200KB——而"加字段"这个动作本身没人会想到"要评估缓存成本"。
// 防渐变的两个动作
1 上线前记录 value 大小基线(写进接口文档)
2 大小增长超过 2 倍时触发评审(不是告警,是评审)
// 关键:它属于"设计漂移",而不是"故障",所以不能靠告警发现
7. 边界:什么情况大 value 是合理的
| 场景 | 为什么 | 做法 |
|---|---|---|
| 低频访问(分钟级) | 带宽压力小 | 可接受 |
| 一次取用全部内容 | 无浪费 | 可接受 |
| 用于导出/下载 | 本来就是大块 | 可接受 |
| 已被压缩的媒体 | 压缩无收益 | 直存对象存储 |
判断口径一句话:这个 value 被"整块用"还是"只用一小部分"。最后一条与"容量规划"有关的边界:按"条目数"规划容量是常见错误,因为条目大小可能差两个数量级。正确做法是"按平均大小 × 条目数 + 最大值 × 安全系数"估算。
整块用,大小不是问题;只用一小部分,就说明"存的东西"与"取的目的"不匹配。
8. 自测
- 缓存值大小的四个成本项分别随什么增长?为什么"命中率很好"也可能有问题?
- 为什么"400KB × 2000 QPS"最先打满的是网络而不是 CPU?
- 四种拆法里,哪两种最有效?"按变化频率拆"解决了什么额外问题?
- 什么情况下"压缩"反而得不偿失?
- 为什么"取缓存可能比查库还慢"?判据是什么?
- 判断"大 value 是否合理"的那个问题是什么?