动态属性字段必须用扁平对象而非数组,如{"color":"black"};需服务端归一化、枚举校验、单字段索引、schema_version版本控制,并将SKU内嵌于商品文档以保障一致性。

动态属性字段必须用扁平对象,不能用数组存键值对
常见错误是把动态属性写成 [{k:"color",v:"Black"}] 这种数组结构。MongoDB 无法对这种结构高效索引或查询,find({ "attrs.k": "color", "attrs.v": "Black" }) 会命中所有含 k 字段的文档,结果不可靠,且 explain() 显示全表扫描。
正确做法是强制使用固定字段名的扁平对象:{"color": "black", "size": "m"}。这样既能用点号语法精准过滤(如 { "attrs.color": "black" }),也能为 attrs.color 单独建索引。
- 服务端必须做字段归一化:把前端传来的“颜色”“Color”“colour”统一映射为
color - 值必须来自预定义枚举(如
["black","white","blue"]),禁止自由字符串入库 - 数据库层加 validator:
skus.$.attrs.color类型为 string,且enum限定取值
高频筛选字段要单独建索引,别指望复合索引兜底
电商场景下,用户常按 attrs.color、attrs.size、price、status 组合筛选。如果只建一个 { "attrs": 1, "price": 1, "status": 1 } 复合索引,MongoDB 在部分查询条件下会跳过索引——比如只查 attrs.color 和 status,而没带 price,这个复合索引就失效。
实操建议是拆开建单字段索引:
db.products.createIndex({ "attrs.color": 1 })db.products.createIndex({ "attrs.size": 1 })db.products.createIndex({ "price": 1, "status": 1 })
注意:MongoDB 对嵌套超过 2 层的字段(如 spec.detail.color)建索引效果差,所以 attrs 必须扁平,最多一层嵌套。
schema_version 字段不是可选,是演进必需
动态属性上线后,业务很快会新增字段(如加个 material)、废弃字段(如停用 weight_class)、甚至改字段语义(size 从“尺码”变成“包装规格”)。没有版本标识,应用代码读到旧文档时会 panic 或逻辑错乱。
必须在文档根级加 schema_version 字段,并配合迁移策略:
- 新写入文档默认
schema_version: 2,老文档保持1 - 应用读取时先判断版本,再决定如何解析
attrs结构 - 后台跑定时任务逐步更新旧文档,避免一次性
updateMany导致锁表或慢查询
别依赖 MongoDB 的“无 Schema”特性放任不管——版本失控比加字段更难修复。
SKU 必须嵌套在商品文档里,别拆集合
看到“动态属性”就想到“每个 SKU 可能有不同属性”,于是有人把 sku 拆成独立集合,用 $lookup 关联。这是典型反模式:库存扣减要原子操作,$lookup 查完再 update 会丢数据;分页查商品列表时,一次请求得发 N 次查询,延迟直接翻倍。
正确结构是 product 文档内嵌 skus 数组:
{ "_id": "prod_123",
"name": "iPhone 15",
"skus": [
{ "sku_id": "sku_123-a", "attrs": {"color":"black","capacity":"256GB"}, "price": 7999, "stock": 42 },
{ "sku_id": "sku_123-b", "attrs": {"color":"white","capacity":"128GB"}, "price": 6999, "stock": 18 }
]
}
库存更新必须用 $inc 原子操作:db.products.updateOne({ "skus.sku_id": "sku_123-a" }, { $inc: { "skus.$.stock": -1 } })。嵌套不是为了省事,是为了保证强一致性。
真正容易被忽略的是:动态属性字段名和值的约束必须在服务端和数据库双层校验,光靠前端或文档 validator 不够——一旦非法数据混入,后续所有聚合、索引、迁移都会出问题。

















