面向复杂读场景的社交平台数据架构:事实表、投影、缓存与事件总线

一个社交产品往往同时拥有以下读场景:

  • 帖子详情需要关系上下文、作者、媒体、权限和计数;
  • 首页需要按用户定制的候选列表,并在几十毫秒内返回一页卡片;
  • 搜索需要分词、相关度排序、handle 精确匹配和删除及时性;
  • 通知需要不可重复的收件箱、未读计数、实时增量和可点击目标;
  • 内容中心、收藏夹和浏览历史又有各自的筛选、排序和占位语义。

如果所有请求都从同一套规范化关系表实时联查,系统在功能上可能正确,但延迟、连接数和查询复杂度会迅速失控。如果业务服务同时直接写 PostgreSQL、MongoDB、Redis 和搜索索引,一次网络抖动就可能留下永久分叉。

真正的问题不是“应该选择 SQL 还是 NoSQL”,而是如何定义四种角色:权威事实在哪里,面向场景的投影在哪里,短暂状态在哪里,变更如何可靠传播

本文设计一套可重建的多存储参考架构:PostgreSQL 负责 owner truth 和事务,MongoDB 负责场景化读模型,Redis 负责缓存、锁、去重和游标状态,Kafka 负责跨边界传播事件;Transactional Outbox 把数据库提交与消息发布之间的裂缝收拢到可恢复范围。具体产品可以替换其中任一技术组件,但不能省略对应职责。

适用范围与规模假设

该方案适用于读场景明显多样、异步任务较多、单次业务写入会影响搜索、信息流和通知等多个下游的系统。它假设团队能够运维消息积压、投影重建和多存储监控。若系统规模仍小、查询形状单一,应先使用 PostgreSQL 加少量 Redis,并保留 outbox 接口;过早引入四套基础设施只会增加故障面。

一、先给每种存储明确职责

1. PostgreSQL:决定“事实是否成立”

权威库保存需要事务不变量的数据:

  • 用户、内容、关系、权限和生命周期;
  • 评论父子关系与根帖一致性;
  • 收藏、点赞、转发等唯一关系;
  • 未读计数与收件箱状态;
  • 当前信息流版本指针;
  • 与业务写入同事务落下的 outbox。

判断标准很简单:如果两个字段不一致会导致业务语义错误,它们就应在同一 owner 边界内由事务维护。PostgreSQL 不一定承载所有读取,却负责回答“最终谁说了算”。

2. MongoDB:决定“这个场景如何高效读取”

投影不是数据库备份,而是针对查询形状设计的文档。例如帖子卡片投影可内嵌媒体、提及和话题;搜索文档只保存可索引文本、身份字段、排序时间和源版本;通知卡片投影包含 actor、目标摘要和降级状态。

同一个权威事实可以投影成多种形状:

PostgreSQL Post
  ├─ PostCardProjection      → 首页、详情、收藏
  ├─ SearchPostDocument      → 搜索
  ├─ FeedPostFeature         → 推荐召回与打分
  └─ NotificationExcerpt     → 通知卡片

投影可以丢失、删除或升级,因为它必须能从权威数据重建。不能重建的“投影”,事实上已经偷偷变成第二事实源。

3. Redis:决定“短时间内如何协调”

Redis 适合保存可过期、可再生、对延迟敏感的状态:

  • 热点页面缓存;
  • 单实体投影重建锁;
  • 幂等结果和短期去重;
  • 推荐曝光抑制集合;
  • 重建 debounce、租约和 socket 会话;
  • 某些不透明游标对应的瞬时快照状态。

不要把不可恢复的业务事实只放在 Redis。锁丢失应导致重复工作,而不是数据损坏;缓存丢失应导致回源,而不是页面永久为空。

4. Kafka:决定“谁需要知道事实已变化”

事件总线将内容、搜索、信息流、通知和媒体边界解耦。发布帖子时,内容服务不应同步调用五个下游并等待全部成功;它只需提交权威事务和 outbox。下游各自消费、重试、投影或失效。

消息至少应包含:

type DomainEventEnvelope<T> = {
  eventId: string;
  eventName: string;
  aggregateId: string;
  aggregateVersion: string;
  occurredAt: string;
  traceId: string;
  payloadVersion: number;
  data: T;
};

eventId 用于去重,aggregateVersion 用于拒绝旧状态,payloadVersion 用于契约演进,traceId 用于跨服务追踪。

二、为什么直接双写一定会留下裂缝

考虑一段常见代码:

await postgres.post.create(data);
await mongo.postProjection.updateOne(...);
await redis.del(cacheKey);
await kafka.send(event);

四步都成功当然很好,但任何一步失败都会产生难题:

  • PostgreSQL 成功、MongoDB 失败:用户已发布,首页看不到;
  • 两库成功、Kafka 失败:搜索和通知永远不知道;
  • Kafka 先成功、事务后回滚:下游看到一个从未成立的事实;
  • 客户端超时重试:重复写、重复计数或重复通知。

分布式事务可以理论上包围多资源,但运维复杂、吞吐受限,很多基础设施也不参与两阶段提交。更实际的解法是:只对 owner truth 做本地事务,把“需要传播”也记录为同一事务中的事实

三、Transactional Outbox:把不可控故障变成可重试积压

业务事务内同时写领域表和 outbox:

BEGIN;

UPDATE posts
SET status = 'PUBLISHED', published_at = now(), read_version = read_version + 1
WHERE id = :post_id AND status = 'DRAFT';

INSERT INTO outbox_messages
  (id, topic, event_name, aggregate_id, aggregate_version, payload, status)
VALUES
  (:event_id, 'content.events', 'PostPublished', :post_id, :version, :payload, 'PENDING');

COMMIT;

独立 publisher 使用租约批量领取 PENDING 消息,发送 Kafka 后标记完成。发送成功但标记失败时,消息会再次发送,因此整个系统必须按至少一次投递设计,而不是幻想 exactly-once。

Outbox 解决的是“事实与待发送意图原子提交”,不是自动解决全部一致性。还需要:

  • 消费者幂等;
  • 聚合版本防乱序;
  • 死信与人工重放;
  • publisher 积压监控;
  • 事件契约兼容策略。

四、消费者不要把事件载荷当作最终卡片

事件有两种用法。

第一种是 event-carried state transfer:事件携带完整状态,下游直接写投影。它延迟低、少一次回源,但事件会快速膨胀,敏感字段容易外泄,旧事件也可能覆盖新状态。

第二种是 notification + authoritative reread:事件告诉下游“某实体变了”,消费者按 ID 回读 owner truth,再构建当前投影。一种稳健实现是:收到生命周期事件后,解析严格的 postId,获取单实体锁,回读 PostgreSQL 的帖子、媒体、提及和话题,再以当前 readVersion 条件 upsert。

这种做法有三个优势:

  1. 重放旧事件也会得到当前状态,而不是复活旧正文;
  2. 重建逻辑与在线消费逻辑可以复用;
  3. 事件契约无需携带整张卡片,边界更稳定。

代价是 owner 读压力增加。可以通过批量回读、消费合并、按实体 debounce 和限流来控制。

五、投影写入必须具备版本门禁

异步系统里,顺序不是天然保证的。即使同一 Kafka partition 内有序,重试、跨 topic、补偿任务和人工回放仍会改变到达顺序。

推荐在投影中保存单调版本:

type ProjectionWriteResult =
  | { outcome: "APPLIED"; version: bigint }
  | { outcome: "STALE_IGNORED"; currentVersion: bigint };

MongoDB 条件更新可表达为:仅当文档不存在,或当前 readVersion <= incomingVersion 时更新。删除同样要带版本;否则晚到的旧发布事件可能把已删除内容重新插回。

如果 owner 表没有自然版本,可以使用事务内递增 revision、数据库序列,或由稳定字段计算 sourceVersion。不推荐仅使用 updatedAt,因为时钟精度、批量迁移和跨库时钟会造成歧义。

为什么还需要单实体锁

版本门禁保证最终状态不倒退,但两个重建任务仍可能同时做昂贵回读和多项副作用。短 TTL 的 Redis 锁可以减少重复工作:

lock:projection:post:{postId} -> token, TTL 30s

释放必须 compare-and-delete:只有 token 与自己持有的一致时才删除,避免任务超时后误删后来者的新锁。锁不是正确性的唯一依赖;即使锁过期或 Redis 故障,版本门禁仍应保护数据。

六、缓存失效顺序与“空结果缓存”

投影更新和缓存失效也存在窗口。常见顺序是:

  1. 成功写入投影;
  2. 删除实体卡片缓存和相关列表缓存;
  3. 记录失效失败,交给 TTL 或修复任务兜底。

不要先删缓存再写投影,否则并发请求可能在中间窗口回源旧投影并再次把旧值放入缓存。

空结果同样可以短暂缓存,防止不存在的 ID 被频繁穿透。但空缓存 TTL 要明显短于正常对象,并在实体创建/恢复事件后精确失效。

列表缓存比实体缓存更难失效。实践中可以组合:

  • 实体卡片精确失效;
  • 用户时间线使用版本化 key,如 feed:{userId}:{snapshotVersion}:{cursor}
  • 旧版本自然过期,不枚举删除所有分页;
  • 对强时效入口返回“有新内容”提示,由用户刷新切换新版本。

七、读路径仍需最终权限校验

读模型中的可见性字段只是投影时快照。用户可能刚刚拉黑作者、退出私密社区,或帖子在投影更新前已收紧权限。直接把 MongoDB 文档返回给客户端会形成越权窗口。

一个安全读路径通常分三步:

  1. 从信息流、搜索或收藏投影取一批候选 ID;
  2. 批量执行当前 viewer 的权限门禁和实体存活检查;
  3. 对被过滤项继续向后扫描补位,直到凑满页面或达到 scan budget。

因此分页服务需要区分:

  • physicalScanned:实际扫描了多少候选;
  • itemsReturned:最终返回多少可见卡片;
  • filteredByReason:因删除、拉黑、社区权限等过滤多少;
  • scanBudgetExhausted:是否因预算耗尽导致短页。

这也解释了为什么不能把 items.length === 0 简单等同于“没有数据”:可能候选存在,只是全部过期或不可见,需要触发候选修复。

八、投影重建与双版本切换

投影 schema 升级时,直接原地批量修改风险很高。更稳妥的流程是:

  1. 新建 schemaVersion = 3 的集合或逻辑命名空间;
  2. 从 PostgreSQL 分片回填,记录 checkpoint;
  3. 持续消费增量事件,使新投影追上实时状态;
  4. 比对样本数量、字段、不变量和查询结果;
  5. 原子切换 active projection pointer;
  6. 保留旧版本观察一段时间,再清理。

信息流候选池同样适合版本指针:先离线构建 poolVersion=B,完成后在 PostgreSQL 中把用户当前指针从 A 原子切到 B。读请求永远只看到完整版本,不会看到“回填了一半”的池。

回填不是直接往 MongoDB 随便插数据

正确的回填应尽量复用正式重建函数:按 owner 读取、应用相同过滤、生成相同 schema、执行相同版本门禁和缓存失效。直接写数据库脚本虽然快,却容易跳过提及、媒体、权限或 sourceVersion,制造一批“看起来有数据、实际无法演进”的孤儿记录。

九、降级策略:让依赖故障可见但不扩散

故障建议行为不建议行为
Redis 不可用绕过缓存、禁用非关键锁或返回受控错误把缓存 miss 当成业务空数据
MongoDB 投影不可用详情可回源权威简版;首页返回明确可重试状态静默返回空列表
Kafka 不可用owner 写入和 outbox 仍可提交,积压待恢复同步调用所有下游
搜索投影滞后提示索引延迟,详情仍以 owner 为准让搜索文档决定权限
单条坏事件隔离到死信并继续消费阻塞整个 partition 永不前进

“返回空数组”通常不是友好降级,因为它把基础设施故障伪装成真实业务结果,前端会显示“暂无内容”,运营也难以区分是否需要回填。

十、可观测性要沿着因果链设计

建议统一传播 traceIdeventIdaggregateIdaggregateVersion。关键仪表盘包括:

  • owner 写入成功率、事务冲突率、最终门禁失败率;
  • outbox pending 数量、最老消息年龄、发布重试与死信;
  • Kafka consumer lag、单事件处理耗时和失败类别;
  • 投影 APPLIED / STALE_IGNORED / NOT_FOUND_DELETE 数量;
  • PostgreSQL 与 MongoDB 抽样差异率;
  • Redis 命中率、锁竞争、compare-delete 失败;
  • 查询端物理扫描量、权限过滤率、补位次数和短页率。

一次“首页没有内容”的排查应该能回答:owner 有没有已发布帖子?事件是否进入 outbox?是否已发送?消费者是否落后?投影是否存在?候选池是否引用正确版本?读取时是否被权限过滤?前端是否把错误吞成空数组?如果监控无法沿这条链回答,架构虽然组件齐全,仍然不可运营。

十一、如何选择更简单的方案

不是所有系统都需要这套复杂度。

  • 单体早期、数据量小、查询简单:优先 PostgreSQL + Redis,先把 outbox 和边界写正确。
  • 搜索需求明确:增加专用搜索投影,不必立刻把所有详情都搬到 MongoDB。
  • 首页联表成为瓶颈:为卡片和候选列表增加读模型。
  • 跨模块通知、媒体处理增多:引入消息总线和独立 worker。

复杂架构应该由恢复目标、吞吐和团队边界驱动,而不是由技术名词驱动。每增加一种存储,都要回答三个问题:它保存的是事实还是派生?丢失后如何重建?谁负责监控与恢复?答不出来就不应引入。

结语

多存储架构的成熟标志,不是“同时用了 PostgreSQL、MongoDB、Redis 和 Kafka”,而是每种系统只承担它擅长的职责,并且任何派生数据都能从权威事实恢复。

以本地事务写 owner truth 和 outbox,以至少一次投递传播变化,以权威回读构建投影,以版本门禁抵抗乱序,以缓存和锁优化而不托管唯一事实,再用最终权限门禁保护每一次读取——这些设计共同把不可避免的分布式故障从“永久数据分叉”降级为“可观测、可重试、可回填的延迟”。

发表评论