社交产品最容易被低估的模块,往往是“发一条帖子”。在原型阶段,一张 posts 表似乎已经足够:作者、正文、创建时间,再加一个可空的 parent_id,回复和转发就都有了。但当产品继续生长,问题会成倍出现:
- 回复的是根帖子,还是某条直接父评论?
- 转发卡片顶部显示转发者,还是原作者?
- 引用和纯转发能否共用一套计数与权限?
- 原内容被删除或转为私密后,历史对话是否还能理解?
- 同一内容出现在首页、搜索、收藏夹和通知中时,谁负责保证展示一致?
- 媒体尚未处理完成时,发布动作是失败、等待,还是先发文本?
这些并不是 UI 文案问题,而是领域模型没有把“内容本体、关系语义和展示上下文”分开。本文给出一套可实施的设计:使用统一的 Post 聚合承载可独立展示、可计数、可检索的内容节点;使用明确的关系字段表达回复、引用和转发;以 PostgreSQL 保存权威事实,以事件驱动方式构建 MongoDB 读模型,并在读取时执行最终权限门禁。
本文讨论的是可迁移的设计方法。示例技术栈用于说明边界、约束和恢复能力,不构成对数据库或消息系统的强制选择。
适用范围与规模假设
本文面向支持公开与受限内容、嵌套回复、引用、转发、媒体和动态权限的社交产品。假设写入需要事务一致性,首页和详情属于高频读取,并允许读模型存在秒级最终一致。若产品只有单层留言、没有独立评论详情,也不需要搜索和传播评论,可以采用更轻量的独立 Comment 表,不必完整套用统一 Post 聚合。
一、先确定设计目标与非目标
内容系统至少要满足六个目标。
- 语义无歧义:看到一条记录即可判断它是原创、回复、引用还是转发,并知道直接目标和对话根节点。
- 作者身份正确:卡片主作者始终是“执行当前动作的人”;被引用或被转发内容作为嵌套来源展示,不能覆盖动作主体。
- 关系可恢复:父内容删除、隐藏或权限收紧时,关系仍然存在,并能输出稳定占位,而不是把整条时间线截断。
- 写入强约束、读取高吞吐:关系合法性和计数在权威事务中确认,高频卡片读取走专门读模型。
- 重复消息安全:事件、重试和网络超时不能制造重复转发、重复计数或旧投影覆盖新投影。
- 可演进:以后增加投票、长文、直播卡片或社区帖子时,不需要推翻回复与转发的基础语义。
非目标同样重要:读模型不负责决定最终权限;消息总线不是数据库;“前端拼装正确”不能替代后端关系约束;所有场景也不必共享一个无限膨胀的 DTO。
二、统一 Post,并不等于把所有关系塞进一个 parent_id
推荐把可发布内容统一成四种类型:
type PostKind = "ORIGINAL" | "REPLY" | "QUOTE" | "REPOST";
type PostRelation = {
replyToPostId: string | null;
rootPostId: string | null;
quoteOfPostId: string | null;
repostOfPostId: string | null;
};
统一模型的价值是:四类内容都可以拥有稳定 ID、作者、生命周期、权限、媒体、话题、提及、计数和事件流。它们可以进入收藏、搜索、通知和个人主页,也可以作为其他关系的来源。
但统一模型必须搭配关系矩阵约束:
| kind | replyToPostId | rootPostId | quoteOfPostId | repostOfPostId | 是否允许正文 |
|---|---|---|---|---|---|
| ORIGINAL | 空 | 空 | 空 | 空 | 是 |
| REPLY | 必填 | 必填 | 空 | 空 | 是 |
| QUOTE | 空 | 空 | 必填 | 空 | 是,通常必填 |
| REPOST | 空 | 空 | 空 | 必填 | 通常否 |
数据库约束与服务层校验都应落实这张矩阵,并禁止自引用。否则一条记录可能同时“回复 A、引用 B、转发 C”,下游永远无法确定该用哪种卡片、哪个计数和哪条权限规则。
为什么回复需要两个 ID
假设帖子 P1 下有评论 C1,用户又回复了 C1 形成 C2:
C2.replyToPostId = C1:记录直接回复对象,用于显示“回复了 @handle”和构建局部对话。C2.rootPostId = P1:记录整个对话所在的根帖子,用于详情页、计数、权限和分页。
只有 parent_id 时,查询根节点需要递归;只有 root_id 时,又丢失直接回复语义。两者同时保存是一种有意的冗余,但必须在事务内校验:直接父节点存在、已发布、可回复,且其规范根节点与 rootPostId 一致。
为什么转发者不能被来源作者覆盖
转发卡片实际上包含两层身份:
- 动作主体:谁执行了转发,决定这条记录出现在谁的个人主页、谁可删除它、顶部显示“谁转发了”。
- 内容来源:原帖作者、正文与媒体,作为嵌套来源卡片展示。
因此聚合 DTO 应显式区分:
type RepostCard = {
postId: string; // 转发记录 ID
actor: UserCard; // 转发者
kind: "REPOST";
source: {
postId: string;
author: UserCard; // 原作者
bodyText: string | null;
media: MediaCard[];
} | Tombstone;
};
如果适配器用 source.author 覆盖顶层 actor,就会出现“明明是我转发,卡片却像原作者自己发了一遍”的错误。模型层分开后,前端不需要猜。
三、权威写模型与卡片读模型分工

一种通用实现可以把 PostgreSQL 作为 owner truth,把 MongoDB 作为卡片投影,把 Redis 用于锁、幂等和热点缓存:
- PostgreSQL:Post、关系字段、媒体绑定、提及、话题、生命周期、权限快照和 outbox。
- Outbox + Kafka:在同一事务记录领域事实,提交后再可靠发布,避免“数据库成功但消息丢失”。
- 投影 Worker:收到
PostPublished、PostDeleted等事件后,不直接相信消息中的整份卡片,而是按postId回读权威数据。 - MongoDB:保存适合时间线、搜索结果和详情页首屏读取的文档。
- Redis:投影重建锁、列表缓存、幂等键及短期状态。
这一设计看似比“双写 PostgreSQL 和 MongoDB”多了一层,但它提供了关键恢复能力:投影可以删除后重建;事件可以重放;某次失败只造成短暂延迟,不会让两个数据库永久分叉。
一个可读的投影文档可以是:
{
"schemaVersion": 2,
"postId": "p_01J...",
"authorUserId": "u_01J...",
"kind": "REPLY",
"bodyText": "我更关心失败恢复策略。",
"replyToPostId": "p_parent",
"rootPostId": "p_root",
"quoteOfPostId": null,
"repostOfPostId": null,
"media": [],
"mentions": [{ "userId": "u_target", "handle": "demo_yunshu" }],
"hashtags": ["系统设计"],
"visibility": "PUBLIC",
"status": "PUBLISHED",
"readVersion": "42",
"publishedAt": "2026-08-07T10:00:00.000Z"
}
schemaVersion 解决投影结构演进,readVersion 解决乱序事件:MongoDB 只接受不旧于当前版本的更新。即使 PostPublished(v42) 比 PostEdited(v43) 晚到,也不能用 v42 覆盖 v43。
四、发布不是一次 INSERT,而是一条状态机
一个包含媒体、提及和社区权限的帖子,发布流程可分为:
DRAFT
└─ 请求发布 → VALIDATING
├─ 媒体未就绪 → WAITING_MEDIA
├─ 权限或关系失败 → PUBLISH_FAILED
└─ 校验通过 → PUBLISHED
└─ 删除 → DELETED
发布服务应完成以下检查:
- 请求者是草稿所有者,幂等键未被其他语义占用。
- 正文、媒体或链接至少存在一项有效内容。
- kind 与关系矩阵一致,来源不是自身。
- 回复根帖、直接父回复、引用来源或转发来源仍然存在且当前可操作。
- 提及目标可被提及;社区上下文、可见性和评论/引用/转发权限有效。
- 所有绑定媒体已进入
READY;未就绪时进入等待发布,而不是生成半成品卡片。 - 在事务内写入生命周期变化、关系、必要计数和 outbox。

关键点是最终门禁。预检能尽快向用户返回友好错误,但从预检到事务提交之间,来源帖子可能被删除、可见性可能改变。因此事务内必须再次回读权威简报,核对作者、状态、可见性、社区和版本等稳定字段。预检用于体验,最终门禁用于正确性。
幂等键应该保护“业务动作”
客户端超时后通常会重试。服务端不能以“请求是否重复到达”作为判断,而应绑定:
(actorUserId, operation, idempotencyKey) -> semantic fingerprint + result
同一个 key、同一个语义返回首次结果;同一个 key、不同语义返回冲突。纯转发还可以对 (actorUserId, repostOfPostId, active) 建唯一约束,避免同一用户产生多个有效转发记录。
五、详情 DTO 应表达“可用、不可用和为什么不可用”
关联内容读取存在三种状态:
- ACTIVE:目标存在、对当前 viewer 可见,返回完整卡片。
- DELETED:目标已删除,返回“原内容已删除”的墓碑占位。
- HIDDEN / UNAVAILABLE:目标存在但当前用户无权查看,返回中性占位。
不要把后两种都映射成“用户不存在”。目标用户、目标帖子和直接父评论是三个不同实体,失败原因也完全不同。推荐类型:
type RelationView<T> =
| { state: "ACTIVE"; card: T }
| { state: "DELETED"; reason: "SOURCE_DELETED" }
| { state: "HIDDEN"; reason: "NOT_VISIBLE" | "BLOCKED" };
回复详情应返回两段上下文:根帖卡片和当前回复卡片;如果回复的是评论,再额外返回直接父评论卡片。这样既能让读者知道“讨论发生在哪”,也能知道“这句话直接回应了谁”。顶栏文案应使用已解析的父作者 handle:回复了 @demo_yunshu。只有父对象确实不可用时才显示占位。
六、计数、删除与级联边界
评论数、引用数和转发数不是简单的 COUNT(*) 替代品。它们是高频读取的派生数据,应明确口径:
- 根帖评论数是否包含所有层级回复?通常包含。
- 一条评论的回复数是否只包含直接子回复?通常是。
- 删除后是立即减计数,还是保留审计占位?产品需先定语义。
- 重复事件和回放是否会重复增减?必须用事件 ID 或关系唯一键幂等。
删除推荐采用“生命周期删除 + 关系保留”:
- 作者删除正文后,Post 进入
DELETED,正文和敏感媒体不再对外输出。 - 回复树中的关系节点仍可返回墓碑,让子回复不失去上下文。
PostDeleted事件触发投影删除或墓碑更新、缓存失效、搜索移除和信息流候选失效。- 不在同步请求中递归删除所有回复,否则大对话会拖垮事务,也会破坏其他作者的内容所有权。
七、接口与游标设计
接口按用途拆分,比“一个万能帖子接口”更稳定:
POST /api/posts 创建或发布内容
GET /api/posts/:postId 获取详情及关系上下文
GET /api/posts/:postId/replies 获取根帖回复分页
GET /api/posts/:postId/quotes 获取引用列表
GET /api/posts/:postId/reposts 获取转发者/转发列表
DELETE /api/posts/:postId 生命周期删除
回复分页使用不透明游标,至少编码排序时间、稳定 ID 和查询版本。服务端返回:
{
"items": [],
"nextCursor": "opaque-base64url-token",
"hasMore": true,
"snapshotAt": "2026-08-07T10:00:00.000Z"
}
游标不能只使用时间戳,同一毫秒内多条记录会重复或遗漏;也不应把数据库偏移量直接暴露给客户端,因为内容删除后偏移会漂移。
八、失败恢复与可观测性
内容系统最需要监控的不是“接口平均耗时”一个数字,而是每条链路的业务不变量。
| 风险 | 保护措施 | 观测指标 |
|---|---|---|
| outbox 未发布 | publisher 重试、租约与死信 | outbox oldest age、retry count |
| 投影乱序 | readVersion 条件更新 | stale ignored count |
| 投影缺失 | 按 postId 权威重建、批量回填 | PG/Mongo 差异率 |
| 缓存陈旧 | 投影成功后失效;TTL 兜底 | cache stale sample |
| 重复转发 | 业务唯一约束、幂等结果缓存 | conflict/dedupe count |
| 关系断裂 | 事务内 root/parent 一致性校验 | relation invariant violations |
| 来源不可见 | 读取最终权限门禁 | hydration filtered count |
恢复工具至少应支持:按单个 postId 重建、按时间窗口回填、全量重建新版本投影、比对权威表与读模型、隔离无法解析的坏事件。修复动作本身也要有审计记录,避免“为了回填”绕过正式链路。
九、测试应围绕不变量,而不是只测成功响应
建议建立四层测试:
- 关系矩阵单元测试:四种 kind 的合法/非法字段组合、自引用、根与父不一致。
- 事务集成测试:发布、重复请求、媒体等待、最终门禁漂移、计数与 outbox 原子性。
- 投影契约测试:旧版本忽略、删除后移除、缺失权威记录、重建幂等。
- 端到端展示测试:转发者与原作者不混淆;回复显示直接 handle;父评论被删时显示占位;卡片可进入正确详情。
尤其要写一个看似简单但极有价值的测试:REPOST 顶层作者必须等于动作执行者,嵌套来源作者必须等于原帖作者。这个断言能阻止适配器在重构时再次把两层身份合并。
十、方案权衡
统一 Post vs 独立 Comment 表:统一 Post 让回复天然获得搜索、收藏、媒体和通知能力,也简化卡片复用;代价是关系约束更严格、表规模更大。若评论永远只是轻量文本且不可独立传播,独立表会更简单;对现代社交产品,统一模型通常更有延展性。
同步组装 vs 异步投影:同步读取权威表一致性直观,但首页一张卡片会触发多表关联,吞吐和尾延迟难以控制;异步投影读取快、可水平扩展,但必须接受最终一致并建设重建工具。
物理删除 vs 墓碑:物理删除节省存储,却会破坏对话结构和审计;墓碑保留关系、保护上下文,但要明确隐私擦除边界。实践中通常是业务层墓碑、敏感字段清除、合规层另行处理。
结语
内容系统的核心不是“存下正文”,而是长期维护一组可验证的语义:谁执行了动作、动作指向什么、对话根在哪里、当前是否可见、来源不可用时如何降级、派生卡片如何从权威事实重建。
只要这些语义在模型和事务中被明确表达,前端就不需要靠文案猜关系,搜索和通知也不必各自发明一套帖子解释。反过来,如果底层只留下一个含糊的 parent_id,再精致的卡片也会在转发、回复和删除场景中不断暴露裂缝。