一个商品列表页做了缓存,命中率只有 12%——而详情页缓存命中率 94%。团队的结论是"列表页缓存没用",于是撤掉。而真实原因是:列表页的键是"筛选条件 × 排序 × 分页"的组合,于是"同样的组合"很少重复(用户总在改条件)。
缓存收益不只看"数据能否缓存",还要看"键的重复率"。而列表页的天然特征就是"键空间巨大、重复率低"——这个转向的判据是"失效粒度":如果"一个字段变化"会让"整块缓存"失效,那这块缓存的生命周期就被"最易变的字段"决定——而它通常很短。
它不适合"按请求缓存",而适合"按数据片段缓存"。
1. 列表页键的"组合爆炸"
| 维度 | 取值数 |
|---|---|
| 分类 | 20 |
| 价格区间 | 5 |
| 排序方式 | 4 |
| 页码 | 10 |
| 每页条数 | 3 |
组合数 ≈ 12,000。而"日均访问 5,000 次"意味着平均每个键被访问不到一次——命中率必然极低。
这个算式很实用:"键空间 × 访问量"决定命中率上限。这个算式还能用来"预估命中率":命中率上限 ≈ 1 - 键空间/访问量(在均匀分布时)。而在"幂律分布"(少数组合占多数)时,实际命中率会更高——所以"热门筛选"仍值得缓存。
键空间接近或超过访问量时,缓存基本无效。
2. 三种"适合列表页"的缓存
| 方式 | 键是什么 |
|---|---|
| 缓存"单条数据" | item:{id} |
| 缓存"ID 列表"(短 TTL) | list:{hash},TTL 30–60s |
| 缓存"聚合结果"(按维度) | agg:{category}:{day} |
第一项的"成本"是"20 次网络往返":所以实践中用"批量取"(MGET 或多条 pipeline)把 20 次压成 1–2 次,于是"分层缓存"的延迟可以接近"整页缓存"。
第一项最稳:列表页展示 20 条,就查 20 次 item:{id},而 detail 页也在用同一个键——于是"复用"发生在"数据层"而不是"页面层"。
第三项适合"变化慢的统计":"各分类的商品数"这类聚合可以缓存很久第三项还有个"分页"用途:"总条数"比"当页数据"更值得缓存,因为它不随页码变,而分页器需要它来渲染总页数。
,而它不随筛选条件变。
// 分层缓存:不同层用不同的键与 TTL
cache:
item: { key: "item:{id}", ttl: 300 } # 单条,复用最高
list_ids: { key: "list:{cond_hash}", ttl: 45 } # ID 列表,短 TTL
agg_count: { key: "agg:{category}:{day}", ttl: 3600 } # 聚合,长 TTL
// 关键:list_ids 只存 ID,不存整条数据 → 值小、可频繁失效
"整页缓存"还有一个"版本"上的麻烦:页面结构一变(加了一个字段),旧缓存里的结构就不对了,于是"缓存键里要带版本号",而"版本一升、命中率归零"。
// 两种值的失效面(同一列表页)
整页 JSON:价格变 / 库存变 / 页面结构变 / 排序规则变 → 都失效
ID 列表 :只随"排序规则"与"新增/下架"失效(数据变化不影响顺序)
// 结论:只存 ID,把"易变字段"留给单条缓存或实时查询
3. 为什么"ID 列表"比"整页 HTML/JSON"好
| 存什么 | 失效影响 |
|---|---|
| 整页渲染结果 | 一条商品变价 → 整页失效 |
| 整页 JSON | 同上 |
| 只存 ID 列表 | 变价不影响列表顺序,只需重取单条 |
第三项的优势在于"失效粒度与数据粒度对齐":价格变了只需要失效 item:{id},而列表顺序(按销量)可能几分钟才变一次。
而"整页缓存"的问题在"任何一个字段变化都会让整页失效"——于是"缓存时间"被迫设得很短,收益也随之消失。
4. 列表页的三个"不可缓存"因素
| 因素 | 原因 |
|---|---|
| 个性化(推荐/画像) | 每人不同 |
| 实时库存/价格 | 秒级变化 |
| 权限过滤 | 每人可见集合不同 |
第二项在"大促"时尤其明显:价格与库存秒级变化第二项的对策是"展示层补偿":列表里的价格可以"标记为参考价",点击进入详情时取实时价——于是"缓存的一致性要求"被降级。
,于是"缓存 30 秒"也可能展示"已售罄还能下单"。
对策是"分层":结构(ID 列表、排序)可缓存;可变字段(价格、库存)单独取,前端合并。
"热门筛选"的处理值得单独做:统计"哪些条件组合占了 80% 的请求",只给它们做缓存。这一小步能把命中率从 12% 提到 50% 以上——因为幂律分布意味着"少数组合占多数请求"。
// 从访问日志里取 Top 组合(每小时/每天)
SELECT cond_hash, COUNT(*) c FROM access_log
WHERE path = '/list' AND ts > NOW() - INTERVAL 1 DAY
GROUP BY cond_hash ORDER BY c DESC LIMIT 20;
-- 若前 20 个组合占 75% 请求 → 只缓存这 20 个键,收益最大
5. 怎么算"值不值得缓存"
| 判据 | 阈值 |
|---|---|
| 键空间 / 日均访问 | < 0.1 值得 |
| 单次查询耗时 | > 20ms 才值得 |
| 数据变化频率 | 变化间隔 > TTL 才值得 |
| 一致性容忍度 | 可容忍 TTL 级延迟 |
第一项决定"命中率上限",第二项决定"省下多少",第三项决定"缓存是否有效",第四项决定"能不能用"。
第一项的阈值"< 0.1"来自经验:键空间是访问量的十分之一时,平均每个键被访问 10 次,于是"缓存一次、命中九次"——收益明显。
四项是"与"的关系:任一不满足,缓存收益都很小。而"列表页"的问题通常卡在第一项与第三项。
"缓存命中率"要看"键级"而不是"全局":整体 12% 里,可能 Top 20 键的命中率是 90%,而长尾键是 0%。所以"撤掉缓存"是错误的结论——正确结论是"只缓存 Top 键"。
// 分键统计(示例)
Top 20 键:命中率 91%,占总请求 74% → 值得缓存
长尾键: 命中率 2%,占总请求 26% → 不值得
// 结论:整体 12% 是被长尾拉低的,而"值不值得"应当按键看
6. 排错:五种"列表页缓存无效"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 命中率 < 20% | 键空间过大 | 改存 ID 列表/单条 |
| 缓存后数据仍旧 | TTL 太短或被高频失效 | 分层 + 短 TTL |
| 内存涨得快 | 值太大(整页 JSON) | 只存 ID |
| 用户看到别人的数据 | 键漏了用户/权限维度 | 加维度 |
| 大促时全失效 | 价格/库存进缓存 | 可变字段分离 |
第四项是最严重的一类:"按筛选条件缓存"但没带"权限/用户"维度第五项(大促全失效)的机制是"批量失效":价格一变,"按分类缓存"的所有键同时失效,于是"缓存瞬间归零"而请求照旧——这就是"缓存雪崩"的一个具体形态。
,于是"A 用户看到 B 用户可见的商品"——这类问题在小流量下不出现。
最后一个"分层"的落地顺序:先做"单条缓存"(收益最高、风险最低),再做"聚合缓存",最后才考虑"ID 列表缓存"。因为单条缓存的键天然稳定、失效点明确,而列表缓存的键与失效都要更复杂的设计。
// 三步走(按投入产出)
1 item:{id} 缓存:改一行代码,命中率高,失效点单一
2 agg:{dim}:{day}:聚合类,TTL 长,几乎无一致性问题
3 list:{cond_hash}:需要"短 TTL + 只存 ID",改造最大
// 多数列表页做完 1、2 就不必做 3
7. 边界:什么情况列表页不必缓存
| 场景 | 为什么 | 做法 |
|---|---|---|
| 访问量小(每天几百次) | 缓存成本高于收益 | 不缓存 |
| 数据实时性强(库存/价格) | 缓存即错误 | 不缓存 |
| 深度个性化(推荐流) | 键空间无限 | 缓存"候选集"而非结果 |
| 已有搜索引擎承载 | 查询本身很快 | 不缓存 |
最后一条与"演进"有关的边界:列表页的缓存策略应当随"排序规则"变化。按"销量"排序的列表可以缓存更久(销量变化慢),而按"上新时间"排序的列表几乎不可缓存(每次请求都可能不同)。
判断口径一句话:"同一个键"会不会在短时间内被访问多次。会(热门分类首页),缓存有效;不会(长尾筛选组合),缓存只是占内存。
8. 自测
- 列表页的"组合爆炸"是怎么产生的?为什么键空间接近访问量时缓存无效?
- 三种适合列表页的缓存里,哪一种复用最高?为什么它能在详情页也被用到?
- "只存 ID 列表"为什么比"存整页 JSON"好?它与"失效粒度"有什么关系?
- 三个"不可缓存"因素里,哪一个在大促时最致命?对策是什么?
- "值不值得缓存"的四个判据是什么关系?列表页通常卡在哪两项?
- 判断"列表页是否需要缓存"的那个问题是什么?