不能用固定字段存租户自定义字段,因硬编码导致频繁altercollection、跨租户字段名冲突、索引爆炸;应采用嵌入式属性模式,以tenant_id+ns隔离,custom_fields数组存储带命名空间的键值对,并建立含tenant_id前缀的复合索引。

为什么不能用固定字段存租户自定义字段
SaaS场景下,每个租户都可能要求添加自己独有的字段(比如教育客户要grade_level,电商客户要sku_category),硬编码字段会立刻卡死:每次新增字段就得alterCollection,生产环境不敢动;不同租户字段名冲突(status在A租户是订单状态,在B租户是用户审核状态);索引爆炸——为每个租户字段单独建索引不现实,全用$or查又没法走索引。
必须用嵌入式属性模式(Attribute Pattern)
核心是把“字段”降级为“数据”,统一收口到一个数组字段里,每条记录是一个带命名空间的键值对。推荐结构如下:
{
"_id": "u123",
"tenant_id": "t_abc",
"basic": { "name": "张三", "email": "zhang@example.com" },
"custom_fields": [
{ "k": "grade_level", "v": "高三", "ns": "t_abc" },
{ "k": "preferred_contact", "v": "wechat", "ns": "t_abc" },
{ "k": "sku_category", "v": "electronics", "ns": "t_def" }
]
}
ns字段强制隔离租户上下文,避免跨租户污染;k和v保持扁平,便于索引与查询。不要把v设成对象或数组——MongoDB对嵌套对象的索引有深度限制,且$elemMatch性能明显下降。
索引必须复合且带租户前缀
只建{ "custom_fields.k": 1, "custom_fields.v": 1 }是错的,会导致跨租户误匹配。正确索引必须包含tenant_id和ns:
db.users.createIndex({ "tenant_id": 1, "custom_fields.k": 1, "custom_fields.v": 1 })
- 如果常按租户+字段名查所有值(比如导出某租户全部
grade_level),再加一个:db.users.createIndex({ "tenant_id": 1, "custom_fields.k": 1, "custom_fields.v": 1 }, { partialFilterExpression: { "custom_fields.k": { $exists: true } } })
没有tenant_id前缀的索引,在多租户查询时无法过滤掉无关数据,实际就是全表扫描。
写入时避免覆盖历史自定义字段
常见错误是直接$set: { custom_fields: [...] },结果把租户之前存的所有自定义字段清空。安全做法分两种:
- 新增单个字段:用
$push + $each,例如{ $push: { custom_fields: { $each: [{ "k": "xxx", "v": "yyy", "ns": "t_abc" }] } } }
- 批量更新多个字段且需去重:用
$addToSet,但注意它对对象去重要求**所有字段完全一致**,所以带ts或ns的字段不适合靠它去重;更稳妥的是先$pull再$push
custom_fields数组长度没做硬限制,但建议应用层控制单文档不超过50条,否则影响传输与解析效率——这不是MongoDB限制,而是HTTP payload和前端渲染的实际瓶颈。
{
"_id": "u123",
"tenant_id": "t_abc",
"basic": { "name": "张三", "email": "zhang@example.com" },
"custom_fields": [
{ "k": "grade_level", "v": "高三", "ns": "t_abc" },
{ "k": "preferred_contact", "v": "wechat", "ns": "t_abc" },
{ "k": "sku_category", "v": "electronics", "ns": "t_def" }
]
}
ns字段强制隔离租户上下文,避免跨租户污染;k和v保持扁平,便于索引与查询。不要把v设成对象或数组——MongoDB对嵌套对象的索引有深度限制,且$elemMatch性能明显下降。
索引必须复合且带租户前缀
只建{ "custom_fields.k": 1, "custom_fields.v": 1 }是错的,会导致跨租户误匹配。正确索引必须包含tenant_id和ns:
db.users.createIndex({ "tenant_id": 1, "custom_fields.k": 1, "custom_fields.v": 1 })
- 如果常按租户+字段名查所有值(比如导出某租户全部
grade_level),再加一个:db.users.createIndex({ "tenant_id": 1, "custom_fields.k": 1, "custom_fields.v": 1 }, { partialFilterExpression: { "custom_fields.k": { $exists: true } } })
没有tenant_id前缀的索引,在多租户查询时无法过滤掉无关数据,实际就是全表扫描。
写入时避免覆盖历史自定义字段
常见错误是直接$set: { custom_fields: [...] },结果把租户之前存的所有自定义字段清空。安全做法分两种:
- 新增单个字段:用
$push + $each,例如{ $push: { custom_fields: { $each: [{ "k": "xxx", "v": "yyy", "ns": "t_abc" }] } } }
- 批量更新多个字段且需去重:用
$addToSet,但注意它对对象去重要求**所有字段完全一致**,所以带ts或ns的字段不适合靠它去重;更稳妥的是先$pull再$push
custom_fields数组长度没做硬限制,但建议应用层控制单文档不超过50条,否则影响传输与解析效率——这不是MongoDB限制,而是HTTP payload和前端渲染的实际瓶颈。
db.users.createIndex({ "tenant_id": 1, "custom_fields.k": 1, "custom_fields.v": 1 })grade_level),再加一个:db.users.createIndex({ "tenant_id": 1, "custom_fields.k": 1, "custom_fields.v": 1 }, { partialFilterExpression: { "custom_fields.k": { $exists: true } } })
$set: { custom_fields: [...] },结果把租户之前存的所有自定义字段清空。安全做法分两种:
- 新增单个字段:用
$push+$each,例如{ $push: { custom_fields: { $each: [{ "k": "xxx", "v": "yyy", "ns": "t_abc" }] } } } - 批量更新多个字段且需去重:用
$addToSet,但注意它对对象去重要求**所有字段完全一致**,所以带ts或ns的字段不适合靠它去重;更稳妥的是先$pull再$push
custom_fields数组长度没做硬限制,但建议应用层控制单文档不超过50条,否则影响传输与解析效率——这不是MongoDB限制,而是HTTP payload和前端渲染的实际瓶颈。











