Skip to content

16 · 案例:Feed 与社区互动架构(500 万日活下的读写模型反转) ​

属于「架构师修炼」· 综合实战 · 案例二 上一篇:15 案例:AI 剪辑任务平台架构 下一篇:17 面试:白板架构设计与追问链

这篇解决什么问题:Feed 是唯一一类「QPS 不算高、但读放大上百倍」的系统——同一个业务的读量是写量的几千倍,于是架构的第一性问题不是"怎么扛 QPS",而是「读写模型往哪边反转」:内容该在发帖时推给粉丝,还是粉丝刷新时现拉?这一篇按00 篇五步法走完:推拉结合阈值 + ZSet 收件箱 + 游标分页 + 计数异步化 + 收件箱重建,并给出「大 V 怎么办、点赞 10 万怎么存、收件箱丢了怎么恢复」的标准答案。


一、第 ① 步 需求澄清 ​

你:Feed 有几种?关注流和推荐流是一回事吗? 产品:三种——关注流(时间序)、推荐流(算法序)、个人主页(作者维度)。互相独立,可以合并展示。 你:一致性要求呢?点赞数错了、Feed 里出现刚删的帖子会怎样? 产品:Feed 和计数可以最终一致,晚几秒没关系;但用户自己发的帖必须立刻在自己主页可见。 你:那就是 P2 用户可见(Feed、计数,秒级收敛)+ 一条硬要求「读己之写」,不需要强一致。 你:深分页要支持吗?用户能翻到第 50 页吗? 产品:不需要,滑动刷新为主,最多翻十几页。 你:那就不做 offset 分页,只做游标——这条决定省掉一大堆深分页问题。@ 大 V 有多少粉丝?产品:头部作者上千万,普通作者几十到几百。排行榜要实时,热榜按小时滚。

澄清项结论对架构的强制约束
数据分级Feed / 计数 = P2 最终一致(秒级)允许异步、允许缓存兜底、允许近似值
例外:读己之写自己发帖必须立刻可见自己主页走 DB 合并,或发帖后直接写自己收件箱并返回
深分页不需要(最多十几页)只用游标分页,不做 offset
粉丝分布极端不均(几十 ~ 千万)必须推拉结合,纯推或纯拉都会死
排行榜实时榜 + 小时/日快照ZSet 实时 + 定时快照 + 差分更新
删除语义删帖后 Feed 里不能再出现下发时过滤 + 删除标记(墓碑)

二、第 ② 步 量级估算:读放大系数 ​

参数取值说明
日活 DAU500 万全球版按区域错峰
人均刷新次数50 次/天含下拉刷新与冷启动
每次返回条数20 条首页一屏
发帖率 / 人均发帖10% / 1.2 条约 60 万条新内容/天
点赞率 / 人均点赞20% / 3 次约 300 万次点赞/天
平均粉丝数200(中位数 ~50)头部作者千万级
text
① 读请求:500w × 50 / 86400 ≈ 2,894 QPS 均值;× 3(峰值系数)≈ 8.7k QPS
   读条数:日 500w × 50 × 20 = 50 亿条/天 → 均值 5.8 万条/s,峰值 ≈ 17.4 万条/s
② 发帖:60w / 86400 ≈ 6.9 QPS 均值;峰值 ≈ 21 QPS(活动期可达 100 QPS)
   点赞:300w / 86400 ≈ 35 QPS 均值;峰值 ≈ 104 QPS
③ 读放大(同一个系统,三个口径都能算,面试要会挑口径):
   · 请求级 = 8.7k 读请求 / 21 写请求 ≈ 414 倍
   · 行级   = 17.4 万条/s / 21 条/s ≈ 8,300 倍
   · 内容级 = 50 亿条读取 / 60 万条新内容 ≈ 8,333 次/条
   → 结论:无论哪个口径都在**百倍以上**,这就是"Feed 是读放大 100 倍系统"的由来。
     所以架构的第一目标是**把读成本前置到写**(写扩散)或用缓存把读吃掉。
④ 收件箱内存:500 万活跃 × cap 300 条 × ≈40 B/元素 ≈ 60 GB
   → 需 3 台 32 GB Redis 分片(或按 uid 哈希分 8 节点,留 2 倍冗余)
⑤ 推扩散写量:60w 条 × 200 粉丝 = 1.2 亿次 ZADD/天 ≈ 1,389/s 均值、~4,200/s 峰值
   → Redis Cluster 完全够;但大 V 一个人就能把这条曲线打爆(见 4.2 阈值)

结论:Feed 的第一个瓶颈不是 QPS,而是读放大 + 热点不均——读放大约 400~8,000 倍,粉丝分布又是极端长尾。所以后面所有设计都在回答三件事:读怎么用 O(1) 拿到、热点怎么写得不炸、计数怎么不锁行。


三、第 ③ 步 找瓶颈 ​

顺序自检问题答案判定
1有单点吗?收件箱 Redis、内容库⚠️ 是:收件箱必须可重建,内容库要分片冗余
2单机到顶了吗?17.4 万条/s 的读,若每次查 DB 必然打挂⚠️ 是:读放大是 1 号瓶颈
3数据库瓶颈在哪?读:Feed 聚合;写:点赞对同一行的更新⚠️ 是:行锁热点是 2 号瓶颈
4有热点吗?有:千万粉大 V 发一条要写千万次;单条 10 万赞⚠️ 是:长尾分布是 3 号瓶颈
5依赖下游扛得住吗?推荐服务超时会拖死 Feed⚠️ 是:推荐必须可降级为时间序
6数据量到上限了吗?内容表 60 万条/天 → 2 亿条/年否(先归档,亿级再分片)

一句话:读放大 → 写扩散(推拉结合);行锁热点 → 计数异步化;长尾分布 → 阈值切拉模式。


四、第 ④ 步 架构设计与选型 ​

4.1 整体架构 ​

4.2 核心决策:推 vs 拉(本案例的分水岭) ​

维度A 纯拉(读扩散)B 纯推(写扩散)C 推拉结合(本项目选型)
读路径聚合所有关注者的最新内容O(1) 读收件箱普通用户走收件箱,大 V 走现拉合并
写成本O(1)(发帖只写内容表)O(粉丝数),1 亿粉 = 1 亿次写普通用户 O(粉丝数),大 V O(1)
读延迟关注多则慢(要归并几百路)快且稳定(一次 ZRANGE)快(≤ 十几路归并)
热点问题大 V 被反复拉取(读热点)大 V 发帖写风暴热点被推给少数活跃粉丝、其余拉取
存储成本低高(N 份收件箱副本)中
实时性天然实时有扩散延迟(秒级)秒级(普通作者也基本实时)
适用边界粉丝少、关注少、读少粉丝分布均匀、写少读多粉丝分布极端长尾的真实社交产品

阈值与混合策略(要能报出具体数字):

text
阈值:粉丝数 > 10 万 → 该作者进入"大V名单",发帖**不推**,粉丝刷新时**现拉**
     粉丝数 ≤ 10 万 → 发帖时**异步推**给粉丝收件箱(分片批量 ZADD)
依据:① 10 万粉丝 × 每天发 3 条 = 30 万次 ZADD/条帖子,尚可接受;
      ② 1 亿粉丝 × 1 条 = 1 亿次 ZADD,按 Redis 单机 5~8 万写 QPS
         (pipeline 约 20 万)需 500~2000 秒,且内存与成本不可接受;
      ③ 粉丝多的人通常发帖频繁 → 写量呈平方级放大,必须切断。
落库方式:
      · 普通作者:发布 → 写 content 表 → 异步分批(按粉丝 uid 分片,1000/批)
        推给"活跃粉丝"(近 30 天登录)的收件箱 ZSet,score = 发帖时间戳
      · 大 V:发布 → 只写 content 表 + 更新"大V名单"缓存;
        粉丝刷新时,Feed 服务取自己关注的大 V(通常 ≤ 50 个),
        对每个大 V 取"最近 N 条"做 **K 路归并**(见 4.4),结果缓存 30~60 s
      · 冷粉丝:不推(离线超过 30 天),下次登录时走"冷启动重建"再补推

4.3 Feed 存储:ZSet + 游标(为什么必须用游标) ​

text
收件箱:Redis ZSet   key = inbox:{uid}   member = feed_id   score = 时间戳(或权重)
        · 容量上限 cap = 300 条(ZREMRANGEBYRANK 裁掉尾部)→ 控内存
        · 写:ZADD(幂等,重复推同一 feed_id 只是覆盖 score)
        · 读:ZREVRANGEBYSCORE inbox:{uid} +inf (last_score LIMIT 0 20
        · 游标 = 本页最后一条的 score(+ feed_id 做 tie-break),而非 offset

为什么 Feed 必须用游标、不能用 offset 分页?

问题offset 分页(LIMIT 20 OFFSET 1000)游标分页(score < last_score)
深翻成本O(offset),Redis ZSet 与 MySQL 都要跳过前 N 条,第 50 页慢 50 倍O(log N + 20),翻到第几页都一样快
新内容插入列表头部插入 1 条,用户上一页的内容被"挤走" → 重复或漏读以 score 为锚点,新内容只出现在下一页之前,不会错位
列表删除前面的条目被删 → 后续整体前移 → 漏读不受影响
一致性无稳定顺序保证稳定:分页序列由 score 决定

一句话:offset 分页是"往前数第 N 个",游标分页是"从锚点往后再取 20 个"。Feed 是持续插入的高频变化列表,只有后者不重不漏。

内容详情与关注关系(跨表设计要点):

数据存储分片键说明
内容正文MySQL content 分 64 片author_id大 V 会单表热点,作者维度分片便于"取某作者最近 N 条"
收件箱Redis ZSetuid 哈希只存 feed_id(8B),不存正文,正文靠详情批量取
关注关系MySQL followfollower_id需要"我关注的都有谁"(写扩散时反向查粉丝)
内容详情缓存本地 LRU + Redis Hashcontent_id一次刷新 20 条 → 一次 MGET,命中率 > 95%

4.4 大 V 拉取:K 路归并(Go) ​

go
// 拉模式:把 K 个大 V 的"已按时间降序"的内容流做 K 路归并,取最新 N 条。
// 每个大 V 的流来自本地/Redis 缓存,避免 N 次独立 DB 往返。
type stream struct {
	authorID  int64
	items     []FeedItem // 按 score 降序
	idx       int
}
type mergeHeap []*stream

func (h mergeHeap) Len() int { return len(h) }
func (h mergeHeap) Less(i, j int) bool {
	return h[i].items[h[i].idx].Score > h[j].items[h[j].idx].Score // 最大堆:score 大者优先
}
func (h mergeHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
func (h *mergeHeap) Push(x interface{}) { *h = append(*h, x.(*stream)) }
func (h *mergeHeap) Pop() interface{} {
	old := *h
	n := len(old)
	it := old[n-1]
	*h = old[:n-1]
	return it
}

// MergeBigV 从 K 路有序流中取 top-N(N=20,K=关注的大 V 数,通常 ≤ 50)
func MergeBigV(ctx context.Context, streams []*stream, limit int) ([]FeedItem, error) {
	h := make(mergeHeap, 0, len(streams))
	for _, s := range streams {
		if len(s.items) > s.idx {
			h = append(h, s)
		}
	}
	heap.Init(&h)
	out := make([]FeedItem, 0, limit)
	for len(out) < limit && h.Len() > 0 {
		top := h[0]
		out = append(out, top.items[top.idx])
		top.idx++
		if top.idx < len(top.items) {
			heap.Fix(&h, 0) // 该流还有数据:原位下沉
			continue
		}
		heap.Pop(&h) // 该流取完:换下一个
	}
	// 关键:结果按 (uid, 关注大V集合指纹) 缓存 30~60s,
	// 让同一用户 1 分钟内的多次刷新只做一次归并(Feed 预生成)
	return out, nil
}

拉模式的三个必做优化:① 每路只取 20 条(LIMIT 20 per author),归并总输入 ≤ K×20 条;② 缓存归并结果(TTL 30~60s,key 带关注列表版本号,关注变化即失效);③ 并行取数用 errgroup,K 路同时发(K=50 时串行 50×5ms=250ms → 并行 ~10ms)。

4.5 计数与互动:点赞 10 万怎么不把行锁打死 ​

问题量化:单条爆款 10 万赞集中在 1 小时 ≈ 28 QPS;明星官宣 10 分钟 50 万赞 ≈ 833 QPS 打在同一个 InnoDB 行上。同一行的 UPDATE 是串行的(行锁 + undo + redolog 顺序写),实测单行热点更新约 1k~3k TPS——833 QPS 虽然没到上限,但每次更新都进 binlog → 从库重放压力 + 事务持有时间上升,P99 直接爆炸。

层级做法说明
幂等content_like 唯一约束 (content_id, user_id),INSERT IGNORE重复点赞天然去重,是"点没点过"的事实源(也即 user_id + content_id 唯一)
计数Redis Hash 分桶:cnt:{content_id} 的 field b0..b9,按 uid % 10 打散单 key 的 833 QPS 被摊成 10 个 field,每桶 83 QPS
展示读时 HGETALL 求和(10 个 field 一次取回),本地缓存 1s展示用近似值,允许秒级延迟
落库MQ 异步,按 content_id 分区,每 5s 或每 1000 次 flush 一次 UPDATE ... SET like_cnt = ?(绝对值覆盖,不累加)绝对值覆盖天然幂等,重放不重复计数
对齐每 10 分钟用 content_like 的真实 COUNT(*) 校准 Redis 与落库值小误差(1 万赞以上用估算)时不影响展示与推荐
防刷网关频控(同 uid 点赞 20 次/分钟)+ 风控(设备指纹、异常速率、批量相似行为)单 uid 的无效点赞在入口就拦掉,不消耗计数资源

关键 DDL:

sql
CREATE TABLE `content` (                    -- 分 64 片,分片键 author_id
  `content_id`  BIGINT UNSIGNED NOT NULL,
  `author_id`   BIGINT UNSIGNED NOT NULL,
  `body`        TEXT            NOT NULL,
  `media_uri`   VARCHAR(512)    NOT NULL DEFAULT '',
  `visibility`  TINYINT         NOT NULL DEFAULT 0 COMMENT '0 公开 1 粉丝 2 私密',
  `status`      TINYINT         NOT NULL DEFAULT 0 COMMENT '0 正常 1 已删 2 审核中',
  `created_at`  DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`content_id`), KEY `idx_author_time` (`author_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `content_like` (               -- 幂等的事实源;分 64 片,分片键 content_id
  `content_id` BIGINT UNSIGNED NOT NULL,
  `user_id`    BIGINT UNSIGNED NOT NULL,
  `created_at` DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`content_id`, `user_id`),    -- 重复点赞直接冲突,天然幂等
  KEY `idx_user_time` (`user_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `content_counter` (            -- 计数落库:绝对值覆盖,分桶抗热点
  `content_id` BIGINT UNSIGNED NOT NULL,
  `shard`      TINYINT         NOT NULL DEFAULT 0,
  `like_cnt`   INT             NOT NULL DEFAULT 0,
  `comment_cnt` INT            NOT NULL DEFAULT 0,
  `collect_cnt` INT            NOT NULL DEFAULT 0,
  `version`    BIGINT UNSIGNED NOT NULL DEFAULT 0,  -- 乐观锁,防旧值覆盖新值
  `updated_at` DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`content_id`, `shard`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `follow` (                     -- 写扩散的反向查询:谁关注了我
  `follower_id` BIGINT UNSIGNED NOT NULL,
  `author_id`   BIGINT UNSIGNED NOT NULL,
  `created_at`  DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`follower_id`, `author_id`),
  KEY `idx_author_follower` (`author_id`, `follower_id`)   -- 扫粉丝推送用
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

4.6 排行榜:ZSet 实时榜 + 定时快照 ​

text
实时榜:ZSet  key = rank:hot:{period}   member = content_id   score = 热度分
        热度分 = 点赞数 × w1 + 评论数 × w2 + 完播次数 × w3,按小时窗口滑动
        滑动窗口:维护"当前小时"与"上一小时"两个 ZSet,按时间衰减加权求和,
        读时用 ZUNIONSTORE ... WEIGHTS 合并成展示榜(不做逐条重算)
快照:每整点把榜单前 1000 名固化到 MySQL(榜单**不可变**,只做差分更新),
      用户看到的"昨日榜"读快照,避免 ZSet 与展示口径不一致

要点:榜单不要做成"每次读都重算"——热榜读 QPS 也是万级,重算会把 Redis CPU 打满。实时榜只做 O(log N) 的 ZINCRBY,展示榜按分钟级定时物化。

4.7 读路径优化与游标一致性 ​

手段做法效果
多级缓存本地 LRU(feed 页 1~2s)+ Redis(收件箱 60s 级)单用户连续刷新几乎不打后端
Feed 预生成活跃用户每 5 分钟后台预生成首页 20 条,写入 feed:pre:{uid}冷启动/高峰首屏 P99 降 60%
空 Feed 兜底关注为空 → 推荐流兜底 → 再空 → 热门内容新用户永不"白屏",是留存关键
批量取详情20 条 feed_id → 一次 MGET/Hash 批量20 次 RTT → 1 次,命中率 > 95%
已删内容过滤下发前用删除墓碑(status=1 的 content_id 集合)过滤Feed 里不出现已删内容
游标一致性翻页过程中新内容只影响"最新一页",不插入已翻过的区间不重不漏(见 4.3 的对比表)

4.8 发帖与刷新的完整时序 ​


五、第 ⑤ 步 演进路径与兜底 ​

阶段规模(DAU)读 QPS读放大约新增组件触发指标
01 万~17400 倍无起点
150 万~870400 倍Redis 缓存、MySQL 从库DB CPU > 70%、Feed P99 > 300ms
2500 万~8.7k400 倍(被收件箱吃掉)ZSet 收件箱、扩散 Worker、计数 Hash、Kafka收件箱内存 > 60 GB、单表 > 5,000 万行
35,000 万~87k同上分片库、Redis Cluster、多活、榜单物化单集群 QPS 上限、跨机房延迟 > 100ms

迁移与回滚(阶段 1 → 2 的关键一步):先对新用户开启收件箱(双读:收件箱 + 旧聚合,比对差异率 < 0.1% 才放量),再按 uid 哈希灰度 1% → 10% → 50% → 100%;异常时开关切回"读时聚合",收件箱数据保留不删(回滚后仍是可用的加速层)。


故障与一致性边界 ​

挂的组件现象处理一致性边界
Redis 收件箱丢数据 / 分片挂用户刷新后 Feed 变空或只剩大 V 内容重建 = 从 DB 重新推:按 follow 扫关注作者 → 取每人最近 300 条 → 批量 ZADD;重建必须限速(每 shard 每秒 ≤ 5 万次写,避免重建流量打爆 Redis);重建期间降级为纯拉模式(读时聚合,慢但不空)收件箱是缓存不是事实源:丢了不丢数据,只是变慢;重建期间 Feed 可能"变旧"(不推的内容缺失),文案上按"加载更多"处理
计数与真实值不一致点赞数显示 9.8 万而真实 10 万① 定时对齐任务用 content_like 的 COUNT(*) 校准 Redis 与落库表;② 展示层对 > 1 万的值用"万"为单位(容错设计:误差 1% 用户感知不到);③ 落差超阈值(> 5%)才告警计数是 P3 展示级:允许最终一致(分钟级收敛),事实源是 content_like;"用户有没有点过赞"必须准确(走主键唯一)
大 V 发帖引发写风暴扩散 Worker 消费延迟飙升,收件箱写入打满 Redis① 名单前置:粉丝 > 10 万直接不推(主线策略);② 若名单判定延迟,Worker 端二次校验粉丝数再决定推/拉;③ 异步分批推 + 限流(每 Worker 每秒限量)+ 只推活跃粉丝推扩散是尽力而为:漏推的粉丝靠下次刷新的拉模式补齐(大 V 内容永远走拉),普通作者的漏推由"冷启动重建"补齐
Feed 出现已删内容用户投诉"我删了还能被看到"① 删除时写墓碑(status=1 + 墓碑集合缓存);② 下发前逐条过滤墓碑命中的 feed_id;③ 收件箱里的僵尸条目由后台任务按墓碑批量 ZREM;④ 详情接口对已删内容返回 404 而不是旧缓存内容可见性的判定必须在读路径上做(下发时过滤),不能只依赖"推送时是对的";缓存里的旧值靠墓碑 + TTL 收敛
推荐服务超时Feed 首屏被拖慢,P99 从 80ms 涨到 2s超时 50ms 即降级为纯时间序(不要空 Feed);熔断推荐依赖,用上一版缓存结果兜底推荐是尽力而为:降级只影响"排序好看程度",不影响"有没有内容"
Redis 整体不可用Feed 全部走 DB 聚合熔断收件箱读 → 降级为拉模式(按关注列表现聚合,限制最多 100 个关注作者,超出部分只取最近);同时网关限流保护 DB降级可接受但必须限流:纯拉模式 QPS 承载力只有正常的 1/10,所以要"降级 + 限流"一起上,不能让 DB 被打死
MySQL 主库切换发帖/点赞写失败发帖:客户端带幂等键重试((author_id, request_id) 唯一);点赞:唯一主键冲突即视为成功(幂等)切换窗口写不可用、读可用;点赞与发帖都要幂等(重复提交 = 同一条内容/同一次点赞)

贯穿全局的原则:Feed 与计数这一层,"事实源"和"展示层"必须分开——事实源是 MySQL(内容、点赞关系),展示层是 Redis(收件箱、计数)。任何展示层的丢失都只能导致"变慢/变旧",绝不能导致"数据丢失"。 这条线划定后,"收件箱丢了怎么办"就不再是难题。


面试追问链 ​

  1. 推 vs 拉怎么选? → 看三个变量:读放大、粉丝分布、实时性要求。读放大百倍以上 + 粉丝分布均匀 → 纯推(O(1) 读);粉丝极端长尾 → 推拉结合,阈值定在粉丝 10 万(依据是大 V 一条帖子的 ZADD 次数会到千万~亿级,Redis 需要几分钟且成本不可接受)。任何情况下我都不会选纯拉作为终态,因为读放大几百倍时它必然把 DB 打挂。
  2. 大 V 到底怎么办? → 三层:① 不推(粉丝 > 10 万进名单,写只落 content 表);② 拉时不重算——每路只取 20 条 + K 路归并 + 结果缓存 30~60 s;③ 关注列表管理——限制单用户关注上限(如 5000),并对"关注的大 V 数"做分层,超阈值时只拉最近活跃的 N 个 + 推荐流补足。核心是让大 V 的成本从"粉丝数 × 发帖数"变成"活跃读者数 × 刷新次数"。
  3. Feed 为什么不能用 offset 分页? → 两个原因:成本 O(offset)(第 50 页要跳过 1000 条,Redis ZSet 与 MySQL 都慢几十倍)和一致性(Feed 头部持续插入新内容,offset 会整体位移,导致同一页重复或漏读)。游标以 score 为锚点,翻页成本恒定 O(log N + 20),且插入/删除都不影响已翻过的区间。
  4. 点赞数 10 万怎么存? → 分三层:① 关系入 content_like,主键 (content_id, user_id) 保证幂等;② 计数进 Redis Hash 分 10 桶(按 uid % 10 打散),把单 key 833 QPS 摊成每桶 83 QPS;③ 落库异步批量、绝对值覆盖(SET like_cnt = ? 而非 +1,重放幂等),每 10 分钟用 COUNT(*) 对齐。绝对不能在点赞主链路上 UPDATE content SET like_cnt = like_cnt + 1——单行热点写会把行锁、binlog、从库重放全部拖垮。
  5. 收件箱丢了怎么恢复? → 重建 + 降级并行:① 立即切到纯拉模式(读时聚合,用户不白屏);② 后台按 follow 扫粉丝/关注关系,分片重建 ZSet,写入限速(每分片 ≤ 5 万次/s)避免重建把 Redis 打满;③ 重建完成按 uid 灰度切回。因为收件箱是缓存不是事实源(事实源是 content + follow),所以重建一定能收敛,最坏结果是"短暂变慢 + 部分内容变旧"。
  6. 怎么保证用户发帖自己立刻可见(读己之写)? → 不要把希望寄托在"扩散延迟小"上,而是发帖成功后同步写自己的收件箱(一次 ZADD,成本 O(1)),或者自己主页走"DB 直读 + 收件箱合并"。读己之写是硬需求,必须走确定性路径,不能用"异步扩散通常很快"来赌。
  7. 计数和服务端真实值不一致时,展示以谁为准? → 展示用服务端近似值(Redis 分桶求和 + 1s 本地缓存),事实源是 content_like。差异靠定时对账收敛;只要"我有没有点过赞"是准的(主键唯一),数字差几百用户感知不到。所以我把计数设计成"可对账、可容错、不阻塞主链路",而不是"强一致"。
  8. Feed 缓存怎么保证不会发旧内容? → 用版本号 + 墓碑而不是"只靠 TTL":① 关注关系变化 → 该用户缓存 key 的版本号 +1,立即失效;② 内容删除 → 写墓碑,下发前逐条过滤,后台任务批量 ZREM 收件箱;③ 收件箱缓存 TTL 短(秒级)+ 详情缓存 TTL 长(分钟级),但详情命中已删内容时返回 404。TTL 只是最后兜底,不能当正确性机制。

自测清单 ​

  • [ ] 能算出本题的读放大系数(三个口径),并说出为什么"读放大"比"QPS"更能定义 Feed 的架构
  • [ ] 能默写推 / 拉 / 推拉结合三方案对比,并给出粉丝 10 万这个阈值的量化依据
  • [ ] 能说清 Feed 必须用游标而不是 offset 的两个原因(成本 + 一致性)
  • [ ] 能写出收件箱的 key / member / score / cap 设计与 ZREVRANGEBYSCORE 游标用法
  • [ ] 能解释点赞为什么要"Redis 分桶 + 异步绝对值覆盖 + 定时对账",以及单行热点写的上限
  • [ ] 能说出收件箱重建方案的三个要素(降级为纯拉、限速重建、缓存非事实源)
  • [ ] 能对故障表任意一行,30 秒内给出「现象 → 处理 → 一致性边界」
  • [ ] 能背出「读己之写」的确定性实现方式,并解释为什么不能靠"扩散很快"

下一篇:17 面试:白板架构设计与追问链 —— 把这两篇案例的方法论压缩成白板 6 分钟答题模板。

持续学习,持续构建。