《Spring Mongo实战》第3招 别把表直接搬过来
凌晨两点,商品详情页的接口又超时了。前台页面要读商品、SKU、属性、图片、分类五个地方,代码里塞了五次查询和一堆拼装逻辑,比原来 MySQL 的 JOIN 还难维护。 我是老李,做后端十几年,最近接手了一个把 MySQL 商品中心迁到 MongoDB 的项目。团队一开始的做法非常直接:把原来的 `product`、`sku`、`attribute`、`image`、`category` 五张表,一张表建一个 Collection,字段都照搬。结果上线后详情页接口响应时间从 300ms 飙到 1.2s,库存扣减还出现了超卖迹象。 为什么现在要讲这件事?因为这两年数据库迁移和选型很热,但真正卡住大家的往往不是 CRUD、不是索引,而是从“表设计”到“文档设计”这道看不见的坎。 ## 01、痛点 很多人在学 MongoDB 时,把精力都放在 CRUD 和语法上,觉得会 `insertOne`、`updateOne`、聚合管道就算入门了。可真到迁移项目时,第一反应还是沿用关系库那套设计:每张表对应一个 Collection,字段一一照搬,外键照旧。结果就是前面说的场景:商品详情页要先查商品集合,再查 SKU 集合,再查属性集合,再查图片集合,最后再查分类集合。原本在 MySQL 里一条 `JOIN` 能拿到的数据,现在变成了应用层多次查询或一个巨大的聚合管道。 问题不在 MongoDB,而在我们用关系模型的思路去驱动一个文档模型。CRUD 只是招式,文档设计才是内功。官方文档里反复强调“按查询模式建模”,但很多人没当回事。等查询越来越慢、代码越来越复杂、事务越来越多,才意识到真正卡住自己的不是语法,而是设计。 ## 02、是什么 MongoDB 的文档模型和关系模型有本质区别。关系库里,数据被拆成扁平的二维表,用主外键维护关系,追求消除冗余,这就是第三范式。而 MongoDB 的一条文档可以嵌套数组和子对象,天然可以表达“一对多”甚至“多对多”的结构。 设计 MongoDB 模型时,核心问题只有一个:数据是嵌入(Embed)还是引用(Reference)。官方给出的建议是:先分析应用 Workload,也就是工作负载,再决定。具体来说,如果几类数据总是一起被读取,而且读取频率远高于更新频率,数据量有界,通常倾向于嵌入;如果数据需要独立查询、更新频繁、可能无界增长,或者需要单独管理,就更适合引用。 放到商品中心这个例子里,商品和 SKU、属性、图片几乎总是同时出现在详情页和列表页,这是典型的“一起读取”。SKU 数量通常几百个以内,属性十几条,图片几张到十几张,都是有界且读多写少,因此天然适合嵌进商品文档。而分类则不同,分类往往独立维护,一个分类下有大量商品,分类名称可能单独展示,也可能作为筛选条件被独立查询,所以更适合引用。 ## 03、为什么 为什么把 MySQL 每张表机械转换成一个 Collection 通常得不到 MongoDB 的优势?因为这样做等于保留了关系模型的结构,却丢失了关系数据库最擅长的事情——优化器、索引和成熟的 JOIN 机制。MongoDB 虽然提供了 `$lookup` 来模拟关联查询,但它本质上是在运行时做额外聚合,开销远高于关系库的预编译 JOIN。把五张表拆成五个集合后,每次详情页请求都要做多次磁盘读取或者多次 `$lookup`,随机 IO 增多,延迟自然就上来了。 更关键的是,文档数据库的核心优势之一就是单文档原子性和局部性:一个文档内的数据读写是原子的、一起加载的。如果模型设计合理,一次详情页查询只需要读取一个文档;如果像关系表那样拆开,就放弃了这一优势。另一个优势是横向扩展时的数据分片:如果商品和 SKU 分散在不同分片,跨片查询代价很高;但如果 SKU 嵌入商品文档,它们天然就在同一个分片上。 机械平移还会造成“四不像”:既没有关系库的强一致性约束,又没得到文档库的读取性能,代码里还要自己维护外键一致性。所以,不是 MongoDB 不行,而是需要真正按照文档思维重新设计。 ## 04、架构 传统的商品中心通常有五张关系表:`product`(商品主表)、`sku`(库存量单位,含颜色、规格、价格、库存)、`attribute`(商品属性键值对)、`image`(商品图片列表)、`category`(商品分类)。它们通过 `product_id`、`category_id` 等外键关联。在 MySQL 里,这是一套标准的第三范式设计,没什么问题。 迁移到 MongoDB 后,我们希望大多数商品查询一次就能拿到完整数据。于是把 `sku`、`attribute`、`image` 都嵌入到 `product` 文档中,而 `category` 保留为独立的 `categories` 集合,商品文档里只存一个 `category_id` 引用。新的 Product Document 大致长这样: ```json { _id: 'prod_10001', name: '氮化镓充电器 65W', category_id: 'cat_20', skus: [ { sku_id: 'sku_1', color: '白色', price: 129, stock: 200, attrs: { interface: '2C1A', power: '65W' } }, { sku_id: 'sku_2', color: '黑色', price: 129, stock: 50, attrs: { interface: '2C1A', power: '65W' } } ], attributes: [ { name: '品牌', value: '倍思' }, { name: '材质', value: 'PC' } ], images: [ 'https://cdn.example.com/prod_10001_1.jpg', 'https://cdn.example.com/prod_10001_2.jpg' ] } ``` 这个文档把详情页需要的主数据、SKU、属性、图片全都包含在一个文档里。一次 `db.products.findOne({_id: 'prod_10001'})` 就能拿到全部内容,不再需要跨集合拼装。 分类为什么保留引用?因为分类信息有独立生命周期:运营可能会单独修改分类名称、排序、上下级关系;分类也常作为列表页的筛选条件,独立查询需求明显;并且同一分类下可能有几十万商品,如果嵌入分类全量信息,冗余和一致性问题会很大。所以在商品文档里只放 `category_id`,需要分类名称时,再查一次 `categories` 集合,或者用缓存解决。 ## 05、实战 重构不是拍脑袋,必须从查询路径出发。我们第一步先列出商品中心最常执行的 Top10 查询,并标注频率和读写比。简化后的列表如下: | 编号 | 查询内容 | 频率 | 读写比 | | --- | --- | --- | --- | | Q1 | 按商品 ID 查详情页 | 极高 | 读多写少 | | Q2 | 按分类筛选商品列表 | 高 | 读多写少 | | Q3 | 扣减某个 SKU 库存 | 高 | 写多 | | Q4 | 修改某个 SKU 价格 | 中 | 写多 | | Q5 | 按属性组合搜索商品 | 中 | 读多 | | Q6 | 更新商品主图 | 低 | 写少 | | Q7 | 分类信息维护 | 低 | 读多写少 | | Q8 | 商品上架下架 | 中 | 写多 | | Q9 | 导出商品数据 | 低 | 读多 | | Q10 | 商品审核 | 低 | 读多写少 | 从这些查询可以看到,Q1、Q2、Q5、Q6、Q8 都涉及商品和 SKU、属性的整体读取或局部更新,所以把 SKU 和属性嵌入商品文档是合理的。Q3、Q4 虽然写多,但更新的是 SKU 数组内的某个字段,仍然在单文档内完成,可以用数组过滤更新。例如扣减库存可以写成: ```js db.products.updateOne( { _id: 'prod_10001', 'skus.sku_id': 'sku_1' }, { $inc: { 'skus.$.stock': -1 } } ) ``` 这条更新是单文档原子操作,不需要跨文档事务,也就避免了超卖。价格修改类似,用同样的数组定位方式更新 `skus.$.price`。 Q2 按分类筛选商品,由于商品文档里已经有 `category_id` 字段,只需要建立索引 `{ category_id: 1, created_at: -1 }` 即可。Q5 属性搜索虽然嵌入了属性数组,但可以建立多键索引 `{ 'attributes.name': 1, 'attributes.value': 1 }` 来优化。 分类信息独立成集合后,Q7 的分类维护完全不碰商品文档,运营改分类名称不会触发海量商品更新。详情页如果需要分类名称,可以在应用层做一次 `categories.findOne({_id: 'cat_20'})` 然后缓存,或者将分类名称冗余到商品文档中但采用异步修复。这里我们选择引用加缓存,保持商品文档的稳定。 重构完成后,我们对比了迁移前后的慢查询日志。商品详情页的平均查询次数从 6 次降到 1 次,响应时间从 1.2s 降到 180ms;库存扣减的 RT 从 80ms 降到 30ms,超卖问题消失。 ## 06、洞见 这次重构让我最深的感受是:MongoDB Schema 设计首先服务查询路径,而不是追求第三范式。关系库设计从实体和关系出发,目标是通过分解消除冗余;文档模型设计则从实际查询和更新出发,目标是最小化单次请求的读放大和跨文档操作。 在文档模型里,适当冗余不是罪过,反而是提高性能的常用手段。存储成本比计算成本便宜得多,把SKU、属性、图片塞进商品文档,虽然会让数据重复(比如多个 SKU 共享同一个属性组),但换来的是单文档读取的局部性和原子性。真正需要警惕的是写放大:如果某个嵌入数据变化非常频繁,且被大量文档冗余,那么每次更新都会引发大量写操作,这时就必须改成引用。 所以区分主数据和派生数据很关键。主数据应当尽量单点存储,比如分类名称;派生数据或者读多写少的数据可以冗余,比如商品上的分类名称。我们这里选择引用分类,是考虑到分类独立维护、商品数量大,冗余成本高。但如果分类信息极小且几乎不变,也可以嵌入分类名称,减少一次读取。 另一个洞见是:没有一劳永逸的模型。查询模式变了,文档设计也要跟着演进。官方强调 Workload-first,就是要求我们先量化查询频率和读写比例,而不是凭直觉。你可以把文档模型理解成针对 Top 查询的物化视图,如果 Top 查询变了,视图自然要重构。 ## 07、行动 如果你现在正打算把 MySQL 应用迁到 MongoDB,或者已经迁移但性能不佳,可以从下面几步开始纠偏: 1. 列出业务最核心的 Top10 查询,每条查询标注频率、读写比、涉及字段。 2. 按查询路径把实体分成若干“读取簇”,把总是同时出现的字段归为同一个簇。 3. 对每个簇判断是否适合嵌入:读多写少、数据量有界、需要原子性,就嵌入;独立查询、写入频繁、无界增长,就引用。 4. 设计文档结构时,优先保证 Top3 查询能通过一次文档读取完成。 5. 用真实数据量做压力测试,重点关注磁盘 IO、响应时间和写放大。 6. 上线后持续监控慢查询,定期复盘 Top10 查询是否变化,调整文档形状。 可以做一个简单表格来辅助决策: | 实体关系 | 是否一起读取 | 更新频率 | 数据量 | 决策 | | --- | --- | --- | --- | --- | | 商品-SKU | 是 | 低 | 小 | 嵌入 | | 商品-属性 | 是 | 低 | 小 | 嵌入 | | 商品-图片 | 是 | 低 | 小 | 嵌入 | | 商品-分类 | 偶尔 | 低 | 大 | 引用 | ## 08、小结 MongoDB 学习和迁移真正的第一道坎,不是 CRUD 语法,而是从“表设计”切换到“文档设计”。机械地把每张 MySQL 表转成一个 Collection,既丢了关系库的 JOIN 性能,又没得到文档库的单文档原子性和局部性。正确姿势是:先分析应用 Workload,列出 Top10 查询,再反推 Document Shape。频繁一起读取的数据倾向嵌入,独立查询、频繁变化或无界增长的数据更适合引用。商品中心的实战证明,按查询路径重新设计后,详情页查询次数从六次降到一次,性能提升明显。 ## 09、链接与概念说明 文中涉及几个关键概念,这里集中说明: - **文档模型(Document Model)**:MongoDB 的基本数据模型,一条记录称为文档,可以包含嵌套数组和子文档。 - **嵌入与引用(Embed vs Reference)**:MongoDB 数据建模的核心决策。嵌入将关联数据放在同一个文档中,引用只存关联键,需要时再查询。 - **Workload Analysis(工作负载分析)**:在建模前分析应用的读写模式、查询频率、数据量、读写比例等,指导建模。 - **第三范式与反范式化**:第三范式是关系库设计规范,强调消除非主属性对主属性的传递依赖;反范式化则是有意引入冗余以提升读取性能。 - **读放大与写放大**:读放大指一次逻辑读取需要多次物理读取;写放大指一次逻辑更新需要多次物理写入。文档建模需要在两者之间权衡。 - **$lookup**:MongoDB 聚合管道中的关联查询操作符,可以在查询时把两个集合的数据合并,但代价高于关系库 JOIN。 更多官方资料可以查看 MongoDB 文档中的 Data Modeling 部分:[https://www.mongodb.com/docs/manual/data-modeling/](https://www.mongodb.com/docs/manual/data-modeling/)。