本附录把卷二各章出现的表整理在一起,方便读者对照代码与设计自己的库表。字段命名与正文一致,类型以常见关系型数据库为参照;实际项目里字符串长度、时间精度、分区策略需要按规模调整。
先说明三条通用约定:
- 所有表都有
created_at与updated_at,下文不再重复列出。 - 主键统一用
id(字符串,应用侧生成),需要自增序号时单独加seq字段。 - 软删除统一用
deleted_at(可空时间),不用布尔字段,避免「删除时间」丢失。
内容主表
内容主表只存「内容是什么」与「内容处于什么状态」,不存审核详情、不存统计数、不存正文大字段。
| 字段 | 类型 | 说明 | 写入方 |
|---|---|---|---|
id | varchar(32) | 内容 ID,应用生成 | 内容服务 |
author_id | varchar(32) | 作者用户 ID | 内容服务 |
kind | varchar(16) | text / image / video / mixed | 内容服务 |
title | varchar(200) | 标题,可空 | 内容服务 |
body | text | 短文本正文,长正文另存 | 内容服务 |
state | varchar(16) | draft / reviewing / published / rejected / deleted | 内容服务 |
visibility | varchar(16) | public / friends / private | 内容服务 |
published_at | bigint | 发布时间,未发布为空 | 内容服务 |
deleted_at | bigint | 软删除时间 | 内容服务 |
version | bigint | 乐观锁版本号 | 内容服务 |
索引建议:
(author_id, state, published_at desc):作者主页与「我的内容」。(state, published_at desc):运营后台与审核队列。(id, version):更新时校验版本,避免并发覆盖。
正文与状态放在同一张表,是因为它们总是一起被读取;但大字段(长文、富文本)建议拆到 content_body,避免列表查询把大字段带进缓冲池。
媒体表
一条内容可以挂多个媒体,媒体表与内容表是一对多。
| 字段 | 类型 | 说明 |
|---|---|---|
id | varchar(32) | 媒体 ID |
content_id | varchar(32) | 所属内容 |
media_kind | varchar(16) | image / video / audio |
object_key | varchar(255) | 对象存储键 |
width / height | int | 图片或视频尺寸 |
duration_ms | int | 视频时长,图片为 0 |
size_bytes | bigint | 文件大小 |
status | varchar(16) | uploading / transcode / ready / failed |
sort_order | int | 在内容中的顺序 |
索引:(content_id, sort_order)。对象存储的键不建议直接暴露给客户端,读接口返回签名 URL 或 CDN 地址。
status 与内容状态是两条独立的状态机:内容可以先发布,媒体后转码完成。读接口要能处理「内容已发布、媒体还没 ready」的情况,通常返回占位图而不是报错。
内容状态迁移
状态迁移用一张表约束,而不是散落在各个接口的 if 分支里。
| 从 | 到 | 触发者 | 条件 |
|---|---|---|---|
| draft | reviewing | 作者提交 | 元数据校验通过 |
| reviewing | published | 审核服务 | 机审通过或人审通过 |
| reviewing | rejected | 审核服务 | 机审拒绝或人审拒绝 |
| reviewing | draft | 作者撤回 | 尚未产生审核结论 |
| published | deleted | 作者或管理员 | 有权限 |
| rejected | draft | 作者修改 | 在申诉时限内 |
| deleted | published | 管理员恢复 | 有恢复权限,且未超过保留期 |
这张表是代码里 Allowed(Transition) 的权威来源。新增状态时先改表,再改代码;反过来做,迟早会出现「代码允许、产品不认」的迁移。
几个容易漏的约束:
rejected不能直接到published,必须经过作者修改再重新提交。deleted到published只允许管理员操作,且要有审计记录。reviewing到draft只允许在审核结论产生前,否则会出现「审核通过但内容被撤回」的竞态。
审核记录表
审核记录与内容是一对多:一次内容可能经历多轮机审、人审、复审。
| 字段 | 类型 | 说明 |
|---|---|---|
id | varchar(32) | 审核记录 ID |
content_id | varchar(32) | 被审核内容 |
round | int | 第几轮审核 |
stage | varchar(16) | machine / human / appeal |
decision | varchar(16) | approve / reject / escalate |
confidence | decimal(5,4) | 机审置信度,人审为空 |
reason_code | varchar(32) | 拒绝原因码 |
reviewer_id | varchar(32) | 人审员,机审为空 |
decided_at | bigint | 结论时间 |
索引:(content_id, round)、(reviewer_id, decided_at)。
审核记录只追加、不更新。需要修正结论时新增一条记录,而不是改旧记录;否则无法回答「当时为什么这么判」。
关系表
社交关系用一张表存三种关系,通过 relation 区分,而不是三张表。
| 字段 | 类型 | 说明 |
|---|---|---|
from_id | varchar(32) | 关系发起方 |
to_id | varchar(32) | 关系目标方 |
relation | varchar(16) | follow / friend / block / mute |
state | varchar(16) | active / pending / removed |
created_at | bigint | 建立时间 |
主键:(from_id, to_id, relation)。索引:(to_id, relation, state) 用于查粉丝与好友。
几点设计说明:
- 关注是单向记录,好友是两条记录(A→B 与 B→A 都是 friend),拉黑是一条记录且优先级最高。
mute与block不同:mute 只影响推荐与通知,不影响可见性;block 影响可见性与互动。- 关系解除用
state=removed,保留历史;但查询时必须过滤,否则会出现「删了好友还能看到」。
关系判定链的顺序必须是 block → 内容可见性 → 好友/关注 → 场景规则。顺序换一下,就会出现「拉黑的人通过关注关系绕过」。
评论表
评论是树,但存储用邻接表加物化路径,避免递归查询。
| 字段 | 类型 | 说明 |
|---|---|---|
id | varchar(32) | 评论 ID |
content_id | varchar(32) | 所属内容 |
author_id | varchar(32) | 评论者 |
parent_id | varchar(32) | 父评论,顶级为空 |
root_id | varchar(32) | 所属根评论 |
depth | int | 层级,顶级为 1 |
body | text | 评论正文 |
state | varchar(16) | visible / folded / deleted |
like_count | int | 冗余计数 |
索引:(content_id, root_id, created_at)、(parent_id, created_at)。
depth 在写入时计算并固化,读取时不再递归。超过 maxDepth 的回复挂到父级下并标记「展开更多」,而不是拒绝写入。
like_count 是冗余字段,允许短暂不一致;真相在互动关系表,计数由异步任务对账。
互动关系表
点赞、收藏、关注内容等「用户对目标的态度」统一存成关系表。
| 字段 | 类型 | 说明 |
|---|---|---|
user_id | varchar(32) | 操作者 |
target_type | varchar(16) | content / comment / user |
target_id | varchar(32) | 目标 ID |
action | varchar(16) | like / favorite / hide |
created_at | bigint | 操作时间 |
主键:(user_id, target_type, target_id, action)。索引:(target_type, target_id, action) 用于统计与「谁赞过」。
这张表是计数的真相来源。缓存里的计数、内容表上的冗余计数都是派生值,对账任务以这张表为准。
计数表
计数表按「对象 + 指标」存物化结果,可选按天分桶。
| 字段 | 类型 | 说明 |
|---|---|---|
target_type | varchar(16) | content / comment / user |
target_id | varchar(32) | 目标 ID |
metric | varchar(16) | like / comment / view / share |
value | bigint | 当前值 |
version | bigint | 对账版本 |
主键:(target_type, target_id, metric)。
高频写入的计数(如浏览量)建议先写 Redis 或本地聚合,再批量落库;低频但要求准确的计数(如收藏数)可以同步更新。两类计数不要放在同一张表同一套更新路径里,否则要么慢,要么不准。
会话与消息表
IM 的存储按会话分片,消息表以 conversation_id 为分区键。
会话表:
| 字段 | 类型 | 说明 |
|---|---|---|
id | varchar(64) | 会话 ID |
type | varchar(16) | single / group / system |
member_hash | varchar(64) | 单聊去重键 |
max_members | int | 群成员上限 |
last_seq | bigint | 会话最新序号 |
last_message_at | bigint | 最后一条消息时间 |
消息表:
| 字段 | 类型 | 说明 |
|---|---|---|
conversation_id | varchar(64) | 会话 ID |
seq | bigint | 会话内单调序号 |
msg_id | varchar(64) | 客户端或服务端生成的消息 ID |
sender_id | varchar(32) | 发送者 |
kind | varchar(16) | text / image / system |
body | text | 消息内容 |
sent_at | bigint | 发送时间 |
recalled_at | bigint | 撤回时间,可空 |
主键:(conversation_id, seq);唯一索引:(conversation_id, msg_id) 用于去重。
seq 由会话服务在写入时分配,保证会话内单调;跨会话不要求有序。客户端重发时带着原 msg_id,服务端命中唯一索引后返回原 seq,而不是插入新消息。
已读表
已读状态按「用户 + 会话 + 设备」存,读取时取最大值。
| 字段 | 类型 | 说明 |
|---|---|---|
user_id | varchar(32) | 用户 |
conversation_id | varchar(64) | 会话 |
device_id | varchar(64) | 设备 |
read_seq | bigint | 已读到的最大序号 |
updated_at | bigint | 更新时间 |
主键:(user_id, conversation_id, device_id)。
未读数 = last_seq - max(read_seq),下限为 0。多端已读取最大值,而不是求和;求和会出现「手机读了、电脑没读,未读变成负数或翻倍」。
已读表更新非常频繁,建议只保留最近活跃设备,历史设备定期清理。
搜索索引表
搜索索引是独立的读模型,字段与主表不同。
| 字段 | 类型 | 说明 |
|---|---|---|
doc_id | varchar(32) | 内容 ID |
title | varchar(200) | 标题 |
body | text | 可搜索正文 |
author_id | varchar(32) | 作者 |
state | varchar(16) | 内容状态快照 |
visibility | varchar(16) | 可见性快照 |
version | bigint | 内容版本,用于幂等 |
indexed_at | bigint | 索引时间 |
deleted | boolean | 墓碑标记 |
索引同步用版本号做幂等:只有 version 更大的变更才应用。删除时写墓碑,而不是物理删除,避免旧消息晚到后把文档「复活」。
搜索结果的可见性过滤必须在查询阶段完成,不能只依赖索引里的快照字段;因为用户关系可能已经变化,而索引还没更新。
哪些字段可以冗余
冗余字段能减少一次查询,但会引入一致性问题。下面这张表给出卷二涉及字段的默认建议。
| 字段 | 是否冗余 | 一致性要求 | 对账方式 |
|---|---|---|---|
内容 like_count | 是 | 最终一致 | 定时按关系表重算 |
评论 like_count | 是 | 最终一致 | 同上 |
会话 last_seq | 是 | 强一致(同会话内) | 写入时同事务更新 |
会话 last_message_at | 是 | 最终一致 | 消息写入后更新 |
搜索 state | 是 | 最终一致 | 版本号幂等 |
| Feed 收件箱条目 | 是 | 最终一致 | 以内容表为准 |
| 未读数 | 否(派生) | 读时计算 | 不需要对账 |
判断标准很简单:如果这个字段错了,用户会不会立刻发现?会,就不能只靠异步;不会,就可以冗余加对账。
常见建模错误
最后列几个在评审中反复出现的建模错误,读者可以拿来对照自己的库表。
- 用布尔字段表达多状态。
is_published、is_deleted、is_reviewing三个布尔可以组合出八种状态,其中五种是非法的。用枚举加迁移表。 - 把关系存成一张无类型的边表。
(from, to)不区分关注、好友、拉黑,查询时靠业务代码猜。加relation字段。 - 把已读存成未读数。 未读数是派生值,多端同步时无法合并。存已读序号。
- 删除时物理删除。 索引、缓存、Feed 都还在引用,物理删除会让它们返回 404 或旧数据。用软删除加墓碑。
- 把搜索索引当主库查。 索引有滞后,且字段不全;详情页必须回主库。
- 消息表用全局自增 ID 当序号。 会话内顺序会跳号,客户端无法判断「有没有漏消息」。用会话内序号。
这些错误的共同点是:它们在低并发、单端、短链路的测试里都不会暴露。只有在多端、重试、删除、审核同时发生时才会出现。设计时多问一句「这个字段在并发和重试下还成立吗」,能省下很多线上排查。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。