1. 电商数据模型的第一课:先忘掉MySQL的范式设计
做了多年后端开发,前阵子给一个创业团队做技术选型评审,对方拿来的PRD一看就是照着传统电商后台写的,会员表、商品表、订单表、订单明细表一应俱全,外键关系画得漂漂亮亮。我问了一句“订单里要不要存商品快照”,产品经理愣住了。这个细节恰恰是区分MySQL思维和MongoDB思维的关键分水岭。
用MongoDB设计电商模型,最忌讳的就是把MySQL那套范式设计原封不动搬过来。范式设计的核心是“消灭冗余、靠关联查询还原数据”,这在关系型数据库里天经地义,因为JOIN是它的看家本领。但MongoDB的看家本领是文档模型,一个文档可以装下一整条业务链路上的所有相关数据。你非要把一条订单拆成订单主表加五六个明细表,然后再用聚合管道里的$lookup把它们拼回来,等于放弃了MongoDB最强的武器,还额外承担了多次网络往返的延迟开销。
那是不是意味着MongoDB建模就不需要设计了?恰恰相反,MongoDB建模需要更多的“预设计”,因为你要在写入那一刻就想清楚:哪些数据该嵌套进文档,哪些数据该引用另一个集合的ID,哪些数据可以容忍最终一致性,哪些数据必须靠事务保证强一致。这个决策过程比MySQL建表复杂得多,但一旦建对了,查询性能和开发效率会有质的提升。
本文要解决的,就是一套可落地、可复现的电商平台MongoDB模型设计方法。我不打算给你画一堆华而不实的概念图,而是直接把用户、商品、库存、订单、购物车、评论这些电商核心域逐个拆开,讲清楚每个集合的字段怎么定、嵌套到什么程度、什么场景下该分集合,最后再聊事务、索引、分片这些建模阶段就必须想好的事。
提示:这套设计不是银弹,它适用于中小规模电商、垂直行业商城、以及从零起步需要快速迭代的业务。如果你手头已经是千万级订单量的平台,需要的是分片集群加更细致的容量规划,但底层的建模思路依然有参考价值。
2. 核心集合逐个拆解:用户、商品、库存、订单到底怎么存
2.1 用户集合:一个文档管好账号、资料与收货地址
先看最简单的用户模型。传统MySQL做法是user表、user_address表、user_login_log表,登录查一张表、取资料查一张表、下单取地址再查一张表。MongoDB里完全可以把用户档案和地址直接合在同一个文档里,因为它们的读写频率和生命周期高度一致。
// 用户集合:users { _id: ObjectId("..."), phone: "13800138000", email: "user@example.com", passwordHash: "bcrypt哈希值", nickname: "某某某", avatar: "https://cdn.example.com/avatar/001.png", gender: "unknown", // male / female / unknown status: "active", // active / frozen / disabled createdAt: ISODate("2025-06-01T12:00:00Z"), lastLoginAt: ISODate("2025-06-10T09:30:00Z"), tags: ["vip", "高复购"], // 用户标签,运营打标用 // 地址簿,嵌套数组,最多存20个 addresses: [ { tag: "家", receiver: "张三", phone: "13800138000", province: "浙江省", city: "杭州市", district: "西湖区", detail: "某路某号某幢某室", isDefault: true, createdAt: ISODate("2025-06-01T12:00:00Z") } ] }为什么把地址嵌套进用户文档而不是单独建一张user_addresses集合?核心原因是地址在绝大多数业务里只跟着用户走,查询入口永远是“我的地址列表”,几乎不存在“查全国所有住西湖区的人”这种反向需求。既然没有反向查询需求,冗余就是安全的,嵌套就是合理的。
密码字段我刻意用了passwordHash而不是password,这是基本功,但每次看到有人把明文密码存进MongoDB还是觉得离谱。另外phone字段如果要做登录查询,必须在建集合时就加上唯一索引。
2.2 商品模型:SPU与SKU的MongoDB表达方式
商品是电商建模的重头戏。传统电商里有个经典概念:SPU(标准产品单元)和SKU(库存量单位)。一件“iPhone 15 Pro”是SPU,而“iPhone 15 Pro 蓝色 256G”是SKU。MySQL的经典做法是spu表和sku表一对多关联,再配上category表、brand表,查询详情要搞好几个JOIN。
MongoDB的建模有两种主流选择,我逐一说明适用场景。
第一种是SPU与SKU分开集合,适合SPU属性复杂、SKU数量大、需要从类目维度做筛选排序的平台型电商。
// 商品SPU集合:products { _id: ObjectId("..."), spuNo: "SPU20250601001", title: "某品牌 无线蓝牙耳机 主动降噪", subtitle: "40小时续航 低延迟", brandId: ObjectId("..."), categoryId: ObjectId("..."), mainImage: "https://cdn.example.com/p/001/main.jpg", images: ["https://cdn.example.com/p/001/1.jpg", "..."], attributes: { "品牌": "某品牌", "型号": "BT-200", "蓝牙版本": "5.3", "颜色": ["黑色", "白色"] }, detail: "<html>富文本详情</html>", status: "onSale", // onSale / offSale / deleted createdAt: ISODate("2025-06-01T12:00:00Z") } // 商品SKU集合:skus { _id: ObjectId("..."), spuId: ObjectId("..."), skuNo: "SKU20250601001001", spec: { "颜色": "黑色", "版本": "256G" }, barcode: "6901234567890", price: 1999, // 单位:分,避免浮点误差 marketPrice: 2399, stock: 1000, sales: 126, image: "https://cdn.example.com/p/001/black.jpg", status: "onSale" }第二种是一个商品一个文档,把SKU嵌套进去,适合SKU种类少、属性层级浅的业务,比如卖茶叶、卖手工艺品的小型商城。这种模型查询最爽,一条find就拿到商品所有信息,但缺点是SKU一多文档就会变得巨胖,而且修改单个SKU价格时得更新整个大文档。
我个人对平台型电商更推荐SPU/SKU分集合的方案。原因有三:第一,SKU的库存和价格是高频更新字段,独立集合可以大幅降低写放大;第二,购物车和订单引用SKU ID比引用嵌在文档里的子对象更自然;第三,索引设计更灵活,比如按SKU的price排序、按spuId查所有SKU,都能走独立索引。
2.3 购物车:高频读写,轻量建模
购物车是个特别典型的MongoDB适用场景。它的特点是:读写频率极高,但单条数据量极小,且强一致要求不高。MySQL里购物车通常是一张大表,每个用户买过的东西都往里面插行,量大之后索引膨胀很快。
MongoDB里我偏好“用户维度一行文档,SKU维度嵌套数组”的设计,也就是每个用户只有一个购物车文档,里面嵌套N个购物项。
// 购物车集合:carts { _id: ObjectId("..."), userId: ObjectId("..."), items: [ { skuId: ObjectId("..."), spuId: ObjectId("..."), title: "某品牌 无线蓝牙耳机 主动降噪", spec: { "颜色": "黑色", "版本": "256G" }, price: 1999, // 加入时的快照价,结算时重新校验 quantity: 2, checked: true, addedAt: ISODate("2025-06-08T15:20:00Z") } ], updatedAt: ISODate("2025-06-08T15:20:00Z") }选这个结构的原因很简单:用户加购、改数量、勾选、删商品,全部操作都落在同一个文档里。一个update就能完成“把商品A数量改成3并且勾选B”这种组合操作,避免了跨文档事务。
这里的price字段存的是加购时的快照,是一个很重要的产品细节。用户A加购时商品卖1999,三天后降价到1899,购物车里显示什么?大部分电商选择结算时以最新价格为准,所以这个快照一般只用于展示和对比,真正的实付价必须在提交订单时重新查询SKU价格并做校验。
2.4 库存:单独集合、原子更新是底线
库存模型最容易踩坑,我见过不少人在SKU文档里直接放一个stock字段,下单扣减就走一条findOneAndUpdate。这在并发量低的时候没问题,一旦流量上来,超卖问题就会爆雷。
核心问题在于:MongoDB默认存储引擎不支持行级锁,两个并发事务同时读到一个库存为1的SKU,各自减1,最终库存可能变成-1。这当然是可以通过严谨的条件更新语句来缓解的,但更稳健的做法是设计库存的两段式结构:
// SKU集合中跟库存相关的字段(如果是高并发秒杀场景,建议独立库存集合) { skuId: ObjectId("..."), availableStock: 100, // 可售库存 lockedStock: 0, // 已锁定(已下单未支付)库存 soldStock: 900 // 已售库存 }扣减库存必须用原子更新操作符,且必须带条件判断:
db.skus.updateOne( { _id: skuId, availableStock: { $gte: quantity } // 前置条件:可售库存足够 }, { $inc: { availableStock: -quantity, lockedStock: quantity } // 原子自减 } )检查modifiedCount,如果为0说明库存不足或并发冲突,直接返回“已售罄”,不需要额外加锁。这种基于条件更新的乐观并发控制,是MongoDB处理库存扣减最优雅的方式。
秒杀这类极端场景,可以把SKU库存单独拆成sku_stocks集合,一个SKU一个文档,配合分片把高热的SKU散到不同分片节点,但多数中小电商用上面这个方案就足够了。库存方案的选择还要看你是否用事务:如果你的事务覆盖了“扣库存+建订单”两个写操作,那么库存的一致性由事务来兜底,条件更新依然保留,作为容错和性能优化的手段。
3. 嵌套还是引用?一张决策表解决大部分建模纠结
很多初学者反复纠结“这个字段到底该嵌进去还是该放ID引用”,其实MongoDB官方文档和社区实践早就总结出了一套可量化的标准。我自己做了一个决策表,每次建模遇到选择困难就拿出来过一遍。
| 判断维度 | 选择嵌套(Embedded) | 选择引用(Reference) |
|---|---|---|
| 数据访问方式 | 总是随父文档一起读取 | 需要独立访问、或者访问频率不随父文档 |
| 数据更新频率 | 低频更新(如订单快照中的商品名称) | 高频更新(如SKU价格、库存) |
| 数据量级 | 子项数量有限且稳定(地址不超过20个,订单明细一般不超过50条) | 子项无限增长(订单流水、操作日志、评论) |
| 一致性要求 | 可以容忍轻微冗余,展示时拿本地字段 | 需要保证单一数据源,不允许出现两处价格不一致 |
| 查询需求 | 几乎总是从父文档入口出发查询 | 存在按子项属性独立查询的需求(如查所有过期的优惠券) |
| 关系粒度 | 1对1、1对少 | 1对多、多对多 |
拿订单和订单明细来举例。订单明细确实可以嵌套在订单文档里,而且多数情况下就应该嵌套——查询订单时你肯定希望一次性拿到所有明细,而不是先查订单再循环查明细。但如果你要做“按商品维度统计销量”,明细嵌在订单里就会让你写出一条复杂的聚合管道去$unwind,性能远不如建一个独立的order_items集合做引用。
这个矛盾怎么解?我的经验是:主链路上的“读顺路”字段优先嵌套,次链路需要独立分析的字段优先引用。订单明细既需要在读订单时顺路拿到,又需要在做经营分析时独立聚合,那就在订单文档里嵌套一份完整快照,同时单独建一个order_items集合存引用数据——这就是所谓的“双写”。
3.1 评论系统的最佳实践:引用为主,冗余摘要字段
商品评论是有名的“假嵌套重灾”区域。新手常常会把评论直接嵌进商品文档里,评价一多,商品文档变成几十兆的巨型文档,每次查询商品详情都要把全部评论拖出来,性能直接崩盘。
正确的做法是:商品与评论分离集合,评论只存商品ID引用;评论里冗余出商品标题和SKU规格文本,用来展示和过滤。
// 评价集合:reviews { _id: ObjectId("..."), productId: ObjectId("..."), // 引用商品SPU skuId: ObjectId("..."), // 引用具体SKU userId: ObjectId("..."), // 引用用户 orderId: ObjectId("..."), // 引用订单,防刷单校验用 rating: 5, // 1~5星 content: "音质不错,降噪效果超出预期", images: ["https://cdn.example.com/review/123/1.jpg"], reply: { content: "感谢支持,欢迎再次光临", repliedAt: ISODate(...) }, likeCount: 12, createdAt: ISODate("2025-06-09T10:00:00Z") }这里的关键决策是评论集合绝不内嵌在商品里,但商品集合可以冗余一个“评论摘要”字段,比如评分总数、平均分、评论数量。这个摘要字段在商品详情页是高频展示数据,每次实时聚合开销太大,冗余在商品文档里,评价新增时做一次$inc原子更新,完全够用。
3.2 分类与品牌:多对多关系的经典处理
电商的分类体系是树形结构,品牌则与分类构成多对多关系。MongoDB处理树形分类的常用手段是在分类文档里存parentId和level字段,一次性查全量再在应用层组装成树,因为分类表通常是几千条级别的数据,全量加载毫无压力。
品牌和分类的多对多关系,我建议在分类文档里冗余brandIds数组,而不是在品牌文档里存categoryIds。理由是业务查询入口通常从“某个分类”点进去“只看某品牌”,从分类出发过滤品牌是高频路径;反过来从品牌出发查全部分类,应用层处理即可,低频需求不需要刻意建模。
// 分类集合:categories { _id: ObjectId("..."), name: "耳机/耳麦", parentId: ObjectId("..."), // 顶级分类时为空 level: 2, path: "数码/耳机耳麦", // 冗余的完整路径,前端展示用 brandIds: [ObjectId("..."), ObjectId("...")], sortOrder: 10, status: "enabled" }4. 订单的两次落盘:购物快照和履约记录为什么要分开
4.1 订单主文档:一次下单,全量快照
订单是整个电商系统里信息密度最高的文档,它的核心特征是快照性。用户在支付那一瞬间看到的商品、价格、优惠、地址,必须原封不动地保存下来。之后无论商品改名、涨价、下架,都绝不能影响这笔订单的历史事实。
// 订单集合:orders { _id: ObjectId("..."), orderNo: "ORD202506101234567890", userId: ObjectId("..."), // 用户快照,冗余常用信息,避免JOIN用户表 userSnapshot: { nickname: "某某某", phone: "13800138000" }, // 地址快照 addressSnapshot: { receiver: "张三", phone: "13800138000", province: "浙江省", city: "杭州市", district: "西湖区", detail: "某路某号某幢某室" }, // 商品明细快照 items: [ { skuId: ObjectId("..."), spuId: ObjectId("..."), title: "某品牌 无线蓝牙耳机 主动降噪", spec: { "颜色": "黑色", "版本": "256G" }, image: "https://cdn.example.com/p/001/black.jpg", price: 1999, // 成交单价(分) quantity: 2, subtotal: 3998, extra: { couponId: ObjectId("..."), discount: -200 } } ], // 金额汇总,全部单位用分 amountSummary: { totalGoodsAmount: 3998, // 商品总价 totalDiscount: -200, // 优惠总额 freightAmount: 0, // 运费 payableAmount: 3798, // 应付金额 paidAmount: 3798 // 实付金额 }, // 订单状态机 status: "paid", // pending / paid / shipped / completed / cancelled / refunding / refunded statusHistory: [ { status: "pending", time: ISODate("2025-06-10T12:00:00Z") }, { status: "paid", time: ISODate("2025-06-10T12:05:00Z") } ], payment: { method: "wechat", // wechat / alipay / balance transactionId: "wx1234567890", paidAt: ISODate("2025-06-10T12:05:00Z") }, logistics: { company: "SF", trackingNo: "SF1234567890", shippedAt: ISODate("2025-06-11T09:00:00Z") }, createdAt: ISODate("2025-06-10T12:00:00Z") }这个文档涵盖了你查订单详情时需要的几乎一切:用户是谁、买了什么、多少钱、怎么付、送到了哪、现在什么状态。一次findOne全搞定,没有任何$lookup,这就是嵌套模型的威力。
命名上我用userSnapshot、addressSnapshot、items这种语义,就是想强调“快照”和“实时数据”的区别。很多人不重视命名,字段叫user、address,结果下游业务误以为这些数据会和用户表保持同步,写出与预期不符的逻辑,这是非常隐蔽的坑。
4.2 拆分订单明细集合:给经营分析留一条后路
我知道有人看到4.1节的嵌套写法会问:那我要做“近30天热卖TOP10”怎么办?从订单文档里$unwind出来聚合,数据量小的时候没问题,但量一旦上来就很吃力。
我的建议是:订单明细双写。订单主文档里嵌套快照满足“读单顺路”;同时另建order_items集合,每条记录对应订单里的一个商品行。
// 订单明细集合:order_items { _id: ObjectId("..."), orderId: ObjectId("..."), orderNo: "ORD202506101234567890", userId: ObjectId("..."), skuId: ObjectId("..."), spuId: ObjectId("..."), title: "某品牌 无线蓝牙耳机 主动降噪", spec: { "颜色": "黑色", "版本": "256G" }, price: 1999, quantity: 2, subtotal: 3998, status: "paid", // 跟随订单状态演变 createdAt: ISODate("2025-06-10T12:00:00Z") }这个集合的定位是分析型数据源,所有围绕商品维度、用户维度的统计都走它。可能你会担心双写带来的数据一致性问题——这在MongoDB里可以用事务解决,两个写操作放进同一个事务里,要么都成功要么都回滚。后面的章节我会专门讲事务。
4.3 售后与退款:独立集合比字段堆叠更清爽
常见的错误做法是在订单文档里堆一堆refundStatus、refundAmount、refundReason字段,一次退款写进去,两次退款就装不下了。售后是有独立生命周期的业务,退换货可能走好几轮,必须独立集合。
// 售后集合:after_sales { _id: ObjectId("..."), asNo: "AS20250610001", orderId: ObjectId("..."), orderNo: "ORD202506101234567890", userId: ObjectId("..."), skuId: ObjectId("..."), quantity: 1, type: "refund", // refund / return_refund / exchange reason: "商品与描述不符", status: "pending", // pending / approved / rejected / completed refundAmount: 1999, evidenceImages: ["https://cdn.example.com/as/1.jpg"], createdAt: ISODate("2025-06-11T14:00:00Z"), processedAt: ISODate("2025-06-11T15:30:00Z") }订单集合里只要能感知“有售后中/已售后”的状态标记即可,具体流程全在售后集合里查。这样订单文档不会被无休止的售后变更污染,聚合分析售后数据也更直接。
5. 强烈建议:建模阶段就规划好的索引与分片策略
5.1 索引设计:电商查询三板斧
MongoDB的索引设计直接决定接口延迟。电商系统最常见的查询模式就那么几类,索引必须提前规划,而不是等慢查询出现后再补救。
第一板斧:用户维度的订单查询。用户端的“我的订单”列表、按状态筛选订单,是最高频的查询之一。对应的索引是复合索引{ userId: 1, status: 1, createdAt: -1 }。顺序很关键:先按userId等值命中,再按status过滤,最后按创建时间倒序返回。这样分页查“某用户待发货订单”就能走索引有序扫描,不需要内存排序。
db.orders.createIndex( { userId: 1, status: 1, createdAt: -1 }, { name: "idx_user_status_time" } )第二板斧:运营后台的订单查询。后台运营经常按订单号、手机号、时间范围查订单。订单号要建唯一索引,手机号查询如果在用户集合里,先查出userId再查订单;如果要在订单集合里直接搜,就得在addressSnapshot.phone上建索引。这类查询一般量级不大、频率不高,普通索引即可。
第三板斧:商家维度的商品与SKU查询。商家后台要看自己店铺的SKU列表,skuId在SKU集合就是主键,天然覆盖。但你要按spuId查一个SPU下的所有SKU,就必须给skus.spuId建索引。商品上架/下架的批量操作经常按status过滤,{ status: 1, updatedAt: -1 }也要建上。
5.2 分片键的噩梦:热文档问题
电商平台的订单集合是天然的“写热点”集合——几乎每个订单都有独立的_id,写入会均匀分布到各个分片,这没问题。但如果你天真地选择了userId作为分片键,一个高频购买用户的订单全部落在一个分片上,这个分片节点就变成了热点,其他分片闲置。
更麻烦的是商品集合。如果有爆款商品,它的SKU文档会被高频访问。以spuId做分片键,一个爆款商品的SKU全部挤在一个分片,查询压力全落在单节点上。这类问题没有完美解法,只能靠冗余缓存前置,比如把SKU热点数据放到Redis,MongoDB分片主要承担持久化职责。
我的建议是,如果不需要海量数据支撑,中小电商先用副本集架构,主节点负责读写,副本节点承担读扩展和容灾。等单节点写入吞吐确实扛不住了,再横向扩展。分片键选_id(哈希分片)永远是最不容易出错的起步选择。
5.3 TTL索引:让垃圾数据自己消失
购物车数据、登录Token、短信验证码这种有时效性的数据,可以给createdAt字段设置TTL索引,到期后MongoDB后台线程自动删除文档。
db.carts.createIndex( { updatedAt: 1 }, { expireAfterSeconds: 30 * 24 * 3600 } // 30天未更新自动清理 )需要注意TTL索引的扫描周期是60秒一次,不是精确到期立即删除;过期字段必须是日期类型,不能是字符串。购物车建议以updatedAt为准而不是createdAt,因为用户可能30天前加的购物车,但昨天又改过数量,这个购物车不能被清掉。
6. 事务、并发扣减和唯一约束:MongoDB建模最容易翻车的三件事
6.1 什么时候必须上事务,什么时候可以用最终一致性
很多同学对MongoDB事务又爱又怕。爱的是4.0版本开始支持多文档事务,怕的是用不好性能崩、锁冲突。我总结了一套判断标准:
必须用事务的场景:订单创建过程(扣库存+生成订单+清购物车)、退款过程(更新售后单+回补库存+改订单状态)。这类操作如果中途失败,数据会陷入不一致状态。
可以不用事务的场景:用户改昵称(即使失败也只是昵称没更新)、点赞评论数(用$inc原子操作即可,不用事务包一层)、浏览历史记录(丢了就丢了)。这些操作追求的是“最终正确”而不是“实时强一致”。
订单创建的事务写法:
const session = client.startSession(); session.startTransaction(); try { // 1. 条件更新扣减可售库存 const stockResult = db.skus.updateOne( { _id: skuId, availableStock: { $gte: quantity } }, { $inc: { availableStock: -quantity, lockedStock: quantity } }, { session } ); if (stockResult.modifiedCount === 0) { throw new Error("库存不足"); } // 2. 插入订单文档(含明细快照) db.orders.insertOne(orderDoc, { session }); // 3. 删除购物车中对应的条目 db.carts.updateOne( { userId: userId }, { $pull: { items: { skuId: skuId } } }, { session } ); await session.commitTransaction(); } catch (err) { await session.abortTransaction(); throw err; } finally { session.endSession(); }这段事务里最核心的是第一步的条件更新。availableStock: { $gte: quantity }这个条件不仅保证不超卖,配合事务还避免了一个经典问题:两个事务同时读到库存1,同时尝试减1,在事务隔离级别下可能造成脏读。加上条件更新后,即使并发执行,也只有一个事务能拿到modifiedCount === 1。
6.2 唯一约束:MongoDB的软肋,用幂等键弥补
MongoDB单字段唯一索引很好建,但复合唯一约束就很头疼——它不像MySQL那样支持UNIQUE KEY (order_no, sku_id)的组合唯一。电商里最常见的重复写场景是:客户端网络超时重试,同一次下单请求被发了两次,生成两个订单。
解法是在订单集合里加一个idempotentKey字段,存业务层面的“幂等键”(比如用户ID+时间戳+来源请求ID拼接的字符串),对它建唯一索引。第二次重复请求过来试图插入相同idempotentKey时,插入会因唯一索引冲突而失败,应用层捕获这个错误后直接返回第一次的成功结果即可。
db.orders.createIndex( { idempotentKey: 1 }, { unique: true } )这个字段不算业务字段,纯粹是为系统健壮性设计的,但价值极大。凡是外部系统对接、客户端重复提交高发的场景,我都强烈建议留一个这样的键。
6.3 并发场景下的乐观锁:版本号还是条件更新?
MongoDB没有MySQL的SELECT ... FOR UPDATE,但乐观锁足够应对绝大多数电商场景。两种实现方式:
方式一:版本号字段。文档里加version: 1,更新时条件带上version: 1,更新成功后$inc: { version: 1 }。修改行数0说明期间被别人改过,重试即可。
方式二:条件更新。这是我在库存扣减那里讲过的思路,直接依赖业务条件本身,比版本号少一个字段。但业务条件不足时(比如“更新商品标题”没有自然条件可用),版本号更通用。
我个人的习惯是:库存、优惠券数量这种有明确“剩余量”语义的用条件更新;普通的资料编辑用版本号。两者都是行级粒度的乐观控制,在MongoDB里就是“带条件的updateOne+检查modifiedCount”,成本极低,强烈推荐默认都带上。
6.4 数据库模型迭代的演进路径
最后聊一下“第一次就把模型设计完美”的执念。我见过太多团队在建模讨论会上花好几个星期,试图把所有未来需求都塞进第一版模型里,结果上线后发现业务变了,被迫做迁移,痛苦不堪。MongoDB的文档模型天然支持演进,刚开始不必追求极致。
一个务实的演进路径是这样的:
第一版:把核心链路跑通。用户、商品、SKU、订单、购物车、库存,全部嵌套化优先,能放一个文档就不拆两个文档。此时你获得的是开发速度最快、代码最直观的模型。
第二版:跑出规模后复盘。哪些查询开始变慢了?哪些文档变得过大会影响更新性能?把order_items拆出来解决分析查询慢的问题,把评论从商品文档挪到独立集合解决大文档问题。
第三版:引入分片。用户量上来了,订单量上来了,按业务维度做归档,冷热数据分离,热点集合做分片。
这个路线的核心思想是:MongoDB建模是“演进式”的,不是“规划式”的。它没有MySQL那种“建错表就要付出惨痛迁移代价”的恐惧,让你能把更多精力花在业务逻辑上。但这不代表可以乱来,嵌套/引用的取舍、索引的规划、事务边界的判断,这些基础功夫在第一版就得做对,否则后续演进要还的债会很多。
我在实际项目中对比过好几轮,最痛的一次教训是,没建复合索引就放量上线,结果首页数据从200毫秒涨到2秒,排查后补上{ categoryId: 1, status: 1, sales: -1 }复合索引,直接降回50毫秒。MongoDB的慢查询从来不是引擎慢,而是索引设计没跟上访问模式。建模阶段多想一步,后续运维能少熬好几个通宵。