1.1 需求拆解:信息流、发帖、互动

场景:运营把需求拍在桌上

我们的小程序叫巷议,做本地资讯社区。上线第二个月,运营把同城三万条历史帖子过了一遍,甩给我一张需求清单:首页要按版块切换,每个版块排序权重不同;帖子支持图文混排,最多九张图;评论要楼中楼,最少两层;点赞数要实时,用户还要能看到自己帖子的浏览量。字数不多,但每一条背后都是表结构、接口和缓存的设计。团队四个人,后端只有我一个,表结构定错一版,前端、管理后台、统计脚本全都要跟着改,所以我不敢马上动手写代码。

更麻烦的是需求之间互相打架。信息流要按版块权重排序,但热门帖子又要求全网聚合一处;评论要树形,但管理后台又要求按时间扁平导出;点赞要实时,但 MySQL 扛不住每个点赞都写一条落库记录。这些矛盾不是产品故意刁难,而是内容类产品的天然形态,必须在拆解阶段就想清楚取舍,不然等开发到一半再改,连测试数据都要重造一遍。

意识到这点之后,我没有急着建表,而是花两周做了三件事:跟运营坐了一天看她怎么操作后台、翻了两百条客服投诉记录、把用户群里吐槽最多的五条截图贴了出来。走访的结果直接改掉了两个设计:帖子必须有隐藏而非删除的接口,因为客服违规处置需要留档可查;版块顺序必须运营自己在后台调,因为每周都在做专题。前者让我加了一张状态流转表,后者促成了 t_section 表的诞生。需求拆解到这一步,才算是从功能清单变成了可落地的设计输入。

方案:三张主表加两类接口

我的做法是先画数据流,再定接口。帖子从发帖接口进 MySQL,过审核模块后才允许进信息流;信息流按版块从 Redis 取缓存,缓存未命中再回源数据库;图片不经过后端服务器,小程序直传 COS,成功后把对象 Key 回调给后端落库;评论独立成表,靠 parent_id 拼树形结构;点赞先用 Redis 的 Set 计数,定时段批量同步回 MySQL。需求清单还是那张清单,但每一条都已经挂到具体的数据流上,谁写谁读、缓存放哪、审核卡在哪一步,一目了然。

写路径只有一条:发帖接口写 t_post,图片 Key 写 t_post_img,审核模块异步处理状态流转。读路径分成三类:信息流按版块分页拉、评论按树形拉、用户中心按 uid 拉。读写拆开之后,索引建在哪、缓存设多长过期、哪些字段允许冗余计数,全都有明确答案。这套拆解迭代过两次:第一次是加了合集功能,把 t_post 与合集的关系拆成多对多的关联表;第二次是短视频上线,图片表扩出 media_type 字段兼容视频封面。好设计是改出来的,但骨架一直没动。

下表是拆解后的对应关系,后来需求扩到二十多个接口,也只是在下面这张表里加行:

模块 核心表 缓存 接口 设计要点
发帖 t_post 不缓存 POST /api/post 先审后发
信息流 t_post Redis zset GET /api/feed 按版块缓存
详情 t_post_detail Redis string GET /api/post/:id 五分钟过期
评论 t_comment 不缓存 GET /api/comment parent_id 树形
点赞 t_like_log Redis set POST /api/like Set 去重
浏览量 t_post Redis counter GET 时累加 批量回写

表格里最值得多说一句的是详情缓存。信息流接口哪怕回源,扫的也只是索引上的轻量列,但详情页要把正文富文本整体拉出来,一次查询动辄几十 KB,所以详情缓存是全站唯一设置五分钟过期、并且允许读时异步续期的模块。后来正文里加了投票组件,投票结果和正文一起缓存会让票数延迟五分钟感知,我们又把投票结果单独拆出来走独立计数。缓存设计永远要跟着内容形态走,不能一把梭。

配置与代码

帖子主表只存列表页需要的字段,正文单独拆一张表,避免列表查询把大字段也捞出来拖慢响应。计数全部冗余在主帖上,由计数服务异步对账保证最终一致:

CREATE TABLE t_post (
  post_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  uid BIGINT NOT NULL,
  section_id INT NOT NULL,
  title VARCHAR(64) NOT NULL,
  summary VARCHAR(140) DEFAULT '',
  cover_url VARCHAR(255) DEFAULT '',
  status TINYINT DEFAULT 0,
  like_cnt INT DEFAULT 0,
  comment_cnt INT DEFAULT 0,
  view_cnt INT DEFAULT 0,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  KEY idx_section (section_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

idx_section 这个联合索引是踩坑之后补的。第一版只建了 section_id 单列索引,列表页按版块分页时要回表再过滤 status,晚高峰慢查询告警一天十几条,改成联合索引后扫描行数从三万降到两百出头。九宫格图片同样独立建表,更新其中一张图不影响主帖,也方便后续换成视频封面:

CREATE TABLE t_post_img (
  img_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  post_id BIGINT NOT NULL,
  img_url VARCHAR(255) NOT NULL,
  img_order TINYINT DEFAULT 0,
  media_type TINYINT DEFAULT 0,
  KEY idx_post (post_id, img_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

发帖接口的骨架因为审核解耦变得很干净:写入即返回草稿态,正文审核异步进行,通过后由状态机推动到可见:

router.post('/api/post', async ctx => {
  const { title, summary, imgs } = ctx.request.body;
  const post_id = await postModel.create({
    uid: ctx.state.uid, title, summary, status: 0
  });
  await postImgModel.batchCreate(post_id, imgs);
  await mq.push('audit', post_id);   // 异步审核
  ctx.state.data = { post_id };
});

改完之后的发帖接口,从用户点发布到拿到响应,平均耗时一百二十毫秒,其中数据库写入只占三十毫秒,审核全程异步不阻塞主流程。

踩坑

第一个坑是浏览量。产品要实时数字,我最早直接在详情接口里执行 UPDATE t_post SET view_cnt = view_cnt + 1,看一次写一次库,晚高峰 MySQL 的 CPU 飙到百分之八十,行锁把详情接口耗时拖到八百毫秒。改成 Redis 计数 INCR view:post:{post_id},再每五分钟批量回写,CPU 降到百分之二十,接口耗时回到八十毫秒以内。第二个坑是草稿箱,前端一开始把草稿存在小程序本地 storage,用户换手机草稿全丢,连着被投诉两次,我们才在服务端补了 t_draft 表,只给 create 和 get 两个接口。第三个坑是九张图的上限,我最早按九张图正好建了九个字段,后来产品说图片里要混排短视频封面,九个字段根本没法扩展,老老实实拆成图片表才彻底解决。第四个坑是 summary 字段,一开始没设计,前端靠截取正文前一百四十字做摘要,图片混排的正文截出来全是乱码,最后还是老老实实在发帖时让运营或作者手填摘要。第五个坑发帖接口忘记做空字符串校验,有人发了只有标题的空帖还带样本数据刷接口,加了参数校验层后这类脏数据绝迹。

小结

需求拆解的关键不是列功能,而是把每条功能翻译成数据流向:谁写、谁读、缓存在哪、审核卡在哪一步。表结构跟着数据流走,接口跟着表结构走,后来运营又加投票、加合集、加话题,都只是在这套结构上挂钩子,没再推翻过设计。前置花在走访运营和客服上的两周,比后面任何技术选型都值钱,因为真正的需求藏在投诉记录和后台操作里。还有一个反直觉的经验:越是小的需求点越要问到底。比如点赞数实时这个需求,追问一句实时到什么程度,答案可能是允许几分钟延迟,那么一个批量同步任务就够了,根本不必引入昂贵的实时链路。需求拆解做到位,后面每个环节的复杂度都会自动下降一级。如果你也在做内容类小程序,建议先画数据流图再建表,能省掉一半返工。信息流排序的具体策略,可以参考 腾讯云综合实践 中的相关拆解。

延伸阅读