Feed流:从推拉模式到三级缓存架构的工程实践
开篇:你以为 Feed 流就是"查库分页"?
先问个问题:你做过的 Feed 流系统是怎么实现的?
- A. 每次请求直接
ORDER BY create_time LIMIT offset, size? - B. 加了个 Redis 做全页缓存?
- C. 听说过"推模式""拉模式",但没想清楚该用哪个?
如果选了 A 或 B,别急------大部分人的第一版都是这样。但随着用户量上来、并发上去,你会发现:
数据库 CPU 飙到 90%,Redis 内存爆掉,用户刷 Feed 还要等 200ms+。
这篇文章不讲空泛的理论,直接拆解一个生产级 Feed 流的三级缓存架构,从推拉模式选型到数据结构设计,从写入流程到一致性保障,全部是可落地的实战经验。
第一篇:推模式 vs 拉模式------先搞清楚你在做什么
1.1 一句话定义
| 模式 | 核心思想 | 类比 |
|---|---|---|
| 推模式 (Fan-out) | 用户发布内容后,主动把这条内容的 ID 推送到所有粉丝的收件箱 | 报纸发行:编辑印好报纸,主动送到每户信箱 |
| 拉模式 (Fan-in) | 用户刷新 Feed 时,实时去关注的人那里拉取最新内容 | 去超市买菜:你想吃什么,自己去货架上拿 |
1.2 两种模式的完整流程
推模式(写扩散)
粉丝N的收件箱 粉丝C的收件箱 粉丝B的收件箱 消息队列 数据库 用户A(发帖者) 粉丝N的收件箱 粉丝C的收件箱 粉丝B的收件箱 消息队列 数据库 用户A(发帖者) 获取粉丝列表 (假设有100万粉丝) loop [遍历每个粉丝] 写入完成! 总操作数 = 粉丝数 (100万次) 1. 发布帖子 (INSERT INTO posts) 2. 发送消息 "新帖:{postId}, 粉丝列表:[B,C,...,N]" 3. LPUSH fanout:B {postId} 3. LPUSH fanout:C {postId} 3. LPUSH fanout:N {postId}
文字版说明:
- 用户 A 发布帖子后,系统获取他的粉丝列表(可能百万级)
- 通过消息队列异步遍历每个粉丝,把帖子 ID 写入他们的收件箱(Redis List)
- 读的时候极快 :用户 B 刷新 Feed 只需
LRANGE fanout:B 0 9,O(1) 复杂度
拉模式(读扩散)
关注的用户N 关注的用户C 关注的用户A 数据库 缓存层 用户B(浏览者) 关注的用户N 关注的用户C 关注的用户A 数据库 缓存层 用户B(浏览者) 检查本地/Redis缓存 loop [遍历每个关注者] alt [缓存命中] [缓存未命中] 1. 刷新Feed (GET /feed?page=1&size=10) 直接返回 (耗时 <10ms) 2. 查询关注列表 (SELECT * FROM follows WHERE user_id=B) 返回 [A, C, ..., N] (关注了500人) 3. 查询A的最新帖子 (SELECT * FROM posts WHERE user_id=A ORDER BY time LIMIT 10) 3. 查询C的最新帖子 3. 查询N的最新帖子 4. 合并排序 + 分页 (归并算法) 返回 FeedPageResponse (耗时 50~100ms)
文字版说明:
- 用户 B 刷新 Feed 时,先查他关注了谁(比如 500 人)
- 实时去这 500 个人的发布记录里拉取最新内容
- 在内存里做归并排序(按时间倒序),再分页返回
- 写的时候极简:发帖只需 INSERT 一条记录,无需通知任何人
1.3 核心权衡对比
| 维度 | 推模式 (Fan-out) | 拉模式 (Fan-in) |
|---|---|---|
| 写入成本 | 高(O(粉丝数)) | 低(O(1)) |
| 读取成本 | 低(O(1),直接读收件箱) | 高(O(关注数),需聚合排序) |
| 内存占用 | 大(每个粉丝一个收件箱 List) | 小(只需缓存聚合结果) |
| 实时性 | 强(发帖即推送) | 弱(依赖缓存 TTL) |
| 适用场景 | 大V少、粉丝多(明星/媒体号) | 普通用户多、粉丝少(社交网络早期) |
| 扩展性 | 粉丝量线性增长时写放大严重 | 关注数增长时读性能下降 |
1.4 工业界怎么选?
实际情况:大部分系统用的是「推拉结合」
┌─────────────────────────────────────────────┐
│ 混合模式(推拉结合) │
│ │
│ 用户发帖 │
│ │ │
│ ├── 粉丝 < 1000 → 推模式(直接写收件箱) │
│ │ │
│ └── 粉丝 >= 1000 → 拉模式(只写 Timeline)│
│ │
│ 用户刷新 Feed │
│ │ │
│ ├── 先读收件箱(推过来的内容) │
│ └── 再拉取大V的最新帖子(补齐) │
│ │
└─────────────────────────────────────────────┘
典型场景举例:
| 平台 | 策略 | 原因 |
|---|---|---|
| Twitter/X | 推为主 + 大V降级为拉 | 明星账号粉丝过亿,全推会打爆存储 |
| 微博 | 推拉结合 + 分层缓存 | 普通用户推,大V拉,热点内容额外加速 |
| 微信公众号 | 纯推模式 | 订阅关系明确,粉丝量可控 |
| 拉模式 + 智能推荐 | 关注数通常 < 1000,读扩散成本可控 | |
| 抖音/TikTok | 推荐流(非社交流) | 基于算法推荐,不是纯时间线 |
面试加分回答:
Q:你们项目为什么选推模式还是拉模式?
A:我们选的是拉模式为主 + 多级缓存优化。原因有三点:
- 我们的场景是知识分享社区,普通用户平均关注人数在 200~500 之间,读扩散的成本可控;
- 如果用推模式,当一个帖子被编辑或删除时,需要批量清理所有粉丝的收件箱,一致性维护复杂度高;
- 我们通过三级缓存把拉模式的读取延迟压到了 10ms 以内(L2 本地命中 <1ms,L1 Redis 拼接 ~5ms),用户体验不输推模式。
当然代价是实现复杂度更高,需要精心设计缓存层级和失效策略。
第二篇:为什么单一缓存扛不住?------三级缓存的设计动机
2.1 单一 Redis 全页缓存的痛点
假设你一开始只用了一个 Redis 存完整的 Feed 页面 JSON:
java
// 第一版:简单粗暴的全页缓存
String cacheKey = "feed:" + userId + ":" + page + ":" + size;
String json = redis.get(cacheKey);
if (json != null) {
return JSON.parseObject(json, FeedPageResponse.class);
}
// 缓存未命中,查库...
FeedPageResponse result = queryFromDB(userId, page, size);
redis.setex(cacheKey, 60, JSON.toJSONString(result));
return result;
很快你会遇到这些问题:
| 问题 | 表现 | 根因 |
|---|---|---|
| 内存爆炸 | 100万用户 × 10页 × 10KB = 100GB | 每个用户的每页都存完整 JSON,大量重复数据 |
| 缓存雪崩 | 热点页面同时过期,流量瞬间打到 DB | TTL 相同,同时失效 |
| 更新困难 | 某条帖子点赞数变了,要清掉所有包含它的页面缓存 | 粒度太粗,牵一发动全身 |
| 用户态污染 | A 点赞后,B 刷 Feed 也看到红心 | 公共流缓存包含了个人态标志 |
2.2 三级缓存的分层哲学
核心思想:按访问频率和成本分层,热点走高速通道,冷门走普通道路。
┌─────────────────────────────────────────────────────────────┐
│ 三级缓存分层目标 │
├─────────────────────────────────────────────────────────────┤
│ │
│ [L2] 本地缓存 ──→ 命中成本最低,扛住瞬时高并发 │
│ ↓ (Caffeine, 15s TTL, 1000容量) │
│ │
│ [L1] 页面骨架 ──→ 减少网络往返,快速拼装 │
│ ↓ (IDs + hasMore, 60-90s TTL) │
│ │
│ [L0] 内容碎片 ──→ 按需加载,避免重复传输 │
│ ↓ (单条 item JSON, 60-180s TTL) │
│ │
│ [DB] 数据库回源 ──→ 单航班防击穿,保护数据库 │
│ (ConcurrentHashMap 锁) │
│ │
└─────────────────────────────────────────────────────────────┘
类比理解:
想象你去图书馆借书:
- L2(本地缓存)= 你书包里的书:已经借出来放包里了,随时能看(最快)
- L1(页面骨架)= 书架上的目录卡片:告诉你这本书在第几排第几列(快速定位)
- L0(内容碎片)= 书架上的书本身:根据目录找到后抽取出来(稍慢但要走过去)
- DB(数据库)= 图书馆仓库:如果书架上没有,要去仓库找(最慢)
第三篇:L2 本地缓存------Caffeine 的艺术
3.1 为什么选 Caffeine 而不是 Guava?
| 特性 | Guava Cache | Caffeine |
|---|---|---|
| 淘汰算法 | LRU(最近最少使用) | W-TinyLFU(窗口+小频率) |
| 命中率 | 基准线 | 比 Guava 高 25%+(官方 benchmark) |
| 并发性能 | 分段锁(Segment) | 细粒度 CAS 无锁 |
| Spring Boot 集成 | 需手动配置 | spring-boot-starter-cache 原生支持 |
| 维护状态 | 停更于 2020 年 | 活跃维护,Spring Boot 3.x 默认 |
3.2 配置参数详解
java
@Configuration
@EnableCaching
public class FeedCacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
// 公共 Feed 流本地缓存配置
manager.registerCustomCache("feedPublicCache", Caffeine.newBuilder()
.maximumSize(1000) // 最大缓存 1000 个页面
.expireAfterWrite(15, TimeUnit.SECONDS) // 写入后 15 秒过期
.recordStats() // 开启统计(用于监控命中率)
.build()
);
// 个人 Feed 流本地缓存配置
manager.registerCustomCache("feedMineCache", Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.SECONDS) // 个人流变更频繁,TTL 更短
.recordStats()
.build()
);
return manager;
}
}
关键参数解释:
| 参数 | 值 | 设计意图 |
|---|---|---|
maximumSize |
1000 | 限制内存占用,防止 OOM。1000 页面 × 10KB ≈ 10MB |
expireAfterWrite |
15s (公共) / 10s (个人) | 平衡新鲜度和性能。公共流变化慢可以长一点,个人流(点赞/收藏)变化快需短一点 |
recordStats() |
开启 | 生产环境必须开启!用于监控 hitRate()、hitCount()、missCount() |
3.3 Key 设计策略
java
// 公共流的本地缓存 Key(包含布局版本号)
private String cacheKey(int page, int size) {
return "feed:public:" + size + ":" + page + ":v" + LAYOUT_VER;
// 示例: "feed:public:10:1:v1"
// 当前端 UI 布局变更时,LAYOUT_VER++,旧缓存自动失效
}
// 个人流的本地/Redis 共用 Key
private String myCacheKey(long userId, int page, int size) {
return "feed:mine:" + userId + ":" + size + ":" + page;
// 示例: "feed:mine:12345:10:1"
}
布局版本号的妙处 :
当前端改版(比如从双列瀑布流改成三列),只需要把
LAYOUT_VER从 1 改成 2,所有旧缓存自动失效,无需手动清空。
第四篇:L1 Redis 页面缓存------骨架层的精妙设计
这是整个架构中最精彩的部分!公共流不会把整个页面的 JSON 存成一个 Value,而是拆解为 5 种独立结构。
4.1 五种 Redis Key 的职责划分
| 序号 | Key 模板 | 数据类型 | 存储内容 | 用途 |
|---|---|---|---|---|
| ① | feed:public:ids:{size}:{hourSlot}:{page} |
List | 该页的内容 ID 列表(有序) | 页面的"骨架",告诉客户端这一页有哪些帖子 |
| ② | feed:public:ids:{size}:{hourSlot}:{page}:hasMore |
String | "1" 或 "0" |
翻页信号灯,标记是否还有下一页 |
| ③ | feed:item:{itemId} |
String (JSON) | 单条内容的轻量元数据详情 | 内容的"血肉",按需加载 |
| ④ | feed:public:index:{itemId}:{hourSlot} |
Set | 包含该内容的页面 Key 列表 | 反向索引,用于精准失效 |
| ⑤ | feed:public:pages |
Set | 所有已生成的页面 Key 集合 | 全局索引,用于一键清空 |
4.1.1 核心问题:为什么不把整个页面存成一个 JSON?
先看两种方案的对比:
方案 A:传统全页缓存(一个 Key 存所有东西)
java
// 一个 Key 存完整页面
Key: "feed:public:10:1"
Value: {
"items": [
{"id":"1001", "title":"Java并发编程...", "authorNickname":"张三", "likeCount":100, "liked":true, ...},
{"id":"1003", "title":"Redis实战指南...", "authorNickname":"李四", "likeCount":200, "liked":false, ...},
// ... 共 10 条
],
"page": 1,
"size": 10,
"hasMore": true
}
方案 B:拆分缓存(5 个 Key 各司其职)
java
// ① ID 列表(骨架)
Key: "feed:public:ids:10:1737072000:1" [List]
Value: ["1001", "1003", "1005", "1008", ...]
// ② 翻页信号灯
Key: "feed:public:ids:10:1737072000:1:hasMore" [String]
Value: "1"
// ③ 单条内容详情(碎片)
Key: "feed:item:1001" [String/JSON]
Value: {"id":"1001", "title":"Java并发编程...", "authorNickname":"张三"}
// ④ 反向索引
Key: "feed:public:index:1001:1737072000" [Set]
Value: ["feed:public:ids:10:1737072000:1", "feed:public:ids:20:1737072000:1"]
// ⑤ 全局索引
Key: "feed:public:pages" [Set]
Value: ["feed:public:ids:10:1737072000:1", "feed:public:ids:10:1737072000:2", ...]
4.1.2 设计动机总结:为什么一定要拆成 5 种?
维度 1:内存占用对比(100万用户场景)
┌─────────────────────────────────────────────────────────────┐
│ 内存占用对比(100万用户场景) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 全页缓存(1个Key存所有) │
│ 100万用户 × 10页 × 10KB/页 = 100 GB │
│ │
│ 拆分缓存(5种Key各司其职) │
│ ├── IDs列表: 100万 × 10页 × 0.1KB = 100 MB │
│ ├── hasMore: 100万 × 10页 × 0.01KB = 10 MB │
│ ├── Item碎片: 10万条 × 3KB = 300 MB │
│ ├── 反向索引: 10万条 × 0.05KB × 5页 = 25 MB │
│ └── 全局索引: 1000万 × 0.01KB = 100 MB │
│ │
│ 总计: 535 MB(是原来的 0.5%!!) │
│ │
└─────────────────────────────────────────────────────────────┘
维度 2:失效粒度对比
| 操作 | 全页缓存 | 拆分缓存 |
|---|---|---|
| 某条帖子被点赞 | 删除所有包含它的页面缓存(几十个) | 不需要操作(计数运行时注入) |
| 某条帖子被编辑 | 删除所有包含它的页面缓存(几十个) | 只需 DEL feed:item:{id}(1个) |
| 某条帖子被删除 | 删除所有包含它的页面缓存(几十个) | 通过反向索引精准删除(2~5个) |
| UI 大改版 | 等待 TTL 自然过期(可能要几分钟) | 一键清空 feed:public:pages(秒级) |
维度 3:工程复杂度 vs 收益权衡
┌──────────────────┬─────────────┬─────────────────────────────┐
│ │ 全页缓存 │ 拆分缓存(本文方案) │
├──────────────────┼─────────────┼─────────────────────────────┤
│ 实现复杂度 │ ★☆☆ 低 │ ★★★ 高 │
│ 内存占用 │ ★★★ 高 │ ☆☆☆ 极低 │
│ 失效精度 │ ★☆☆ 粗糙 │ ★★★ 精准 │
│ 读取延迟 │ ★★★ 快 │ ★★☆ 中等(L2命中时也快) │
│ 计数实时性 │ ★☆☆ 有延迟 │ ★★★ 实时 │
│ 用户态隔离 │ ★☆☆ 易污染 │ ★★★ 天然隔离 │
│ 可扩展性 │ ★☆☆ 差 │ ★★★ 强(加字段灵活) │
│ 适合场景 │ 小规模(<10万)│ 中大规模(>10万) │
└──────────────────┴─────────────┴─────────────────────────────┘
结论:
- 如果你的日活 < 1万,用户数 < 10万 → 用全页缓存就够了,别过度设计
- 如果你的日活 > 10万,用户数 > 100万 → 必须用拆分缓存,否则内存成本扛不住
4.1.3 最终回答:为什么这么设计?
核心答案:为了解决高并发场景下的三大矛盾
矛盾1: 数据量大 vs 内存有限
→ 解决: 碎片化存储,跨页面复用,内存降低 200 倍
矛盾2: 变更频繁 vs 缓存稳定
→ 解决: 动态数据(计数/用户态)运行时注入,静态数据缓存复用
矛盾3: 失效需求 vs 误伤代价
→ 解决: 反向索引实现手术刀式精准失效,不影响无关页面
这不是过度设计,而是在特定约束条件下的最优解。如果你的系统还没到这个规模,简单方案就好;但如果你的 QPS 已经上万、用户百万级,这套架构就是必须要掌握的工程实践。
4.2 ① ID 列表------页面的骨架
java
// Key 生成规则
long hourSlot = System.currentTimeMillis() / 3600000L; // 当前小时戳(如 1737072000)
String idsKey = "feed:public:ids:" + size + ":" + hourSlot + ":" + page;
// 示例: "feed:public:10:1737072000:1"
// 存储的数据结构(Redis List)
LPUSH idsKey "1001" "1003" "1005" "1008" "1012"
EXPIRE idsKey 75 // TTL = 60s基础 + 15s随机抖动
// 读取时
LRANGE idsKey 0 9 // 获取第1页的前10条ID
// 返回: ["1001", "1003", "1005", "1008", "1012"]
为什么用 List 而不是 Set?
- List 保持插入顺序(时间倒序),天然适合分页
- Set 是无序的,无法保证"第1页永远是最新内容"
LRANGE时间复杂度 O(S+N),S 是起始偏移,N 是元素数量,性能优秀
hourSlot(小时槽)的作用:
时间轴:
13:00:00 ──→ hourSlot = 1737072000
14:00:00 ──→ hourSlot = 1737075600
15:00:00 ──→ hourSlot = 1737079200
好处:
- 13:00 发的新帖,只会写入 1737072000 这个槽位的缓存
- 14:00 刷新时,自然读到新槽位,旧槽位逐渐过期
- 避免跨小时的大面积缓存失效(雪崩防护)
具体作用(读取流程中的角色)
用户请求: GET /feed/public?page=1&size=10
Step 1: LRANGE feed:public:ids:10:1737072000:1 0 9
→ 返回 ["1001", "1003", "1005", ..., "1030"] (10个ID)
Step 2: 根据 ID 列表,构建 itemKeys
→ ["feed:item:1001", "feed:item:1003", "feed:item:1005", ..., "feed:item:1030"]
Step 3: MGET feed:item:1001 feed:item:1003 ... feed:item:1030
→ 批量获取 10 条内容的详细信息
Step 4: 组装成 FeedPageResponse 返回给前端
为什么叫"骨架"?就像人体的骨骼决定了身高体型一样,ID 列表决定了这一页有哪些内容、什么顺序。有了骨架,再去填充血肉(具体的标题、作者、封面等)。
4.3 ② hasMore 信号灯------翻页的关键
java
String hasMoreKey = idsKey + ":hasMore";
// 示例: "feed:public:ids:10:1737072000:1:hasMore"
// 写入规则
if (当前页已填满 && 数据库还有更多) {
redis.setex(hasMoreKey, 15, "1"); // 有下一页,TTL 较短(10-20s)
} else {
redis.setex(hasMoreKey, 10, "0"); // 已是最后一页
}
// 读取时的特殊处理
String hasMore = redis.get(hasMoreKey);
List<String> ids = redis.lrange(idsKey, 0, size - 1);
if ("0".equals(hasMore) && ids.isEmpty()) {
// 空缓存标记 → 直接返回空响应,避免穿透到数据库!
return FeedPageResponse.empty();
}
具体作用(三个关键场景)
场景 1:正常翻页判断
java
String hasMore = redis.get("feed:public:ids:10:1737072000:1:hasMore");
// 前端根据这个值决定是否显示"加载更多"按钮
response.setHasMore("1".equals(hasMore));
场景 2:空缓存标记(防穿透!) 最重要!
java
String hasMore = redis.get(hasMoreKey);
List<String> ids = redis.lrange(idsKey, 0, size - 1);
if ("0".equals(hasMore) && ids.isEmpty()) {
// 关键判断:hasMore=0 且 ID列表为空
// 说明这个页面确实没有数据(可能是最后一页之后)
// 直接返回空响应,不再查询数据库!
return FeedPageResponse.empty(page, size, false);
}
为什么这能防穿透?想象一个恶意用户疯狂请求 ?page=99999(根本不存在这么多数据):
- 没有 hasMore:每次都查数据库 → 发现没数据 → 返回空(数据库被打爆)
- 有 hasMore :第一次查库后发现是最后一页 → 设置 hasMore="0" + IDs=[] → 之后所有
page>=最后一页的请求直接从缓存返回空响应
场景 3:TTL 独立控制
java
// hasMore 的 TTL 比 ID 列表短!
redis.setex(hasMoreKey, 15, "1"); // 信号灯: 15秒过期
redis.expire(idsKey, 75); // ID列表: 75秒过期
为什么 TTL 更短?翻页状态变化比内容变化更频繁。比如原来只有 15 条数据(2 页),突然有人发了新帖变成 16 条(3 页),hasMore 需要及时更新为 "1",但 ID 列表的旧数据还可以暂时保留。
为什么不把 hasMore 放进 ID 列表的 Value 里?
如果合并:
Key: feed:public:ids:10:1
Value: {"ids":["1001",...], "hasMore": true} (JSON字符串)
问题1: 想知道有没有下一页,必须先 LRANGE 再 JSON.parse(多一步操作)
问题2: 无法单独设置更短的 TTL
问题3: 无法快速判断"空缓存标记"(要先解析整个JSON)
拆分后:
GET hasMoreKey → 1次命令,O(1),直接得到结果
4.4 ④ 反向索引------精准失效的核心
java
// 写入时:建立"内容 → 页面"的映射
String indexKey = "feed:public:index:" + itemId + ":" + hourSlot;
redis.sadd(indexKey, idsKey); // 记录哪些页面包含了这个内容
redis.expire(indexKey, ttl);
// 失效时:通过反向索引找到受影响的页面
Set<String> affectedPages = redis.smembers(indexKey);
for (String pageKey : affectedPages) {
redis.del(pageKey); // 删除对应的页面缓存
}
使用场景示例:
用户 A 对帖子 #1003 点赞 → 触发 CounterService.updateLike(1003)
↓
FeedCacheInvalidationListener 监听到事件:
1. SMEMBERS "feed:public:index:1003:1737072000"
→ 返回 ["feed:public:ids:10:1737072000:1", "feed:public:ids:20:1737072000:1"]
2. DEL "feed:public:ids:10:1737072000:1" (第1页的10条/页缓存失效)
DEL "feed:public:ids:20:1737072000:1" (第1页的20条/页缓存失效)
3. 下次用户刷新这两个页面时 → 缓存未命中 → 重新查库 → 新的点赞数被加载
为什么用 Set 而不是 List?
| 操作 | Set | List |
|---|---|---|
| 添加元素 | SADD O(1) | LPUSH O(1) |
| 判断是否存在 | SISMEMBER O(1) | 需遍历 O(N) |
| 获取全部成员 | SMEMBERS O(N) | LRANGE O(N) |
| 去重 | 自动去重 | 允许重复 |
关键点:同一个页面 Key 可能被多次添加(比如该帖子被编辑了两次,每次都会重建索引),Set 自动去重保证不会重复。
对比没有反向索引的后果
传统方案(模糊删除):
KEYS feed:public:* → 匹配到 10000 个 Key
DEL feed:public:ids:10:1737072000:1
DEL feed:public:ids:10:1737072000:2
...
DEL feed:public:ids:20:1737072000:50
问题:
1. KEYS 命令会阻塞 Redis(生产环境禁用!)
2. 删了很多无关页面(误伤率 90%+)
3. 这些无关页面的下次请求都要重新查库(雪崩风险)
反向索引方案:
SMEMBERS feed:public:index:1001:1737072000 → 只返回 2 个相关页面
DEL 这 2 个页面 → 手术刀式精准打击
优势:
1. O(1) 复杂度,瞬间完成
2. 零误伤,其他页面不受影响
3. 受影响范围可控,不会触发雪崩
4.5 ⑤ 全局页面索引------一键清空的"总开关"
它是什么?
记录系统中所有已生成的公共流页面 Key 的集合。
Key: feed:public:pages
Type: Set (持久化,不过期)
Value: [
"feed:public:ids:10:1737072000:1",
"feed:public:ids:10:1737072000:2",
"feed:public:ids:10:1737072001:1", // 新的时间槽
"feed:public:ids:20:1737072000:1",
"feed:public:ids:20:1737072000:2",
... (可能有几万个)
]
具体作用(两个核心场景)
场景 1:前端 UI 布局大改版
产品经理: "我们要把双列瀑布流改成三列卡片布局!"
开发: "好,我把 LAYOUT_VER 从 1 改成 2"
→ 所有旧的本地缓存 Key (v1) 自动失效
→ 但是 Redis 里的旧页面缓存还在!
这时需要一键清空:
SUNIONSTORE temp_key feed:public:pages // 把所有页面Key复制到临时Set
DEL temp_key // 原子性批量删除(Lua脚本)
结果: 所有用户的下一次刷新都会走新的布局逻辑
场景 2:运营活动需要重排 Feed
运营: "今天有个重磅公告,要把所有用户的首页第一条都换成这条"
开发: "好,我先清掉所有第1页的缓存"
SMEMBERS feed:public:pages → 得到所有页面Key
过滤出 *:1 结尾的(第1页)
DEL 这些Key
结果: 所有用户刷新首页时,重新查库,看到最新的排序
为什么需要全局索引?
没有 feed:public:pages 时想清空所有缓存:
1. SCAN 0 MATCH feed:public:ids:* COUNT 1000 (慢,可能要扫很久)
2. 或者记下所有 Key 到外部存储(MySQL/文件)(增加复杂度)
3. 或者重启 Redis(暴力,影响所有业务)
有 feed:public:pages:
1. SMEMBERS feed:public:pages (一次命令,毫秒级)
2. 遍历结果集执行 DEL (Pipeline 打包发送)
3. 完成!(可预测的耗时)
第五篇:L0 Redis 碎片缓存------单条内容的轻量元数据
5.1 数据结构定义(FeedItemResponse)
java
/**
* Feed 流单条内容的响应 DTO
*
* 设计原则:
* 1. 只保留前端展示必需的字段,避免过度查询
* 2. 动态字段(计数、用户态)不在缓存中存储,运行时注入
*/
public record FeedItemResponse(
// ========== 基础展示信息(来自数据库,写入缓存)==========
String id, // 内容唯一ID (如帖子主键)
String title, // 标题 (最多100字)
String description, // 描述/摘要 (前端截断显示)
String coverImage, // 封面图 URL (取 imgUrls JSON数组的第一张)
List<String> tags, // 标签列表 (从逗号分隔字符串解析)
// ========== 作者信息(来自 Users 表关联查询,写入缓存)==========
String authorAvatar, // 作者头像 URL (CDN地址)
String authorNickname, // 作者昵称
String tagJson, // 作者领域标签 JSON (如 "[\"Java\",\"Redis\"]")
// ========== 交互计数(运行时动态注入,不写入缓存!)==========
Long likeCount, // 点赞数 (由 CounterService 批量填充)
Long favoriteCount, // 收藏数 (由 CounterService 批量填充)
// ========== 用户态标志(运行时动态注入,不写入缓存!)==========
Boolean liked, // 当前用户是否点赞 (null=未知/未登录)
Boolean faved, // 当前用户是否收藏 (null=未知/未登录)
// ========== 特殊标记 ==========
Boolean isTop // 是否置顶 (仅个人流返回)
) {}
它是什么?
单条 Feed 内容的轻量元数据,不包含动态变化的计数和用户态。
Key: feed:item:1001
Type: String (JSON)
Value: {
"id": "1001",
"title": "Java并发编程实战:从入门到精通",
"description": "本文将带你深入理解线程池、锁机制...",
"coverImage": "https://cdn.example.com/covers/1001.jpg",
"tags": ["Java", "并发", "多线程"],
"authorAvatar": "https://cdn.example.com/avatars/zhangsan.jpg",
"authorNickname": "张三",
"tagJson": "[\"Java架构师\",\"技术博主\"]",
// 注意:以下字段在缓存中为 null 或不存在!
"likeCount": null, // 运行时注入
"favoriteCount": null, // 运行时注入
"liked": null, // 运行时注入
"faved": null // 运行时注入
}
5.2 具体作用(为什么需要单独存?)
作用 1:跨页面复用(节省内存!)
假设帖子 #1001 很热门,出现在多个页面中:
全页缓存方案:
Page1 的缓存: {..., {id:"1001", title:"...", likeCount:100, ...}, ...} (10KB)
Page2 的缓存: {..., {id:"1001", title:"...", likeCount:100, ...}, ...} (10KB)
Page3 的缓存: {..., {id:"1001", title:"...", likeCount:100, ...}, ...} (10KB)
→ 帖子 #1001 的数据重复存储了 3 次!(30KB)
碎片缓存方案:
feed:item:1001 = {id:"1001", title:"...", ...} (只存1次,3KB)
Page1 的 IDs: [..., "1001", ...] (只存ID,0.1KB)
Page2 的 IDs: [..., "1001", ...] (只存ID,0.1KB)
Page3 的 IDs: [..., "1001", ...] (只存ID,0.1KB)
→ 总共只用了 3.3KB,节省了 90% 内存!
实际计算(100万用户场景):
假设平均每条帖子出现在 5 个不同用户的页面中:
全页缓存:
100万用户 × 10页 × 10KB = **100 GB**
碎片缓存:
- ID列表: 100万 × 10页 × 0.1KB = **100 MB**
- Item碎片: 10万条帖子 × 3KB = **300 MB**
- 总计: **400 MB** (是原来的 1/250!)
作用 2:精准失效(只更新变化的部分)
场景: 用户修改了帖子 #1001 的标题
全页缓存方案:
必须找到并删除所有包含 #1001 的页面缓存
→ 可能涉及几十个页面 Key
→ 漏删一个就出现脏数据
碎片缓存方案:
只需: DEL feed:item:1001
→ 下次读取时自动回源,拿到最新标题
→ 不影响任何页面缓存(因为页面里只存了 ID "1001")
作用 3:计数与用户态隔离
场景: 用户A对帖子#1001点赞
❌ 如果把 likeCount 存入缓存:
feed:item:1001 = {..., likeCount:101}
但此时用户B来读 → 也看到 likeCount=101 (暂时正确)
用户C来读 → 也看到 likeCount=101 (暂时正确)
问题: 过一会儿用户D点赞 → likeCount应该变成102
→ 必须更新 feed:item:1001 的缓存
→ 但如果有100个人同时点赞呢?频繁更新!
✅ 我们的方案:
feed:item:1001 = {..., likeCount:null} (缓存里不存!)
用户A/B/C/D 来读时:
Step1: 从缓存读到基础信息 (title, author...)
Step2: 实时调用 CounterService.getLikeCount(1001) → 返回 104
Step3: 动态填入 response
结果: 永远准确,无需更新缓存!
5.3 字段来源与写入策略全景图
| 字段名 | 数据来源 | 是否写入 L0 缓存 | 填充时机 | 说明 |
|---|---|---|---|---|
id |
KnowPosts.id | Yes | 写入时 | 主键 |
title |
KnowPosts.title | Yes | 写入时 | 标题 |
description |
KnowPosts.description | Yes | 写入时 | 摘要 |
coverImage |
KnowPosts.imgUrls[0] | Yes | 写入时 | 取第一张图 |
tags |
KnowPosts.tags | Yes | 写入时 | 解析为数组 |
authorAvatar |
Users.avatar_url | Yes | 写入时 | LEFT JOIN |
authorNickname |
Users.nickname | Yes | 写入时 | LEFT JOIN |
tagJson |
Users.tags | Yes | 写入时 | LEFT JOIN |
| likeCount | CounterService | No | 读取时注入 | 实时统计 |
| favoriteCount | CounterService | No | 读取时注入 | 实时统计 |
| liked | CounterService | No | 读取时注入 | 按当前用户 |
| faved | CounterService | No | 读取时注入 | 按当前用户 |
isTop |
KnowPosts.is_top | 仅个人流 | 写入时 | 公共流不返回 |
5.3 为什么计数和用户态不进缓存?
这是一个关键的架构决策:
错误做法:把 likeCount 写入 L0 缓存
问题1: 频繁失效
帖子被点赞 → likeCount 从 100 变成 101
→ 必须更新所有包含该帖子的缓存(可能有几十个页面Key)
→ 写放大严重!
问题2: 用户态污染
用户A点赞 → cachedItem.setLiked(true)
→ 用户B读到这个缓存 → 看到自己"也点赞了"??
→ 数据错误!
正确做法:运行时动态注入
步骤1: 从 L0 读取基础信息 (id, title, author...)
步骤2: 批量调用 CounterService.batchGetCounts(itemIds)
→ {1001: {likes:100, faves:50}, 1003: {likes:200, faves:80}}
步骤3: 批量调用 CounterService.batchGetUserStatus(userId, itemIds)
→ {1001: {liked:true, faved:false}, 1003: {liked:false, faved:true}}
步骤4: 合并后返回给前端
优势:
- L0 缓存稳定,不会被频繁更新
- 用户态精准隔离,A 和 B 看到的状态各自正确
- 计数实时准确,无延迟
第六篇:缓存读写流程------Pipeline 与 MGET 的艺术
6.1 写入流程(writeCaches 方法)
当数据库查询完成后,需要将结果写入 Redis。这里使用了 Pipeline(管道)技术,把多个命令打包成一次网络发送。
Redis Pipeline 数据库 应用服务 Redis Pipeline 数据库 应用服务 返回 List<KnowPostFeedRow> (原始行数据) === 以下命令打包成一次网络往返 === loop [遍历每条记录 (10次)] === Pipeline EXECUTE (一次发送) === 1. 执行分页SQL (SELECT ... ORDER BY publish_time LIMIT 10 OFFSET 0) 2. 转换为 List<FeedItemResponse> (组装DTO) 3. Pipeline 开始打包 3.1 LPUSH idsKey [id1,id2,...,id10] 3.2 EXPIRE idsKey 75s (60基础 + 15随机抖动) 3.3 SETEX hasMoreKey 15s "1" (有下一页) 3.4 SADD "feed:public:pages" idsKey (注册全局索引) 3.5 SADD indexKey{itemId} idsKey (反向索引) 3.6 EXPIRE indexKey 75s 3.7 SETEX feed:item:{itemId} 75s jsonBytes (L0碎片) 4. 返回所有命令的结果 5. 写入完成!
文字版说明:
- 应用服务先执行 SQL 查询,拿到原始数据
- 将数据库行对象转换为 DTO(FeedItemResponse)
- 打开 Redis Pipeline,把所有写命令缓冲在客户端
- 一次性发送给 Redis 服务端(1 次网络往返 ≈ 18 条命令)
- Redis 顺序执行并返回所有结果
性能收益计算:
不用 Pipeline: 18次网络往返 × 0.5ms/次 = 9ms
使用 Pipeline: 1次网络往返 × 0.5ms/次 = 0.5ms
提升: (9 - 0.5) / 9 = 94.4% 的网络开销节省!
6.2 读取与组装流程(assembleFromCache)
CounterService Redis L2(Caffeine) 用户请求 CounterService Redis L2(Caffeine) 用户请求 alt [hasMore="0" 且 IDs为空] alt [L2 命中 (<1ms)] [L2 未命中] GET feed:public:10:1:v1 直接返回 FeedPageResponse 进入 L1/L0 层 1. GET hasMoreKey 返回空响应 (防穿透) 2. LRANGE idsKey 0 9 返回 ["1001","1003","1005","1008"] 3. 构建 itemKeys = ["feed:item:1001", "feed:item:1003", ...] 4. MGET itemKeys (一次网络往返!) 返回 [json1, json2, json3, json4] 5. 反序列化为 List<FeedItemResponse> 6. batchGetCounts([1001,1003,1005,1008]) 返回 {1001: {likes:100}, 1003: {likes:200}, ...} 7. batchGetUserStatus(currentUserId, [1001,1003,...]) 返回 {1001: {liked:true}, 1003: {liked:false}, ...} 8. 动态填充 likeCount/liked/faved 字段 9. 组装 FeedPageResponse 并回填 L2 返回最终结果 (~5-10ms)
文字版说明:
- 先查 L2 本地缓存(Caffeine),命中则直接返回(<1ms)
- 未命中则进入 Redis 层:
- 先检查 hasMore 信号灯,判断是否为空缓存标记
- 用
LRANGE获取该页的 ID 列表 - 用 MGET 批量获取所有 item 的 JSON(关键优化!避免 N+1 问题)
- 反序列化 + 动态注入 :
- 调用 CounterService 批量获取计数(likeCount/favoriteCount)
- 调用 CounterService 批量获取用户态(liked/faved)
- 合并后生成最终的 FeedItemResponse
- 回填 L2:把最终结果写入本地缓存,下次同请求直接命中
MGET vs GET 循环的性能对比:
方案A: FOR循环 GET (N+1问题)
for (String id : ids) {
redis.get("feed:item:" + id); // 每次 0.5ms
}
总耗时: 10 IDs × 0.5ms = 5ms (10次网络往返)
方案B: MGET 批量获取 (推荐!)
redis.mget("feed:item:1001", "feed:item:1003", ...); // 一次 0.5ms
总耗时: 0.5ms (1次网络往返)
提升: 10倍!
第七篇:缓存一致性保障------让数据和缓存保持同步
7.1 四大保障机制全景图
| 机制 | 触发场景 | 实现方式 | 目标 |
|---|---|---|---|
| 延时双删 | 内容编辑/删除 | 先删缓存 → 延迟几秒再删一次 | 防止脏写入 |
| 旁路计数更新 | 点赞/收藏事件 | 通过反向索引定位页面,直接修改缓存中的计数值 | 保证计数实时性 |
| 单航班防击穿 | 缓存未命中回源 | ConcurrentHashMap 分布式锁,同一页面只允许一个线程查库 | 保护数据库 |
| 用户态隔离 | 写入公共缓存 | 强制设置 liked/faved=null | 防止用户态污染 |
7.2 延时双删------解决"先删缓存还是先更新数据库"的经典难题
java
/**
* 内容更新时的缓存失效流程
*/
@Service
public class FeedCacheServiceImpl {
@Autowired
private RedisTemplate<String, String> redis;
@Autowired
private ApplicationEventPublisher eventPublisher;
public void invalidateOnUpdate(Long postId) {
String itemKey = "feed:item:" + postId;
// 第一次删除:同步删除(立即生效)
redis.delete(itemKey);
// 通过事件机制触发延时第二次删除
eventPublisher.publishEvent(
new DelayedCacheDeleteEvent(postId, 500) // 延迟 500ms
);
}
}
/**
* 延时删除监听器(基于 Redis 延时队列或 ScheduledExecutorService)
*/
@Component
public class DelayedDeleteListener {
@EventListener
public void onDelayedDelete(DelayedCacheDeleteEvent event) {
// 延迟 500ms 后再次删除
Thread.sleep(event.getDelayMs());
String itemKey = "feed:item:" + event.getPostId();
redis.delete(itemKey);
// 同时通过反向索引清除相关页面缓存
clearAffectedPages(event.getPostId());
}
}
为什么要删两次?
时间线(不加延时双删):
T1: 线程A 更新数据库 (title="新版")
T2: 线程B 读缓存 → 未命中 → 查数据库 → 读到"新版"
T3: 线程B 把"新版"写入缓存
T4: 线程A 删除缓存 ← 此时线程B刚写完,白删了!
结果: 缓存里的数据是正确的(碰巧),但不可靠!
---
时间线(加延时双删):
T1: 线程A 更新数据库 (title="新版")
T2: 线程A 第一次删除缓存 ✅
T3: 线程B 读缓存 → 未命中 → 查数据库 → 读到"新版"
T4: 线程B 把"新版"写入缓存
T5: (延迟 500ms 后) 线程A 第二次删除缓存 ✅
T6: 下次读取 → 未命中 → 查数据库 → 重新加载最新数据
结果: 即使有并发写入,也能保证最终一致性!
7.3 旁路计数更新------点赞/收藏的高效同步
传统的做法是:点赞后直接删除相关缓存,下次读取时重新查库。但这会导致缓存命中率大幅下降。
我们的方案是:不删除缓存,而是直接修改缓存中的值。
java
/**
* 点赞事件监听器
*/
@Component
public class FeedCacheInvalidationListener {
@Autowired
private RedisTemplate<String, String> redis;
/**
* 监听点赞事件
*/
@EventListener
public void onLikeEvent(LikeEvent event) {
Long postId = event.getPostId();
long hourSlot = System.currentTimeMillis() / 3600000L;
// 1. 通过反向索引找到受影响的页面
String indexKey = "feed:public:index:" + postId + ":" + hourSlot;
Set<String> affectedPages = redis.opsForSet().members(indexKey);
if (affectedPages == null || affectedPages.isEmpty()) {
return; // 没有缓存受影响,直接跳过
}
// 2. 对于每个受影响的页面,更新其中的计数
for (String pageKey : affectedPages) {
// 注意:这里我们选择删除页面缓存而不是就地更新
// 因为页面缓存是 ID 列表,不包含具体计数值
// 计数值是在 L0 碎片缓存中,但 L0 不存储计数(运行时注入)
// 所以实际操作是:删除 L1 页面缓存,强制下次重新组装
redis.delete(pageKey);
// 同时删除对应的 hasMore 信号灯
redis.delete(pageKey + ":hasMore");
}
}
}
架构设计的巧妙之处 :
由于我们把计数放在运行时注入而非缓存存储,所以点赞/收藏事件只需要删除 L1 页面缓存即可,不需要修改 L0 碎片缓存。这样既保证了一致性,又避免了复杂的缓存更新逻辑。
7.4 单航班防击穿------保护数据库的最后一道防线
java
@Service
public class KnowPostFeedServiceImpl {
// 分布式锁:防止同一个页面并发回源
private final ConcurrentHashMap<String, CompletableFuture<FeedPageResponse>>
loadingCache = new ConcurrentHashMap<>();
public FeedPageResponse getFeedWithProtection(String cacheKey, Supplier<FeedPageResponse> dbLoader) {
// 尝试从 loadingCache 获取正在加载的 Future
CompletableFuture<FeedPageResponse> future = loadingCache.get(cacheKey);
if (future != null) {
// 其他线程正在加载,等待结果(不重复查库!)
try {
return future.get(5, TimeUnit.SECONDS); // 最多等5秒
} catch (Exception e) {
throw new RuntimeException("等待缓存加载超时", e);
}
}
// 创建新的加载任务
CompletableFuture<FeedPageResponse> newFuture = CompletableFuture.supplyAsync(() -> {
try {
return dbLoader.get(); // 执行数据库查询
} finally {
loadingCache.remove(cacheKey); // 无论成功失败,都要移除锁
}
});
// CAS 操作:只有第一个线程能放入,其他线程看到已有的 Future
CompletableFuture<FeedPageResponse> existingFuture =
loadingCache.putIfAbsent(cacheKey, newFuture);
if (existingFuture != null) {
// 已经有其他线程在加载,等待它完成
newFuture.cancel(true); // 取消自己创建的多余任务
return existingFuture.join();
}
return newFuture.join(); // 等待自己的任务完成
}
}
防击穿效果演示:
高并发场景:1000个请求同时访问同一个冷门页面(缓存未命中)
没有防击穿:
请求1 → 查数据库 (耗时 100ms)
请求2 → 查数据库 (耗时 100ms) ← 重复查询!
请求3 → 查数据库 (耗时 100ms) ← 重复查询!
...
请求1000 → 查数据库 (耗时 100ms)
结果: 数据库承受 1000 次查询压力!CPU 飙升,连接池耗尽
有单航班防击穿:
请求1 → 放入 loadingCache → 查数据库 (耗时 100ms)
请求2 → 发现 loadingCache 已有 key → 等待请求1的结果 (耗时 ~100ms)
请求3 → 发现 loadingCache 已有 key → 等待请求1的结果
...
请求1000 → 发现 loadingCache 已有 key → 等待请求1的结果
结果: 数据库只承受 1 次查询!其余 999 个请求共享结果
第八篇:TTL 策略与热度感知------让缓存更智能
8.1 基础 TTL 计算(带随机抖动)
java
public int calculateBaseTtl() {
int baseTtl = 60; // 基础 60 秒
int jitter = ThreadLocalRandom.current().nextInt(0, 31); // 随机抖动 0~30 秒
return baseTtl + jitter; // 最终范围: 60~90 秒
}
为什么加随机抖动?
固定 TTL = 60秒:
1000个页面都在 14:00:00 写入缓存
→ 全部在 14:01:00 同时过期
→ 14:01:01 瞬间 1000 个请求打到数据库
→ 缓存雪崩!
随机抖动 TTL = 60~90秒:
1000个页面在 14:00:00 写入缓存
→ 过期时间分散在 14:01:00 ~ 14:01:30 之间
→ 任何时刻只有少量缓存过期
→ 数据库压力平滑
8.2 热点 Key 动态延长
java
/**
* 热度检测器:根据访问频率调整 TTL
*/
@Component
public class HotKeyDetector {
// 滑动窗口计数器(Caffeine 实现)
private final Cache<String, AtomicLong> visitCounter = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterAccess(1, TimeUnit.MINUTES)
.build();
/**
* 检测热度等级
*/
public HotLevel detect(String itemId) {
AtomicLong counter = visitCounter.get(itemId, k -> new AtomicLong(0));
long count = counter.incrementAndGet();
if (count > 100) return HotLevel.HIGH; // 高热 (>100次/分钟)
if (count > 30) return HotLevel.MEDIUM; // 中热 (30~100次/分钟)
return HotLevel.LOW; // 低热 (<30次/分钟)
}
/**
* 根据热度延长 TTL
*/
public int adjustTtlByHotLevel(int baseTtl, String itemId) {
HotLevel level = detect(itemId);
return switch (level) {
case LOW -> baseTtl; // 低热: 不延长 (60~90s)
case MEDIUM -> baseTtl + 60; // 中热: +60s (120~150s)
case HIGH -> baseTtl + 120; // 高热: +120s (180~210s)
};
}
}
enum HotLevel { LOW, MEDIUM, HIGH }
热度分级效果:
| 热度等级 | 访问频率阈值 | TTL 范围 | 适用场景 |
|---|---|---|---|
| 冷门 (LOW) | < 30 次/分钟 | 60~90 秒 | 普通内容,快速释放内存 |
| 低热 (MEDIUM) | 30~100 次/分钟 | 120~150 秒 | 偶尔被访问的内容 |
| 中热 (HIGH) | > 100 次/分钟 | 180~210 秒 | 爆款/置顶/热门内容 |
实际案例:
14:00:00 - 帖子#1001 发布(普通内容)
→ TTL = 75s (60基础 + 15抖动)
→ 14:01:15 过期
14:05:00 - 帖子#1001 突然火了(被大V转发)
→ 访问频率飙升到 150次/分钟
→ 系统检测到 HIGH 热度
→ 下次写入缓存时 TTL 自动调整为 195s (75 + 120)
→ 14:08:15 才过期(比原来多了 3 分钟存活时间)
收益: 热点内容缓存命中率提升,数据库压力降低
第九篇:面试高频追问?
Q1:你们的三级缓存和 CDN 的边缘缓存有什么区别?
回答方向:
| 维度 | 三级缓存(应用层) | CDN 边缘缓存 |
|---|---|---|
| 缓存内容 | 业务数据(JSON、DTO) | 静态资源(图片、CSS、JS)+ API 响应 |
| 粒度 | 可精确到字段级别(如排除用户态) | 通常整页或整接口 |
| 个性化能力 | 强(可按用户维度隔离) | 弱(依赖 Vary header 或 Query String) |
| 一致性控制 | 精准(反向索引 + 延时双删) | 粗粒度(Purge API 或 TTL 过期) |
| 适用场景 | 动态业务数据(Feed、订单、购物车) | 静态资源 + 可缓存的 API |
一句话总结:CDN 解决的是"离用户近"的问题(物理距离),三级缓存解决的是"离数据源近"的问题(逻辑分层)。两者互补,不冲突。
Q2:如果 Redis 挂了,你的系统怎么降级?
回答方向:
java
/**
* 降级策略:Redis 不可用时直接查数据库(带限流)
*/
@Retryable(value = {RedisConnectionFailureException.class}, maxAttempts = 2, backoff = @Backoff(delay = 100))
public FeedPageResponse getFeedWithFallback(long userId, int page, int size) {
try {
// 正常路径:三级缓存
return assembleFromCache(userId, page, size);
} catch (RedisConnectionFailureException e) {
log.warn("Redis 不可用,降级到数据库直查");
// 降级路径:直接查库(带限流保护)
if (rateLimiter.tryAcquire()) {
return queryFromDBDirectly(userId, page, size);
} else {
throw new ServiceUnavailableException("系统繁忙,请稍后再试");
}
}
}
降级三板斧:
- 重试机制:短暂故障自动重试 2 次(间隔 100ms)
- 限流保护:降级到数据库时用 RateLimiter 限制 QPS(防止打死 DB)
- 优雅响应:限流触发时返回 503 + 友好提示,而不是报错
Q3:你们的缓存命中率是多少?如何监控?
回答方向:
java
/**
* 缓存监控指标收集
*/
@Component
public class CacheMetricsCollector {
@Autowired
private CacheManager cacheManager;
@Scheduled(fixedRate = 60000) // 每分钟采集一次
public void collectMetrics() {
Cache feedPublicCache = cacheManager.getCache("feedPublicCache");
if (feedPublicCache instanceof com.github.benmanes.caffeine.cache.Cache) {
com.github.benmanes.caffeine.cache.Cache<?, ?> caffeineCache =
(com.github.benmanes.caffeine.cache.Cache<?, ?>) feedPublicCache.getNativeCache();
var stats = caffeineCache.stats();
log.info("L2 缓存统计: hitRate={}, hitCount={}, missCount={}, loadSuccessCount={}",
stats.hitRate(), // 命中率 (目标 > 80%)
stats.hitCount(), // 命中次数
stats.missCount(), // 未命中次数
stats.loadSuccessCount() // 成功加载次数(回源次数)
);
// 告警阈值:如果命中率低于 60%,触发告警
if (stats.hitRate() < 0.6) {
alertService.send("L2 缓存命中率过低: " + stats.hitRate());
}
}
}
}
生产环境监控大盘:
| 指标 | 健康值 | 告警阈值 | 说明 |
|---|---|---|---|
| L2 命中率 | > 80% | < 60% | 本地缓存命中率 |
| L1 命中率 | > 70% | < 50% | Redis 页面缓存命中率 |
| 平均响应时间 | < 20ms | > 100ms | P99 延迟 |
| 回源 QPS | < 100 | > 500 | 打到数据库的请求数 |
| 缓存内存占用 | < 2GB | > 4GB | Redis 内存使用量 |
Q4:如何处理缓存和数据库的最终一致性问题?
回答方向:
我们采用**"宽松一致性 + 快速收敛"**策略:
- 写路径:先更新数据库,再删除缓存(延时双删兜底)
- 读路径:缓存未命中时查数据库并回填,保证最终能读到最新数据
- 不一致窗口:最长不超过 TTL 时间(90秒内必然收敛)
- 强一致性场景(如支付金额):不走缓存,直接查数据库或用分布式锁
对于 Feed 场景来说,几秒钟的数据延迟是可以接受的(用户不会因为晚看到几秒前的点赞数而投诉)。但如果是对一致性要求极高的场景(库存扣减、余额变更),就需要用更强的机制(事务、分布式锁、CDC binlog 同步)。
第十篇:选型建议------什么场景用什么方案
10.1 Feed 流方案选型矩阵
| 场景特征 | 推荐方案 | 关键技术点 | 典型案例 |
|---|---|---|---|
| 用户量 < 10万,日活 < 1万 | 拉模式 + 单层 Redis 缓存 | 简单粗暴,够用就行 | 内部工具、初创产品 |
| 用户量 10~100万,日活 1~10万 | 拉模式 + 二级缓存(Redis + 本地) | 引入 Caffeine 抗瞬时并发 | 成长期社区 |
| 用户量 > 100万,日活 > 10万 | 拉模式 + 三级缓存(本文方案) | L2/L1/L0 分层 + Pipeline + MGET | 成熟期平台 |
| 有大V账号(粉丝 > 100万) | 推拉混合 | 大V用拉,普通人用推 | 微博、Twitter |
| 强个性化推荐 | 推荐流(非时间线) | 协同过滤 + 向量召回 | 抖音、今日头条 |
10.2 缓存层数的选择建议
┌─────────────────────────────────────────────────────────────┐
│ 缓存层数决策树 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 你的 QPS 是多少? │
│ │ │
│ ├── < 1000 → 1层缓存 (Redis) 就够了 │
│ │ │
│ ├── 1000 ~ 10000 → 2层缓存 (Redis + Caffeine本地) │
│ │ │
│ └── > 10000 → 3层缓存 (本文方案) │
│ │ │
│ ├── 数据库能扛住吗? │
│ │ ├── 能 → 按本文实现 │
│ │ └── 不能 → 考虑读写分离 / 分库分表 │
│ │ │
│ └── 预算允许吗? │
│ ├── 允许 → 上 Redis Cluster + 本地缓存集群 │
│ └── 不允许 → 优化 SQL + 加索引 + 限流降级 │
│ │
└─────────────────────────────────────────────────────────────┘
10.3 避坑指南
| 坑位 | 现象 | 解决方案 |
|---|---|---|
| 缓存穿透 | 查不存在的数据,每次都打 DB | 布隆过滤器 + 空缓存标记(hasMore="0") |
| 缓存雪崩 | 大面积同时过期 | TTL 随机抖动 + 多级缓存错峰 |
| 缓存击穿 | 热点 Key 过期瞬间高并发 | 单航班防击穿(LoadingCache) |
| 缓存污染 | 用户态数据互相影响 | 公共缓存强制设 null,运行时注入 |
| 内存溢出 | 缓存无限增长 | maximumSize + LFU 淘汰 + 定期清理 |
| 序列化性能 | JSON 序列化太慢 | 考虑 Protobuf / MessagePack(慎用,调试不便) |
总结:三级缓存的设计哲学回顾
回到开头的问题:为什么不能简单地"查库分页"?
因为在高并发场景下,数据库会成为系统的瓶颈。而三级缓存的核心思想是:
把"昂贵的操作"(数据库查询)的结果,按访问频率分层缓存到"便宜的地方"(内存/Redis),让绝大多数请求在缓存层面就被满足。
最终效果:
热点页面: L2 本地缓存命中 → <1ms 返回 (占 70% 请求)
普通页面: L1 Redis 拼装返回 → 5~10ms (占 25% 请求)
冷门页面: L0 碎片 + DB 回源 → 50~100ms (占 5% 请求)
平均响应时间: ~3ms (P99 < 20ms)
数据库 QPS: 降低 95% 以上
用户体验: 丝滑流畅,无明显卡顿
最后的一张架构全景图:
┌─────────────────────────────────────────────────────────────────┐
│ Feed 流三级缓存架构 │
│ │
│ 用户请求 │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ L2: Caffeine 本地缓存 (进程内) │ │
│ │ │ │
│ │ Key: feed:public:{size}:{page}:v{ver} │ │
│ │ Value: FeedPageResponse (完整对象) │ │
│ │ TTL: 15s | 容量: 1000 | 命中率: >80% │ │
│ └─────────────────┬────────────────────────┘ │
│ │ 未命中 │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ L1: Redis 页面骨架缓存 │ │
│ │ │ │
│ │ ① IDs: feed:public:ids:{size}:{h}:{p} [List] │
│ │ ② HasMore: ...:hasMore [String: "1"/"0"] │
│ │ ③ Index: feed:public:index:{id}:{h} [Set] │
│ │ ④ Pages: feed:public:pages [Set] │
│ │ │ │
│ │ TTL: 60~90s (base+jitter) │ │
│ └─────────────────┬────────────────────────┘ │
│ │ 需要详情 │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ L0: Redis 碎片缓存 (单条内容) │ │
│ │ │ │
│ │ Key: feed:item:{itemId} │ │
│ │ Value: FeedItemResponse (JSON) │ │
│ │ │ │
│ │ 字段: id, title, description, coverImage│ │
│ │ authorAvatar, authorNickname, tags│ │
│ │ (likeCount/faved 运行时注入!) │ │
│ │ │ │
│ │ TTL: 60~210s (随热度动态调整) │ │
│ └─────────────────┬────────────────────────┘ │
│ │ 未命中 │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ DB: 数据库回源 (单航班防击穿) │ │
│ │ │ │
│ │ SQL: SELECT ... FROM know_posts kp │ │
│ │ LEFT JOIN users u ON kp.author_id │ │
│ │ ORDER BY kp.publish_time DESC │ │
│ │ LIMIT ? OFFSET ? │ │
│ │ │ │
│ │ 保护: ConcurrentHashMap 分布式锁 │ │
│ │ 限制: 同一页面只允许1个线程查库 │ │
│ └──────────────────────────────────────────┘ │
│ │
│ 一致性保障: │
│ ├── 延时双删 (编辑/删除时) │
│ ├── 旁路计数更新 (点赞/收藏时) │
│ ├── 单航班防击穿 (回源时) │
│ └── 用户态隔离 (写入公共缓存时) │
│ │
└─────────────────────────────────────────────────────────────────┘
当你能把这套架构从设计动机 → 数据结构 → 读写流程 → 一致性保障 → 选型边界完整讲清楚,面试官就会明白:你不是背八股文,你是真正做过设计并且踩过坑的人。