多租户(Multi-Tenancy)是 SaaS 平台的必修课:一套基础设施服务成千上万客户,既要隔离数据、又要共享成本。MongoDB 的文档模型天然适合租户字段嵌入,但"适合"不等于"简单"。隔离粒度的选择、复合索引的设计、资源治理的手段、以及数据删除的合规要求,都直接决定系统能撑到多大、出问题时多难收拾。本文将从三种主流隔离方案讲起,深入多租户下的查询索引、资源治理、分片分布与租户生命周期管理。
1. 租户隔离方案对比
多租户隔离的核心决策是:多个租户的数据如何在物理/逻辑上分开。MongoDB 世界有三种经典方案,各有明确的适用边界。
| 方案 | 隔离粒度 | 优势 | 代价 |
|---|---|---|---|
| 每租户一库 | 数据库级 | 隔离最强,备份恢复独立 | 连接管理复杂、资源隔离成本高 |
| 集合前缀 | 集合级 | 中等隔离、管理直观 | 无法按租户独立备份,运维繁琐 |
| 共享集合 + 复合键 | 文档级 | 资源利用率最高,索引统一 | 隔离弱,需代码纪律与校验兜底 |
1.1 每租户一库(Database per Tenant)
为每个租户创建独立数据库(甚至独立集群),是最彻底的隔离。适合大客户、合规要求高、需要独立备份恢复的场景。
// 为租户创建独立库
db.getSiblingDB("tenant_acme_orders").orders.insertOne({
orderNo: "A-1001",
amount: 1200,
status: "shipped"
})
// 租户级备份/恢复(mongodump --db)
// mongodump --uri="$MONGO_URI" --db=tenant_acme_orders --out=/backup/acme
优点:单租户故障不会波及其他租户,可以按租户做点恢复、按租户定价。缺点:租户数量大时数据库实例/连接数激增,管理成本高;对共享资源池的方案,还需要在应用层维护"租户 → 连接串"的映射。
1.2 集合前缀(Collection Prefix per Tenant)
同一库内用前缀区分租户集合(tenant_abc_orders)。隔离适中,管理比"每租户一库"轻,但集合数量随租户线性增长,且无法按租户单独做备份。
// 集合名 = 前缀 + 业务名
db.getCollection("tenant_abc_orders").insertOne({ orderNo: "B-2002" })
// 按租户遍历集合
db.getCollectionNames().filter(n => n.startsWith("tenant_abc_"))
适用场景:租户数量中等(几十到几百)、隔离要求一般、需要快速上线的 SaaS。
1.3 共享集合 + 复合键(Shared Collection + tenantId)
所有租户共享同一集合,文档内用 tenantId 字段区分归属。资源利用率最高、索引统一、运维最简,是规模化 SaaS 的主流选择,但对代码纪律要求最高。
db.orders.insertOne({
tenantId: "t_1001",
orderNo: "C-3001",
amount: 99,
createdAt: new Date()
})
// 强制约束:任何查询都必须带 tenantId(应用层约定 + 校验兜底)
db.orders.createIndex({ tenantId: 1, createdAt: -1 })
| 方案 | 最大租户规模 | 隔离强度 | 备份恢复 | 运维复杂度 | 首选场景 |
|---|---|---|---|---|---|
| 每租户一库 | 数百 | 最强 | 按库 | 高 | 大客户/合规 |
| 集合前缀 | 数千 | 中 | 按前缀脚本 | 中 | 中型 SaaS |
| 共享集合 | 十万+ | 弱-中 | 全量 | 低 | 规模化 SaaS |
决策铁律:隔离方案应在第一版就定好。从"共享集合"改造成"每租户一库"需要数据搬迁,而从"每租户一库"收敛到"共享集合"同样涉及全部应用的改造。没有完美的方案,只有适配业务形态的方案。
2. 查询与索引的多租户设计
多租户场景下,查询的第一过滤条件永远是 tenantId。所有索引设计都必须以 tenantId 作为复合索引的前缀(最左原则),否则就会出现"扫全表 + 过滤租户"的灾难。
// 正确:tenantId 前置,查询可被索引裁剪
db.orders.createIndex({ tenantId: 1, status: 1, createdAt: -1 })
// 错误:tenantId 未在前缀,查询退化为 COLLSCAN
db.orders.createIndex({ status: 1, createdAt: -1 })
// 查询示例:某租户的待发货订单
db.orders.find({
tenantId: "t_1001",
status: "pending",
createdAt: { $gte: ISODate("2026-09-01T00:00:00Z") }
}).sort({ createdAt: -1 }).limit(50)
// explain 确认命中索引
db.orders.find({ tenantId: "t_1001", status: "pending" }).explain("executionStats")
2.1 视图隔离(View-based Isolation)
MongoDB 视图(view)可以基于共享集合定义"只见某个租户"的逻辑投影,配合应用层认证,能把租户过滤固化到数据库侧。
// 为租户定义视图:只暴露该租户的订单
db.createView("tenant_t_1001_orders", "orders", [
{ $match: { tenantId: "t_1001" } },
{ $project: { _id: 0, orderNo: 1, amount: 1, status: 1, createdAt: 1 } }
])
// 应用层使用视图
db.getCollection("tenant_t_1001_orders").find({ status: "shipped" })
注意:视图只读,不支持写入;视图定义不建索引,底层查询仍依赖
orders集合上的索引。视图适合"读隔离 + 字段裁剪",不适合作为写入入口。
2.2 Schema 校验兜底
共享集合方案最大的风险是漏写 tenantId 导致数据污染。用 $jsonSchema 校验器把 tenantId 设为必填,从数据库层拦截脏数据。
db.runCommand({
collMod: "orders",
validator: {
$jsonSchema: {
bsonType: "object",
required: ["tenantId", "orderNo"],
properties: {
tenantId: { bsonType: "string", pattern: "^t_[0-9]+$" }
}
}
},
validationLevel: "strict",
validationAction: "error"
})
| 隔离手段 | 位置 | 作用 |
|---|---|---|
| tenantId 复合索引 | 索引层 | 保证查询性能 |
| 视图 | 逻辑层 | 固化读隔离与字段裁剪 |
| $jsonSchema | 写入层 | 强制 tenantId 必填 |
| 应用层 AOP/中间件 | 应用层 | 注入 tenantId 上下文 |
3. 资源治理:连接、带宽与读写节流
多租户系统必须防止"一个租户拖垮所有人"。MongoDB 本身没有按租户的内建 QoS,资源治理需要组合拳:连接隔离、读写优先级、应用层限流。
3.1 连接治理
- 每租户一个应用连接池(或连接池分组),避免单租户耗尽全局连接
- 服务端
maxIncomingConnections设定硬上限,配合 ulimit
# mongod.conf
net:
maxIncomingConnections: 2000 # 硬上限
# /etc/security/limits.conf
mongod soft nofile 65535
mongod hard nofile 65535
3.2 读写节流与优先级
副本集可以通过 tags 把不同租户或读写分流到不同成员,实现对带宽和读能力的差异化分配:
// 为副本集成员打标签(示例:把报表租户读流量分流到特定 Secondary)
conf = rs.conf()
conf.members[1].tags = { "tenant-tier": "reporting" }
conf.members[2].tags = { "tenant-tier": "default" }
rs.reconfig(conf)
// 应用连接串指定 readPreferenceTags
// mongodb://app:pass@rs0:27017/?replicaSet=rs0&readPreference=secondaryPreferred&readPreferenceTags=tenant-tier:reporting
应用层限流(以 Node.js 中间件为例):
// 简单的按租户令牌桶限流(示意)
const buckets = new Map() // tenantId -> { tokens, last }
function acquireToken(tenantId, rate) {
const now = Date.now()
let b = buckets.get(tenantId)
if (!b) { b = { tokens: rate, last: now }; buckets.set(tenantId, b) }
b.tokens = Math.min(rate, b.tokens + (now - b.last) / 1000 * rate)
b.last = now
if (b.tokens < 1) throw new Error("rate_limited")
b.tokens -= 1
}
| 治理手段 | 粒度 | 作用 | 缺点 |
|---|---|---|---|
| 连接池分组 | 租户 | 连接资源隔离 | 增加连接总数 |
| readPreference tags | 租户/功能 | 读分流 | 需副本集成员支撑 |
| 应用限流 | 租户 | 请求级节流 | 需每租户配额策略 |
| writeConcern 差异化 | 租户 | 写入延迟分级 | 一致性权衡 |
重要:MongoDB 没有内建的"租户级 IOPS 限速"。真正的资源隔离要么靠"每租户独立集群/实例",要么靠应用层限流 + 分片分布 + 副本集标签的复合方案。不要让一个写入密集的大租户与众多小租户共享同一份 WiredTiger 缓存而不做任何节流。
4. 分片键与租户分布
当共享集合数据量或 QPS 超过单节点能力,需要分片。多租户系统的分片键设计有独特的约束:既要打散写入,又要尽量让单租户查询路由到尽量少的分片。
// 以 tenantId 为分片键(ranged):单租户数据聚到一个分片,查询隔离好
sh.shardCollection("shop.orders", { tenantId: 1, orderNo: 1 })
// 写入热点风险:超大租户独占一个分片
// 折中:复合分片键 或 hashed
sh.shardCollection("shop.orders", { tenantId: "hashed" })
| 分片键方案 | 写入均衡 | 单租户查询路由 | 超大租户表现 |
|---|---|---|---|
| tenantId (ranged) | 差(大租户集中) | 好(单分片) | 分片热点 |
| tenantId (hashed) | 好 | 差(广播到全部分片) | 均衡但查询慢 |
| { tenantId: 1, orderNo: 1 } | 中 | 中 | 可控 |
工程实践中的常见策略是:普通租户走 { tenantId: 1, ... } 复合分片键(范围查询收敛),对少数超大租户使用 Zone Sharding 单独分配到专用分片,避免热点拖累全局。
// Zone Sharding:把超大租户隔离到专用分片
sh.addShardToZone("shard-large", "LARGE_TENANTS")
sh.updateZoneKeyRange(
"shop.orders",
{ tenantId: "t_big", orderNo: MinKey },
{ tenantId: "t_big", orderNo: MaxKey },
"LARGE_TENANTS"
)
5. 计费与配额
多租户系统需要按租户计量资源用量,支撑计费与配额管控。MongoDB 侧的基础计量数据来自 dbStats 与 collStats。
// 统计某租户在共享集合中的数据量(按 tenantId 分组)
db.orders.aggregate([
{ $group: {
_id: "$tenantId",
docCount: { $sum: 1 },
// 用 $bsonSize 近似估算字节(代价高,可抽样)
totalBytes: { $sum: { $bsonSize: "$$CURRENT" } }
}},
{ $sort: { totalBytes: -1 } }
])
为避免全量 $bsonSize 的昂贵扫描,生产上更常见的是"按写入增量累计"的计量方式:在写入路径中把每次操作的租户、字节数累加到独立的计量集合。
// 计量集合:按天 + 租户累计写入量
db.usage.aggregate([
{ $match: { tenantId: "t_1001", day: "2026-09-27" } },
{ $group: { _id: null, total: { $sum: "$bytes" }, ops: { $sum: "$ops" } } }
])
配额管控推荐在应用网关层执行(读取配额表判断是否放行),数据库层只做被动统计:
| 配额维度 | 计量方式 | 超限动作 |
|---|---|---|
| 存储量 | 每日累计写入字节 | 写拒绝 / 通知 |
| QPS | 网关计数 | 限流 |
| 连接数 | 连接池分配 | 拒新连接 |
| 保留时长 | TTL 策略 | 自动过期 |
6. 数据删除租户与合规
租户注销、数据主体行使删除权(如 GDPR Article 17)时,需要可靠地清除租户数据。共享集合方案下,“删除租户"意味着精准删除该租户的所有文档。
// 删除某租户全部数据(大租户建议按 _id 分批,避免长事务)
const cursor = db.orders.find({ tenantId: "t_1001" }, { _id: 1 })
while (cursor.hasNext()) {
const doc = cursor.next()
db.orders.deleteOne({ _id: doc._id })
}
// 更可控的批量删除 + 验证
db.orders.deleteMany({ tenantId: "t_1001" })
db.orders.countDocuments({ tenantId: "t_1001" }) // 应为 0
每租户一库方案则直接 dropDatabase,干净且高效:
db.getSiblingDB("tenant_acme_orders").dropDatabase()
合规要点:
- 删除是"可验证的删除”:保留删除任务的执行记录与结果校验
- 备份中的历史数据:多数法规允许"备份中保留"但需可追溯的删除时间戳
- 若开启审计(audit log),删除操作本身也需留痕
警告:
deleteMany({ tenantId })在无索引保护时是全表扫描删除,会长时间占用写锁并产生大量碎片。删除大租户前,先在tenantId上确认索引存在,并在低峰期分批执行。
7. 迁移租户方案
租户隔离方案不是一成不变的。业务增长后,可能需要把某个大租户从共享集合"升迁"到独立库,或从独立库"回收"到共享集合。租户迁移的本质是"数据搬迁 + 连接切换",与数据库迁移一脉相承。
// 场景:把 t_big 从共享集合迁到独立库
// 1) 在目标库建好结构与索引
db.getSiblingDB("tenant_t_big").orders.createIndex({ orderNo: 1 })
// 2) 分批复制数据(示例:按 _id 范围游标)
const src = db.getSiblingDB("shop").orders
const dst = db.getSiblingDB("tenant_t_big").orders
src.find({ tenantId: "t_big" }).forEach(d => {
delete d.tenantId
dst.insertOne(d)
})
// 3) 校验两边计数与指纹
print(src.countDocuments({ tenantId: "t_big" }), dst.countDocuments({}))
// 4) 应用切换路由,5) 确认后清理源端
src.deleteMany({ tenantId: "t_big" })
| 迁移步骤 | 工具/手段 | 关键动作 |
|---|---|---|
| 全量拷贝 | 游标 + 分批插入 | 目标端先建索引 |
| 增量追平 | 双写 / 迁移期间停写 | 一致性校验 |
| 校验 | count + 抽样比对 | 两侧对账 |
| 切换 | 应用路由更新 | 灰度切换 |
| 清理 | deleteMany / drop | 保留审计记录 |
租户迁移的常见失败模式:迁移期间源端仍持续写入导致对不上账(需短暂停写或双写);目标库索引未建导致线上延迟暴涨(先建索引再迁数据);切换后未清理源端导致双重计费或数据残留。每一步都应有明确的验证动作与回滚路径。
8. 架构选型决策总结
多租户架构没有银弹,最终取决于租户规模、合规要求与运维能力:
- 租户少而大、合规严格 → 每租户一库,隔离最强
- 租户中等、团队规模有限 → 集合前缀 + 统一索引,平衡成本
- 租户海量、追求资源利用率 → 共享集合 + tenantId 复合索引 + 视图 + 校验器,配套应用层限流与分片设计
无论选择哪种方案,有三件事必须始终守住:所有查询/写入都带租户上下文并匹配索引;租户数据删除可验证、有审计;租户迁移有演练过的回滚预案。守住这三条底线,多租户系统的长期演进才不会变成"技术债黑洞"。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。