应采用嵌入式属性模式将标签存为tags数组,每项含k/v/ts字段,避免动态加字段;需建复合索引{"tags.k":1,"tags.v":1}及{"tags.ts":-1},冗余高频字段须配覆盖索引,并严格控制数组长度与分片键设计。

用嵌入式属性模式存标签,而不是加字段
直接给 users 集合加 is_ios_user、tag_trial_expired 这类字段,短期快,长期崩。每次新增标签都要 alterCollection,schema 很快失控,索引数量爆炸,$or 查询条件越写越长。更麻烦的是语义不一致——运营说的“高价值用户”和风控说的“VIP_level_3”,字段名不同但实际想查同一群人。
正确做法是把所有标签收进一个数组字段 tags,每项是 {k: "lifecycle_stage", v: "trial_expired", ts: ISODate(...)}。新增标签不改 schema,只往数组里 push 新对象;查询时用 {"tags.k": "lifecycle_stage", "tags.v": "trial_expired"} 即可。
- 必须建复合索引:
db.users.createIndex({"tags.k": 1, "tags.v": 1}),否则这类查询会全表扫描 -
v字段别存对象或数组,MongoDB 对嵌套结构的索引深度有限制,$elemMatch 性能也差 - 如果要按时间范围查(比如“最近7天被打的标签”),单独给
tags.ts建倒序索引:db.users.createIndex({"tags.ts": -1})
冗余高频读字段,但必须配覆盖索引
用户画像里有些字段被读得极多、改得极少,比如 user.name、user.city、category.name。把这些字段冗余到订单、行为日志等集合里,能避免多次跨集合 join 或聚合,把 nReturned 和 totalDocsExamined 拉到 1:1。
但冗余不是复制粘贴完就完事。没索引的冗余字段等于白加——explain 一看 totalDocsExamined 是 nReturned 的 10 倍,说明 MongoDB 还是在扫文档,只是字段从别处搬过来了。
- 所有投影字段(如
{user_name: 1, product_title: 1})必须进同一个复合索引,例如:db.orders.createIndex({status: 1, user_name: 1, product_title: 1}) - 检查
executionStats.totalKeysExamined是否 ≈nReturned;如果不是,要么索引没覆盖全,要么查询用了未索引字段 - 冗余字段更新时,writeConcern 必须设为
{w: "majority", j: true},否则 5000 条订单里部分成功部分失败,页面上同一用户显示两个名字
避免嵌套过深或无限增长的数组
标签数组 tags 看似灵活,但放任它无限制增长,会导致单文档体积膨胀、内存压力上升、传输带宽浪费,甚至触发 BSON 16MB 上限。更隐蔽的问题是:当数组元素超几百条后,$elemMatch 查询性能明显下降,索引选择性变差。
- 对历史标签做归档:比如只保留最近 90 天的活跃标签,老标签移入
tags_history子集合 - 批量更新时别用
$set: {tags: [...]},那会清空旧值;改用$push+$each+$slice控制长度,例如:{$push: {tags: {$each: [newTag], $slice: -200}}} - 别依赖
$addToSet去重带ts的标签——它要求对象所有字段完全一致,而时间戳天然不同
分片键设计要兼顾标签查询与用户主键路由
用户量上千万后,靠单节点扛不住。分片是必选项,但分片键选错,标签查询就废了。比如用 _id 当分片键,虽然路由均匀,但查“所有 device_os=ios 的用户”就得广播到所有分片;反过来,如果用 tags.k 分片,又会导致数据倾斜——k="lifecycle_stage" 的文档全挤在一个分片上。
现实解法是组合分片键:{uid: 1, shard_hint: 1},其中 uid 是用户唯一标识(保证单用户数据在同一分片),shard_hint 是预计算的哈希桶(比如按 city 或 region 取模),让同类标签查询能落在局部分片组里。
- 不要用动态字段(如
tags.v)直接做分片键,值分布不可控,极易倾斜 - 分片前先用
sh.status()观察 chunk 分布,确保没有单分片负载远高于其他 - 对跨分片的标签统计类查询(如“各城市 iOS 用户数”),接受一定延迟,走定期物化视图,别强求实时
tags.k 和 tags.v 不建索引,再好的结构也撑不过万级用户。还有就是冗余字段更新时 writeConcern 设得太松,线上出现“同个用户昵称一会是张三一会是李四”,这种问题排查起来比加索引还费时间。











