一个社交产品往往同时拥有以下读场景:
- 帖子详情需要关系上下文、作者、媒体、权限和计数;
- 首页需要按用户定制的候选列表,并在几十毫秒内返回一页卡片;
- 搜索需要分词、相关度排序、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。
这种做法有三个优势:
- 重放旧事件也会得到当前状态,而不是复活旧正文;
- 重建逻辑与在线消费逻辑可以复用;
- 事件契约无需携带整张卡片,边界更稳定。
代价是 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 故障,版本门禁仍应保护数据。
六、缓存失效顺序与“空结果缓存”
投影更新和缓存失效也存在窗口。常见顺序是:
- 成功写入投影;
- 删除实体卡片缓存和相关列表缓存;
- 记录失效失败,交给 TTL 或修复任务兜底。
不要先删缓存再写投影,否则并发请求可能在中间窗口回源旧投影并再次把旧值放入缓存。
空结果同样可以短暂缓存,防止不存在的 ID 被频繁穿透。但空缓存 TTL 要明显短于正常对象,并在实体创建/恢复事件后精确失效。
列表缓存比实体缓存更难失效。实践中可以组合:
- 实体卡片精确失效;
- 用户时间线使用版本化 key,如
feed:{userId}:{snapshotVersion}:{cursor}; - 旧版本自然过期,不枚举删除所有分页;
- 对强时效入口返回“有新内容”提示,由用户刷新切换新版本。
七、读路径仍需最终权限校验
读模型中的可见性字段只是投影时快照。用户可能刚刚拉黑作者、退出私密社区,或帖子在投影更新前已收紧权限。直接把 MongoDB 文档返回给客户端会形成越权窗口。
一个安全读路径通常分三步:
- 从信息流、搜索或收藏投影取一批候选 ID;
- 批量执行当前 viewer 的权限门禁和实体存活检查;
- 对被过滤项继续向后扫描补位,直到凑满页面或达到 scan budget。
因此分页服务需要区分:
physicalScanned:实际扫描了多少候选;itemsReturned:最终返回多少可见卡片;filteredByReason:因删除、拉黑、社区权限等过滤多少;scanBudgetExhausted:是否因预算耗尽导致短页。
这也解释了为什么不能把 items.length === 0 简单等同于“没有数据”:可能候选存在,只是全部过期或不可见,需要触发候选修复。
八、投影重建与双版本切换
投影 schema 升级时,直接原地批量修改风险很高。更稳妥的流程是:
- 新建
schemaVersion = 3的集合或逻辑命名空间; - 从 PostgreSQL 分片回填,记录 checkpoint;
- 持续消费增量事件,使新投影追上实时状态;
- 比对样本数量、字段、不变量和查询结果;
- 原子切换 active projection pointer;
- 保留旧版本观察一段时间,再清理。
信息流候选池同样适合版本指针:先离线构建 poolVersion=B,完成后在 PostgreSQL 中把用户当前指针从 A 原子切到 B。读请求永远只看到完整版本,不会看到“回填了一半”的池。
回填不是直接往 MongoDB 随便插数据
正确的回填应尽量复用正式重建函数:按 owner 读取、应用相同过滤、生成相同 schema、执行相同版本门禁和缓存失效。直接写数据库脚本虽然快,却容易跳过提及、媒体、权限或 sourceVersion,制造一批“看起来有数据、实际无法演进”的孤儿记录。
九、降级策略:让依赖故障可见但不扩散
| 故障 | 建议行为 | 不建议行为 |
|---|---|---|
| Redis 不可用 | 绕过缓存、禁用非关键锁或返回受控错误 | 把缓存 miss 当成业务空数据 |
| MongoDB 投影不可用 | 详情可回源权威简版;首页返回明确可重试状态 | 静默返回空列表 |
| Kafka 不可用 | owner 写入和 outbox 仍可提交,积压待恢复 | 同步调用所有下游 |
| 搜索投影滞后 | 提示索引延迟,详情仍以 owner 为准 | 让搜索文档决定权限 |
| 单条坏事件 | 隔离到死信并继续消费 | 阻塞整个 partition 永不前进 |
“返回空数组”通常不是友好降级,因为它把基础设施故障伪装成真实业务结果,前端会显示“暂无内容”,运营也难以区分是否需要回填。
十、可观测性要沿着因果链设计
建议统一传播 traceId、eventId、aggregateId 和 aggregateVersion。关键仪表盘包括:
- 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,以至少一次投递传播变化,以权威回读构建投影,以版本门禁抵抗乱序,以缓存和锁优化而不托管唯一事实,再用最终权限门禁保护每一次读取——这些设计共同把不可避免的分布式故障从“永久数据分叉”降级为“可观测、可重试、可回填的延迟”。