2553 字
约 8 分钟
16
S12-A20 · 为什么列表页缓存收益低:键太多、失效太散

一个商品列表页做了缓存,命中率只有 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. 自测

  1. 列表页的"组合爆炸"是怎么产生的?为什么键空间接近访问量时缓存无效?
  2. 三种适合列表页的缓存里,哪一种复用最高?为什么它能在详情页也被用到?
  3. "只存 ID 列表"为什么比"存整页 JSON"好?它与"失效粒度"有什么关系?
  4. 三个"不可缓存"因素里,哪一个在大促时最致命?对策是什么?
  5. "值不值得缓存"的四个判据是什么关系?列表页通常卡在哪两项?
  6. 判断"列表页是否需要缓存"的那个问题是什么?
S12-A20 · 为什么列表页缓存收益低:键太多、失效太散
https://www.clxhxhhr.top/posts/6567/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。