学习目标
学完本章,你应该能够:
- 用自己的话解释 Feed 流是什么、解决什么问题,以及在微博、朋友圈、B 站动态里它分别以什么形态存在。
- 讲清楚 Feed 流的三种核心模型——拉(读扩散)/ 推(写扩散)/ 推拉结合——各自的优缺点、适用场景,并能对一个给定业务做出选型。
- 设计一套 Feed 流的数据存储方案(收件箱 / 发件箱 / 扩展字段),并落地 Service + Handler 的可扩展架构,新业务接入时 Feed 服务零修改。
- 用 k6 + Prometheus + Grafana 对接口做压测,并正确解读 RPS、P99、延迟分解等关键指标。
- 在面试里把"系统性能不行 → 压测定位 → 优化"讲成一段完整的故事。
前置知识(如果下面任意一点生疏,先回看对应章):
- 第02章 Gin + GORM:知道一个 HTTP 接口怎么写、DAO 怎么分层。
- 第07章 Kafka:知道消息是怎么从生产者到消费者的。
- 第16章 评论与用户关系:知道"关注 / 粉丝"关系数据在哪、怎么查。
- 基本的 MySQL 索引概念(会看
KEY、UNIQUE KEY)。
本章你会动手做的事:
- 画明白"一个用户刷首页"背后到底发生了几次查询。
- 给 Feed 服务新增一个"被 @ 提醒"事件,体会开闭原则。
- 用 k6 把自己本地的 Feed 接口压到 P99 飙升,记录拐点。
一、核心概念:Feed 流到底是个啥
一句话定义:Feed 流就是把"和你有关的一堆动态",按时间顺序攒成一个列表,推到你眼前。
1.1 用生活类比先建立直觉
想象你手机里装了 50 个博主的"看点"。每天打开 App 的"关注"页,你想要的其实是一个东西:这 50 个人最近发了啥,按时间排好,给我看。
这就是 Feed 流要解决的"整合 + 时间序展示"两件事(原文已点出)。难点不在"展示",而在"整合"——这 50 个人的动态分散在文章表、点赞表、评论表、关注表里,你刷一次首页,背后可能要跨好多张表去拼。
flowchart LR
A[关注的人发文章] --> F((Feed 流))
B[有人点赞/收藏你] --> F
C[有人评论/回复你] --> F
D[有人关注你] --> F
E[系统通知] --> F
F --> U[用户刷首页看到的 timeline]为什么这件事值得单独做一章、而不是"刷首页时直接查各张表"?因为一旦用户量和关注数上来,直接查各张表的代价会指数级放大。后面三种模型就是在这个矛盾上做取舍。
1.2 两种产品形态(先想清楚再做)
组织 Feed 流有两种经典做法:
- 形态一(全聚合):把上面所有动态合并到一个流里。复杂,但通用,系统设计挑战大。
- 形态二(只做关注流):只把"关注者新作品"做成 Feed,点赞/评论/关注做成单独的系统通知。简单。
本课程选 形态一。原因很工程:形态一能降级成形态二(删代码即可),反过来不行。你做技术选型时也常遇到这种"先做难的、留降级空间"的思路。
1.3 为什么"拉 / 推"会成为核心矛盾
Feed 流的本质矛盾一句话:生产者少、消费者多,但每个人要的是"全网里和他有关的那一小撮"。
- 如果你读的时候才去各生产者的库里找 → 查询慢(读扩散)。
- 如果你写的时候就提前把内容塞进每个消费者的收件箱 → 写入多(写扩散)。
记住这对矛盾,后面三种模型全是它的变体。
二、Feed 流的三种设计模型
2.1 拉模型(读扩散)
思路:用户查询 Feed 时,实时从各业务方的数据库里检索,再在内存里聚合排序。
类比:就像你每天早上去刷"关注"页,App 当场挨个去你关注的 50 个博主的主页,把每人最新 20 条搬下来,自己排个序给你看。博主发文章时什么额外的事都不做,全攒到你刷的时候。
代价——每次查询都会扩散成 N 个数据库查询:
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 条返回]工程痛点:
- 对数据库压力极大(关注 1000 人 = 一次首页 1000+ 次查询)。
- 分页极难:你没法预知"前 20 条"分布在哪些库/表,只能每个来源各取前 20,再在内存归并。这正是分库分表中间件处理跨表分页的同一套做法。
- 内容社区早期数据量小可以用,一旦用户关注数膨胀就撑不住。
2.2 推模型(写扩散)
思路:以 Feed 模块为核心。每个用户有一个"收件箱",被关注者发内容时,系统把这条动态写进所有粉丝的收件箱。
类比:你关注的博主每发一篇文章,报社立刻复印 N 份,挨家挨户塞进每个粉丝的邮箱。等你刷首页时,只开自己那个邮箱看就行——读的时候爽,发的时候累。
flowchart TD
P[博主 B 发文章] --> W[写入每个粉丝的收件箱]
W --> I1[粉丝 A 的收件箱]
W --> I2[粉丝 C 的收件箱]
W --> I3[粉丝 D 的收件箱]
A[粉丝 A 刷首页] --> R[只查 A 自己的收件箱]
R --> T[数据库分页直接取前 N 条]优点:
- 查询极简:只查自己的收件箱,直接走数据库分页,无需内存聚合。
- 同一用户的数据按
uid分库分表后必然落在同一张表,一次查询搞定。
缺点(致命):
- 写流量被放大 N 倍。B 有 100 万粉丝,发一篇文章就要瞬间写 100 万条收件箱记录。
- 大 V 场景下,这一次写入本身就可能超时、拖垮数据库。
⚠️ 排错 / 面试延伸:推模型最大的雷是"大 V 发一条,数据库被打挂"。所以纯推模型只适合粉丝上限可控的场景(典型如微信朋友圈,好友上限约 5000,写入量天然封顶)。
2.3 推拉结合模型
实践中的主流方案,思路一句话:普通用户走推(写扩散),大 V 走拉(读扩散)。
flowchart TD
P[作者发内容] --> T{粉丝数 > 阈值?}
T -- 否: 普通用户 --> W[写入每个粉丝收件箱
写扩散]
T -- 是: 大 V --> O[只写自己发件箱
读扩散]
A[粉丝 A 刷首页] --> G[合并: 自己收件箱 + 关注的大V发件箱]
G --> S[排序分页]进一步优化:只对活跃粉丝做写扩散,非活跃粉丝(长期不登录)走读扩散。因为给一个三年没上线的僵尸粉写收件箱纯属浪费。
读流程(推拉结合下):
- 先查自己的收件箱(普通好友推过来的);
- 再查你关注的大 V 的发件箱(他们没推给你,你得去拉);
- 合并、排序、分页。
口诀(务必背住):读扩散查询慢,写扩散数据多。推拉结合并没有根治这两个问题,只是把痛点从"所有人"缩小到"大 V 和它们的粉丝",从而可控。
2.4 业务场景选型建议
| 场景 | 推荐模型 | 原因 |
|---|---|---|
| 微信朋友圈(好友数有上限) | 纯写扩散 | 好友数受限,写入量可控,读极快 |
| 微博(千万粉丝大 V) | 推拉结合 | 大 V 走拉,普通用户走推 |
| 系统通知 / 私信类事件 | 写扩散 | 只投递给特定用户,受众明确,写入量小 |
| B 站 UP 主动态 | 推拉结合 + 活跃用户优化 | 百万粉 UP 多,必须控制写入量 |
选型心法:先看"单个生产者最多有几个消费者"。消费者有上限 → 可以推;消费者可能上百万 → 必须拉或推拉结合。
三、数据存储设计
3.1 表结构:收件箱与发件箱
由"推 / 拉"概念直接导出两张表:
feed_push_event:收件箱,写扩散时往这写(“谁收到了这条”)。feed_pull_event:发件箱,读扩散时从这读(“谁发出了这条”)。
类比:收件箱 = 你家门口的邮箱(别人塞给你的信);发件箱 = 你办公室抽屉里"我发出去的所有信"的存根。拉模型就是粉丝来翻大 V 的存根。
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是绝大多数查询的过滤条件(“查 A 的收件箱"“查 B 的发件箱”),必须放索引最左。create_time紧跟uid后面,是为了按用户 + 时间排序翻页能走索引(WHERE uid=? ORDER BY create_time DESC LIMIT ?)。uk_uid_id (uid, id)这个唯一索引很关键:它保证了"同一个用户的收件箱里 id 全局递增且唯一”,既能防重复写入,又能用id < 上次看到的最后一条 id做游标分页(比OFFSET翻深页性能好得多)。
3.2 扩展字段为什么用 JSON
扩展字段两种方案:
- 大 JSON 字段(本课程采用):简单,一种事件一张表搞定。代价是 JSON 里的内容不能参与
WHERE过滤。 - 扩展表:每种事件一张扩展表,类型安全,但代码量翻几倍。
⚠️ 踩坑预警:JSON 字段不能
WHERE content->>'bizId' = 789高效过滤(除非建生成列 + 索引,成本高)。所以选型前先问自己:未来会不会"按扩展字段筛选 Feed"?如果会,从一开始就别用大 JSON,老老实实建扩展表。本课程暂不需要,故用 JSON 换简单。
冗余存储权衡:Feed 里要不要顺手存一份"用户昵称 / 文章标题"?冗余了,刷首页就不用回查业务方,BFF 直接拼装,读快;但业务方改了昵称,你得同步更新所有相关 Feed,写麻烦。没冗余则反过来。这是经典的"空间换时间 / 一致性换性能"取舍,按查询模式定。
3.3 数据同步接口:Feed 怎么收到事件
Feed 服务通过 Kafka 接收业务方推送的事件,扩展字段用一个宽泛的 map[string]any 兜住:
// FeedEvent 表示一个 Feed 流事件
type FeedEvent struct {
Uid int64 `json:"uid"` // 目标用户 ID(推模式下为收件人)
Type string `json:"type"` // 事件类型
Content map[string]any `json:"content"` // 扩展字段,业务方自定义
Timestamp int64 `json:"timestamp"` // 事件时间戳
}
// 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)
}
四、Service + Handler 架构实战
4.1 设计思路:把"公共逻辑"和"业务逻辑"切开
目标:新业务(比如以后要加"被 @““被转发”)接入时,Feed 服务核心代码一行都不用改。这就是开闭原则——对扩展开放、对修改关闭。
拆两个核心角色:
- Service:管所有业务共有的事——分发、聚合、排序。
- Handler:管某个具体业务的逻辑——决定推还是拉、校验扩展字段、查业务数据。
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[(发件箱)]4.2 核心接口与分发逻辑
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 索引
// 默认 handler:当找不到对应业务 handler 时走默认逻辑
defaultHandler 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)
}
工程含义:新业务只要实现
Handler并handlers["newType"] = NewXxxHandler(...)注册进去即可。Feed 核心的CreateFeedEvent永远不动——这就是"零修改接入”。
4.3 点赞事件 Handler:写扩散(分步拆解)
业务判断:A 点赞了 B,只 A 和 B 关心,受众明确且只有 1 个收件人 → 推模型,写进 B 的收件箱。
package feed
import "context"
// LikeHandler 点赞事件处理器
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: interface conversion: interface {} is float64, not int64。这是 Feed / Kafka 相关代码最高频的线上故障之一。 更稳的写法:用json.Number或在结构体里定义强类型字段,别用map[string]any裸取。
4.4 发表文章 Handler:推拉结合(分步拆解)
发表文章是大流量事件,要不要给每个粉丝写收件箱,得看粉丝数。
// PublishArticleHandler 发表文章处理器
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)
}
为什么"先写发件箱、再判断推不推"? 因为发件箱是拉模型的唯一数据源,必须先落,否则大 V 的粉丝来拉的时候会读不到。顺序不能反。
⚠️ 性能排错:步骤 4 的
BatchCreate如果粉丝几万,单次事务写几万行会慢且锁表。生产上会拆批 + 异步(放另一个 Kafka 消费组慢慢写),或只给活跃粉丝写。这正是推模型在普通用户量大时也要优化的点。
4.5 查询 Feed 流:合并推拉两边
// 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
}
flowchart TD
Q[用户 A 查 Feed] --> P[查 A 收件箱]
Q --> F[查 A 关注列表]
F --> PE[批量查这些人的发件箱]
P --> M[合并]
PE --> M
M --> S[按时间降序排序]
S --> C[截断取前 limit 条]⚠️ 排错点:步骤 2 的
GetFollowees如果 A 关注了几千人,这里一次性拉几千个followeeIds去FindByUids,SQL 的IN (...)会很长、很慢。生产上要对关注列表做分页 / 限制拉取的发件箱数量(比如只看 Top 200 关注的动态),否则大 V 的粉丝刷首页会被自己的关注列表拖死。
五、为什么写入用异步接口(Kafka)
Feed 流对实时性的要求是"秒级 / 十秒级",不是毫秒级。A 发文章,B 在 10 秒内看到,用户完全无感。所以写入走异步(Kafka)完全够用。
设计原则(很重要):不要默认"能用同步就用同步",反过来——只要业务没有强制同步要求,就用异步。异步带来三件事:
flowchart LR
B[业务方] -->|发消息| K[Kafka]
K -->|削峰缓冲| F[Feed 服务]
F --> DB[(数据库)]
B -. 不等 Feed 返回 .-> OK[业务主流程立即成功]- 解耦:业务方只管发消息,不依赖 Feed 服务是否在线。
- 削峰:突发流量被 Kafka 缓冲,下游数据库不被冲垮。
- 鲁棒性:Feed 服务短暂宕机,消息在 Kafka 里攒着,恢复后接着消费,业务主流程不受影响。
六、压力测试:k6 实战
压测不是"把机器跑挂"取乐,而是用数据给限流阈值、降级策略、容量规划提供依据。
6.1 工具选型
| 工具 | 适用场景 | 特点 |
|---|---|---|
| pprof | Go 程序内部性能分析 | 定位 CPU/内存/goroutine 瓶颈,单机调试 |
| wrk | 快速 HTTP 压测 | 轻量,lua 脚本扩展,适合少量非正式测试 |
| k6 | 正式压测 + 可视化 | JS 脚本,多协议,分布式,集成 Prometheus/Grafana |
本课程选 k6 + Prometheus + Grafana:k6 能写复杂用例、输出可视化报表,且原生对接 Grafana,老板/同事一眼看懂。
6.2 分步上手 k6
第 1 步:安装(macOS)
brew install k6
第 2 步:写脚本 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); // 模拟用户思考时间,别把请求打满成纯轰炸
}
第 3 步:跑起来
# 设置 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
关键参数:
-o experimental-prometheus-rw:把指标写进 Prometheus。--duration:时长,支持30s/5m/1h。--vus:虚拟用户数,支持范围式--vus 10-100做阶梯加压(逐步加用户,看拐点出现在哪)。
第 4 步:Grafana 看板
启动 Grafana,配置 Prometheus 数据源,Import dashboard:
- 打开 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 / 延迟分解]6.3 关键指标解读
吞吐与响应时间:
- Peak RPS:峰值 QPS,系统的吞吐上限。
- HTTP Request Duration:默认 99 线(P99),即 99% 的请求在这个时间内返回。P99 比平均值更有意义——平均值会被少数快请求掩盖长尾。
延迟分解(哪个环节慢,一眼看出来):
flowchart LR
B[req_blocked] --> S[req_sending] --> W[req_waiting] --> R[req_receiving]req_blocked:k6 自身阻塞(VU 排队等调度)。正常应≈0;不为 0 说明 k6 自己成了瓶颈。req_sending:发送请求耗时。非 0 可能是网络差或请求体过大。req_waiting:服务端处理耗时(近似总耗时,不含发送)。这是你最该盯的。req_receiving:接收响应耗时。非 0 可能是响应体过大或服务端慢。req_tls_handshaking:TLS 握手延迟。
健康基线:正常情况下只有
req_duration和req_waiting不为 0,其余都应接近 0。否则按上表逐段排查。
⚠️ 排错:k6 自己先趴了。如果你看到
req_blocked明显不为 0、但服务端 CPU 还很闲,说明压不上去是因为压测机/单进程 k6 到顶了,不是被测系统的问题。解决:换更强的压测机、用 k6 分布式、或降低--vus重新观察。新手常把"k6 瓶颈"误判成"系统瓶颈",白优化半天。
6.4 Feed 接口压测场景设计
webook 预置了多个典型场景(位于 webook/feed/test):
写扩散压测:扩散百人 / 千人 / 万人三档,验证不同粉丝量级下的写入性能。
读接口压测:两个维度组合——
- 数据来源:只读 push / 只读 pull / 混合读
- 数据量级:1W / 10W / 100W
压测发现(真实数据):10W 数据量下,混合读的 P99 增长很快,普通开发机只能支撑约 100+ 并发。这恰恰是后续优化(加缓存、异步化、批量接口)的依据——没有这步压测,你根本不知道该优化哪。
6.5 面试话术(把优化串成故事)
我进来后发现 Feed 读接口性能不理想,于是用 k6 设计了一套压测:分数据源(push/pull/混合)× 数据量(1W/10W/100W)组合加压。结果发现混合读在并发 100、数据 10W 时 P99 飙升到 500ms,是瓶颈。 在此基础上我做了三件事:① 引入 Redis 缓存热点收件箱,命中即返回;② 把同步回查业务方改成 Kafka 异步,削峰;③ 把循环里的单条查询改成批量接口。优化后 P99 降到 50ms 以内。
这套"压测定位 → 对症优化 → 量化收益“的讲法,就是把缓存、异步、批量三个知识点串成了一段优秀的性能优化回答。
七、工程实践要点
- 业务接入靠线下约定:Feed 团队和业务团队通过会议约定
type和扩展字段 key,没有强类型约束,所以文档和沟通是第一生产力,否则两边对不上字段就出诡异空 Feed。 - 兜底 Handler 是安全网:找不到对应 Handler 走默认逻辑,新业务无特色逻辑可直接接入,Feed 服务零修改。
- 异步优先:Feed 写入一律走 Kafka,别为了"看起来更实时"牺牲系统稳定性。
- 冗余存储权衡:是否冗余昵称/标题,取决于你愿不愿意承担回查成本。读多写少就冗余,写多读少就回查。
- 压测要分层:单元/内部瓶颈用 pprof,接口压测用 wrk/k6,全链路用 k6 分布式;压前先备好不同量级测试数据。
- 限流阈值靠压测定:限流阈值不是拍脑袋,而是压出系统能承受的 QPS 后再打 8 折,留安全余量。
- JSON 扩展字段的边界:能用 JSON 简化就用在"不需按扩展字段过滤"的场景;一旦要按扩展字段查,提前建扩展表或生成列索引。
八、自测题与动手练习
自测题(合上书能答出来,才算懂):
- 什么场景适合纯推(写扩散)模型?为什么微信朋友圈可以用纯推,微博不行?
- 推拉结合下,一个 500 万粉大 V 发一条微博,系统内部做了什么?一个普通粉丝刷首页时又做了什么?
- Feed 事件用
map[string]any传扩展字段,在 Go 里取数字为什么要用.(float64)断言?直接. (int64)会怎样? - k6 压测时
req_blocked不为 0、但服务端 CPU 很闲,说明什么?该怎么处理? - 限流阈值为什么不能直接拍脑袋定?压测数据还能用来支撑哪些决策?
动手练习(建议真做一遍):
- 扩展一个新事件:给 Feed 加"被 @ 提醒"事件。要求:① 定义
type="mention"和content约定;② 实现MentionHandler并注册进Service;③ 发一条 Kafka 消息触发;④ 查收件箱验证收到。体会"Feed 服务零修改接入”。 - 亲手压到拐点:用 k6 把本地 Feed 读接口从
--vus 10阶梯加到--vus 500,记录 P99 开始飙升的那个并发数,写成一句话结论。 - 优化思考题:针对"10W 数据混合读 P99 飙升",分别给出"加 Redis 缓存 / Kafka 异步化 / 批量接口"三种方案的预期收益与副作用,并排个优先级。
九、本章小结
- Feed 流的核心矛盾是 “查询性能 vs 写入放大”:拉模型查询慢、推模型写入多、推拉结合是工程折中(没根治,只缩小痛点范围)。
- 选型先看"单个生产者最多几个消费者":消费者有上限可推,可能百万则必须拉或推拉结合。
- 存储上区分收件箱(推)/ 发件箱(拉),扩展字段用 JSON 换简单,但记住 JSON 不能高效
WHERE。 - 架构用 Service + Handler 分离公共与业务逻辑,新业务靠实现
Handler+ 注册接入,核心零修改(开闭原则)。 - 写入默认异步(Kafka),换来解耦、削峰、鲁棒性;实时性要求是秒级,异步足够。
- 压测是后端基本功:k6 + Prometheus + Grafana 一套打通,数据用来定限流、降级、容量。读指标时盯 P99 和延迟分解,
req_blocked非 0 先怀疑压测机自己。 - 面试核心口诀:读扩散查询慢,写扩散数据多,推拉结合只是缓解而非根治。
下一章(第20章)我们将进入即时通讯 IM 服务,用 WebSocket 把"消息可靠投递、离线消息"这些更难的问题啃下来。