内功修炼转入工程实务。关系型建模的第一课是范式化——消除冗余、保证更新一致;NoSQL 建模的第一课恰好相反:适度冗余数据,让每个已知查询一次读取命中。本节讲清这对方法论反转的因果,以及"嵌入还是引用"这组日常抉择的判断规则;第 4.2 节会把它落到 MongoDB 文档模型上。
范式化的前提是廉价的关联查询:数据拆散存,查的时候现场连接。这个前提在分布式 NoSQL 上不成立——关联的两端可能分在不同分片、不同机房,现场连接意味着跨网络的多次往返与聚合协调,代价高到不可接受。
于是建模目标从"更新最省"变成"读取最省":把一个查询需要的所有数据物理上放在一起(数据局部性),宁可存两份也不让读取跨网络。这带来的设计顺序反转很重要:关系型是"先建实体关系,查询自然写出来";NoSQL 是"先列查询清单,再为每个查询设计数据形状",数据模型是查询的影子。
嵌入(Embedding):把关联数据放进同一个文档/行/聚合内。适合"一起读、从属关系、总量有限"的数据——订单内嵌订单项、文章内嵌作者摘要。收益是一次读取拿全、写入天然原子(单文档内);风险是文档膨胀与更新放大。
引用(Reference):只存对方标识,读取时第二次查询。适合"无限增长、多方共享、独立生命周期"的数据——评论、粉丝关系。收益是更新隔离与规模自由;代价是多一次读取且失去单文档原子性。
冗余副本(Denormalized Copy):同一数据在多处各存一份,以写入时的同步为代价换取多个查询路径的局部性——订单里冗余商品名(下单时的快照语义),商品集合里冗余最新评价摘要(列表页免关联)。冗余字段的选择要问一句:这是快照语义(下单时的价格就该冻结)还是最新语义(需要写入方同步更新)?语义错了,冗余就从优化变成 bug 温床。
背景:博客系统有用户、文章、评论三实体,核心查询有四个:文章详情页(文章 + 作者昵称头像)、文章列表页(标题摘要 + 作者名)、文章评论流、用户主页(该用户的文章列表)。
操作:第一步写出查询清单(如上四条),标注每条的读取频次;第二步设计文章文档:正文全量嵌入,作者仅冗余昵称与头像(最新语义,用户改名时同步更新文章文档——低频写可接受);第三步评论独立成集合(无限增长),按文章 ID 引用并建索引;第四步用户文档内嵌"文章 ID 列表"以服务用户主页查询,文章数超过阈值后改为只存最近一百条,其余走引用查询。
结果:四个查询全部一次或两次读取命中,无跨分片关联;踩过的坑是早期在文章文档里嵌了评论数组,热文评论过万后文档逼近体积上限,读取也变慢,重构为独立评论集合。
解读:案例验证了判断口诀——一起读的嵌入、无限长的引用、多方共享的引用。同时注意冗余的同步责任要明确:作者改名同步更新文章文档,这个更新逻辑写在用户服务里,若忘记就是不一致隐患。冗余不是免费的,它把"更新一致性"从数据库约束变成了代码义务。
变式:如果产品新增需求"全站热门文章按任意条件筛选",现有模型覆盖不了——按 2.3 的经验,新建一张为该查询服务的表或集合(写入双份),而不是改造现有模型。查询清单是活的,模型随它演进。
第一个大坑是无节制嵌入:数组字段无限增长(粉丝列表、浏览记录),迟早撞上单文档上限或性能悬崖;规则是数组长度有业务上限才嵌入,否则引用加分页。第二个是冗余同步遗漏:冗余了"最新语义"字段却没有写同步逻辑,数据漂移没人发现;上线前把每个冗余字段的"谁负责同步"写进设计文档。第三个是照搬关系型的心智:为"将来的可能查询"预留大量可空字段与关联表——NoSQL 的优势恰恰是结构可以等查询确定后再演进,过度设计不如按需重构。
非范式化建模的核心动作只有一个:按查询反推结构。下面四种手法是它的具体形态。
| 手法 | 结构 | 适用查询 | 代价 |
|---|---|---|---|
| 嵌入 | 子对象放进父文档 | 总是与父对象一起读 | 子对象更新需定位到父文档;文档变大 |
| 引用 | 父文档只存子对象 ID | 子对象常被单独访问、或数量无上限 | 查询需多次往返(N+1 风险) |
| 混合(部分嵌入) | 高频字段嵌入,完整信息放别处 | 列表页只要少数字段 | 冗余字段的同步维护 |
| 分桶 | 把一个时间窗口或一批数据打包成一行/一篇 | 时序、日志、按批读取 | 查询必须带上桶键 |
需求:一篇文章下有评论,文章页要展示最新 20 条与总数,评论详情页要展示单条评论及用户信息。
方案 A,全嵌入:评论数组放进文章文档。一次读取拿到全部,但文章文档会随评论增长无限膨胀,且"分页查评论""按用户查评论"都变得昂贵。适合评论数有明确上限、且只整读的场景(如商品的固定几条标签)。
方案 B,全引用:文章只存评论 ID 数组,评论独立成集合。查询灵活,但展示文章页要查一次文章 + 一次评论集合,高并发下往返次数翻倍。适合评论量大、需要多种查询维度的场景。
方案 C,混合:文章文档里嵌入"最新 20 条评论的摘要字段(作者昵称、前 50 字、时间)"与"评论总数",完整评论内容放独立集合。
// 方案 C 的文档形态:列表页一次命中,详情页再取全文 { _id: "article-8801", title: "从零搭建一套监控系统", comment_count: 1372, latest_comments: [ { id: "c-91", author: "阿澈", excerpt: "指标选型那段讲得很透…", ts: ISODate("2026-09-01T09:12:00Z") }, { id: "c-90", author: "小满", excerpt: "采集端埋点我们踩过同样的坑…", ts: ISODate("2026-09-01T08:57:00Z") } ] }
选型时的判据是读写比与增长上限:评论数无上限 → 不能全嵌入;列表页访问量远高于详情页 → 值得为它做部分冗余。多数生产系统最终都会落到方案 C。
最后一条经验:冗余字段必须有明确的更新路径。评论新增时要同时更新评论集合与文章文档的摘要数组,这一步要么放在同一个事务里,要么接受短暂不一致并配补偿任务。只写一半的冗余,是脏数据最主要的来源。
数据形状定了,下一个问题是:数据量涨起来,摊到多少台机器、怎么摊——这就是分片。