《Spring Mongo实战》第4招 Embed还是Reference
一个商品文档最多能塞多少条评论?先算一笔账。假设每条评论 500 字节,MongoDB 单文档上限 16 MiB,大约能存 3.3 万条。看起来不少,可一个爆款商品一天新增 2000 条评论,不到三周就会顶到天花板。更难受的是,每次修改库存、更新价格,都要把几万条评论一起读出来吗?我是老李,今天不聊增删改查,聊 MongoDB 建模里最容易被低估、也最容易后悔的决定:嵌入还是引用。 ## 01、痛点 很多团队刚开始用 MongoDB 时,都会把评论直接嵌进商品文档。原因很直接:一个商品详情页要展示商品基本信息、SKU、规格、图片,还要展示前几条评论和评分。如果全放在一个文档里,一次查询就能拿到所有数据,开发起来非常舒服。老李自己也这么干过。项目初期,评论量只有几十条,商品数据量也小,接口响应很快。测试数据一跑,感觉这把稳了。 可一旦商品火了,噩梦就开始了。第一,文档快速膨胀。评论是无界数据,用户每写一条,商品文档就变大一点。MongoDB 单文档有 16 MiB 硬限制,不是压线才出问题,而是文档越大,内存、网络、索引、写入的成本都越高。第二,查询路径被污染。商品列表页只需要名称、价格、主图和评分,完全不需要加载几万条评论。但文档模型一旦把评论塞进去,查询商品列表时这些评论字段也可能被读进内存,哪怕用投影,存储引擎扫描大文档的开销仍然存在。第三,写入放大。评论新增、库存修改、价格调整都是独立变化的数据,但它们落在同一个文档里,任何一个小更新都可能触发整个大文档的写盘和复制。第四,并发操作互相干扰。单文档写是原子的,但大文档更新频率高,锁竞争和版本冲突的概率也随之上升。最后,索引也不好建。对嵌套数组里的评论做排序、筛选,容易建出巨大的多键索引,维护成本很高。 所以问题不是 MongoDB 不行,而是建模一开始就把读取路径和更新频率搞混了。 ## 02、是什么 嵌入和引用是 MongoDB 两种基本建模手段。嵌入,就是把子数据直接放在父文档里,形成嵌套结构。比如商品文档里有一个 skuList 数组、一个 imageSummary 数组、一个 spec 对象。引用,就是把子数据拆到独立集合,通过字段关联。比如 Review 集合里每条评论都带 productId,需要时再单独查询或聚合。 先记住一个重要事实:MongoDB 单文档大小上限是 16 MiB。这不是建议值,是硬限制。其次,MongoDB 的单文档操作是原子的。更新一个文档里的多个字段,要么全部成功,要么全部失败。这个能力对很多场景非常有价值,但它只作用于单个文档。跨文档的原子性需要事务来保证,事务有额外开销。因此,如果某些数据需要一起原子更新,嵌入往往更合适;如果某些数据各自独立变化,硬塞进同一个文档反而会放大冲突。 嵌入适合的数据通常有几个特征:数量有限、生命周期跟父文档一致、读取父文档时几乎总是一起读、子数据很少独立修改。引用适合的数据特征则相反:数量无界、生命周期不同、经常单独查询、更新频繁且独立、需要单独建索引。 ## 03、为什么 为什么嵌入可以一次读取并支持单文档原子更新?因为 MongoDB 的存储模型把文档当作基本单位,读一个文档就是一次磁盘 I/O 或内存定位。把商品主数据和有限 SKU 放一起,商品详情页的读取路径就变成一次主键查询。价格、规格、图片摘要、SKU 列表这些字段都在同一个文档里,既不用多次往返,也不用在应用层拼装。单文档原子更新则适合处理类似扣减库存并更新销售数量这种需要同时成功的操作。 但嵌入有边界。16 MiB 是硬边界,无界数组是最容易被忽略的软边界。评论、日志、订单明细、用户操作记录这些数据天然会增长,如果嵌进父文档,父文档会不断变大。MongoDB 写操作基于文档级锁,大文档更新时写放大非常明显。复制集之间传输的是整个文档变更后的结果,文档越大,网络开销越大。更别说查询商品列表时,哪怕用投影跳过评论字段,数据库仍然需要定位和读取这个庞大文档。 所以理论结论很清晰:嵌入解决的是读取局部性和原子性,引用解决的是无界扩展和独立演化。选择哪一个,不是看数据像不像父子关系,而是看数据在真实读写路径里的角色。 ## 04、架构 回到电商 Product 场景。我们可以这样设计架构。 Product 集合仍然承担商品主数据角色。一个商品文档大致长这样: { _id: ObjectId('...'), name: '某品牌手机', price: 2999, spec: { color: '黑色', storage: '256GB' }, imageSummary: [ 'img1.jpg', 'img2.jpg', 'img3.jpg' ], skuList: [ { skuId: 'SKU-001', color: '黑色', storage: '128GB', price: 2799 }, { skuId: 'SKU-002', color: '黑色', storage: '256GB', price: 2999 } ], ratingAverage: 4.7, ratingCount: 128 } skuList 是有限的,一个商品通常只有几种到几十种 SKU,内嵌不会让文档失控。imageSummary 只是图片 URL 摘要,不是图片文件本身。spec 是规格参数,读取商品详情时必须一起展示。ratingAverage 和 ratingCount 是评论的汇总值,不是评论本身。 Review 集合独立存在: { _id: ObjectId('...'), productId: ObjectId('...'), userId: ObjectId('...'), rating: 5, content: '手感不错,拍照清晰', createdAt: ISODate('...') } 商品文档里只保留评分汇总,具体评论按需分页查询。这样商品列表页不会碰 Review 集合,商品详情页先读 Product 得到评分和基本信息,再按分页参数查 Review。修改库存或价格时,不会动到评论数据。新增评论时,只需要往 Review 插入一条,同时在小得多的 Product 文档上更新 ratingAverage 和 ratingCount。 读取路径和更新路径被分开了,各自保持简单。 ## 05、实战 假设我们要实现一个电商系统的商品详情和评论功能。核心做法如下。 第一步,创建 Product 集合。商品基本信息、规格、图片摘要、有限 SKU 内嵌,评分汇总字段初始为 0 和 0。SKU 只放展示所需的静态信息,比如 skuId、颜色、容量、价格。不要把实时库存数量内嵌进去,库存是另一个高频变化的数据。 第二步,创建 Review 集合。在 productId、createdAt、rating 上建立联合索引,方便按商品分页查询最新评论,也方便按评分筛选。评论内容可长可短,独立集合可以单独控制文档大小。 第三步,写入评论。插入一条 Review 后,更新 Product 文档。可以用近似增量方式更新评分汇总,避免每次重算全部评论。比如维护 ratingCount 和 ratingSum 两个字段,新增评论时 $inc 增加数量和总分,ratingAverage 由应用层计算或读取时 $divide。也可以直接存 ratingAverage 并根据旧值和新值推算。无论哪种方式,都是在一个小文档上做原子更新,成本很低。 第四步,读取商品列表。只查 Product 集合,并使用投影只取 name、price、主图、ratingAverage、ratingCount。因为评论已经不在 Product 内,列表查询不会背负评论大数组。 第五步,读取商品详情。先查 Product 得到完整商品主数据和评分汇总,再分页查 Review 集合。前 2 条高赞评论可以直接查出来展示,更多评论按分页加载。 这样做以后,商品文档保持在几 KB 到几十 KB 级别,不会因为评论增长而膨胀。评论集合可以独立扩展,甚至在未来按 productId 分片。 你会发现,这个方案并不比一开始的全嵌入复杂多少。唯一的代价是写入评论时要多一次 Product 更新,以及读取详情时要多一次 Review 查询。但这个代价换来的是可持续的模型。 ## 06、洞见 MongoDB 最反直觉的一点是:反规范化不是偷懒,而可能是一种主动架构设计。关系型数据库强调规范化,减少冗余,通过外键和连接保证一致性。很多从关系型转过来的人,会把 MongoDB 的嵌入当成偷懒版外键,觉得把所有相关数据塞进一个文档是在逃避设计。 但真实情况往往相反。在 MongoDB 里,嵌入可能是为了匹配真实读取路径。比如商品详情页需要一次性展示 SKU、规格、图片摘要,这些数据内嵌在一起,就是为了让读取路径最短。这不是偷懒,而是基于 MongoDB 文档模型做出的主动选择。 然而,主动选择也必须有边界意识。评论嵌入商品,看似匹配了详情页读取路径,实际上没有匹配完整。商品列表页不需要评论,修改商品价格时与评论无关,新增评论时也不应该波及商品主数据。评论的读取路径是被分页的,不是一次加载全部。把无界数据硬塞进共享文档,是把读取路径简单化,却把更新路径复杂化。真正的反规范化,是把高频一起读、低频改的数据放在一起;而不是把所有相关的数据都放在一起。 所以判断标准不是数据之间有没有关系,而是它们是否共享同一条读取路径,并且没有冲突的更新频率。评论与商品有关,但它们的读取路径在列表页和详情页不同,评论的更新频率远高于商品本身。因此,把评论拆出去,商品只留汇总,反而是更合理的设计。 ## 07、行动 面对常见建模对象,可以这样做出判断。 Address:Embed 在 User 文档中。理由:一个用户的收货地址通常有限,几个到十几个,属于用户主数据的组成部分。读取用户信息时,地址往往一起展示。地址的生命周期跟随用户,修改地址可以通过单文档原子更新完成。但要注意,订单里的收货地址应该保存一份快照,而不是引用用户当前地址,否则用户以后改地址,历史订单会跟着变。 SKU:Embed 在 Product 文档中。理由:SKU 是商品规格的有限展开,数量通常可预期。商品详情页必须一次展示所有 SKU 的颜色、容量、价格,与商品主数据共享同一条读取路径。SKU 的生命周期与商品一致。如果 SKU 的实时库存被包含在内,则要小心,库存变动非常频繁,更适合拆出。 Review:Reference 独立集合。理由:评论数量无界,属于频繁独立变化的子数据。商品列表和详情页通常分页加载评论,而不是一次全部读取。独立集合可以按 productId、时间、评分单独建索引,支持灵活查询。商品文档只保留 ratingAverage 和 ratingCount 汇总值,避免评论增长拖垮商品主数据。 Inventory:Reference 独立集合。理由:库存是典型的高频独立变化数据。用户下单、取消订单、补货都会修改库存。如果把库存内嵌在商品文档里,每次库存变化都会引发整个商品文档的更新,写放大严重。独立库存集合可以按 SKU 建文档,利用单文档原子更新实现扣减。商品详情页需要显示库存时,按需查询或聚合;商品列表页一般不显示实时库存,可以只展示有货/无货状态,这样也不必每次都查库存。 这四个判断可以总结成一句话:有限、同生共死、一起读的,嵌入;无界、独立变化、分开读的,引用。 ## 08、小结 嵌入和引用不是数据库功能的选择,而是数据模型的选择。判断时先看数据形状:数量有限还是无界?再看读取路径:是不是总在一起读?还要看更新频率:子数据会不会频繁独立变化?最后看生命周期:子数据是否和父文档同生共死。16 MiB 是硬边界,无界数组是警告信号。把评论塞进商品文档,最开始舒服,是因为它简化了详情页读取。但数据增长后,文档膨胀、写放大、并发冲突、索引负担都会一起找上门。 更稳妥的模型是:商品主数据内嵌有限 SKU、规格和图片摘要;评论独立成集合,商品只留评分汇总;库存独立管理。这样既保留了嵌入读取快的优势,又避免了无界数据带来的长期风险。 ## 09、链接与概念说明 概念说明: 16 MiB 上限:MongoDB 单篇 BSON 文档的最大体积,硬限制,不可调。 单文档原子性:MongoDB 保证对单个文档的多个字段更新具有原子性。 无界数组:随着时间持续增长的数组字段,如评论、日志、事件记录。 写放大:大文档或大索引更新时,实际写入磁盘的数据量远大于逻辑变更的数据量。 读取路径:一次业务请求需要读取哪些字段、以什么顺序、是否分页。 多键索引:针对数组字段建立的索引,数组元素越多,索引体积越大。 延伸阅读可以搜索 MongoDB 官方文档中的 Data Modeling Introduction、Model One-to-Many Relationships with Embedded Documents、Model One-to-Many Relationships with Document References。这些文档提供了更多模式示例。 链接与概念说明就到这里。建模没有银弹,只有适合当前读取路径和增长曲线的方案。