写在最前面:这一版和普通讲义有什么不同
这是《内容社区》课程第 19 章。第 17、18 章你搭好了搜索、日志和数据同步这些「基础设施」,本章回到业务主线,啃整个课程设计难度最高的一块——Feed 流:打开微博、小红书、B 站看到的那条「动态列表」背后,藏着整个内容社区最尖锐的一对矛盾。
一句话预告这个矛盾:百万粉丝的大 V 发一条帖子,怎么让所有粉丝都看到、系统还不被写炸? 「发的时候提前给每个粉丝发一份」会把数据库写爆;「看的时候现场去拼」又把接口拖到秒级。两头都是死路,工程答案叫推拉结合。
这一版做了三件事:
- 每个概念先补课再使用。读扩散、写扩散、写放大、开闭原则、游标分页、BFF、压测、P99、RPS、VU……全部就地补「补课胶囊」,不让你去搜。
- 用类比先让你看见。邮箱与存根、报社复印塞信、食堂打饭排队——三种 Feed 模型的生活原型全都有。
- 结尾有 15 道面试问答。按「基础 → 进阶 → 追问」三档,每题告诉你一句话结论、怎么展开、面试官想听什么词、新手最容易答错在哪。
如果你想要「高密度速查版」,可以看原版第 19 章 ;两版知识点完全一致,只是讲法不同。
一、本章导读
先看一个真实到不能再真实的事故现场,感受一下这一章在解决什么问题:
事故现场:上午 10 点 02 分,你们社区签约的头部博主「阿紫」(128 万粉丝)发了条新帖。产品群里一片欢腾,值班群里一片哀嚎——发帖接口耗时从 30ms 飙到 8 秒;MySQL 主库 CPU 打到 100%,写入线程堆积;五分钟后,登录、评论、点赞、搜索全部变慢,因为它们共用这同一个 MySQL 实例。想回滚都来不及:那几秒里已经写进去的几十万行数据,还在库里躺着。
复盘结论:Feed 流上线时选了纯推模型——作者发帖的瞬间,给每个粉丝的收件箱都写一条记录。128 万粉丝,等于一瞬间的 128 万行 INSERT,数据库直接被写炸。
那反过来选纯拉模型(用户刷新时现场去查关注的人发了什么)行不行?也不行:一个关注了 500 人的用户,刷一次首页就是 500+ 次数据库查询,接口照样 2 秒起步,高峰期数据库一样被打挂。
两头都是死路——这就是 Feed 流的核心矛盾:「查询性能」和「写入放大」不可兼得。 本章的主线就是沿着这个矛盾展开的:先讲透三种经典模型(拉 / 推 / 推拉结合)各自把代价压在哪一边,再落地存储设计(收件箱 / 发件箱)和可扩展架构(Service + Handler,新业务接入零修改),用 Kafka 异步写入削峰,最后用 k6 压测工具把你自己写的 Feed 接口的性能拐点真刀真枪压出来——这也是你第一次完整跑通一次生产级压测。
学完这一章,你会拥有一个能「扛住百万粉丝大 V 发帖」的 feed-service,以及一段可以直接在面试里讲的「压测定位 → 对症优化 → 量化收益」的完整故事。下一章进入即时通讯 IM——WebSocket、消息可靠投递,把最后一块硬骨头啃掉。
开读前先看一眼全局:Feed 流只有两种极端方案和一种折中——推模式(写扩散,读快写慢)、拉模式(读扩散,写快读慢)、推拉结合(普通用户推、大 V 拉)。本章先在图里把这三种选型的取舍想清楚,再用压测数据告诉你为什么最终选推拉结合。
flowchart LR
A["大 V 发帖"] --> B{"Feed 方案选型"}
B -->|"推模式:写扩散"| C["发帖时写进每个粉丝的收件箱
读快,写压力大"]
B -->|"拉模式:读扩散"| D["读时现查关注者最新帖
写快,读压力大"]
B -->|"推拉结合"| E["普通用户推
大 V 用拉 + 合并读"]
C --> F["feed-service
收件箱 zset + 分页"]
D --> F
E --> F
G["k6 压测定位瓶颈"] -.->|"对症优化"| F这张图怎么读:从「大 V 发帖」进入方案选型的三分叉——走推模式就在发帖瞬间写进每个粉丝的收件箱(读快、写压力大),走拉模式就等用户读时现查关注者的最新帖(写快、读压力大),推拉结合按粉丝数分流:普通用户推、大 V 只写发件箱供粉丝来拉。三条路最终都汇入 feed-service 的「收件箱 zset + 分页」读接口;下方 k6 那条虚线是本章后半场的角色——先压测定位这个接口的瓶颈,再对症优化。
二、本章知识点大纲
先把这张地图看一遍,知道这一章要往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 设计 feed-service 的三个 Redis key(发件箱 / 收件箱 / 大V表),写出发帖推送链路:先写发件箱保底 → 判大V → 普通作者用 Pipeline 分批推粉丝收件箱(每批 100 个)。
- 写出推拉结合的 Feed 拉取接口:收件箱 + 关注的大V发件箱合并、去重排序、游标分页、批量查详情,外加新用户冷启动 explore。
- 落地 Service + Handler 分发骨架:新业务只要实现
Handler接口并注册,Feed 核心代码一行不改(开闭原则)。 - 写 follow-events 消费:关注回填收件箱、取关清理、粉丝数跨阈值时维护大V表。
- 用 k6 对自己写的接口压测:会写脚本(梯度加压 + 阈值断言 + 自定义指标),会跑,更重要的是会读报告——找到 P99 开始飙升的那一档并发。
理解(能讲清原理和取舍,不要求背代码)
- Feed 流的本质矛盾:查询性能 vs 写入放大;拉模型 = 读扩散、推模型 = 写扩散,推拉结合只是把痛点缩小到「大V和它的粉丝」,不是根治。
- 三种模型各自的优缺点、致命伤和适用场景;选型心法:先看「单个生产者最多有几个消费者」。
- 收件箱 / 发件箱的表结构为什么这样建索引:
uid最左、create_time紧跟其后做排序翻页、uk_uid_id支撑游标分页。 - 发表文章的 Handler 为什么「永远先写发件箱,再判断推不推」——顺序为什么不能反。
- 异步优先原则:Feed 的实时性要求是秒级不是毫秒级,所以写入走 Kafka,换解耦、削峰、鲁棒三样东西。
- k6 指标解读:为什么 P99 比平均值有意义;延迟分解四段(blocked / sending / waiting / receiving)各代表什么;
req_blocked不为 0 意味着压测机自己先趴了。 map[string]any取数字必须. (float64)断言的原理(JSON 数字无类型)——Feed/Kafka 代码最高频的线上故障。- JSON 扩展字段 vs 扩展表的取舍;Feed 里要不要冗余昵称 / 标题的「空间换时间」权衡。
了解(知道有这回事,面试能说出名字和一句话定位)
- 活跃用户优化:只给活跃粉丝写收件箱,非活跃粉丝上线时自己拉——面试主动提是加分项。
- BFF(Backend For Frontend):Feed 只存原始数据,文案拼装、国际化留给面向前端的一层。
- 压测工具分工:pprof(单机内部瓶颈)、wrk(快速非正式)、k6(正式压测 + 可视化)。
- 扩展字段的三种传法(
map/string/bytes)和「直接存成品文案」的变种方案。 - ZSet 的 score 用毫秒时间戳会在「同一毫秒发两条」时互相顶掉——生产用雪花 ID;
sort.Slice全量排序可以升级成多路归并(堆合并)。
前置依赖与补课清单
| 需要你已掌握 | 在哪章学过 | 本章还会补 | 补在哪一节 |
|---|---|---|---|
| Gin 路由 + Handler / Service / DAO 分层 | 第 02 章 | ❌ 不补 | — |
| Redis ZSet 基本命令(ZADD / ZREVRANGE) | 第 09 章(热榜) | ❌ 不补(本章把它升级成 Feed 主存储) | — |
| Kafka topic / 消费组 / offset / at-least-once | 第 07 章 | ❌ 不补 | — |
| 削峰、解耦、最终一致性 | 第 07 章 | ❌ 不补(本章是它的实战案例) | — |
| 关注 / 粉丝关系表与查询 | 第 16 章 | ❌ 不补 | — |
| ETCD 服务注册、优雅停机 | 第 12 章 | ❌ 不补(跟随写直接复用) | — |
| zap 日志 + viper 配置 | 第 05 章 | ❌ 不补 | — |
| 幂等(重复消费的后果与对策) | 第 07 / 18 章 | ❌ 不补 | — |
| MySQL 索引(KEY / UNIQUE KEY 会看) | 第 02 章 | ❌ 不补 | — |
| 什么是读扩散 / 写扩散 | 未讲过 | ✅ 重点补 | 3.2 / 3.3 节 |
| 什么是游标分页 | 未讲过 | ✅ 补 | 3.5 节 |
| 什么是开闭原则 | 未讲过 | ✅ 补 | 3.6 节 |
| 什么是 BFF | 未讲过 | ✅ 补 | 3.5 节 |
| 什么是压测 / P99 / RPS / VU | 未讲过 | ✅ 重点补 | 3.8 节 |
不需要你提前懂的:k6 的全部 API(用到哪查哪)、Grafana 看板定制(会 Import 现成模板即可)、推荐系统排序(本章 Feed 按时间序,推荐流只提一嘴)、分库分表中间件(知道推模型的分表思路即可)。
预计时长与学习成果
预计时长:5.5 小时(概念精读 2 小时 + 跟着写 feed-service 3 小时 + k6 压测 0.5 小时);本章概念较多(三种模型、两个存储结构、一个架构模式),建议分两次学:第一次读完 3.1–3.5 把「模型 + 存储」想透,第二次学 3.6–3.9 并做「跟着写」。
学完你能跑出来的东西(这是本章的验收标准,做到才算过关):
feed-service 全链路跑通。往 Kafka 的
post-events手发一条发帖消息:普通作者(uid=7)的帖子 101 秒级出现在所有粉丝(1、2、3)的收件箱里;把fan_threshold改成 3 并SADD feed:bigv 9后,大V(uid=9)再发帖只有发件箱有记录、任何粉丝收件箱都没有;GET /api/feed?uid=1返回的 items 里既有推送帖也有大V拉取帖、按时间倒序、带next_cursor;最后k6 run压测输出http_req_failedrate<0.01、p(95)<500ms,以及feed_bigv_ms/feed_merge_ms两条自定义指标。
本章的学习路线(先模型、再存储、再架构、最后压测,跟着写把全部串起来):
flowchart LR
A["3.1 Feed 是什么
核心矛盾"] --> B["3.2-3.4 三种模型
拉·推·推拉结合"]
B --> C["3.5 存储设计
收件箱·发件箱"]
C --> D["3.6 架构
Service+Handler"]
D --> E["3.7 异步写入
Kafka 削峰"]
E --> F["3.8-3.9 压测
k6 找拐点"]
F --> G["第四节 跟着写
feed-service 落地"]
G --> H["第七节 面试问答"]这张图怎么读:从左到右的单向路线:3.1 先用「刷一次首页发生了什么」定义 Feed 流与核心矛盾,3.2-3.4 把拉、推、推拉结合三种模型的代价压在哪边讲透,3.5 落到存储(收件箱 / 发件箱表与索引),3.6 抽出 Service + Handler 架构让新业务零修改接入,3.7 把写入改走 Kafka 异步,3.8-3.9 用 k6 压测找拐点并串起优化话术,最后第四节跟着写 feed-service,第七节面试问答收尾。
三、正文
3.1 Feed 流是什么:从一次「刷首页」说起(核心概念一)
先抛问题。打开小红书的「关注」页,你看到的是一个不断往下刷的列表:关注的博主发了新帖、有人点赞了你的作品、有人评论了你、有人关注了你……这个列表就叫 Feed 流。表面上看它只是「一个接口返回一个列表」,为什么不直接写个 SQL 查出来?——因为你要的内容散落在好几张表里(文章表、点赞表、评论表、关注表),而且「和你有关的那一小撮」要从全站的数据里挑出来。用户量和关注数一大,「刷的时候现场拼」的代价会指数级放大。不提前想清楚这个代价压在哪边,就是导读里那个 128 万行 INSERT 的事故。
补课胶囊 · Feed 流:Feed 流 = 把「和你有关的一堆动态」按时间顺序整合成一个列表、持续推到你眼前的产品形态 → 类比:你家楼下的信箱和门口塞满的小区通知栏,你不用挨家挨户打听,路过一眼就能看到「最近发生了什么」→ 回到主线:技术上它要解决两件事——整合(从多张表、多个业务里凑齐内容)和时间序(按发生时间排好给你看)。
给直觉。想象你手机里装了 50 个博主的「看点」。每天打开 App 的关注页,你想要的其实就一句话:这 50 个人最近发了啥,按时间排好,给我看。 难点不在「看」,而在「凑」——这 50 个人的动态分散在不同表、甚至不同服务里,你刷一次首页,背后可能要跨好多张表去拼:
flowchart LR
A[关注的人发文章] --> F((Feed 流))
B[有人点赞/收藏你] --> F
C[有人评论/回复你] --> F
D[有人关注你] --> F
E[系统通知] --> F
F --> U[用户刷首页看到的 timeline]先想清楚产品形态(两种做法)。动手设计前有个更前置的选择——Feed 里到底装什么:
- 形态一(全聚合):上面图里所有动态合并进一个流。复杂,但通用。本课程选它。
- 形态二(只做关注流):只把「关注的人新作品」做成 Feed,点赞 / 评论 / 关注做成单独的「系统通知」页。简单,但功能弱。
为什么选难的形态一?一个很工程的理由:形态一可以降级成形态二(删掉几类事件即可),反过来不行。这种「先做难的、留降级空间」的思路你在做任何技术选型时都会反复遇到。
Feed 流不止社交产品在用。几乎所有「持续产生内容、用户持续消费」的系统都有它:
| 场景 | Feed 流里流动的内容 | 侧重点 |
|---|---|---|
| 社交媒体(微博/朋友圈) | 动态、点赞、评论、关注 | 实时性、社交互动 |
| 新闻资讯 | 最新新闻、热门话题 | 实时推送 + 个性化推荐 |
| 电子商务 | 商品推荐、促销、评价 | 个性化 |
| 视频/博客平台(B 站) | 关注的 UP 主新作品 | 多样性(视频、文章混排) |
| 在线教育 | 新课程、作业提醒、同学互动 | 时效性 |
| 游戏 | 好友动态、成就、游戏内事件 | 社交互动 |
常见坑:新手容易把 Feed 流当成「文章列表接口的升级版」,直接 SELECT * FROM article WHERE author_id IN (关注列表) ORDER BY create_time DESC LIMIT 20 就交差。这条 SQL 在「只看关注的人的文章」这个窄场景里能跑,但只要需求一变(「点赞了我的也要显示」「被 @ 的也要显示」),你就得在查询里越拼越多表——最终变成一个没人敢动的巨型 SQL。Feed 流是把「整合」当成一个独立的领域问题来设计的,这是本章和第 02 章「写个列表接口」的本质区别。
小收口:这一节确立了 Feed 流 = 整合 + 时间序,以及它的本质矛盾——生产者少、消费者多,但每个人要的是「全网里和他有关的那一小撮」。接下来三节看三种模型分别怎么解决这个矛盾、各自把代价压到哪一边。
3.2 拉模型(读扩散):读的时候现场拼
先抛问题。最直觉的做法是什么?用户刷首页的那一瞬间,现场去查:关注的人发了什么、谁点赞了我、谁评论了我……查完在内存里拼一起、排个序返回。听起来没有任何提前准备的必要——但代价是什么?
补课胶囊 · 读扩散:读扩散 = 把「整合」这个动作推迟到每次读取的时候现场做,一次读操作会扩散成 N 次底层查询 → 类比:你每天早上去 50 个博主主页挨个翻「最新动态」,翻完自己排个序——博主们发帖时什么额外的事都不做,全攒到你来看的时候 → 回到主线:拉模型(用户主动来拉)就是读扩散,「拉模型 = 读扩散」是同一件事的两套说法。
给直觉。整个流程长这样:
flowchart TD
U[用户 A 刷首页] --> Q1[查 article 表
A 关注的人的文章]
U --> Q2[查 like 表
A 收到的点赞]
U --> Q3[查 comment 表
A 收到的评论]
U --> Q4[查 follow 表
A 的新粉丝]
Q1 --> M[内存按时间戳归并排序]
Q2 --> M
Q3 --> M
Q4 --> M
M --> R[取前 N 条返回]这张图怎么读:用户 A 刷首页这一个动作,被现场拆成四路底层查询——article 表查关注者的文章、like 表查收到的点赞、comment 表查收到的评论、follow 表查新粉丝;四路结果在内存里按时间戳归并排序,最后截取前 N 条返回。查询次数随 A 的关注数线性放大,且翻页要记清每个来源各自翻到哪——这就是拉模型「写快读慢」的具体代价。
最小可运行示例。用一条 SQL 体会一下「拉」的最简形态(只有文章、只关注 3 个人时它毫无压力):
-- 用户 A 关注了 3 个人(3, 17, 42),拉他们最近的文章
SELECT id, uid, title, create_time FROM article
WHERE uid IN (3, 17, 42)
ORDER BY create_time DESC
LIMIT 20;
-- 预期:20 行,执行时间 < 1ms(数据量小时拉模型完全够用)
逐行讲解:注意 uid IN (...) 这个列表——它来自你的关注表。关注 3 个人时它是 3 个元素,关注 500 人时它是 500 个元素,数据库要在一个大索引里做 500 次定位再归并。这条 SQL 的成本和你的关注数成正比,这正是拉模型的原罪:查询代价被每个用户自己的社交关系放大。
工程痛点(两刀都致命):
- 对数据库压力极大:关注 1000 人的用户,刷一次首页就是 1000+ 次查询(每种事件类型都要查一遍)。这些查询在「刷首页」这个高频动作上叠加,数据库先于一切倒下。
- 分页极难:你想取「前 20 条」,但没法预知这 20 条分布在哪些来源里——只能每个来源各取前 20,内存里归并排序后再截断。翻第二页更麻烦(还得记住每个来源各自翻到哪了)。这和分库分表中间件处理跨表分页是同一套难题,只是在这里你亲手遇到了它。
常见坑:新手会说「加缓存不就行了」。缓存能救点赞、评论这种和 A 自己绑定的数据,但「A 关注的 500 人各自的新文章」——任何一个人发帖,500 万个粉丝各自的这份数据都变了。缓存命中率极低,救不了读扩散。
小收口:拉模型把代价全部压在了读这一边——写时零负担、读时大爆炸。内容社区早期数据量小时可以用(上面那条 SQL 就能跑),一旦用户关注数膨胀就撑不住。那把代价压到写那边呢?下一节。
3.3 推模型(写扩散):写的时候提前发
先抛问题。既然「读的时候现场拼」太慢,那就反过来:作者发内容的瞬间,系统提前把这条动态写进每个粉丝的收件箱。粉丝刷首页时只查自己的收件箱,一条 SQL 搞定。读是快了——写的那一边发生了什么?
补课胶囊 · 写扩散 / 写放大:写扩散 = 把「整合」这个动作提前到每次写入的时候做,一次写操作会扩散成 N 次底层写入;这个 N 倍的写入量就叫写放大 → 类比:报社印报纸,一个记者写一篇文章,报社立刻复印 100 万份,挨家挨户塞进每个订户的信箱——订户早上开自家信箱就能看(读极快),代价是报社的印刷机(写被放大了 100 万倍)→ 回到主线:推模型(系统推给粉丝)就是写扩散,「推模型 = 写扩散」。
给直觉。
flowchart TD
P[博主 B 发文章] --> W[写入每个粉丝的收件箱]
W --> I1[粉丝 A 的收件箱]
W --> I2[粉丝 C 的收件箱]
W --> I3[粉丝 D 的收件箱]
A[粉丝 A 刷首页] --> R[只查 A 自己的收件箱]
R --> T[数据库分页直接取前 N 条]这张图怎么读:上半是写路径——博主 B 发文章的瞬间,系统把这条动态各写一份进粉丝 A、C、D 的收件箱,粉丝越多写入越多(百万粉就是百万行 INSERT,写放大);下半是读路径——粉丝 A 刷首页只查 A 自己的收件箱,按时间倒序分页取前 N 条,成本与关注多少人彻底无关。推模型的本质就是拿「写时 N 倍放大」换「读时一条 SQL」。
读路径有多爽:
-- 推模型下,用户 A 刷首页只剩这一条 SQL
SELECT id, type, content, create_time FROM feed_push_event
WHERE uid = 10086 -- A 自己的收件箱
ORDER BY create_time DESC
LIMIT 20;
-- 预期:20 行;无论 A 关注了多少人,这条 SQL 的成本都不变
逐行讲解:注意 WHERE uid = ?——查询成本和「A 关注了多少人」彻底解耦了。关注 3 人和关注 3000 人,查询代价一模一样,走 uid 索引直接分页。这就是推模型的核心卖点:读路径变成了一个常数。
但致命伤在写路径:
- B 有 100 万粉丝,发一篇文章 = 瞬间 100 万行 INSERT。导读里 128 万粉丝的事故就是这么来的。
- 更糟的是这 100 万行要在「发帖请求」的同步链路里写完,或者即便异步写,数据库的写入能力也会被这一波打满,连累所有业务。
常见坑:新手会想「把 100 万行写入选异步、慢慢写不就行了」。异步只是把「一瞬间的 100 万行」摊平成「几秒内的 100 万行」,总量一克没少——存储成本照样爆炸:每天 1 万个大 V 发 10 条,就是每天新增上亿行收件箱数据。写放大不只打挂数据库,还烧磁盘。
小收口:推模型把代价全部压到了写这一边——读时零思考、写时大爆炸。拉模型和推模型是完美的一对镜像:一个读时死、一个写时死。真实世界的选择题从来不是二选一,而是「什么时候读、什么时候写」——下一节的推拉结合。
3.4 推拉结合:大 V 走拉、普通人走推(工程主流)
先抛问题。写炸的元凶是谁?复盘一下:大 V。普通用户(几十几百个粉丝)发帖,写扩散的量完全可控——粉丝 200 人就是 200 行,毫无压力;出事的永远是百万粉的大 V。那能不能区别对待:普通用户发帖走推、大 V 发帖走拉?
给直觉。这就是推拉结合,实践中的主流方案,思路一句话:普通用户走推(写扩散),大 V 走拉(读扩散)。
flowchart TD
P[作者发内容] --> T{粉丝数 > 阈值?}
T -- 否: 普通用户 --> W[写入每个粉丝收件箱
写扩散,量可控]
T -- 是: 大 V --> O[只写自己发件箱
不发,粉丝来拉]
A[粉丝 A 刷首页] --> G[合并: 自己收件箱 + 关注的大V发件箱]
G --> S[排序分页]读流程(推拉结合下,粉丝刷首页要干三件事):
- 先查自己的收件箱(普通关注的人推过来的);
- 再查自己关注的大 V 的发件箱(大 V 没推给你,你得去拉);
- 合并、排序、分页。
逐层体会这个设计:大 V 的「百万写入」被砍成了「一次写入发件箱」,代价转移到了大 V 的粉丝头上——每个粉丝刷首页时多查几个大 V 的发件箱。但注意:一个用户关注的大 V 数量通常有限(几十个顶天了),这部分的拉取代价是可控的。推拉结合的本质是把痛点从「所有人 × 全部写入」缩小到「大 V 的粉丝 × 读取时多查几张表」。
怎么选型?一张表 + 一句心法:
| 场景 | 推荐模型 | 原因 |
|---|---|---|
| 微信朋友圈(好友数有上限) | 纯写扩散 | 好友数受限(约 5000),写入量可控,读极快 |
| 微博(千万粉大 V) | 推拉结合 | 大 V 走拉,普通用户走推 |
| 系统通知 / 私信类事件 | 写扩散 | 只投递给特定用户,受众明确,写入量小 |
| B 站 UP 主动态 | 推拉结合 + 活跃用户优化 | 百万粉 UP 多,必须控制写入量 |
选型心法:先看「单个生产者最多有几个消费者」。消费者有上限(几千以内)→ 可以推;消费者可能上百万 → 必须拉或推拉结合。这张表里没有纯拉模型的场景——因为它只适合数据量极小的早期产品,长大了迟早要换。
进化玩法:只给活跃粉丝写收件箱。推拉结合还能再省一笔:作者发内容时,只给活跃粉丝(最近登录过、刷过首页的)写收件箱;非活跃粉丝(三年没上线的僵尸粉)不写,等他们上线时自己去作者发件箱拉。给僵尸粉写收件箱纯属浪费数据库。「活跃」怎么判定:产品视角看最近 7/30 天的登录和互动行为;技术实现上要么 T+1 定时任务离线算活跃名单,要么 Redis 记 uid → 最后活跃时间 写入时实时判断。本课程为简化没有实现这个优化,但它是「按用户价值分配资源」思想的最佳体现,面试时主动提出来是加分项。
常见坑:推拉结合里「大 V 的判定阈值」不是拍脑袋。设小了(比如 1 万),一堆中腰部作者也被判成大 V,他们的粉丝刷首页要拉一大堆发件箱,读路径变重;设大了(比如 1000 万),大 V 发帖照样产生千万级写入。常见取值在 10 万~100 万之间,并且要用压测数据来定——这也是本章后半场要学压测的原因之一。
进阶延伸:推拉结合的配套限制。工业界在推拉结合之外还有两条辅助手段值得知道:push 侧设关注上限——微信 / QQ / 小红书都有「最多关注 N 人」的产品限制(朋友圈好友上限 5000 是同款思路),从源头给写扩散封顶;pull 侧限频——拉模型读得狠,就给客户端加轮询间隔、限制刷新频率(下拉刷新限次),把「每秒亿级查询」压到可接受水位。还有一招「合并读请求」:同一时刻多个粉丝查同一批大 V 的发件箱,可以把这些请求合并成一次 DB 访问、结果共享——听起来很美,但实测优化甚微(大 V 发件箱本身有缓存兜底),面试知道有这回事即可,别当成主优化讲。
小收口:三种模型全部讲完:拉模型读时死、推模型写时死、推拉结合让大 V 和普通人各走各的路。模型定了,接下来要回答的问题是:收件箱、发件箱在数据库里到底长什么样——建表、索引、扩展字段,一节讲透。
3.5 存储设计:收件箱与发件箱(核心概念二)
先抛问题。「推到粉丝的收件箱」「从大 V 的发件箱拉」这两句话里的收件箱、发件箱,落地到数据库是什么?两张表。但真正决定性能的不是表结构本身,而是索引怎么建、分页怎么做、扩展字段怎么存——这三个问题的答案直接决定 Feed 能扛多大量。
补课胶囊 · 收件箱 / 发件箱:收件箱 = 每个用户一条专属数据流,「别人塞给我的东西」按时间躺着(推模型写这里、读接口第一来源)→ 类比:你家门口的邮箱,邮差(系统)往里塞信;发件箱 = 每个作者一条专属数据流,「我发出去的所有东西」的存根(拉模型的唯一数据源)→ 类比:你办公室抽屉里「我发出去的所有信」的留底,粉丝要看来翻我的抽屉 → 回到主线:两张表结构几乎一模一样,唯一区别是
uid的含义——收件箱里是「谁的收件箱」,发件箱里是「谁发的」。
给直觉。两张表的关系:
erDiagram
feed_push_event {
bigint id PK
bigint uid "收件人 ID"
varchar type "事件类型"
json content "扩展字段"
bigint create_time "排序用时间戳"
}
feed_pull_event {
bigint id PK
bigint uid "作者 ID"
varchar type "事件类型"
json content "扩展字段"
bigint create_time "排序用时间戳"
}建表语句(先照抄,下面逐个解释索引为什么这么建):
-- 收件箱:推事件表(写扩散写入这里)
CREATE TABLE `feed_push_event` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`uid` BIGINT UNSIGNED NOT NULL COMMENT '收件人 ID,即谁的收件箱',
`type` VARCHAR(32) NOT NULL COMMENT '事件类型:like/comment/follow/publish',
`content` JSON NOT NULL COMMENT '扩展字段,存储事件个性化数据',
`create_time` BIGINT UNSIGNED NOT NULL COMMENT '事件创建时间戳,用于排序',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_uid_id` (`uid`, `id`),
KEY `idx_uid_create_time` (`uid`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Feed 收件箱';
-- 发件箱:拉事件表(读扩散从这读)
CREATE TABLE `feed_pull_event` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`uid` BIGINT UNSIGNED NOT NULL COMMENT '产生事件的人,即作者',
`type` VARCHAR(32) NOT NULL,
`content` JSON NOT NULL,
`create_time` BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_uid_create_time` (`uid`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Feed 发件箱';
索引为什么这么设计(三个问题三个答案):
- 为什么
uid放索引最左? 绝大多数查询都是「查某个人的收件箱 / 发件箱」(WHERE uid = ?),过滤条件必须能走索引最左列,否则全表扫描。 - 为什么
create_time紧跟uid? 查询永远是「某人的收件箱,按时间倒序,取前 20 条」(WHERE uid=? ORDER BY create_time DESC LIMIT 20)。(uid, create_time)联合索引让排序也走索引,不用 filesort。 uk_uid_id这个唯一索引干嘛用的? 两个用途:① 防重复写入(同一条事件不会重复进同一个收件箱);② 支撑游标分页——这是它真正的杀招,马上展开。
补课胶囊 · 游标分页:游标分页 = 翻页时不数「第几页」,而是记住「上一页最后一条的位置」,下一页从这个位置接着取 → 类比:看书夹书签,下次直接从书签处继续,而不是每次都从第 1 页数到第 100 页 → 回到主线:
WHERE uid=? AND id < 上次最后一条id ORDER BY id DESC LIMIT 20,无论翻到第几页,性能都和第一页一样。而传统的LIMIT 20 OFFSET 100000要先数过前 10 万条再丢弃——深翻页越翻越慢。
扩展字段为什么用 JSON? Feed 里除了 uid/type/时间 这些公共字段,每种事件还带自己的个性化数据(点赞事件带「谁赞了谁」,发帖事件带「文章 id」)。两种存法:
- 大 JSON 字段(本课程采用):
content一个字段兜住所有业务的自定义数据。简单,一张表搞定所有事件类型。代价是 JSON 里的内容不能参与WHERE过滤。 - 扩展表:每种事件一张扩展表,类型安全、可索引可过滤,但每接一个新业务就要建一张表,代码量翻几倍。
补课胶囊 · BFF:BFF = Backend For Frontend,为前端服务的后端聚合层——各种后端服务的数据在它这里拼装成前端直接能渲染的形状 → 类比:装修队的「总包」,业主(前端)只对总包提要求,不需要分别指挥水电工、木工、油漆工(各个后端服务)→ 回到主线:Feed 只存业务方的原始数据,渲染要用的文案、昵称、国际化,由 BFF 回查各服务后拼装。
冗余存储的权衡:Feed 事件里要不要顺手存一份「用户昵称 / 文章标题」?冗余了,刷首页时 BFF 不用回查业务方,读快;但业务方改了昵称,你得同步更新所有相关 Feed,写麻烦。不冗余则反过来。这是经典的「空间换时间 / 一致性换性能」取舍——读多写少就冗余,写多读少就回查。本课程选择不冗余、读时回查(跟随写第 3 步的批量查详情就是这个决策)。
扩展字段的进阶传法。本课程用 map[string]any,但设计多业务方对接系统时还有别的选择:
| 传法 | 平台方怎么处理 | 特点 |
|---|---|---|
map[string]any | 原样存储、原样返回 | 结构可见,平台方能顺手校验约定 key(本课程方案) |
string | 平台方不管内容格式 | 人类可直接阅读,具体业务自己解释(内部约定一般是 JSON) |
bytes | 平台方不管内容格式 | 人读不懂,可以是 protobuf 二进制或压缩流,省空间省带宽 |
另外还有一种罕见方案:Feed 直接存前端要展示的成品文案(比如存一段「A 发表了《Go 入门》」的 HTML,名字带超链接)。查询性能极好(BFF 什么都不用查),但扩展性极差——前端改个文案格式就要刷全量历史数据,实践中几乎没人用。它的一个变种更务实一点:业务方只传 aid 和 uid,Feed 收到后反查 article 和 user 表,把 title / nickname 冗余下来一起存。
最小可运行示例。Feed 事件本体长什么样——业务方发到 Kafka、Feed 原样落库的就是这个结构:
// internal/feed/service/event.go
type FeedEvent struct {
Uid int64 `json:"uid"` // 目标用户 ID(推模式下为收件人)
Type string `json:"type"` // 事件类型
Content map[string]any `json:"content"` // 扩展字段,业务方自定义
Timestamp int64 `json:"timestamp"` // 事件时间戳
}
逐行讲解:Content 用 map[string]any 就是大 JSON 字段在 Go 里的映射——平台方不定义结构、业务方想塞什么塞什么,这是「Feed 不懂业务」的前提。Timestamp 单独拎出来是因为它是排序键,不能埋在 content 里。
常见坑:新手会把 create_time 建成 MySQL 的 DATETIME 类型再跟现在的时间比。本表用的是 BIGINT 时间戳——因为排序、游标、跨服务传递时,整数时间戳比字符串日期好处理得多(第 16 章的 ctime 也是同样的选择)。
小收口:两张表 + 三个索引决策 + JSON 扩展字段,Feed 的存储骨架成型。但还有个架构问题没解决:点赞、发帖、关注……每种事件的处理逻辑不一样(有的推、有的拉、有的推拉结合),这些「不一样」的代码放哪,才能让 Feed 核心不被业务代码淹没?下一节的 Service + Handler 就是答案。
3.6 Service + Handler:新业务零修改接入(核心概念三)
先抛问题。今天是发帖和点赞两种事件,下个月产品要加「被 @ 提醒」和「被转发」。最笨的写法是在 Feed 的核心逻辑里堆 if type == "like" {...} else if type == "publish" {...}——每加一种事件改一次核心代码,改一次回归测试一次,改挂一次全站 Feed 挂一次。有没有办法让「新业务接入」和「Feed 核心代码」彻底解耦?
补课胶囊 · 开闭原则:开闭原则(OCP,Open-Closed Principle)= 对扩展开放、对修改关闭——加新功能靠「新增代码」而不是「修改老代码」→ 类比:插线板,要给新设备供电就插一个新孔,不需要拆开插线板重新焊线 → 回到主线:Feed 的目标就是把自己做成「插线板」:每种事件类型是一个「插头」(Handler),Feed 核心(Service)只负责把电(事件)送到对应的孔里。
给直觉。
flowchart LR
K[Kafka 消息] --> S[Service 分发]
S -->|type=like| H1[LikeHandler]
S -->|type=publish| H2[PublishHandler]
S -->|type=unknown| HD[DefaultHandler 兜底]
H1 --> DB[(收件箱)]
H2 --> DB
H2 --> DB2[(发件箱)]拆两个角色:Service 管所有业务共有的公共逻辑(分发、聚合、排序);Handler 管某个具体业务的专属逻辑(决定推还是拉、校验扩展字段、回查业务数据)。分界线一句话:排序规则是公共的,推还是拉是业务专属的。
核心代码(先看骨架):
// internal/feed/service/service.go
package feed
import "context"
// Handler 每个业务方实现一个 Handler,处理自己那部分逻辑
type Handler interface {
// Create 处理业务方推送过来的事件,决定走推模型还是拉模型
Create(ctx context.Context, event FeedEvent) error
// FindFeedEvents 查询本业务在 Feed 流中的事件(可选,无特殊逻辑可省略)
FindFeedEvents(ctx context.Context, uid int64, t int64, limit int) ([]FeedEvent, error)
}
// Service Feed 流核心服务,分发到各个 Handler
type Service struct {
handlers map[string]Handler // 按 type 索引
defaultHandler Handler // 兜底:找不到对应业务 handler 时走默认逻辑
}
// CreateFeedEvent Service 层分发逻辑
func (s *Service) CreateFeedEvent(ctx context.Context, event FeedEvent) error {
h, ok := s.handlers[event.Type]
if !ok {
// 兜底策略:新业务接入但无特色逻辑时,无需改 Feed 服务
h = s.defaultHandler
}
return h.Create(ctx, event)
}
逐行讲解:handlers map[string]Handler 按 event.Type 索引——这就是「插线板的孔」;CreateFeedEvent 永远只有这一段分发逻辑,它不会因为业务增多而变长;defaultHandler 是安全网:新业务还没来得及写 Handler 时,事件走默认逻辑(一般是通用写收件箱),不会丢。新业务接入的全部动作 = 实现一个 Handler + handlers["newType"] = NewXxxHandler(...) 注册——Feed 核心零修改,这就是开闭原则落地。
第一个 Handler:点赞(纯推,受众只有 1 人)。业务判断:A 点赞了 B,只有 B 关心这件事,收件人唯一且明确 → 推模型,写进 B 的收件箱。
// internal/feed/service/handler_like.go
type LikeHandler struct {
pushEventDAO PushEventDAO // 操作收件箱
}
func (h *LikeHandler) Create(ctx context.Context, event FeedEvent) error {
// 步骤 1:从扩展字段取出收件人(被点赞者)
// 约定:liked = 被点赞者 ID(即收件人)
liked, ok := event.Content["liked"].(float64)
if !ok {
return errors.New("缺少 liked 字段")
}
// 步骤 2:写入收件人收件箱(推模型,受众只有 1 人,写入量可控)
return h.pushEventDAO.Create(ctx, PushEvent{
Uid: int64(liked), // 收件人
Type: event.Type,
Content: event.Content,
CreateTime: event.Timestamp,
})
}
⚠️ 新手必踩的坑:float64 断言。JSON 里的数字没有类型,Go 把消息反序列化进 map[string]any 时,所有数字一律变成 float64,不是 int64!所以必须先 . (float64) 再 int64(liked) 转换。
如果你写成 event.Content["liked"].(int64),运行时直接 panic:
panic: interface conversion: interface {} is float64, not int64
这是 Feed / Kafka 相关代码最高频的线上故障之一。更稳的写法:用 json.Number,或干脆在结构体里定义强类型字段、别用 map[string]any 裸取。
第二个 Handler:发表文章(推拉结合的完整落地)。发帖是大流量事件,写不写收件箱要看粉丝数。代码里四步,每步都对应 3.4 节的流程图:
// internal/feed/service/handler_publish.go
type PublishArticleHandler struct {
pushEventDAO PushEventDAO // 收件箱
pullEventDAO PullEventDAO // 发件箱
followSvc FollowService
fanThreshold int64 // 粉丝数阈值:超过走拉(大 V)
}
func (h *PublishArticleHandler) Create(ctx context.Context, event FeedEvent) error {
// 步骤 1:永远先写发件箱(保底,拉模型一定读得到)
if err := h.pullEventDAO.Create(ctx, PullEvent{
Uid: event.Uid, Type: event.Type,
Content: event.Content, CreateTime: event.Timestamp,
}); err != nil {
return err
}
// 步骤 2:取粉丝列表,同时拿到粉丝数
// 只取 fanThreshold+1 个:一旦超过阈值,说明是大 V,后面就不用全取了
fans, err := h.followSvc.GetFollowers(ctx, event.Uid, 0, h.fanThreshold+1)
if err != nil {
return err
}
// 步骤 3:粉丝数超阈值 → 大 V,走拉模型,到此为止(不再写收件箱)
if int64(len(fans)) > h.fanThreshold {
return nil
}
// 步骤 4:普通用户 → 推模型,批量写每个粉丝收件箱
fanIds := make([]int64, 0, len(fans))
for _, f := range fans {
fanIds = append(fanIds, f.Uid)
}
return h.pushEventDAO.BatchCreate(ctx, fanIds, event)
}
逐行讲解(重点是那个 fanThreshold+1):判断「粉丝数是否超过阈值」不需要取全部粉丝——只取 阈值+1 个,取出来的数量比阈值大就说明超了,这一行省掉了大 V 场景下「取 100 万粉丝列表」这个动作本身。另外注意步骤 1 和步骤 3 的顺序:先写发件箱,再判断推不推。为什么顺序不能反?发件箱是拉模型的唯一数据源,必须先落——如果先判断「是大 V,直接 return」,发件箱没写,大 V 的粉丝来拉的时候什么都拉不到。
查询 Feed 流:合并推拉两边。读路径(3.4 节的三步)落在 Service 层:
// internal/feed/service/service.go
// GetFeedEvents 查询用户 A 的 Feed 流
func (s *Service) GetFeedEvents(ctx context.Context, uid int64, t int64, limit int) ([]FeedEvent, error) {
// 1. 从 A 的收件箱读(推模型部分)
pushEvents, err := s.pushEventDAO.Find(ctx, uid, t, limit)
if err != nil {
return nil, err
}
// 2. 从 A 关注的人的发件箱读(拉模型部分)
followeeIds, err := s.followSvc.GetFollowees(ctx, uid)
if err != nil {
return nil, err
}
pullEvents, err := s.pullEventDAO.FindByUids(ctx, followeeIds, t, limit)
if err != nil {
return nil, err
}
// 3. 合并 + 按时间戳降序排序 + 截断
all := append(pushEvents, pullEvents...)
sort.Slice(all, func(i, j int) bool {
return all[i].Timestamp > all[j].Timestamp // 降序
})
if len(all) > limit {
all = all[:limit]
}
return all, nil
}
逐行讲解:为什么查询写在 Service 而不转发给各 Handler?因为查询对所有业务没有差异——收件箱查出来、发件箱查出来、合并排序,完事。只有当某个业务在查询时有个性化逻辑(比如要把自家事件的 content 转换成另一种格式)时,才让它实现 Handler.FindFeedEvents 由 Service 聚合——这就是 Handler 接口里 FindFeedEvents 标注「可选」的原因。
GetFollowees 如果 A 关注了几千人,一次性拉几千个 id 去 FindByUids,SQL 的 IN (...) 会很长、很慢。生产上要对关注列表做分页 / 限制拉取的发件箱数量(比如只看最近活跃的 200 个关注对象),否则「关注狂魔」刷一次首页会被自己的关注列表拖死。常见坑:新手写 Handler 时容易把「回查展示数据」也塞进 Create 里(写收件箱前先查一遍 user 表拿昵称)。能跑,但这让写路径多了一次跨服务 RPC——大 V 发帖时会被放大成海量回查。回查展示数据应该是读路径(BFF)的事,或者写路径异步做(3.7 节)。
小收口:Service + Handler 把「公共逻辑」和「业务专属逻辑」切开了,新业务零修改接入。还剩最后一个设计决策:业务方的事件怎么到达 Feed——同步调接口,还是发消息?下一节。
3.7 写入为什么走 Kafka 异步:解耦、削峰、鲁棒
先抛问题。A 点了赞,赞要出现在 B 的 Feed 里——这个「写进收件箱」的动作,是点赞接口同步完成(写完收件箱才返回点赞成功),还是先返回点赞成功、Feed 慢慢写?同步的话:Feed 服务或数据库抖一下,点赞接口跟着失败——B 业务被 A 业务拖死;异步的话:B 上线后 2 秒才看到动态,用户无感。选哪个?
给直觉。Feed 流对实时性的要求是「秒级 / 十秒级」,不是毫秒级——A 发文章,B 在 10 秒内看到,用户完全无感。既然实时性没有强制要求,异步就是免费的午餐。业务方发完消息就返回成功,「写收件箱」这个动作交给 Feed 服务在 Kafka 消费侧慢慢做:
flowchart LR
B[业务方] -->|发消息| K[Kafka]
K -->|削峰缓冲| F[Feed 服务]
F --> DB[(数据库)]
B -. 不等 Feed 返回 .-> OK[业务主流程立即成功]逐个拆开异步换来的三样东西:
- 解耦:业务方只管发消息,完全不依赖 Feed 服务是否在线。Feed 挂了,点赞照常成功。
- 削峰:大 V 发帖的百万级写扩散本来会瞬间砸向数据库;现在 Kafka 在中间当缓冲池,Feed 按自己的节奏(比如每秒 5 万条)从池子里取着写,数据库永远只承受稳定的写入速率。
- 鲁棒性:Feed 服务重启 / 短暂宕机,消息在 Kafka 里攒着,恢复后从上次的 offset 接着消费,一条不丢(第 07 章的老知识)。
最小可运行示例。消费侧的入口就是 3.5 节那个 FeedEvent 的反序列化 + 3.6 节的 Service 分发:
// internal/feed/service/consume.go
// ConsumeFromKafka 消费 Kafka 中的 Feed 事件
func (s *FeedService) ConsumeFromKafka(msg []byte) error {
var event FeedEvent
if err := json.Unmarshal(msg, &event); err != nil {
// 消息体非法:打日志 + 返回 error,让 Kafka 进入重试/死信,
// 千万别 silently 吞掉,否则这条动态就永远丢了。
return err
}
return s.CreateFeedEvent(context.Background(), event)
}
逐行讲解:注意解析失败的分支——返回 error 让消息进重试 / 死信(第 18 章死信的老套路),不能吞掉。一条坏消息卡死整个消费组(返回 error 导致无限重试)也不对,生产上常见折中是「重试 N 次后转死信 topic 留底、提交位点继续跑」——跟随写第 2 步你会看到这个折中的落地。
常见坑:新手把「异步」理解成「开个 goroutine 去写收件箱」。本地 goroutine 和 Kafka 的区别在进程重启的瞬间:goroutine 里的任务随进程消失就没了(丢动态);Kafka 里的消息重启后接着消费(不丢)。「异步」的本质不是「不等它」,而是「把任务交给一个比你活得久的系统」。
小收口:至此 Feed 的设计蓝图全部完成——模型(推拉结合)、存储(收件箱/发件箱)、架构(Service + Handler)、写入方式(Kafka 异步)。但蓝图写得再漂亮,都有同一个灵魂拷问:它到底能扛多少并发? 不跑压测,你永远只能猜。下一节上 k6。
3.8 压测:k6 实战,把性能拐点压出来
先抛问题。上线前老板问你三个问题:这套 Feed 能扛多少并发?限流阈值设多少合适?要不要再买两台机器?——这三个问题的答案都只能来自同一件事:压测。不压测拍脑袋,要么阈值设太低误杀正常流量,要么设太高挡不住洪峰(第 14 章的限流阈值当时是「拍」的,现在补上正确姿势)。
补课胶囊 · 压测:压测(压力测试)= 用工具模拟大量并发请求打向系统,测出它的吞吐上限和响应时间变化 → 类比:桥的承重测试——派 100 辆卡车间隔通过、逐步加到 200 辆,看桥在哪辆车压上去时开始晃 → 回到主线:压测不是「把机器跑挂」取乐,而是用数据给限流阈值、降级策略、容量规划提供依据。
补课胶囊 · RPS / QPS:RPS = Requests Per Second,系统每秒能处理的请求数,代表吞吐能力;QPS(Queries Per Second)常被混用,严格说指每秒查询数 → 类比:餐厅每分钟能出多少道菜 → 回到主线:RPS 是「系统能扛多大量」的第一指标。
补课胶囊 · P99:P99 = 99 分位数延迟,即「99% 的请求都比这个值快」,只有最慢的 1% 比它慢 → 类比:全班考试成绩,P99 就是「第 99 百分位那位同学的分」,比平均分更能暴露「最差的用户体验」→ 回到主线:为什么看 P99 不看平均值?平均值会被大量快请求拉低——1000 个请求里 990 个 5ms、10 个 3 秒,平均 34ms「看着还行」,但那 10 个用户已经骂娘了。
补课胶囊 · VU:VU = Virtual User,虚拟用户,压测工具里模拟的一个并发执行脚本的用户 → 类比:餐厅试营业时雇的「托儿」,10 个托儿不停点单 → 回到主线:VU 越多并发越高,阶梯加压(VU 从 50 逐步加到 100、200)就是「看桥什么时候开始晃」。
工具选型。后端性能工具三件套,分工不同:
| 工具 | 适用场景 | 特点 |
|---|---|---|
| pprof | Go 程序内部性能分析 | 定位 CPU / 内存 / goroutine 瓶颈,单机调试 |
| wrk | 快速 HTTP 压测 | 轻量,lua 脚本扩展,适合少量非正式测试 |
| k6 | 正式压测 + 可视化 | JS 脚本,多协议,分布式,集成 Prometheus / Grafana |
本课程选 k6 + Prometheus + Grafana:k6 用 JavaScript 写用例(能表达「梯度加压」「阈值断言」这种复杂逻辑),指标原生对接 Prometheus,Grafana 出图后老板同事一眼看懂。
最小可运行示例。一个最简单的 k6 脚本长这样——每 0.1 秒发一次 POST,共 10 个 VU 打 30 秒:
// k6/k6_example.js
import http from 'k6/http';
import { check, sleep } from 'k6';
// 压测配置:10 个虚拟用户,持续 30 秒
export const options = {
vus: 10,
duration: '30s',
};
export default function () {
// 构造请求体
const payload = JSON.stringify({
uid: 123,
type: 'like',
content: { liker: 456, liked: 123, biz: 'article', bizId: 789 },
});
const params = { headers: { 'Content-Type': 'application/json' } };
// 发送 POST 请求
const res = http.post('http://localhost:8080/feed/create', payload, params);
// 断言:状态码必须是 200,否则这次请求算失败
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(0.1); // 模拟用户思考时间,别把请求打满成纯轰炸
}
逐行讲解:options 是压测计划——vus: 10, duration: '30s' 表示 10 个 VU 各自循环执行 30 秒;export default function () 是每个 VU 反复执行的「用户行为脚本」;check 是断言,状态码不是 200 就计一次失败(没有它,接口全 500 了压测还显示「跑完了」);sleep(0.1) 模拟真实用户「刷一屏歇一下」的节奏——不打 sleep 就是纯粹的轰炸模式,两种模式测的是不同的东西。
跑起来 + 接入监控:
# 安装(macOS)
brew install k6
# 设置 Prometheus 接收地址(第 08 章起的 Prometheus 还在跑)
export K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write
# 执行压测,指标写进 Prometheus(5 分钟、100 虚拟用户)
k6 run -o experimental-prometheus-rw --duration 5m --vus 100 k6_example.js
# 预期:终端实时滚动输出 RPS / 延迟 / 失败率;结束后打印汇总表
命令行参数覆盖脚本里的 options:--duration 支持 30s / 5m / 1h;--vus 支持范围式 --vus 10-100 做阶梯加压(逐步加用户,看拐点出现在哪一档)。Grafana 看板直接 Import 社区模板:打开 https://grafana.com/grafana/dashboards/19665-k6-prometheus/
复制 Dashboard ID 19665,Grafana 里 Import → Load → 选 Prometheus 数据源。
flowchart LR
K[k6 压测] -->|remote write| P[(Prometheus)]
P --> G[Grafana 看板]
G --> U[你看到 RPS / P99 / 延迟分解]关键指标解读(压测报告怎么读)。
吞吐与响应时间:Peak RPS 是系统吞吐上限;HTTP Request Duration 默认展示 P99。盯 P99 不盯平均值,理由在补课胶囊里讲过了。
延迟分解——哪个环节慢,一眼看出来。一次 HTTP 请求的总耗时被 k6 拆成四段:
flowchart LR
B[req_blocked] --> S[req_sending] --> W[req_waiting] --> R[req_receiving]| 指标 | 含义 | 非 0 说明什么 |
|---|---|---|
req_blocked | k6 自身阻塞(VU 排队等调度) | k6 自己成了瓶颈,正常应 ≈ 0 |
req_sending | 发送请求耗时 | 网络差或请求体过大 |
req_waiting | 服务端处理耗时(近似总耗时) | 你最该盯的一段——它高说明系统真的慢 |
req_receiving | 接收响应耗时 | 响应体过大或服务端慢 |
健康基线:正常情况下只有 req_duration(总耗时)和 req_waiting 不为 0,其余都应接近 0。
⚠️ 排错:k6 自己先趴了。如果 req_blocked 明显不为 0、但服务端 CPU 还很闲,说明压不上去是因为压测机 / 单进程 k6 到顶了,不是被测系统的问题。解决:换更强的压测机、用 k6 分布式、或降低 --vus 重新观察。新手最常把「k6 瓶颈」误判成「系统瓶颈」,白优化半天。
--vus 100,Grafana 上 vus 的最大值却可能冲到 200 左右——这是 k6 内部的调度行为(旧迭代还在收尾、新迭代已经启动),不是你配错了,更不是系统出问题了。按成败分离的指标(再往下钻一层):看板里延迟和速率都分 success / errors 两条线。失败请求的延迟普遍贴着超时上限 → 超时是主要失败原因(请求没被处理,干等到超时),优化方向是扩容或限流;失败请求「失败得很快」→ 多半是被拒(429 / 503),去看限流阈值。成功速率掉头向下、失败速率陡增的交叉点,就是系统过载拐点的直观证据。
Feed 接口的压测场景怎么设计。不能只拿一个「平均用户」压——Feed 的性能跟数据形态强相关,要按维度组合设计用例:
- 数据来源:只读 push(收件箱)/ 只读 pull(发件箱)/ 混合读;
- 数据量级:收件箱里 1 万条 / 10 万条 / 100 万条;
- 写扩散档位:发帖粉丝 100 人 / 1000 人 / 10000 人三档,验证不同粉丝量的写入性能。
压测设计的两个细节(和做实验一个道理):
- 控制变量:压读接口时排除 follow 接口的影响(可以认为关注关系的响应时间是 0)。目的是单独度量 Feed 存储层的性能——混入 follow 的耗时,你分不清 P99 高是 Feed 的锅还是关注关系的锅。压测和做实验一样,一次只变一个变量。
- 混合读的比例不用精雕细琢:push 和 pull 的混合比例确实会影响性能,但影响不显著(除非两张表结构差异巨大)。把精力放在「数据来源组合 × 数据量级」两个维度上。
常见坑:压测直接打生产库 / 生产 Redis。压测数据会和真实数据混在一起(用户会看到一堆测试动态),写入放大还可能打挂生产。正确姿势:压测环境独立部署,或者至少用独立的 uid 段(比如 uid 从 10000 开始)隔离数据,压完清理。
小收口:k6 的用法、指标怎么读、场景怎么设计都齐了。但压测本身只是「发现问题」——发现 P99 飙升之后怎么说清楚、怎么优化、怎么量化收益,是面试里拉开差距的部分。下一节把这套话术串成完整的故事。
3.9 面试话术:把「压测 → 优化」串成一个完整的故事
先抛问题。面试官问「你做过什么性能优化」,新手最常见的回答是「我加了缓存,快了很多」——没有数据、没有定位过程、没有对比,全是形容词。好的回答是一段有因果链的故事:压测定位 → 对症优化 → 量化收益。
给直觉。把 3.8 节的压测发现和前几章学过的优化手段串起来,就是一段可以直接背的话术:
参考话术:
「我进来后发现 Feed 读接口性能不理想,于是用 k6 设计了一套压测:分数据源(push / pull / 混合)× 数据量(1W / 10W / 100W)组合加压。结果发现混合读在并发 100、数据 10W 时 P99 飙升到 500ms,是瓶颈。
在此基础上我做了三件事:① 引入 Redis 缓存热点收件箱,命中即返回;② 把同步回查业务方改成 Kafka 异步,削峰;③ 把循环里的单条查询改成批量接口。优化后 P99 降到 50ms 以内。」
逐句拆解这段话为什么好:
- 「用 k6 分维度组合加压」——说明你先定位,不是上来就加缓存;
- 「混合读、并发 100、数据 10W 时 P99 飙到 500ms」——瓶颈被精确到三个维度的交点,有数字;
- 「三件事:缓存 / 异步 / 批量」——三个优化各自针对瓶颈的哪一段说得清(缓存治读慢、异步治回查链路长、批量治循环查询);
- 「P99 从 500ms 降到 50ms 以内」——量化收益,10 倍。
这三步分别对应你学过的第 06 章(缓存)、第 07 章(异步)、第 02 章的升级(批量查询)——性能优化没有新知识,全是老知识的组合拳,这句话本身就是面试的加分总结。
常见坑:把优化故事讲成「我做了 A、B、C 三件事」的流水账(没有定位过程,为什么是这三件而不是别的?)。正确的因果链是「压测数据告诉我瓶颈在 X → 所以优化方向必然是治 X 的手段」。另外收益别只说「快了」——给 P99 前后对比、给 RPS 前后对比,数字才是硬通货。
小收口:至此正文全部完成。设计蓝图(模型 / 存储 / 架构 / 异步)+ 验证手段(压测)+ 表达能力(优化故事)三件套齐了,接下来进「跟着写」,亲手把 feed-service 落地。
补充知识 · 进阶延伸【补充:小红书亿级流量项目】
课程第 4 章还有一个和 Feed 流同属「内容分发」的模块——附近的人 / 附近的帖子(陌陌、探探的「附近的人」,小红书 / 抖音的「附近」tab)。它不在本系列 21 章的主线里,但与「给用户分发内容」语义最近,补充在本章末尾。
原理:GeoHash——把二维坐标压成一维格子。地球每个点有经纬度 (lng, lat),「附近的人」要回答「谁在我周围 1 公里内」。GeoHash 的思路:像切豆腐一样把地球反复二分(经度切一刀、纬度切一刀交替进行),每个格子得到一个二进制编码,再 base32 编码成一串字符(如 wx4g0s)——字符串前缀相同 = 地理位置相邻,于是「找附近」变成了「按前缀查索引」,数据库和 Redis 都能直接干。一个隐藏坑:边界两侧的点可能很远、而真正最近的点可能在隔壁格子——所以查询时要连同周围 8 个格子一起查,再精算距离。
Redis 原生支持:Redis 的 GEO 命令底层就是 zset + geohash 编码(score 存 52 位的 geohash 整数),go-redis 直接可用:
// 上报位置:把帖子挂进所在城市的地理集合
rdb.GeoAdd(ctx, "geo:xian", &redis.GeoLocation{
Name: strconv.FormatInt(postId, 10), // member 存 postId(或 uid)
Latitude: 34.3416, Longitude: 108.9398,
})
// 查附近 1km 内的内容,按距离排序,取前 20 条(以西安某点为圆心)
locs, _ := rdb.GeoSearchLocation(ctx, "geo:xian", &redis.GeoSearchLocationQuery{
GeoSearchQuery: redis.GeoSearchQuery{
Longitude: 108.9398, Latitude: 34.3416, // FROMLONLAT:以经纬度坐标为圆心
Radius: 1, RadiusUnit: "km",
Sort: "ASC", Count: 20, CountAny: true,
},
}).Result()
// 每个 loc.Name 是 member(即 postId),再回查 post 表拼装标题、图片等详情(06 章的拼装套路)
GeoLocation 结构体字段名写得清楚,但自己拼命令时容易翻车;另外 Redis 限定经度 [-180,180]、纬度 [-85.05,85.05],超范围直接报错。工程化要点(课程原方案):MySQL + geohash 字段做持久化层(geohash 前缀查询 + 经纬度精确筛选,postgis 是备选)+ Redis 按城市 / 地区分集合做热查询;集合里只存最近一周的内容(附近的帖子没人看一年前的),到期清理;同城内容百万级时对 Redis 集合做大 key 拆分(按 geohash 前缀再分桶——06 章 3.26 的手法在地理场景复用)。产品差异也要知道:陌陌 / 探探筛人(性别、年龄、精确距离排序),微博 / 小红书 / 抖音发内容(精度要求低、无需筛选)——精度要求低,方案就可以更轻。
小收口:附近的人 = GeoHash 编码(二维变一维)+ Redis GEO(zset 的地理皮肤)+ 8 邻域查询 + 只存近期数据。面试聊到 Feed 流时主动补一句「地理维度的分发我是这么做的」,故事就更完整了。
四、跟着写:分步实现
上一章搭好了日志与数据同步,本章动手实现整个课程设计难度最高的功能——Feed 流。我们新建独立微服务 feed-service(HTTP 8500,注册 ETCD),落地推拉结合的完整闭环。
动手前先说清一个存储决策:正文 3.5 节讲的两张 MySQL 表(feed_push_event / feed_pull_event)在实现里降级为「持久化兜底」,热数据全部放 Redis。为什么?
- ZSet 天然按 score 有序,
ZREVRANGEBYSCORE一条命令 = 「按时间倒序取前 N 条」,连ORDER BY都省了; - 收件箱 / 发件箱都是「最近的最常被读」——Redis 的 ZSet 配一条
ZREMRANGEBYRANK就能只保留最近 1000 条(更早的回落 MySQL 查),内存占用可控; - Feed 读多写多的形态,内存读写的延迟优势正好压在热点上。
正文 3.6 节已把 Service + Handler 分发骨架和推拉判断逻辑(教学版)讲透,本节不重复——写侧的判断逻辑同 3.6 节的 PublishArticleHandler,合并思路同 GetFeedEvents,只是存储从 MySQL DAO 换成 Redis ZSet,读侧再升级成「只拉大V发件箱」。老规矩不变:Gin 暴露 HTTP、zap 打日志、viper 读配置、复用第 12 章 pkg/registry 注册 ETCD、复用第 18 章的 MustConsume 回调式消费者封装(源自第 07 章消费者组三件套)收消息。
步骤总览:
- repository 层:发件箱 / 收件箱 / 大V表三个 key 的全部读写原语(含 Pipeline 批量推送与 1000 条裁剪);
- 发帖推送链路:消费 post-events——先写发件箱,再判大V,普通作者分批推粉丝收件箱;
- Feed 拉取接口(核心):收件箱 + 关注的大V发件箱合并,游标分页 + 批量查详情 + 冷启动;
- follow-events 消费:关注回填、取关清理、粉丝数跨阈值时维护大V表;
- HTTP 路由与启动入口:注册 ETCD,挂两个 Kafka 消费者;
- k6 压测:梯度加压、阈值断言、自定义「大V拉取 / 合并耗时」指标。
第 1 步:repository 层——三个 key 与读写原语
Feed 的存储就是一个 FeedCache 结构体,围着三个 key 转。所有读写原语集中在 repository 一层,后面推送、拉取、关注处理全部复用——key 拼法和「保留最近 1000 条」的裁剪逻辑只写这一处。先看 key 的设计和类型定义:
// internal/feed/repository/cache.go
package repository
import (
"context"
"fmt"
"strconv"
"github.com/redis/go-redis/v9"
)
// 三个 key(member=post_id,score=发帖毫秒时间戳):
// 发件箱 feed:outbox:<uid> ZSet:作者自己的帖子,拉模型唯一数据源
// 收件箱 feed:inbox:<uid> ZSet:普通作者推送过来的帖子,读接口第一来源
// 大V表 feed:bigv Set :粉丝超阈值的作者,发帖不推送、读时实时拉
const (
keyOutbox = "feed:outbox:%d"
keyInbox = "feed:inbox:%d"
keyBigV = "feed:bigv"
maxKeep = 1000 // 只保留最近 1000 条,更早的回落到 3.5 节的 MySQL 表查
)
// ZItem ZSet 里的一条:帖子 id + 时间戳(合并前的中间结构)
type ZItem struct {
PostID int64
Score int64
}
type FeedCache struct{ rdb redis.Cmdable }
func NewFeedCache(rdb redis.Cmdable) *FeedCache { return &FeedCache{rdb: rdb} }
逐行讲解:%d 拼进 uid,每个用户一个独立的 ZSet——这就是「以用户为单位隔离数据」的存储形态(跟 3.5 节表结构里 uid 是同一个思想,只是从「行过滤」变成「key 隔离」);member 存帖子 id、score 存毫秒时间戳——score 可排序,所以 ZSet 天然按时间有序;maxKeep = 1000 是每个收件箱 / 发件箱的容量上限,防内存无限膨胀。
接着写写入侧的两个原语——写发件箱(单个)和推收件箱(批量):
// internal/feed/repository/cache.go(续)
// zaddAndTrim 写一条并裁剪:ZREMRANGEBYRANK 0 -(maxKeep+1) 把更旧的记录挤出去
func (c *FeedCache) zaddAndTrim(ctx context.Context, pipe redis.Pipeliner, key string, postID, ts int64) {
pipe.ZAdd(ctx, key, redis.Z{Score: float64(ts), Member: strconv.FormatInt(postID, 10)})
pipe.ZRemRangeByRank(ctx, key, 0, -(maxKeep + 1))
}
// PushOutbox 写作者发件箱(无论是否大V都要写,3.6 节:"先写发件箱,顺序不能反")
func (c *FeedCache) PushOutbox(ctx context.Context, authorID, postID, ts int64) error {
pipe := c.rdb.Pipeline()
c.zaddAndTrim(ctx, pipe, fmt.Sprintf(keyOutbox, authorID), postID, ts)
_, err := pipe.Exec(ctx)
return err
}
// BatchPushInbox 同一条帖子推给一批粉丝,每批 100 个走 Pipeline
// Pipeline 只合并网络往返、不是原子事务;失败整批重试即可,ZAdd 天然幂等
func (c *FeedCache) BatchPushInbox(ctx context.Context, fanIDs []int64, postID, ts int64) error {
for i := 0; i < len(fanIDs); i += 100 {
end := min(i+100, len(fanIDs)) // Go 1.21+ 内置 min
pipe := c.rdb.Pipeline()
for _, fan := range fanIDs[i:end] {
c.zaddAndTrim(ctx, pipe, fmt.Sprintf(keyInbox, fan), postID, ts)
}
if _, err := pipe.Exec(ctx); err != nil {
return fmt.Errorf("推送收件箱粉丝批 [%d,%d): %w", i, end, err)
}
}
return nil
}
逐行讲解(三个细节):① zaddAndTrim 把 ZAdd 和裁剪打包在一个 Pipeline 里,任何写入入口都自动带上裁剪——「只保留最近 1000 条」不会漏;② BatchPushInbox 每 100 个粉丝一个 Pipeline:100 个粉丝 = 100 个不同的 key,不打包就是 100 次网络往返,打包后 1 次往返发完(第 09 章讲过的 Pipeline 用法);③ Pipeline 不是事务,失败整批重试——但 ZAdd 对同一个 member 重复写只是覆盖 score,天然幂等,重试不怕重复(这正是选 ZSet 而不是 List 的原因之一:List 会重复追加)。
再写读取侧——按 score 倒序拉最近 N 条:
// internal/feed/repository/cache.go(续)
// InboxRev / OutboxRev 按 score 倒序拉最近 limit 条(游标开区间由上层拼进 opt.Max)
func (c *FeedCache) InboxRev(ctx context.Context, uid int64, opt *redis.ZRangeBy) ([]ZItem, error) {
return c.revByScore(ctx, fmt.Sprintf(keyInbox, uid), opt)
}
func (c *FeedCache) OutboxRev(ctx context.Context, uid int64, opt *redis.ZRangeBy) ([]ZItem, error) {
return c.revByScore(ctx, fmt.Sprintf(keyOutbox, uid), opt)
}
func (c *FeedCache) revByScore(ctx context.Context, key string, opt *redis.ZRangeBy) ([]ZItem, error) {
vals, err := c.rdb.ZRevRangeByScore(ctx, key, opt).Result()
if err != nil {
return nil, err
}
items := make([]ZItem, 0, len(vals))
for _, z := range vals {
id, _ := strconv.ParseInt(z.Member.(string), 10, 64)
items = append(items, ZItem{PostID: id, Score: int64(z.Score)})
}
return items, nil
}
逐行讲解:ZRevRangeByScore + opt.Max/Count 就是「按 score 倒序、从 Max 往下、取 Count 条」——3.5 节讲的游标分页在 ZSet 里的形态:翻页时把 opt.Max 设成 (上一页最后一条的score(带括号 = 开区间,排除那一条本身)。opt 由上层(第 3 步的 GetFeed)拼好传进来,repository 只管执行。
最后是大V表的判定与维护:
// internal/feed/repository/cache.go(续)
// FilterBigV 一批用户里筛出大V:SMISMEMBER 一次往返批量判定
func (c *FeedCache) FilterBigV(ctx context.Context, uids []int64) ([]int64, error) {
members := make([]any, len(uids))
for i, u := range uids {
members[i] = u
}
flags, err := c.rdb.SMIsMember(ctx, keyBigV, members...).Result()
if err != nil {
return nil, err
}
var bigv []int64
for i, ok := range flags {
if ok {
bigv = append(bigv, uids[i])
}
}
return bigv, nil
}
// IsBigV / MarkBigV / UnmarkBigV 大V表判定与维护(第 2、4 步用)
func (c *FeedCache) IsBigV(ctx context.Context, uid int64) (bool, error) {
return c.rdb.SIsMember(ctx, keyBigV, uid).Result()
}
func (c *FeedCache) MarkBigV(ctx context.Context, uid int64) error {
return c.rdb.SAdd(ctx, keyBigV, uid).Err()
}
func (c *FeedCache) UnmarkBigV(ctx context.Context, uid int64) error {
return c.rdb.SRem(ctx, keyBigV, uid).Err()
}
逐行讲解:FilterBigV 用 SMISMEMBER(复数版)一次网络往返判定一批人是不是大V——第 3 步读 Feed 时「关注列表 ∩ 大V表」就靠它,比循环调 100 次 SISMEMBER 快 100 倍。MarkBigV / UnmarkBigV 是给第 4 步的 follow-events 消费用:粉丝数跨阈值时把作者加进 / 移出大V表。
验证:
go build ./...
# 预期:无任何输出,编译通过
第 2 步:发帖推送链路——消费 post-events
推送链路需要两个远程依赖:粉丝列表来自 relation-service(第 16 章)、帖子详情来自 post-service。先把事件结构和依赖接口定义好:
// internal/feed/service/consume.go —— 发帖推送链路(推拉结合的"写"侧)
package service
import (
"context"
"github.com/yourname/content-community/internal/feed/repository"
)
// FollowService 粉丝/关注列表来自 relation-service(第16章)
type FollowService interface {
GetFollowers(ctx context.Context, uid int64, offset, limit int) ([]int64, error) // 粉丝(分页)
GetFollowees(ctx context.Context, uid int64) ([]int64, error) // 关注(全量)
}
// PostService 帖子详情来自 post-service
type PostService interface {
BatchGet(ctx context.Context, ids []int64) (map[int64]Post, error)
}
// Post 帖子详情(只留 Feed 渲染要用的字段;批量回查用)
type Post struct {
ID int64 `json:"id"`
AuthorID int64 `json:"author_id"`
Title string `json:"title"`
}
// PostEvent post-service 发帖后发到 post-events 的事件
type PostEvent struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"` // post_published
PostID int64 `json:"post_id"`
AuthorID int64 `json:"author_id"`
CreatedAt int64 `json:"created_at"` // 毫秒时间戳,直接做 ZSet 的 score
}
逐行讲解:FollowService / PostService 定义成本地接口而不是直接 import 远程服务的包——第 16 章讲过的依赖倒置:feed-service 只依赖自己声明的接口,具体实现(HTTP 调用第 16 章 relation-service)放 remote.go 里,测试时还能换成假实现。CreatedAt 用毫秒时间戳,后面直接做 ZSet 的 score(秒级精度会撞车,3.6 节 hint 讲过同毫秒撞车,毫秒已够本地用)。
再组装 FeedService 本体和消费逻辑:
// internal/feed/service/consume.go(续)
import (
"encoding/json"
"fmt"
"go.uber.org/zap"
)
// FeedService 业务层:主存储在 Redis,粉丝/详情依赖两个远程服务
type FeedService struct {
cache *repository.FeedCache
followSvc FollowService
postSvc PostService
fanThreshold int64 // 大V粉丝阈值(配置项,默认 10 万)
backfillN int64 // 关注普通作者时回填他最近 N 条
logger *zap.Logger
}
func NewFeedService(cache *repository.FeedCache, followSvc FollowService, postSvc PostService,
fanThreshold, backfillN int64, logger *zap.Logger) *FeedService {
return &FeedService{cache: cache, followSvc: followSvc, postSvc: postSvc,
fanThreshold: fanThreshold, backfillN: backfillN, logger: logger}
}
// HandlePostEvent 消费 post-events:先写发件箱保底、再判大V、
// 普通作者才推送(判断逻辑与 3.6 节 PublishArticleHandler 逐条对应,存储换成 ZSet)
func (s *FeedService) HandlePostEvent(ctx context.Context, msg []byte) error {
var e PostEvent
if err := json.Unmarshal(msg, &e); err != nil {
// 脏消息记日志后返回 nil 丢掉,别让一条坏消息卡死整个消费组(3.7 节的约定)
s.logger.Error("post-events 解析失败", zap.ByteString("msg", msg), zap.Error(err))
return nil
}
// a. 无论是否大V,先写作者发件箱——拉模型的唯一数据源("顺序不能反")
if err := s.cache.PushOutbox(ctx, e.AuthorID, e.PostID, e.CreatedAt); err != nil {
return fmt.Errorf("写发件箱: %w", err)
}
// b. 大V不推送:粉丝读时实时来拉,这就是推拉结合省掉百万写入的关键
if isBigV, err := s.cache.IsBigV(ctx, e.AuthorID); err != nil {
return fmt.Errorf("查大V标记: %w", err)
} else if isBigV {
s.logger.Info("大V发帖走拉模型", zap.Int64("author", e.AuthorID), zap.Int64("post", e.PostID))
return nil
}
// c. 普通作者:分页拉粉丝,每批交给 Pipeline 推进收件箱
const pageSize = 500
for offset := 0; ; offset += pageSize {
fans, err := s.followSvc.GetFollowers(ctx, e.AuthorID, offset, pageSize)
if err != nil {
return fmt.Errorf("拉粉丝列表: %w", err)
}
if err := s.cache.BatchPushInbox(ctx, fans, e.PostID, e.CreatedAt); err != nil {
return err
}
if len(fans) < pageSize {
break // 最后一页不满,说明粉丝拉完了
}
}
s.logger.Info("发帖推送完成", zap.Int64("author", e.AuthorID), zap.Int64("post", e.PostID))
return nil
}
逐行讲解(对照 3.6 节的教学版,看两处升级):教学版判大V用「取 阈值+1 个粉丝再数数量」,这里改用大V表(第 4 步的 follow-events 消费者会维护它)——因为关注关系数据在远端的 relation-service,本地一张 Set 判定只花一次 Redis 往返;粉丝列表从「一次取」改成 pageSize=500 分页取——粉丝几万个时一次拉全量会把 relation 接口和内存都打爆。另外注意解析失败返回 nil(丢弃)而不是 error(重试)——坏消息重试一万次还是坏的,记日志留证据后放行,这是 3.7 节说的折中。
config.yaml 里把 feed.fan_threshold 改成 3,再 redis-cli SADD feed:bigv <某作者>,就能立刻验证「大V发帖不推送」这条分支。验证(发一条发帖事件,看收件箱有没有出现):
# 手动往 post-events 发一条发帖事件(author=7 不是大V,粉丝是 1、2、3)
echo '{"event_id":"e1","event_type":"post_published","post_id":101,"author_id":7,"created_at":1712345678000}' \
| kafka-console-producer.sh --bootstrap-server localhost:9092 --topic post-events
# 预期:日志出现"发帖推送完成";发件箱和三个粉丝的收件箱都出现帖子 101
redis-cli ZSCORE feed:outbox:7 101 # 输出 1712345678000
redis-cli ZSCORE feed:inbox:1 101 # 输出 1712345678000
第 3 步:Feed 拉取接口——推拉合并与游标分页(本章核心)
读链路与 3.6 节教学版的唯一差别:教学版把所有关注的人的发件箱都拉一遍,这里只拉大V的——普通作者的帖子早在发帖时就被推进收件箱了,再去拉纯属浪费(正是 3.4 节推拉结合的读流程)。
先定义返回结构(注意两个耗时字段,第 6 步压测要用):
// internal/feed/service/feed.go —— 推拉结合的读链路(本章核心)
package service
import (
"context"
"fmt"
"sort"
"time"
"github.com/redis/go-redis/v9"
)
// FeedItem Feed 流里的一条(帖子详情由 post-service 批量回查后拼上)
type FeedItem struct {
PostID int64 `json:"post_id"`
AuthorID int64 `json:"author_id"`
Title string `json:"title"`
PublishedAt int64 `json:"published_at"`
}
// FeedResult BigvMs/MergeMs 两个耗时字段透出给 k6 自定义指标(第 6 步走响应头)
type FeedResult struct {
Items []FeedItem `json:"items"`
NextCursor int64 `json:"next_cursor"` // 下一页游标;0 表示没有更多
BigvMs int64 `json:"-"`
MergeMs int64 `json:"-"`
}
逐行讲解:NextCursor 是 3.5 节讲的游标分页——本页最后一条的时间戳就是下一页的「书签」,客户端带着它请求下一页;BigvMs / MergeMs 标了 json:"-"(不序列化进响应体),它们只通过响应头透出给 k6 采集——给压测留观测点这件事要在写业务代码时就埋好,而不是压测时再回来改代码。
主函数 GetFeed——前半段「收集来源」:
// internal/feed/service/feed.go(续)
// GetFeed 收件箱(推来的) + 关注的大V发件箱(拉来的) → 去重合并按时间倒序 → 游标分页
func (s *FeedService) GetFeed(ctx context.Context, uid, cursor, limit int64) (*FeedResult, error) {
// 游标分页:cursor=0 拉第一页;之后用 "(cursor" 开区间排除上一页最后一条,避免深翻页
max := "+inf"
if cursor > 0 {
max = fmt.Sprintf("(%d", cursor)
}
opt := &redis.ZRangeBy{Max: max, Min: "-inf", Offset: 0, Count: limit}
// 1. 收件箱(推模型部分):普通关注推过来的帖子
all, err := s.cache.InboxRev(ctx, uid, opt)
if err != nil {
return nil, fmt.Errorf("读收件箱: %w", err)
}
// 2. 大V发件箱(拉模型部分):关注列表 ∩ 大V表,各自按同一游标拉最近 limit 条
bigvStart := time.Now()
followees, err := s.followSvc.GetFollowees(ctx, uid)
if err != nil {
return nil, fmt.Errorf("拉关注列表: %w", err)
}
bigvIDs, err := s.cache.FilterBigV(ctx, followees)
if err != nil {
return nil, fmt.Errorf("筛大V: %w", err)
}
for _, b := range bigvIDs {
out, err := s.cache.OutboxRev(ctx, b, opt)
if err != nil {
continue // 单个大V拉失败只丢他一个人的动态,不阻塞整个 Feed(第14章降级思路)
}
all = append(all, out...)
}
return s.mergeAndFill(ctx, all, limit, time.Since(bigvStart).Milliseconds())
}
逐行讲解(两个关键点):① fmt.Sprintf("(%d", cursor) 生成的 "(1712345678000" 是 Redis 的开区间语法——带括号表示「小于这个值且不含它」,保证翻页时上一页的最后一条不会被重复返回;② 循环里单个大V拉失败只 continue——大V发件箱拉挂一个,不应该让整个 Feed 接口 500,这是第 14 章降级思路的复用。
后半段「合并 + 回填详情」拆成独立函数(读起来清晰,也方便单独测试):
// internal/feed/service/feed.go(续)
// mergeAndFill 去重合并排序 + 批量查详情
func (s *FeedService) mergeAndFill(ctx context.Context, all []ZItem, limit, bigvMs int64) (*FeedResult, error) {
// 3. 合并:去重(同一帖可能"被推过 + 又从大V发件箱拉到")→ 时间倒序 → 截断
mergeStart := time.Now()
seen := make(map[int64]struct{}, len(all))
uniq := all[:0]
for _, z := range all {
if _, ok := seen[z.PostID]; !ok {
seen[z.PostID] = struct{}{}
uniq = append(uniq, z)
}
}
sort.Slice(uniq, func(i, j int) bool { return uniq[i].Score > uniq[j].Score })
if int64(len(uniq)) > limit {
uniq = uniq[:limit]
}
mergeMs := time.Since(mergeStart).Milliseconds()
res := &FeedResult{BigvMs: bigvMs, MergeMs: mergeMs}
if len(uniq) == 0 {
return res, nil // 空结果:客户端应引导用户去 /api/feed/explore 冷启动
}
// 4. 批量查详情(生产走 gRPC 或缓存)
ids := make([]int64, 0, len(uniq))
for _, z := range uniq {
ids = append(ids, z.PostID)
}
posts, err := s.postSvc.BatchGet(ctx, ids)
if err != nil {
return nil, fmt.Errorf("批量查帖子详情: %w", err)
}
for _, z := range uniq {
if p, ok := posts[z.PostID]; ok {
res.Items = append(res.Items, FeedItem{
PostID: p.ID, AuthorID: p.AuthorID, Title: p.Title, PublishedAt: z.Score})
}
}
res.NextCursor = uniq[len(uniq)-1].Score // 本页最后一条的时间戳 = 下一页游标
return res, nil
}
逐行讲解:为什么需要 seen 去重?一个帖子可能「被推过(作者不是大V时)+ 又从大V发件箱拉到(作者后来变成大V)」,两个来源都出现就重复了;uniq := all[:0] 是原地过滤的写法(复用底层数组,不新分配内存);最后 NextCursor 取本页最后一条的 score——下一页拿它做开区间上界,正好接续。
最后补上冷启动:新用户没关注任何人,收件箱空的,Feed 页一片白——用第 09 章的热门榜兜底:
// internal/feed/repository/cache.go(追加)
// HotRank 冷启动兜底:直接读第09章的热门榜
func (c *FeedCache) HotRank(ctx context.Context, limit int64) ([]int64, error) {
vals, err := c.rdb.ZRevRange(ctx, "rank:post:likes", 0, limit-1).Result()
if err != nil {
return nil, err
}
ids := make([]int64, 0, len(vals))
for _, v := range vals {
id, _ := strconv.ParseInt(v, 10, 64)
ids = append(ids, id)
}
return ids, nil
}
// internal/feed/service/feed.go(续)
// Explore 冷启动:新用户没关注任何人,用第09章热门榜兜底(推荐算法预留)
func (s *FeedService) Explore(ctx context.Context, limit int64) ([]FeedItem, error) {
ids, err := s.cache.HotRank(ctx, limit)
if err != nil || len(ids) == 0 {
return []FeedItem{}, err
}
posts, err := s.postSvc.BatchGet(ctx, ids)
if err != nil {
return nil, fmt.Errorf("批量查帖子详情: %w", err)
}
items := make([]FeedItem, 0, len(ids))
for _, id := range ids {
if p, ok := posts[id]; ok {
items = append(items, FeedItem{PostID: p.ID, AuthorID: p.AuthorID, Title: p.Title})
}
}
return items, nil
}
"(1712345678000" 开区间能排除上一页最后一条,但 score 用毫秒时间戳时,同一毫秒发的两条帖子会互相「顶掉」——翻页时开区间把同分的另一条也跳过了。生产做法:score 用雪花 ID(天然递增且不撞车),或 时间戳*1000+序号。另外本实现 sort.Slice 是 O(N log N) 全量排序,大V一多就吃亏,优化答案是堆合并(多路归并)——每个来源的 ZREVRANGE 结果本身已有序,用 container/heap 做 K 路归并只要 O(N log K)。功能跑通后可以试着替换,正好和第 6 步的 feed_merge_ms 指标对上。验证:
curl "http://localhost:8500/api/feed?uid=1&limit=20"
# 预期:{"code":0,...,"data":{"items":[...],"next_cursor":1712345678000}}
# items 里既有收件箱推送的帖子,也有从大V发件箱拉的帖子,且按时间倒序
(第 5 步写完 handler 和 main 之后这条命令才能真正跑通,现在先把预期结果记下。)
第 4 步:follow-events 消费——回填、清理与大V判定
关注 / 取关不能不管收件箱:新关注的人一条动态都刷不到、取关的人还赖在收件箱里,都会被用户当成 bug。这三件事加「粉丝数跨阈值维护大V表」,一个消费者全搞定。先给 FeedCache 追加两个专用方法:
// internal/feed/repository/cache.go(追加)
// BackfillInbox 关注普通作者后回填:把他发件箱最近 n 条 copy 进我的收件箱
func (c *FeedCache) BackfillInbox(ctx context.Context, uid, authorID, n int64) error {
out, err := c.rdb.ZRevRangeWithScores(ctx, fmt.Sprintf(keyOutbox, authorID), 0, n-1).Result()
if err != nil {
return err
}
pipe := c.rdb.Pipeline()
for _, z := range out {
id, _ := strconv.ParseInt(z.Member.(string), 10, 64)
c.zaddAndTrim(ctx, pipe, fmt.Sprintf(keyInbox, uid), id, int64(z.Score))
}
_, err = pipe.Exec(ctx)
return err
}
// RemoveAuthorFromInbox 取关清理:收件箱按 post_id 存、没法按作者过滤,
// 先查对方发件箱拿他的帖子 id,再从我的收件箱逐个 ZREM
func (c *FeedCache) RemoveAuthorFromInbox(ctx context.Context, uid, authorID int64) error {
ids, err := c.rdb.ZRange(ctx, fmt.Sprintf(keyOutbox, authorID), 0, -1).Result()
if err != nil {
return err
}
pipe := c.rdb.Pipeline()
for _, id := range ids {
pipe.ZRem(ctx, fmt.Sprintf(keyInbox, uid), id)
}
_, err = pipe.Exec(ctx)
return err
}
逐行讲解:BackfillInbox 回答一个产品问题——「我昨天刚关注了这个博主,他上周的帖子我能不能刷到?」能:把他的发件箱最近 N 条(backfillN 配置,默认 100)拷贝进我的收件箱。RemoveAuthorFromInbox 的注释值得读两遍:收件箱里只存了 post_id,不知道哪条是哪个作者发的——要清理只能「先查他发件箱里有哪些帖子 id,再挨个从我的收件箱 ZREM」。(如果收件箱里冗余存 author_id,清理就简单了——存储冗余换操作方便的又一个例子。)
再写消费者本体:
// internal/feed/service/follow.go —— follow-events 消费
package service
import (
"context"
"encoding/json"
"go.uber.org/zap"
)
// FollowEvent relation-service 发的 follow-events(事件携带被关注者的最新粉丝数)
type FollowEvent struct {
EventType string `json:"event_type"` // follow_created / follow_canceled
FollowerID int64 `json:"follower_id"` // 谁发起的关注
FolloweeID int64 `json:"followee_id"` // 被关注的人(帖子作者)
FollowerCount int64 `json:"follower_count"` // followee 的最新粉丝数,用来判大V
}
// HandleFollowEvent 大V判定 → 关注回填 / 取关清理,三件事一个消费者
func (s *FeedService) HandleFollowEvent(ctx context.Context, msg []byte) error {
var e FollowEvent
if err := json.Unmarshal(msg, &e); err != nil {
s.logger.Error("follow-events 解析失败", zap.ByteString("msg", msg), zap.Error(err))
return nil
}
// 1. 大V判定:粉丝数跨阈值就更新 bigv 表(成为大V:以后不推送;降级:新帖恢复推送)
if e.FollowerCount >= s.fanThreshold {
_ = s.cache.MarkBigV(ctx, e.FolloweeID)
} else {
_ = s.cache.UnmarkBigV(ctx, e.FolloweeID)
}
// 2. 关注普通作者:把他最近 N 条回填进我的收件箱;关注大V不处理(读时实时拉)
if e.EventType == "follow_created" {
if ok, _ := s.cache.IsBigV(ctx, e.FolloweeID); !ok {
return s.cache.BackfillInbox(ctx, e.FollowerID, e.FolloweeID, s.backfillN)
}
return nil
}
// 3. 取关普通作者:把他的帖子从我的收件箱清掉;大V本来就没推过,不用清
if ok, _ := s.cache.IsBigV(ctx, e.FolloweeID); !ok {
return s.cache.RemoveAuthorFromInbox(ctx, e.FollowerID, e.FolloweeID)
}
return nil
}
逐行讲解:大V判定不靠 Feed 自己数粉丝——事件里直接携带了 relation-service 算好的最新粉丝数(follower_count),Feed 只做比较。这是事件驱动架构的常见约定:上游算好下游要用的数据,下游不做二次查询。注意判定分支覆盖了双向变化:粉丝数跌破阈值时 UnmarkBigV 把作者移出大V表、恢复推送——大V不是终身制。
验证:
echo '{"event_type":"follow_created","follower_id":1,"followee_id":7,"follower_count":3}' \
| kafka-console-producer.sh --bootstrap-server localhost:9092 --topic follow-events
redis-cli ZRANGE feed:inbox:1 0 -1 WITHSCORES # 预期:出现用户 7 的历史帖子(回填生效)
第 5 步:HTTP 路由与启动入口
handler 有一个特殊任务:把 GetFeed 内部统计的两个耗时指标透出到响应头(X-Bigv-Ms / X-Merge-Ms),第 6 步的 k6 直接采集它们——服务端耗时从服务端自己嘴里说出来,比客户端掐表准。
// internal/feed/handler/handler.go
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/yourname/content-community/internal/feed/service"
)
type FeedHandler struct{ svc *service.FeedService }
func NewFeedHandler(svc *service.FeedService) *FeedHandler { return &FeedHandler{svc: svc} }
func (h *FeedHandler) RegisterRoutes(server *gin.Engine) {
server.GET("/api/feed", h.Feed)
server.GET("/api/feed/explore", h.Explore)
}
// Feed GET /api/feed?uid=1000&cursor=0&limit=20(uid 演示从 query 取,生产从 JWT 解出)
func (h *FeedHandler) Feed(ctx *gin.Context) {
uid, _ := strconv.ParseInt(ctx.Query("uid"), 10, 64)
cursor, _ := strconv.ParseInt(ctx.DefaultQuery("cursor", "0"), 10, 64)
limit, _ := strconv.ParseInt(ctx.DefaultQuery("limit", "20"), 10, 64)
if limit <= 0 || limit > 50 {
limit = 20 // limit 夹在 (0, 50]:既是防御,也是压测时的变量控制
}
res, err := h.svc.GetFeed(ctx.Request.Context(), uid, cursor, limit)
if err != nil {
ctx.JSON(http.StatusOK, gin.H{"code": 1, "msg": err.Error()})
return
}
// 两个耗时透出到响应头,k6 的自定义指标直接采集(第 6 步)
ctx.Header("X-Bigv-Ms", strconv.FormatInt(res.BigvMs, 10))
ctx.Header("X-Merge-Ms", strconv.FormatInt(res.MergeMs, 10))
ctx.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": res})
}
// Explore 冷启动:没关注任何人的新用户看热门榜
func (h *FeedHandler) Explore(ctx *gin.Context) {
limit, _ := strconv.ParseInt(ctx.DefaultQuery("limit", "20"), 10, 64)
items, err := h.svc.Explore(ctx.Request.Context(), limit)
if err != nil {
ctx.JSON(http.StatusOK, gin.H{"code": 1, "msg": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"items": items}})
}
逐行讲解:limit 的夹逼(limit <= 0 || limit > 50 时重置为 20)一举两得——防止恶意请求传 limit=100000 把排序拖死,也保证压测时不同脚本默认行为一致。错误返回用 code: 1 + HTTP 200(第 02 章定下的响应约定),前端统一按 code 分支。
配置文件追加 feed 段:
# configs/config.yaml —— 追加 feed 段(大V阈值本地验证改成 3)
feed:
http: 8500 # HTTP 端口
fan_threshold: 100000 # 大V粉丝阈值(本地验证改 3)
backfill_n: 100 # 关注普通作者时回填其最近 N 条
relation_url: http://127.0.0.1:8300 # relation-service(第16章)
post_url: http://127.0.0.1:8100 # post-service
cmd/feed/main.go 与第 16 章 cmd/comment/main.go 完全同构,照抄改包名即可,不重复贴:viper 读配置、zap 日志、redis.NewClient(注意:Redis 是主存储)、组装 NewFeedCache → NewFeedService → NewFeedHandler、gin 路由监听 feed.http、registry 注册 feed-service(先 Listen 再注册、fail-fast、优雅停机,理由见第 12 章)。唯一新增的是挂两个 Kafka 消费者——把第 18 章的 MustConsume(回调式消费者组封装,源自第 07 章三件套)复制进本项目,各起一个独立消费组:
// cmd/feed/main.go —— 新增部分:两个消费者(其余与 cmd/comment/main.go 同构)
go repository.MustConsume(cfg.Kafka.Brokers, "feed-post-group", "post-events",
svc.HandlePostEvent, logger) // 发帖推送
go repository.MustConsume(cfg.Kafka.Brokers, "feed-follow-group", "follow-events",
svc.HandleFollowEvent, logger) // 关注/取关 + 大V判定
逐行讲解:两个 topic 各挂一个独立消费组(feed-post-group / feed-follow-group):post-events 走第 2 步的推送链路,follow-events 走第 4 步的关注处理。MustConsume 的回调 fn 返回 error 就不提交位点、由 Kafka 重投(至少一次),成功才 MarkMessage——这是第 07 章消费组三件套的回调化封装(第 18 章同样用过)。internal/feed/service/remote.go 里的 RemoteFollowService / RemotePostService 就是「拼 URL → GET → 解 JSON」的 30 行小结构体,与第 16 章调 user-service 的写法同构。
验证:
go run ./cmd/feed && etcdctl get /services/feed-service/ --prefix
# 预期:日志显示消费组就绪;etcdctl 输出注册 JSON,Ctrl+C 停服后 key 立刻消失
# 此时回头跑第 3 步的 curl 验证,应能拿到 items + next_cursor
第 6 步:k6 压测——把 P99 拐点压出来
压测前先造数据(不然压的全是空请求——空收件箱的接口响应飞快,数据毫无意义):给 100 个用户灌收件箱、把作者 9 标记成大V并灌发件箱。
# 造数据:用户 1000~1099 的收件箱各灌 1000 条;作者 9 标成大V并灌 200 条发件箱
for u in $(seq 1000 1099); do
for i in $(seq 1 1000); do redis-cli ZADD feed:inbox:$u $((1712340000000+i)) $i > /dev/null; done
redis-cli SADD feed:followees:$u 9 > /dev/null # GetFollowees 演示用的关注表
done
redis-cli SADD feed:bigv 9
for i in $(seq 2001 2200); do redis-cli ZADD feed:outbox:9 $((1712340000000+i)) $i > /dev/null; done
feed:followees:<uid> 这个 Set 演示关注关系,是为了让 GetFeed 的「关注列表 ∩ 大V表」真实走通。若你接的是第 16 章 relation-service,把 RemoteFollowService 的 BaseURL 指过去即可,压测脚本不用改。压测脚本按 3.8 节学的三件套写全:梯度加压找拐点、阈值断言(P95 < 500ms、错误率 < 1%)、两个自定义 Trend 指标采集响应头里的服务端耗时。
// k6/feed_read.js —— Feed 读接口压测:梯度加压 + 阈值断言 + 自定义指标
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend } from 'k6/metrics';
const bigvMs = new Trend('feed_bigv_ms'); // 大V发件箱拉取耗时(服务端透出)
const mergeMs = new Trend('feed_merge_ms'); // 合并排序耗时(服务端透出)
export const options = {
stages: [ // 梯度加压:逐步上量,观察 P99 在哪一档开始飙升(3.8 节的拐点)
{ duration: '30s', target: 50 },
{ duration: '30s', target: 100 },
{ duration: '30s', target: 200 },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<500'], // P95 < 500ms
http_req_failed: ['rate<0.01'], // 错误率 < 1%
},
};
export default function () {
const uid = 1000 + (__VU % 100); // 100 个不同的用户并发刷 Feed,各自收件箱数据不同
const res = http.get(`http://localhost:8500/api/feed?uid=${uid}&limit=20`);
check(res, { 'status is 200': (r) => r.status === 200 });
// 采集服务端透出的两个耗时指标(第 5 步设置的响应头)
const b = parseFloat(res.headers['X-Bigv-Ms']);
const m = parseFloat(res.headers['X-Merge-Ms']);
if (!isNaN(b)) bigvMs.add(b);
if (!isNaN(m)) mergeMs.add(m);
sleep(0.1); // 模拟用户刷一屏歇 100ms,别打满成纯轰炸
}
逐行讲解:stages 数组就是 3.8 节说的「阶梯加压」——每 30 秒上一个台阶(50 → 100 → 200),最后一档归零收尾;thresholds 是硬断言:压测结束时若 P95 ≥ 500ms 或失败率 ≥ 1%,k6 以非零退出码结束(可以直接接进 CI);__VU 是 k6 内置变量(当前虚拟用户编号),1000 + (__VU % 100) 让 200 个 VU 分摊到 100 个用户身上,避免所有人打同一个 uid 把那一个收件箱打成热点;自定义指标 feed_bigv_ms / feed_merge_ms 把「合并慢还是拉大V慢」拆开看——这正是 3.9 节说的「先定位再优化」的落地。
验证:
k6 run k6/feed_read.js
# 预期输出(节选):
# http_req_duration..............: avg=8.42ms p(95)=23.11ms
# http_req_failed................: 0.00%
# feed_bigv_ms...................: avg=1.98ms
# feed_merge_ms..................: avg=0.32ms
# 再把 stages 目标提到 500/1000 复跑,记录 P99 开始飙升的那一档——这就是你机器的拐点
本章完成后的项目结构
content-community/
├── configs/
│ └── config.yaml # 修改:追加 feed 段(端口/阈值/回填条数/两个远端地址)
├── k6/
│ └── feed_read.js # 新增:Feed 读接口压测(梯度加压 + 自定义指标)
├── cmd/
│ └── feed/
│ └── main.go # 新增:组装 + ETCD 注册 + 挂两个 Kafka 消费者
└── internal/
└── feed/
├── handler/
│ └── handler.go # 新增:GET /api/feed、GET /api/feed/explore(耗时透出响应头)
├── ioc/
│ └── etcd.go # 新增:复制第16章同名文件改包名
├── repository/
│ └── cache.go # 新增:发件箱/收件箱/大V表三个 key 的全部原语(含追加方法)
└── service/
├── consume.go # 新增:post-events 消费——发件箱 + 大V判定 + Pipeline 推送
├── feed.go # 新增:GetFeed 推拉合并 + 游标分页 + Explore 冷启动
├── follow.go # 新增:follow-events 消费——回填/清理/大V标记
└── remote.go # 新增:FollowService / PostService 的 HTTP 实现
验证清单
- 三个 key 齐活:
ZSCORE feed:outbox:7/ZSCORE feed:inbox:1查到发帖事件,SCARD feed:bigv≥ 1 - 普通作者发帖:所有粉丝收件箱秒级出现该 post_id;灌满 1001 条后
ZCARD仍是 1000(裁剪生效) - 大V作者发帖:只有发件箱有记录、任何粉丝收件箱都没有——推拉结合「写」侧验证
-
GET /api/feed的 items 里既有推送帖也有大V拉取帖,按时间倒序;带next_cursor翻页不重不漏 - 关注普通作者后收件箱回填其最近 N 条;取关后他的帖子从收件箱消失
- 粉丝数跨阈值(本地把
fan_threshold改成 3)时feed:bigv自动 SADD / SREM -
etcdctl get /services/feed-service/ --prefix能查到注册 JSON,Ctrl+C后 key 立刻消失 - k6 压测通过阈值:
http_req_failedrate<0.01、p(95)<500ms,且输出feed_bigv_ms/feed_merge_ms两条自定义指标
五、工程实践要点
1. 业务接入靠线下约定,文档是第一生产力
Feed 团队和业务团队通过会议约定 type 和扩展字段 key,没有强类型约束(map[string]any 天然如此)。两边对不上字段就会出现「诡异空 Feed」——事件发了、Feed 收到了、扩展字段 key 拼错了、Handler 里取不到收件人。把约定写成文档放在团队 wiki 首页,比任何代码约束都重要。
2. 兜底 Handler 是安全网
找不到对应 Handler 的事件走默认逻辑,新业务无特色逻辑可直接接入,Feed 服务零修改。没有这张安全网,新业务上线忘了注册 Handler,事件就静默丢失——比报错可怕得多。
3. 异步优先,别为了「看起来更实时」牺牲稳定性
Feed 写入一律走 Kafka。同步写入换来的那 500ms 实时性提升,用户根本感知不到;换走的却是稳定性(Feed 抖动连累业务主流程)。判断标准就一条:用户会不会在这 1~10 秒延迟里察觉异常。
4. 冗余与否,按查询模式定
要不要在 Feed 事件里冗余昵称 / 标题:读多写少就冗余(刷首页不用回查),写多读少就回查(不用维护一致性)。没有标准答案,只有「你的业务读多还是写多」。
5. 压测要分层,工具各管一段
单元 / 内部瓶颈用 pprof(CPU 火焰图、goroutine 数量),接口压测用 wrk(快糙猛)或 k6(正式、可视化),全链路用 k6 分布式。压之前先备好不同量级的测试数据——空库压测出的数字毫无意义。
6. 限流阈值靠压测定,再打 8 折
限流阈值不是拍脑袋:压出系统能承受的 QPS 后再打 8 折,留安全余量(压测环境和生产环境有差距、流量也不总是均匀的)。第 14 章拍脑袋定的阈值,现在你知道正确姿势了。
7. JSON 扩展字段的边界要提前想清楚
能用 JSON 简化就用在不需按扩展字段过滤的场景;一旦预见要按扩展字段查询,提前建扩展表或生成列索引。事后从大 JSON 迁移到扩展表,等于全量刷一遍数据。
8. 压测平台是职场机会
面向 C 端的公司,性能直接决定用户留存,但大多数中小公司恰恰缺压测基础设施。主动引入 k6、搭一个「易用的测试平台」(含准备数据、汇总结果、输出解读报告),再根据结果驱动优化——这是巩固技术领导力的正经路径,也是简历上「性能优化」故事的起点。
六、自测题与动手练习
自测题(合上书能答出来才算懂;答案都在正文对应小节里):
- Feed 流要解决的两件事是什么?它的本质矛盾一句话怎么概括?(3.1 节)
- 拉模型和推模型各自的致命伤是什么?为什么微信朋友圈可以用纯推、微博不行?(3.2 / 3.3 / 3.4 节)
- 推拉结合下,一个 500 万粉大 V 发一条帖子系统做了什么?普通粉丝刷首页要干哪三件事?(3.4 节)
- 收件箱表的
uk_uid_id唯一索引有哪两个用途?游标分页和OFFSET分页差在哪?(3.5 节) - 发表文章的 Handler 为什么必须「先写发件箱、再判断推不推」?顺序反过来会怎样?(3.6 节)
map[string]any里取数字为什么要用. (float64)断言?直接. (int64)会看到什么报错?(3.6 节)- Feed 写入为什么敢用异步?判断「一个操作能不能异步」的标准是什么?(3.7 节)
- k6 压测时
req_blocked不为 0、但服务端 CPU 很闲,说明什么?为什么看 P99 不看平均值?(3.8 节)
动手练习(建议真做一遍,难度递增):
- 扩展一个新事件(对应 3.6 节,体会开闭原则):给 Feed 加「被 @ 提醒」事件。要求:① 定义
type="mention"和content的字段约定;② 实现MentionHandler并注册进Service;③ 发一条 Kafka 消息触发;④ 查收件箱验证收到。做到什么程度算完成:全程不改CreateFeedEvent一行代码,能说清楚「如果走 if-else 写法需要改哪几处」。 - 亲手压到拐点(对应第 6 步):用 k6 把本地 Feed 读接口的 stages 从 10 阶梯加到 500,记录 P99 开始飙升的那一档并发数,写成一句话结论。做到什么程度算完成:能说出拐点那一档是「收件箱读慢」还是「大V发件箱拉慢」(看
feed_bigv_ms/feed_merge_ms哪条先涨)。 - 优化思考题(对应 3.9 节):针对「10W 数据混合读 P99 飙升」,分别给出「加 Redis 缓存 / Kafka 异步化 / 批量接口」三种方案的预期收益与副作用,并排个优先级。做到什么程度算完成:每个方案能说清它治的是延迟分解四段中的哪一段。
- 支持活跃用户(课程原版作业,选做):修改写入接口(只给活跃粉丝写收件箱)和读接口(活跃用户只查收件箱),并提交一份文档:① 列出具体修改的代码文件;② 如果你是产品经理,用什么策略判定「活跃用户」;作为设计者,这个策略技术上怎么落地(离线统计 or Redis 实时判断)。
七、模拟面试问答
以下 15 题按「基础 → 进阶 → 追问」三档排列,覆盖本章全部核心考点。每题给出一句话结论(先声夺人)、展开(怎么说满 1~2 分钟)、面试官想听的关键词和常见失分点。建议先遮住答案自己说一遍,再对照。
一句话结论:Feed 流是把「和你有关的一堆动态」按时间序整合成一个持续列表推到你眼前的产品形态;技术上就两件事——整合(从多张表、多个业务里凑齐内容)和时间序(按发生时间排好)。
展开:你要的内容散落在文章表、点赞表、评论表、关注表甚至不同服务里,Feed 流把「整合」当成一个独立的领域问题来设计,而不是文章列表接口的升级版。几乎所有「持续生产内容、持续消费」的系统都有它——社交、资讯、电商、教育、游戏,只是对实时性 / 个性化 / 多样性的侧重不同。它背后是整个内容社区最尖锐的矛盾:生产者少、消费者多,但每个人要的是「全网里和他有关的那一小撮」——查询性能和写入放大不可兼得。本章后面所有设计(三种模型、收件箱发件箱、推拉结合)都是围绕这个矛盾展开的。
面试官想听的关键词:整合、时间序、查询性能 vs 写入放大、产品形态侧重。
常见失分点:把 Feed 流答成「按时间倒序的文章列表」——那只是纯关注流(形态二),说不出「整合」这个领域问题;答不出本质矛盾,后面的模型对比就没了主线。
一句话结论:拉模型(读扩散)读时死——查询代价被每个用户的关注数放大;推模型(写扩散)写时死——大 V 发帖瞬间百万行写入(写放大)。两者是一对完美的镜像。
展开:拉模型是用户刷首页那一刻现场查所有来源再归并排序,关注 500 人 = 一次 500+ 次查询,而且分页极难(只能每个来源各取前 N 再归并截断,翻页还得记住每个来源翻到哪)。推模型把读路径变成常数——只查自己的收件箱,成本和关注数彻底解耦,但百万粉大 V 发帖 = 瞬间 100 万行 INSERT。两个常见「想当然的解法」都要能接住:① 加缓存救不了拉模型——「A 关注的 500 人各自的新文章」,任何一人发帖,几百万粉丝各自的这份数据都变了,命中率极低;② 异步救不了推模型——只是把「一瞬间的 100 万行」摊平成「几秒内的 100 万行」,总量一克没少,存储成本照样爆炸,写放大不只打挂数据库还烧磁盘。
面试官想听的关键词:读扩散、写扩散、写放大、镜像关系、缓存命中率救不了、异步只是摊平。
常见失分点:答「拉模型加缓存、推模型用异步」就以为解决了——两个都没治根,反而暴露没理解矛盾本质。
一句话结论:写侧按粉丝数判阈值——普通用户发帖推给每个粉丝的收件箱(量可控),大 V 只写自己的发件箱不发;读侧粉丝刷首页干三件事:查自己收件箱 + 拉关注的大 V 发件箱 + 合并排序分页。
展开:大 V 的「百万写入」被砍成「一次写入发件箱」,代价转移到读侧——每个粉丝多查几个大 V 的发件箱;但一个用户关注的大 V 数量通常有限(几十个顶天),拉取代价可控。所以推拉结合的本质是把痛点从「所有人 × 全部写入」缩小到「大 V 和它们的粉丝」。面试官若追问缺点,标准答案后半句要背熟:推拉结合并没有根治读扩散慢和写扩散多这两个问题,只是把痛点缩小到可控——这句「没有根治、只是缓解」恰恰是加分的地方,说明你知道自己在做什么取舍。
面试官想听的关键词:阈值判断、收件箱 + 发件箱合并、痛点缩小、没有根治只是缓解。
常见失分点:答成「大 V 也推、普通人也拉」把方向搞反;被问「推拉结合的缺点」时哑火——绕不开「没有根治,只是缓解」这个标准答案。
一句话结论:收件箱 = 每个用户一条「别人塞给我的」数据流(推模型写这里、读接口第一来源),发件箱 = 每个作者一条「我发出去的所有东西」的存根(拉模型的唯一数据源);唯一区别是 uid 字段的含义——收件箱里是「谁的收件箱」,发件箱里是「谁发的」。
展开:类比:收件箱是我家门口的邮箱,邮差(系统)往里塞信;发件箱是我抽屉里「发出去的信」的留底,粉丝要看来翻我的抽屉。因为查询模式完全一致(按 uid 过滤 + 按时间倒序取前 N 条),索引设计也一致:uid 放最左(过滤走索引)、create_time 紧随其后(排序走索引、免 filesort)。工程实现上热数据通常放 Redis ZSet——score 存时间戳,ZREVRANGEBYSCORE 一条命令完成「按时间倒序取前 N」,再配 ZREMRANGEBYRANK 只保留最近 1000 条控制内存,MySQL 做持久化兜底。
面试官想听的关键词:uid 含义差异、(uid, create_time) 联合索引、Redis ZSet、裁剪保留最近 N 条、MySQL 兜底。
常见失分点:说不清读接口为什么要查两个来源(推过来的收件箱 + 大 V 的发件箱);索引设计只答「建了索引」答不出「uid 为什么在最左、时间戳为什么紧随」。
一句话结论:朋友圈好友上限约 5000,一次写扩散最多 5000 行、写入量天然封顶;微博存在千万粉大 V,纯推等于一瞬间千万行 INSERT,必死。
展开:这题考的是选型心法:先看「单个生产者最多有几个消费者」——消费者有上限(几千以内)→ 可以推;可能上百万 → 必须拉或推拉结合。按这个心法套:朋友圈好友数受限 → 纯写扩散、读极快;微博大 V 走拉、普通用户走推;B 站 UP 主动态在推拉结合之上再叠「活跃用户优化」(只给活跃粉丝写收件箱);系统通知 / 私信类受众明确、写入量小,写扩散也合适。注意这张选型表里没有纯拉模型的位置——它只适合数据量极小的早期产品,长大了迟早要换。
面试官想听的关键词:好友上限封顶、写放大可控、选型心法「单个生产者的消费者上限」、活跃用户优化。
常见失分点:不知道朋友圈好友数有上限这个产品前提,把答案归因于「微信技术强」;只会背「微博用推拉结合」却说不出是粉丝量级决定的。
一句话结论:OFFSET 深翻页要先数过并丢弃前 N 条、越翻越慢,游标记住「上一页最后一条的位置」(WHERE uid=? AND id < 游标 ORDER BY id DESC LIMIT 20),翻到第几页性能都和第一页一样;uk_uid_id 的两个用途是防重复写入和支撑游标分页。
展开:类比:看书夹书签直接续读,而不是每次从第 1 页数到第 100 页。实现里游标是毫秒时间戳,接口返回 next_cursor 给前端、下一页请求原样带回;Redis ZSet 版对应 ZREVRANGEBYSCORE 的 Max 传开区间 (cursor。游标分页还有个隐藏好处:翻页过程中恰好有新动态插入,OFFSET 分页会错位(内容重复或跳过),游标锚定在位置上不受影响。批量写入侧再补一句 Pipeline 批量推送(一次网络往返推一批粉丝),把「写扩散」的网络开销也压下来——这些细节堆起来才是「真写过」的信号。
面试官想听的关键词:seek method、深分页扫描代价、性能恒定、防重、next_cursor、开区间、Pipeline 批量推。
常见失分点:只说「OFFSET 慢」说不出「扫描并丢弃前 N 条」的原理;漏掉「防重复写入」这个用途,只答了分页。
一句话结论:把「Feed 核心分发逻辑」和「各业务的特色处理」分离——Service 按 type 从 map 里找 Handler 分发,新业务只需实现 Handler 接口并注册,核心代码零修改,这是开闭原则(对扩展开放、对修改关闭)的落地。
展开:反面教材是 if-else 堆事件类型:每加一种事件就要改一次核心代码、回归测试一次,改挂一次全站 Feed 挂一次。Handler 接口就两个方法(Create / FindFeedEvents),注册进 map[string]Handler;再加一层兜底 defaultHandler——新业务上线忘了注册 Handler,事件走默认逻辑而不是静默丢失(比报错更可怕的是无声消失)。如果面试官继续问「什么时候值得平台化」:看接入方多不多、核心稳不稳定——只有一两个业务方时,老实 if-else 更务实;接入方持续增长、核心需要稳定,才值得抽这层。
面试官想听的关键词:开闭原则、map 按 type 分发、注册、兜底 Handler、静默丢失、平台化时机。
常见失分点:答「为了代码整洁」——面试官要听的是新业务接入成本和核心稳定性;漏说兜底 Handler,接不住「忘了注册会怎样」的追问。
一句话结论:Feed 的实时性要求是秒级 / 十秒级、不是毫秒级,异步是免费的午餐,换来解耦、削峰、鲁棒三样东西;goroutine 和 Kafka 的本质区别在进程重启的瞬间——goroutine 里的任务随进程消失(丢动态),Kafka 里的消息重启后从 offset 接着消费(不丢)。
展开:判断「能不能异步」的标准就一条:用户会不会在这 1~10 秒的延迟里察觉异常——点赞、发帖、关注、通知全都察觉不到,支付扣款、库存扣减才必须同步。三样东西逐个说:解耦(业务方发完消息就返回,Feed 服务挂了点赞照样成功);削峰(大 V 发帖的百万级写扩散被 Kafka 当缓冲池,Feed 按自己的节奏消费,数据库永远只承受稳定写入速率);鲁棒性(Feed 重启,消息在 Kafka 里攒着,恢复后从上次 offset 续传,一条不丢)。收尾金句:「异步」的本质不是「不等它」,而是把任务交给一个比你活得久的系统。
面试官想听的关键词:秒级实时性、解耦 / 削峰 / 鲁棒、offset 续传、比你活得久的系统。
常见失分点:把异步直接等同开 goroutine,接不住「进程重启会怎样」的追问;三样东西只会背名词,说不出任何一个的具体机制。
一句话结论:不拍脑袋——常见取值在 10 万~100 万之间,用压测数据定;而且阈值是动态的,作者粉丝数跨过阈值时由 follow-events 消费者把他加进 / 移出大 V 表(Redis Set),读接口实时按大 V 表筛。
展开:阈值是个双向权衡:设小了(比如 1 万),中腰部作者也被判成大 V,他们的粉丝刷首页要拉一大堆发件箱、读路径变重;设大了(比如 1000 万),大 V 发帖照样产生千万级写入。所以压测要按「写扩散档位」(粉丝 100 / 1000 / 10000 三档)测不同档位的写入性能,再结合读侧「拉发件箱」的代价找平衡点。动态维护:用户关注 / 取关导致作者粉丝数跨过阈值时,消费 follow-events 把作者 ID 加进或移出 feed:bigv 这个 Set;读接口用批量筛(SMISMEMBER 类语义)过滤出关注列表里的大 V。同一个思路推广到限流:阈值靠压测压出系统能承受的 QPS,再打 8 折留安全余量。
面试官想听的关键词:压测定阈值、写扩散档位、读侧拉取代价、动态维护大 V 表、打 8 折。
常见失分点:直接报一个数说「业界都这么定」;不知道大 V 表要随粉丝数动态维护,答成「上线时配死的」。
一句话结论:盯 P99 不盯平均值——平均值会被大量快请求拉低、掩盖最差用户体验(990 个 5ms、10 个 3 秒,平均 34ms「看着还行」但那 10 个用户已经骂娘);再往下钻 k6 的延迟分解四段,定位慢在压测机、网络还是服务端。
展开:四段分解:req_blocked(k6 自身排队,正常 ≈ 0)、req_sending(发送请求)、req_waiting(服务端处理,最该盯的一段)、req_receiving(接收响应)。健康基线:只有总耗时和 waiting 不为 0。排错名场面:blocked 明显非 0 且服务端 CPU 很闲 → 是压测机 / 单进程 k6 先到顶了,换更强的压测机、分布式 k6 或降 VU 重测——新手最常把这个误判成系统瓶颈,白优化半天。看板细节再补两个:VU 冲到设定值 2 倍左右是 k6 调度的 2N 现象、不是配置错;成功速率掉头向下与失败速率陡增的交叉点就是过载拐点——失败请求延迟贴着超时上限说明是超时(扩容或限流),「失败得很快」多半是被拒(429 / 503,查限流阈值)。
面试官想听的关键词:P99 / 分位数、四段延迟分解、req_blocked 排错、2N 现象、速率交叉点、失败延迟形态。
常见失分点:只报平均值;把 k6 自身瓶颈说成系统瓶颈(blocked 非 0 + 服务端闲这两个信号要同时看);说不出「失败得快 vs 失败得慢」分别指向什么。
一句话结论:按「压测定位 → 对症优化 → 量化收益」三段讲:先分维度加压把瓶颈精确到交点(混合读、并发 100、数据 10 万时 P99 飙到 500ms),再对症上缓存 / 异步 / 批量三件套,最后给数字(P99 降到 50ms 以内,10 倍)。
展开:三件事各治延迟的哪一段要说清:Redis 缓存热点收件箱治「读慢」;同步回查业务方改成 Kafka 异步治「回查链路长」;循环里的单条查询改批量接口治「循环放大」。支撑这个故事的工程细节:k6 里自定义 feed_bigv_ms / feed_merge_ms 两个指标(服务端把耗时写进 X-Bigv-Ms / X-Merge-Ms 响应头透出),把「大 V 发件箱拉取」和「合并」两段单独度量——瓶颈先涨哪条,优化就打哪里,前后对比有依据。收尾金句:性能优化没有新知识,全是老知识的组合拳——缓存是第 06 章的、异步是第 07 章的、批量是第 02 章的升级。
面试官想听的关键词:三段式因果链、瓶颈精确到维度交点、三件事各治哪段、自定义分段指标、量化收益。
常见失分点:讲成「我做了 A、B、C 三件事」的流水账——没有定位过程,说不清为什么是这三件而不是别的;收益只说「快了很多」,不给 P99 / RPS 前后对比数字。
一句话结论:写入侧只给活跃粉丝推、僵尸粉不推(省写入省存储),读侧活跃用户只查收件箱;关键补丁是非活跃用户上线后的兜底——收件箱是空的,要么现场去关注的人的发件箱拉,要么上线时触发一次回填。
展开:这是「按用户价值分配资源」思想的最佳体现——给三年没上线的僵尸粉写收件箱纯属浪费。活跃判定两条落地路径:① T+1 定时任务离线算活跃名单(简单,但有一天延迟);② Redis 记 uid → 最后活跃时间、写入时实时判断(准,但每次推送多一次查询)——两条路的取舍(准确性 vs 额外开销)本身就是面试官爱追的点。主动补一句「本课程为简化没实现,但生产上是标配」:主动展示设计视野、又诚实交代实现边界,比硬吹「都做了」可信得多。
面试官想听的关键词:按用户价值分配资源、活跃判定两条路径、读侧兜底、僵尸粉、离线名单 vs 实时判断。
常见失分点:只想了写侧省了,接不住「那不活跃的用户上线后怎么看到内容」的追问;活跃判定只会说「记个标志位」,说不出两条具体落地路径和它们的取舍。
一句话结论:push 侧设关注上限(微信朋友圈 5000 好友同思路)从源头给写扩散封顶,pull 侧限频(轮询间隔、刷新次数限制)压读洪峰;合并读请求听着美,但大 V 发件箱本身有缓存兜底,实测优化甚微——面试知道即可,别当主优化讲。
展开:关注上限的本质是把"最多写扩散量"变成常数:一个人最多关注 N 个人,单次发帖的最大推送量就是可预估的。限频把"每秒亿级查询"压到可接受水位,客户端配合做本地缓存 + 间隔拉取。合并读的失效逻辑要想清楚:多个粉丝同一时刻查同一批大 V,看起来能合并成一次 DB 访问,但大 V 发件箱早就被缓存挡住了,DB 压力本来就不在这里。
面试官想听的关键词:关注上限、限频、合并读、缓存兜底、产品限制封顶。
常见失分点:把合并读当核心优化吹,接不住"发件箱本来就有缓存"的追问;不知道关注上限是产品和技术协同的典型手段。
一句话结论:GeoHash 把经纬度交替二分、base32 编码成一串字符,前缀相同 = 位置相邻,“找附近"就变成了"按前缀查索引”;Redis 的 GEO 命令(GEOADD / GEOSEARCH)底层就是 zset + geohash 整数编码,go-redis 直接可用。
展开:查询流程:中心点编码 → 按半径定位格子 → 连同周围 8 个格子一起查 → 精算距离排序截断;member 存 postId / uid,命中后再回查详情表拼装(06 章拼装套路)。持久化层用 MySQL 存 geohash 字段做前缀查询,Redis 按城市分集合扛热查询。
面试官想听的关键词:二分编码、base32、前缀索引、Redis GEO、zset 底层、8 邻域。
常见失分点:以为"附近的人"要遍历所有人算距离;或不知道 Redis GEO 的底层就是 zset(score 存 geohash 整数)。
一句话结论:GeoHash 有边界效应——同一个格子里的点可能很远、而真正最近的点可能就在隔壁格子,所以中心格子 + 8 邻域一起查再精算距离;数据只存最近一周(附近的帖子没人看一年前的),到期清理,同城百万级时按 geohash 前缀分桶防大 key。
展开:这也是"精度换方案复杂度"的案例:陌陌 / 探探筛人要精确距离排序,方案要重一些;微博 / 小红书 / 抖音发内容精度要求低,Redis GEO + 短时间窗就够——先问产品要什么精度,再定方案重量。数据的时间窗裁剪和大 key 分桶都是前面章节(06 章 3.26)手法在地理场景的复用。
面试官想听的关键词:边界效应、8 邻域、只存近期、前缀分桶、精度决定方案。
常见失分点:只查中心格子(漏掉边界上更近的人);或不知道数据要按时间窗裁剪、一直无限膨胀。
八、本章小结
- Feed 流本质:整合 + 时间序;核心矛盾一句话——生产者少、消费者多,但每个人要的是「全网里和他有关的那一小撮」,查询性能和写入放大不可兼得
- 三种模型:拉 = 读扩散(查询代价被关注数放大、分页极难);推 = 写扩散(读变常数、大 V 发帖百万行写放大);推拉结合 = 普通人走推、大 V 走拉
- 口诀:读扩散查询慢、写扩散数据多;推拉结合没有根治、只是把痛点从「所有人」缩小到「大 V 和它们的粉丝」——面试答缺点时的标准后半句
- 选型心法:先看「单个生产者最多有几个消费者」——几千以内可推(朋友圈)、上百万必拉或推拉结合(微博 / B 站)
- 存储设计:收件箱
feed_push_event(uid = 谁的收件箱)/ 发件箱feed_pull_event(uid = 谁发的);(uid, create_time) 联合索引(过滤 + 排序都走索引);JSON 扩展字段(不能 WHERE 过滤,选前先问未来要不要筛);时间戳用 BIGINT 不用 DATETIME - uk_uid_id 唯一索引:防重复写入 + 支撑游标分页;OFFSET 深翻页扫描丢弃前 N 条越翻越慢,游标(
WHERE uid=? AND id < 游标)每页性能恒定,且翻页时新插入内容不错位 - Service + Handler:开闭原则落地——按 type 从 map 分发、新业务实现 Handler 注册即接入、核心零修改;兜底 defaultHandler 防事件静默丢失
- Kafka 异步:换解耦(Feed 挂了业务照常)、削峰(写扩散摊平成稳定速率)、鲁棒(重启 offset 续传不丢);判断标准 = 用户会不会在 1~10 秒延迟里察觉异常;异步本质 = 把任务交给一个比你活得久的系统
- k6 压测:VU 阶梯加压找拐点;盯 P99 不盯平均(平均掩盖最差体验);延迟分解四段 blocked / sending / waiting / receiving——blocked 非 0 + 服务端 CPU 闲 = k6 自己趴了,别误判成系统瓶颈;2N 现象不是配置错
- 压测设计:控制变量(排除 follow 干扰)、数据源 × 数据量组合用例、独立 uid 段隔离数据;限流阈值 = 压出的 QPS 再打 8 折
- 优化故事三段式:压测定位(瓶颈精确到数据源 × 数据量 × 并发的交点)→ 对症优化(缓存治读慢 / 异步治链路长 / 批量治循环)→ 量化收益(P99 500ms → 50ms);性能优化没有新知识,全是老知识的组合拳
- 活跃粉丝优化:只给活跃粉丝写收件箱 + 非活跃用户上线后读侧兜底(现场拉发件箱 or 回填);活跃判定两条路——T+1 离线名单 or Redis 实时最后活跃时间
下一章(第 20 章)我们进入即时通讯 IM 服务——整个课程最后一块硬骨头:客户端和服务器之间怎么维持一条长连接(WebSocket)、消息怎么做到「发了就一定送达」(可靠投递)、离线消息怎么存、百万长连接怎么扛。你会发现它和 Feed 流是同一类问题的两个面——Feed 是「一条内容扇出给百万人」,IM 是「两个人之间的消息必达」;而 Kafka、Redis、微服务注册发现这些老朋友会全部再次登场。学完它,这门课的 21 章地图就只剩最后一站的总结了。