Feed 流设计与压力测试

2023-04-07T14:11:02+08:00 | 19分钟阅读 | 更新于 2026-08-07T14:11:02+08:00

@

学习目标

学完本章,你应该能够:

  1. 用自己的话解释 Feed 流是什么、解决什么问题,以及在微博、朋友圈、B 站动态里它分别以什么形态存在。
  2. 讲清楚 Feed 流的三种核心模型——拉(读扩散)/ 推(写扩散)/ 推拉结合——各自的优缺点、适用场景,并能对一个给定业务做出选型。
  3. 设计一套 Feed 流的数据存储方案(收件箱 / 发件箱 / 扩展字段),并落地 Service + Handler 的可扩展架构,新业务接入时 Feed 服务零修改。
  4. k6 + Prometheus + Grafana 对接口做压测,并正确解读 RPS、P99、延迟分解等关键指标。
  5. 在面试里把"系统性能不行 → 压测定位 → 优化"讲成一段完整的故事。

前置知识(如果下面任意一点生疏,先回看对应章):

  • 第02章 Gin + GORM:知道一个 HTTP 接口怎么写、DAO 怎么分层。
  • 第07章 Kafka:知道消息是怎么从生产者到消费者的。
  • 第16章 评论与用户关系:知道"关注 / 粉丝"关系数据在哪、怎么查。
  • 基本的 MySQL 索引概念(会看 KEYUNIQUE 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[排序分页]

进一步优化:只对活跃粉丝做写扩散,非活跃粉丝(长期不登录)走读扩散。因为给一个三年没上线的僵尸粉写收件箱纯属浪费。

读流程(推拉结合下)

  1. 先查自己的收件箱(普通好友推过来的);
  2. 再查你关注的大 V 的发件箱(他们没推给你,你得去拉);
  3. 合并、排序、分页。

口诀(务必背住):读扩散查询慢,写扩散数据多。推拉结合并没有根治这两个问题,只是把痛点从"所有人"缩小到"大 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

扩展字段两种方案:

  1. 大 JSON 字段(本课程采用):简单,一种事件一张表搞定。代价是 JSON 里的内容不能参与 WHERE 过滤
  2. 扩展表:每种事件一张扩展表,类型安全,但代码量翻几倍。

⚠️ 踩坑预警: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)
}

工程含义:新业务只要实现 Handlerhandlers["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 关注了几千人,这里一次性拉几千个 followeeIdsFindByUids,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 工具选型

工具适用场景特点
pprofGo 程序内部性能分析定位 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:

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_durationreq_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 以内。

这套"压测定位 → 对症优化 → 量化收益“的讲法,就是把缓存、异步、批量三个知识点串成了一段优秀的性能优化回答。


七、工程实践要点

  1. 业务接入靠线下约定:Feed 团队和业务团队通过会议约定 type 和扩展字段 key,没有强类型约束,所以文档和沟通是第一生产力,否则两边对不上字段就出诡异空 Feed。
  2. 兜底 Handler 是安全网:找不到对应 Handler 走默认逻辑,新业务无特色逻辑可直接接入,Feed 服务零修改。
  3. 异步优先:Feed 写入一律走 Kafka,别为了"看起来更实时"牺牲系统稳定性。
  4. 冗余存储权衡:是否冗余昵称/标题,取决于你愿不愿意承担回查成本。读多写少就冗余,写多读少就回查。
  5. 压测要分层:单元/内部瓶颈用 pprof,接口压测用 wrk/k6,全链路用 k6 分布式;压前先备好不同量级测试数据。
  6. 限流阈值靠压测定:限流阈值不是拍脑袋,而是压出系统能承受的 QPS 后再打 8 折,留安全余量。
  7. JSON 扩展字段的边界:能用 JSON 简化就用在"不需按扩展字段过滤"的场景;一旦要按扩展字段查,提前建扩展表或生成列索引。

八、自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 什么场景适合纯推(写扩散)模型?为什么微信朋友圈可以用纯推,微博不行?
  2. 推拉结合下,一个 500 万粉大 V 发一条微博,系统内部做了什么?一个普通粉丝刷首页时又做了什么?
  3. Feed 事件用 map[string]any 传扩展字段,在 Go 里取数字为什么要用 .(float64) 断言?直接 . (int64) 会怎样?
  4. k6 压测时 req_blocked 不为 0、但服务端 CPU 很闲,说明什么?该怎么处理?
  5. 限流阈值为什么不能直接拍脑袋定?压测数据还能用来支撑哪些决策?

动手练习(建议真做一遍)

  1. 扩展一个新事件:给 Feed 加"被 @ 提醒"事件。要求:① 定义 type="mention"content 约定;② 实现 MentionHandler 并注册进 Service;③ 发一条 Kafka 消息触发;④ 查收件箱验证收到。体会"Feed 服务零修改接入”。
  2. 亲手压到拐点:用 k6 把本地 Feed 读接口从 --vus 10 阶梯加到 --vus 500,记录 P99 开始飙升的那个并发数,写成一句话结论。
  3. 优化思考题:针对"10W 数据混合读 P99 飙升",分别给出"加 Redis 缓存 / Kafka 异步化 / 批量接口"三种方案的预期收益与副作用,并排个优先级。

九、本章小结

  • Feed 流的核心矛盾是 “查询性能 vs 写入放大”:拉模型查询慢、推模型写入多、推拉结合是工程折中(没根治,只缩小痛点范围)。
  • 选型先看"单个生产者最多几个消费者":消费者有上限可推,可能百万则必须拉或推拉结合。
  • 存储上区分收件箱(推)/ 发件箱(拉),扩展字段用 JSON 换简单,但记住 JSON 不能高效 WHERE
  • 架构用 Service + Handler 分离公共与业务逻辑,新业务靠实现 Handler + 注册接入,核心零修改(开闭原则)。
  • 写入默认异步(Kafka),换来解耦、削峰、鲁棒性;实时性要求是秒级,异步足够。
  • 压测是后端基本功:k6 + Prometheus + Grafana 一套打通,数据用来定限流、降级、容量。读指标时盯 P99 和延迟分解,req_blocked 非 0 先怀疑压测机自己。
  • 面试核心口诀:读扩散查询慢,写扩散数据多,推拉结合只是缓解而非根治

下一章(第20章)我们将进入即时通讯 IM 服务,用 WebSocket 把"消息可靠投递、离线消息"这些更难的问题啃下来。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!