27. MongoDB 多租户与隔离架构设计

多租户隔离方案对比(每租户一库/集合前缀/复合键+视图)、多租户下的查询与索引设计、资源治理与读写节流、分片键与租户分布、计费配额与数据删除合规

多租户(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 复合索引 + 视图 + 校验器,配套应用层限流与分片设计

无论选择哪种方案,有三件事必须始终守住:所有查询/写入都带租户上下文并匹配索引;租户数据删除可验证、有审计;租户迁移有演练过的回滚预案。守住这三条底线,多租户系统的长期演进才不会变成"技术债黑洞"。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 26. MongoDB 监控与可观测性实践
  2. 25. MongoDB WiredTiger 存储引擎深入
  3. 24. MongoDB 数据迁移与同步实战