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 到底是审核中还是违规,这份约定就是最好的船锚。表建好之后,下一件事是给它上锁:谁能读、谁能写,这是集合权限那节的内容,也是最容易配出线上事故的一步。
延伸阅读
- Python 云开发实战目录:本书章节索引与字段约定上下文
- 腾讯云实践:其他项目的数据建模与索引实战
