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 字段,一开始没设计,前端靠截取正文前一百四十字做摘要,图片混排的正文截出来全是乱码,最后还是老老实实在发帖时让运营或作者手填摘要。第五个坑发帖接口忘记做空字符串校验,有人发了只有标题的空帖还带样本数据刷接口,加了参数校验层后这类脏数据绝迹。
小结
需求拆解的关键不是列功能,而是把每条功能翻译成数据流向:谁写、谁读、缓存在哪、审核卡在哪一步。表结构跟着数据流走,接口跟着表结构走,后来运营又加投票、加合集、加话题,都只是在这套结构上挂钩子,没再推翻过设计。前置花在走访运营和客服上的两周,比后面任何技术选型都值钱,因为真正的需求藏在投诉记录和后台操作里。还有一个反直觉的经验:越是小的需求点越要问到底。比如点赞数实时这个需求,追问一句实时到什么程度,答案可能是允许几分钟延迟,那么一个批量同步任务就够了,根本不必引入昂贵的实时链路。需求拆解做到位,后面每个环节的复杂度都会自动下降一级。如果你也在做内容类小程序,建议先画数据流图再建表,能省掉一半返工。信息流排序的具体策略,可以参考 腾讯云综合实践 中的相关拆解。
延伸阅读
- 小程序商城实战:从选型到上线(同款小程序工程思路)
- PHP 接口实战:设计与防护(接口约定可互相印证)
- 云开发 Python 实战(我们迁移前的老方案)
