2.3 数据表设计:文章表与用户表

数据库选型敲定文档型之后,接下来的活是落地字段。这一步听着枯燥,实际是最值得花时间的一步:字段设计决定了后面每一行云函数代码的写法,也决定了查询能不能走索引。我们花了两个晚上反复改字段名,把过程里纠结过的每个决定都记下来,给同样起步的人当参考。

场景:两个集合撑起 MVP

图文分享最小闭环只需要两个集合:文章(posts)负责内容,用户(users)负责身份。评论、点赞记录这些等业务跑起来再拆。单表先跑通、随业务扩展的思路,在数据库选型一节里已经论证过,这里直接落地。

先看我们最终敲定的 posts 集合字段约定:

字段 类型 必填 说明
_id string 自动 文档 ID,列表详情用它查
title string 是 标题,最长 50 字,前端截断
cover string 否 封面图 COS 路径,空则用默认图
body string 是 Markdown 正文
tags string[] 否 标签数组,最多 5 个
authorId string 是 用户 _id,列表关联作者用
authorName string 是 冗余存一份,免得列表再查用户表
status int 是 0 草稿 1 已发布 2 审核中 3 违规
views int 否 浏览数,默认 0
likes int 否 点赞数,默认 0
createdAt int 是 创建时间,毫秒时间戳
updatedAt int 是 更新时间,毫秒时间戳

再看 users 集合,字段更少但身份相关:

字段 类型 必填 说明
_id string 自动 用户 ID
openid string 是 登录态唯一标识,建索引
nickname string 是 昵称,列表冗余字段来源
avatar string 否 头像 COS 路径
role int 是 0 普通用户 1 管理员
createdAt int 是 注册时间

方案:三个设计决定

决定一:反范式存作者姓名。 关系型思维会告诉你 authorId 加 JOIN 就够了,但文档型没有 JOIN,列表要显示作者名就得再查一次用户集合。一页十条文章就是十一次额外读取。我们在文章表里冗余存 authorName,用户改名时跑一个批量脚本同步。读取频率远高于改名频率,冗余是赚的。

决定二:status 用 int 不用 string。 草稿、发布、审核、违规四种状态用 0 到 3 表示,比较和筛选都是整数操作,占用小过滤快。早期我用过字符串状态值,后来加了审核功能发现要多一个状态,字符串容易出现笔误(published 少个 d 查不出来),统一改成 int 后世界清静了。审核功能的接入在后面的内容审核一节。

决定三:索引跟着查询走。 把列表页的查询条件列出来,再一对一建索引:

# 控制台或 CLI 建索引
tcb db create index posts --keys '{"status":1,"createdAt":-1}' --name idx_list
tcb db create index posts --keys '{"authorId":1,"createdAt":-1}' --name idx_author
tcb db create index users  --keys '{"openid":1}' --name idx_openid

列表查询是 status 等于 1 加 createdAt 倒序,所以复合索引把 status 放前、createdAt 逆序放后;个人主页是 authorId 加时间;登录链路按 openid 唯一查。建索引前先把查询写出来对照,是腾讯云实践里反复出现的习惯。

// 云函数里对应的列表查询
const res = await db.collection('posts')
  .where({ status: 1 })
  .orderBy('createdAt', 'desc')
  .limit(10)
  .get()

字段约定写完后,我们把它整理成项目根目录的一份 schema.md,集合建好后逐字段粘贴到控制台。这份文档后来成了新人入项目的第一份读物,比任何口头交接都清楚。

字段之外:两份示例文档

光看表格不够直观,贴两条真实的测试数据,建表后直接粘进控制台验证:

{
  "title": "巷口的银杏",
  "cover": "posts/2026/10/x.jpg",
  "body": "## 立秋后的第二条巷子\n银杏黄得突然 ...",
  "tags": ["摄影", "秋天"],
  "authorId": "u_1001",
  "authorName": "老周",
  "status": 1,
  "views": 128,
  "likes": 9,
  "createdAt": 1761292800000,
  "updatedAt": 1761292800000
}
{
  "openid": "oA1b2C3d4E5",
  "nickname": "老周",
  "avatar": "avatars/u_1001.jpg",
  "role": 0,
  "createdAt": 1761206400000
}

测试数据里故意留了几个边界情况:标签两个、views 非零、status 为 1。字段约定文档里要求的必填项齐全,可选字段 cover 也填了,这样联调时所有分支都能被走到。字段约定的完整推导过程在上一节数据库选型里有,这里只给结论。

索引怎么建才有效

索引不是越多越好。每多一个索引,写入就要多维护一份索引数据。我们的原则是只给出现在 where 条件里的字段建。posts 的查询场景和对应索引再细看一遍:

查询场景 where 条件 使用的索引
首页列表 status 等于 1 idx_list(status 加 createdAt)
个人主页 authorId 等于某人 idx_author(authorId 加 createdAt)
标签筛选页 tags 包含某标签 数组索引或全表兜底
详情页 _id 系统默认主键索引

标签筛选那一行比较特殊:数组字段的索引开销大、选择率低,我们标签集合规模小,干脆走全表扫描加缓存兜底,等数据量上来再回头优化。过早优化和过度设计是同一类错误。

踩坑与排查

坑一:_RESERVED 字段不知道。 一开始想用 id 做主键,写进去发现系统不认。文档型数据库的主键是 _id,系统字段还有 _openid、_createTime 等,业务字段一律避开下划线开头。吃了亏才把这条写进约定。

坑二:时间戳精度不统一。 有的地方用秒级时间戳(13 位存成 10 位),排序时年代久远的文章排到最新,体感非常诡异。统一成毫秒后还要注意 JS 大数问题:时间戳在 JSON 传输时别超过安全整数范围,老老实实用毫秒级完全够用。

坑三:冗余字段不同步。 有次用户改名后列表里还是旧昵称,看代码才发现只在用户表更新。补救办法是改名接口里同时更新 posts 的 authorName,加一个云函数批量修正存量数据。批量清洗脚本一定先在测试环境跑,别问我怎么知道的。

坑四:数组成员过多。 标签数量没限制时,有用户一次加了四十多个标签,文档体积暴涨。后来强制最多 5 个标签,列表页的标签筛选改用单独的标签集合计数。凡是会无限增长的数组,都要想清楚边界。

坑五:草稿与发布挤在一个集合里。 早期图省事,草稿也存 posts,靠 status 区分。结果列表查询忘了过滤草稿,首页冒出一堆半成品。后来列表查询统一走 status 为 1 的条件并写成公共函数,所有列表入口只调这一个函数,杜绝再次漏过滤。这个公共函数的完整实现见列表获取一节。

坑六:同一含义两套字段。 旧版本代码里有 pics 和 images 两个数组都存图片列表,历史数据两套都写。清洗脚本按时间切分回填,接口层统一读 images。字段一旦确定就别轻易改,要改必定连同清洗脚本一起提交。

小结

两个集合、十几个字段,撑起了整个项目的数据层。设计原则其实就三条:读写分离决定冗余(authorName),状态字段用 int 别用字符串,索引对着查询条件建。别怕设计得简单,MVP 阶段最怕的是过度设计——评论集合拆不拆、标签用不用独立集合,都可以等流量和业务形态清晰了再决定,这些扩展在本书评论与点赞一节有后续演进。

写完 schema 一定要落在文档里,集体约定大于个人记忆。字段喊时不觉得,两周后自己都会忘记 status 为 2 到底是审核中还是违规,这份约定就是最好的船锚。表建好之后,下一件事是给它上锁:谁能读、谁能写,这是集合权限那节的内容,也是最容易配出线上事故的一步。

延伸阅读