Feed流

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}

文字版说明

  1. 用户 A 发布帖子后,系统获取他的粉丝列表(可能百万级)
  2. 通过消息队列异步遍历每个粉丝,把帖子 ID 写入他们的收件箱(Redis List)
  3. 读的时候极快 :用户 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)

文字版说明

  1. 用户 B 刷新 Feed 时,先查他关注了谁(比如 500 人)
  2. 实时去这 500 个人的发布记录里拉取最新内容
  3. 在内存里做归并排序(按时间倒序),再分页返回
  4. 写的时候极简:发帖只需 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拉,热点内容额外加速
微信公众号 纯推模式 订阅关系明确,粉丝量可控
Instagram 拉模式 + 智能推荐 关注数通常 < 1000,读扩散成本可控
抖音/TikTok 推荐流(非社交流) 基于算法推荐,不是纯时间线

面试加分回答

Q:你们项目为什么选推模式还是拉模式?

A:我们选的是拉模式为主 + 多级缓存优化。原因有三点:

  1. 我们的场景是知识分享社区,普通用户平均关注人数在 200~500 之间,读扩散的成本可控;
  2. 如果用推模式,当一个帖子被编辑或删除时,需要批量清理所有粉丝的收件箱,一致性维护复杂度高;
  3. 我们通过三级缓存把拉模式的读取延迟压到了 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. 写入完成!

文字版说明

  1. 应用服务先执行 SQL 查询,拿到原始数据
  2. 将数据库行对象转换为 DTO(FeedItemResponse)
  3. 打开 Redis Pipeline,把所有写命令缓冲在客户端
  4. 一次性发送给 Redis 服务端(1 次网络往返 ≈ 18 条命令
  5. 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)

文字版说明

  1. 先查 L2 本地缓存(Caffeine),命中则直接返回(<1ms)
  2. 未命中则进入 Redis 层:
    • 先检查 hasMore 信号灯,判断是否为空缓存标记
    • LRANGE 获取该页的 ID 列表
    • MGET 批量获取所有 item 的 JSON(关键优化!避免 N+1 问题)
  3. 反序列化 + 动态注入
    • 调用 CounterService 批量获取计数(likeCount/favoriteCount)
    • 调用 CounterService 批量获取用户态(liked/faved)
    • 合并后生成最终的 FeedItemResponse
  4. 回填 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("系统繁忙,请稍后再试");
        }
    }
}

降级三板斧

  1. 重试机制:短暂故障自动重试 2 次(间隔 100ms)
  2. 限流保护:降级到数据库时用 RateLimiter 限制 QPS(防止打死 DB)
  3. 优雅响应:限流触发时返回 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:如何处理缓存和数据库的最终一致性问题?

回答方向

我们采用**"宽松一致性 + 快速收敛"**策略:

  1. 写路径:先更新数据库,再删除缓存(延时双删兜底)
  2. 读路径:缓存未命中时查数据库并回填,保证最终能读到最新数据
  3. 不一致窗口:最长不超过 TTL 时间(90秒内必然收敛)
  4. 强一致性场景(如支付金额):不走缓存,直接查数据库或用分布式锁

对于 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个线程查库          │                  │
│  └──────────────────────────────────────────┘                  │
│                                                                 │
│  一致性保障:                                                     │
│  ├── 延时双删 (编辑/删除时)                                     │
│  ├── 旁路计数更新 (点赞/收藏时)                                  │
│  ├── 单航班防击穿 (回源时)                                      │
│  └── 用户态隔离 (写入公共缓存时)                                 │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

当你能把这套架构从设计动机 → 数据结构 → 读写流程 → 一致性保障 → 选型边界完整讲清楚,面试官就会明白:你不是背八股文,你是真正做过设计并且踩过坑的人。

相关推荐
☞遠航☜1 小时前
搭建基础的springcloud alibaba项目练习
后端·spring·spring cloud
java1234_小锋1 小时前
String、StringBuilder、StringBuffer的区别?
java·开发语言
星原望野1 小时前
JAVA集合:List、Set和Map
java·开发语言·list·set·map·集合
IT_陈寒1 小时前
React性能优化踩的坑,这个错你可能也会犯
前端·人工智能·后端
2601_957787581 小时前
星链引擎矩阵系统:插件化多平台 API 网关与账号级隔离技术实践
java·矩阵·插件化架构
zhangxingchao1 小时前
AI应用开发三:RAG技术与应用
前端·人工智能·后端
多敲代码防脱发2 小时前
Spring进阶(容器实现)
java·开发语言·后端·spring
可视之道2 小时前
工业物联网前端技术栈选型与性能优化实战
后端
星辰_mya2 小时前
彩云之上——[特殊字符]的架构师
java·后端·微服务·面试·架构