3.1 帖子模型:图文与版块设计

场景:九张图把表结构逼着改了两次

帖子模型看着简单,图文而已嘛。我们第一版只建了一张 t_post,九张图的 URL 用逗号拼在一个字段里,理由是反正读帖子时是一起读出来的。结果上线不到三周,产品要加单图编辑、要按首图分辨率裁剪封面、要支持图片中间插短视频,每个需求都要重拆那个长字符串。改到第三次我终于认输,把图片独立成 t_post_img,主帖只留摘要和封面。逗号分隔字段这种设计在原型期能省一小时,在迭代期会加倍偿还。

版块那边也有一笔账。最初版块只是 t_post 上的一个字段 section_id,运营自己维护版块列表存在一个 JSON 配置文件里。等版块要加权、要排序、要支持隐藏和合并时,JSON 配置彻底不够用,于是有了 t_section 表,承载权重、状态、排序这些运营高频调整的属性。内容模型就是这样,一开始以为自己多省了事,后面都要连本带利还回去。

json 配置被废弃的导火索是一次合并事故。运营想把同城的民生版块临时并进热点版块做专题,结果配置改错把两个版块的帖子混在一起,前后花了一小时才恢复,专题期间的流量统计也全乱了。这件事让大家意识到,凡是会被运营频繁修改的配置,必须走数据库、留痕迹校验、有回滚按钮,JSON 文件的第一属性是脆弱而不是方便。

改完之后我们做过一次复盘,结论是分层的判断标准只有一条:会不会单独变化。图片集合会单独变化,所以拆表;版块属性会单独变化,所以拆表;而标题、正文这些与帖子生命周期强绑定的,留在主表合理。判断标准简单,执行起来才坚决。

复盘会上还定了一个补充规则:凡是冗余字段,必须配一份对账脚本。comment_cnt、view_cnt 这些写多读多的冗余计数,天然的漂移来源是并发与回滚,对账脚本每晚跑一遍,把主帖计数强制校正为事实表的行数。冗余不可怕,不对账才可怕。

方案:主帖加详情加图片三表分离

最终落定的模型按读写场景拆成三张表:t_post 存列表页要的轻量字段,t_post_detail 存正文富文本,t_post_img 存九宫格图片。评论数、点赞数、浏览量这些计数冗余在主帖上,由计数服务异步对账。版块独立成 t_section,运营改排序权重不需要发版,缓存键按 section_id 做版本控制,改一次权重主动失效一次缓存。详情表与主帖分表的最大收益是列表页扫描行不变但每行体积小了一半,查询内存占用降了四成。

多提一句缓存键的设计。版块列表和帖子详情都是读多写少的数据,缓存键里带版本号,版本号由写操作递增,失效粒度从整版精确到单帖。这套键名规范后来贯穿全文:feed 前缀给信息流、post 前缀给详情、session 前缀给登录态,前缀即业务,排查时看键名就知道是哪个模块的责任田。

表 载荷 典型读取 生命周期
t_post 标题摘要计数 信息流列表 永久
t_post_detail 富文本正文 详情页 可冷归档
t_post_img 图片与序号 详情与九宫格 随主帖
t_section 版块权重状态 首页导航 低频改
t_draft 草稿 发帖页 七天清

草稿表是后来补的。前端最初把草稿存在小程序本地缓存里,用户换手机草稿全丢,连续被投诉两次才挪到服务端。草稿表只给 create 和 get 两个接口,保存动作放在离开页面时静默调用,七天后自动清理,体量和成本都很小。详情表拆分时还留了一个可归档的设计:半年前的详情允许整体搬到归档库,主库行数只增不减也能保持在一个可扫描的量级。

配置与代码

版块表是运营高频调整的入口,权重直接参与信息流排序,状态字段控制上下架。改权重等同于改线上的信息流,所以每次更新都留痕,方便回滚:

CREATE TABLE t_section (
  section_id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(24) NOT NULL,
  weight INT DEFAULT 0,
  status TINYINT DEFAULT 1,
  sort_order INT DEFAULT 0,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
    ON UPDATE CURRENT_TIMESTAMP,
  KEY idx_sort (status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

计数字段的更新全部走增量,绝不做整行覆盖,防止并发更新互相踩掉:

async function incrComment(post_id) {
  await mysql.query(
    'UPDATE t_post SET comment_cnt = comment_cnt + 1 WHERE post_id = ?',
    [post_id]);
}

版块权重变更之后要主动失效信息流缓存,否则新排序要等缓存自然过期才能生效,体感非常差:

async function touchSection(section_id) {
  await redis.del('feed:section:' + section_id);
  await redis.set('feed:ver:' + section_id, Date.now());
}

详情表与草稿表的结构同样简洁,一个承载正文,一个承载未完成的编辑状态:

CREATE TABLE t_post_detail (
  post_id BIGINT PRIMARY KEY,
  content MEDIUMTEXT NOT NULL,
  img_count INT DEFAULT 0,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
    ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE t_draft (
  draft_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  uid BIGINT NOT NULL,
  title VARCHAR(64) DEFAULT '',
  content TEXT,
  expire_at DATETIME NOT NULL,
  KEY idx_uid (uid, expire_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

草稿的过期用 expire_at 字段而非插入时间,方便用户继续编辑时顺延,定时任务只删过期行。详情表归档也由定时任务驱动:每周一次把半年前无新互动的详情批量搬走,主库保持轻快,归档数据仍在需要时可以按主帖 id 拉回。 ```

踩坑

第一个坑是正文与摘要的边界。原本列表页给 summary 字段填的是正文前一百四十字,富文本里混着图片标签,截出来的摘要像乱码,运营手动改摘要又嫌烦,最后定了一个折中规则:正文前一段纯文字自动兜底,允许运营手填覆盖。第二个坑是计数的对账漂移,comment_cnt 走增量更新,某次审核回滚操作只回了帖子状态忘回计数,导致前端显示评论数点进去对不上,后来加了每天凌晨的对账任务,以评论表实际行数为准校正主帖计数。第三个坑是软删除引发的统计问题,帖子标隐藏后,部分统计脚本仍把老快照算了进去,报表口径全面改成按状态过滤。第四个坑是封面图比例,九宫格首图有竖图有横图,列表页固定裁剪导致主体缺失,规范成上传时数据万象按中心智能裁剪并支持手动微调。第五个坑是草稿与发帖的冲突,同一用户在两台设备编辑草稿,后写的覆盖先写的且无提示,加了版本号字段后覆盖前会先提示。第六个坑是版块合并的数据迁移,某次运营把同城版块并进社区版块,帖子只改了 section_id 却没失效旧缓存,合并后的首页半小时内新旧混合,从此版块级操作全部走变更事务:改值、失效缓存、刷热榜,三步打包执行。第七个坑是富文本存储的字符编码,用户从其他 App 复制来的正文里混着 emoji,老配置的 utf8 编码直接报错,全表统一升级 utf8mb4 才彻底告别乱码。

小结

帖子模型的经验就一条:把会变的和不变的拆开。正文、列表字段、图片元数据各自独立,计数服务对账兜底。模型一旦分好层,后来加视频、加投票都没伤筋动骨。再强调一次那个简单的判断标准:会单独变化的数据单独建表,这句话值得贴在显示器上。表结构设计还有一层隐性收益:它决定了前端组件的形状,稳定的表结构意味着稳定的页面结构,前端的重构频率也随之下降。排序策略的具体玩法在下一节展开,信息流的设计同样受益于这套表结构,配合 高并发秒杀实战 的计数实践经验会更完整。

延伸阅读

补一句关于版本管理:表结构变更全部走迁移脚本而非手工执行,每次变更都有版本号和回滚语句,任意环境都能复现,这条习惯在迁移 MySQL 那次帮了大忙。