多租户 SaaS 架构基础:隔离级别先想清楚

0阅读12分钟

多租户架构的决策起点

做 SaaS 产品到了一定阶段,几乎都会遇到同一个问题:新来了一个大客户,要求数据完全隔离,但运维团队只有三个人。这时候你才意识到,多租户不是加个 tenant_id 字段就完事的,它是一个贯穿数据层、应用层、运维层的系统性决策。

我早期参与的一个 B2B 项目就吃过这个亏。最初为了快速上线,所有租户数据放在同一张表里,用 tenant_id 做区分。上线半年,客户从 20 家涨到 200 家,问题开始集中爆发:某个客户的批量导出任务把整个数据库的查询拖慢了,其他客户的后台页面全部超时;更严重的是,一次 ORM 查询漏掉了 tenant_id 过滤条件,A 客户在后台看到了 B 客户的订单列表。虽然发现得快,没有造成实际损失,但这件事直接推动了整个架构的重构。

AWS 在 2020 年发布的《SaaS Tenant Isolation Strategies》白皮书里,把多租户隔离分成三种部署模型:桥接模型(Bridge)、池化模型(Pool)和独立模型(Silo)。这个分类框架到现在依然适用,区别只是云服务的实现方式在变。微软 Azure Architecture Center 也单独写了一篇 Noisy Neighbor Antipattern 的分析,专门讲共享资源场景下「嘈杂邻居」的检测和治理。

这篇文章把我在多租户架构上踩过的坑和学到的东西做个系统整理,重点放在数据隔离、权限边界、配置模型和运维可观测性这四个维度。

隔离模型的理论基础

多租户架构的核心矛盾很明确:共享带来成本优势,但也带来隔离风险。AWS 白皮书将隔离模型分为三类,每类对应不同的业务阶段和合规要求。

池化模型(Pool Model) 是所有租户共享同一套计算和存储资源。成本最低,运维最简单,适合早期产品或面向中小客户的标准化 SaaS。代价是隔离最弱,一个租户的异常操作可能影响所有人。

桥接模型(Bridge Model) 是计算资源池化共享,但数据层做分离——比如共享应用服务器,但每个租户有独立的 Schema 或独立的数据库。这是大多数 B2B SaaS 的最终选择,在成本和隔离之间取得平衡。

独立模型(Silo Model) 是每个租户拥有完全独立的计算、存储和网络资源。隔离最强,但运维成本随租户数量线性增长。通常只用于合规要求极严格的场景(金融、医疗、政务)或者付费意愿极高的企业客户。

微软在 Azure Architecture Center 里把「嘈杂邻居」问题单独列为一种反模式。核心观点是:共享资源必然存在噪声风险,不能完全消除,只能通过资源治理(Resource Governance)来降低发生概率和影响范围。具体手段包括配额执行(Quota Enforcement)、限流(Throttling)和基于优先级的队列调度。

下面这张表对比三种模型的核心差异:

维度池化模型 (Pool)桥接模型 (Bridge)独立模型 (Silo)
数据隔离逻辑隔离(tenant_id 过滤)Schema 隔离或库级隔离完全物理隔离
运维复杂度
单租户成本最低中等最高
适合阶段早期验证、中小客户成长期、B2B 中型客户合规要求、大型企业客户
嘈杂邻居风险
数据定制能力

数据隔离:贯穿全链路的租户边界

数据隔离是多租户架构里最关键也最容易出错的部分。很多团队以为在数据库查询里加上 WHERE tenant_id = ? 就完成了隔离,实际上租户边界需要贯穿从 API 入口到日志输出的全链路。

数据库层的三种隔离方案

数据库层的隔离方案通常分三级:共享表、独立 Schema、独立数据库。每一级的隔离强度和运维成本递增。

维度共享表 (Shared Table)独立 Schema (Schema per Tenant)独立数据库 (DB per Tenant)
隔离强度弱,依赖应用层过滤中,数据库引擎级隔离强,物理隔离
运维成本低,一套环境中,需要管理多 Schema 迁移高,每个租户独立备份、迁移
租户定制不支持有限支持完全支持
迁移难度中(需要 DDL 同步到每个 Schema)高(需要跨库协调)
法律合规难以满足数据驻留要求部分满足可满足数据驻留和物理隔离

Bytebase 团队在一篇多租户数据库模式分析中给出了明确的建议:新项目优先选择共享表模式,除非有严格的法律合规要求需要独立数据库。他们认为独立 Schema 模式是「两头不讨好」——既有独立数据库的运维负担,又缺乏足够的法律保护力度。

❌ 坏做法:在应用层手动拼 tenant_id 条件

// 每个查询都手动加 tenant_id,迟早会漏
async function getOrders(userId: string, tenantId: string) {
  // 风险:如果某个开发者忘了加 tenantId 参数,就造成数据越权
  return db.query(
    'SELECT * FROM orders WHERE user_id = ? AND tenant_id = ?',
    [userId, tenantId]
  )
}
 
async function getOrderStats(tenantId: string) {
  // 更危险:统计查询忘了加 tenant_id
  return db.query('SELECT COUNT(*) FROM orders')
}

✅ 好做法:用中间件或 ORM 插件自动注入租户过滤

// 基于请求上下文自动注入 tenant_id,消除人为遗漏
// 使用 Prisma 中间件示例
prisma.$use(async (params, next) => {
  const tenantId = ctx.get('tenantId')
  if (!tenantId) {
    throw new Error('Missing tenant context')
  }
 
  // 自动给所有查询追加 tenant_id 条件
  if (params.model && TENANT_SCOPED_MODELS.includes(params.model)) {
    params.args.where = {
      ...params.args.where,
      tenantId,
    }
  }
 
  return next(params)
})

❌ 坏做法:缓存键不带租户前缀

// 缓存键只用业务 ID,不同租户的数据会互相覆盖
function getUserCacheKey(userId: string) {
  return `user:${userId}`
}
// 租户 A 的 user:123 和租户 B 的 user:123 命中同一个缓存

✅ 好做法:缓存键始终包含租户维度

function getUserCacheKey(tenantId: string, userId: string) {
  return `tenant:${tenantId}:user:${userId}`
}

同样的原则适用于消息队列(Topic 按租户分片或消息头带 tenant_id)、文件存储(对象键包含租户目录)、日志输出(每条日志带 tenantId 字段)。租户边界不是某一层的责任,是全链路的约束。

流程图画布 · 115%
Mermaid 流程图加载中...

案例一:一次漏掉 tenant_id 导致的数据越权

场景:某 SaaS 后台管理系统,提供订单查询和订单统计功能。系统使用共享表模式,所有租户的订单数据在同一张 orders 表中。

翻车:统计报表模块有一个「本月订单总数」的查询,开发者在写 SQL 时只写了 SELECT COUNT(*) FROM orders WHERE status = 'completed',漏掉了 tenant_id 过滤。结果每个租户看到的统计数字是全平台的订单总数,包含了其他客户的数据。虽然统计数据本身不包含敏感明细,但这个错误暴露了系统的隔离缺陷。

修复

  1. 紧急修复:给统计查询加上 tenant_id 条件
  2. 系统修复:引入 ORM 中间件,对所有租户敏感模型的查询自动注入 tenant_id
  3. 防御修复:在 CI 中增加 SQL 审计规则,扫描所有不带 tenant_id 条件的查询语句
// 修复后的查询 —— 通过中间件自动注入,不再依赖开发者手动添加
// 中间件确保所有 TENANT_SCOPED_MODELS 的查询都带 tenant_id
const TENANT_SCOPED_MODELS = ['Order', 'User', 'Product', 'Invoice']
 
prisma.$use(async (params, next) => {
  const tenantId = ctx.get('tenantId')
 
  // 写操作也要拦截
  if (params.action === 'create' && params.model && TENANT_SCOPED_MODELS.includes(params.model)) {
    params.args.data = { ...params.args.data, tenantId }
  }
 
  return next(params)
})

案例二:嘈杂邻居拖垮全平台

场景:一个项目管理 SaaS,大约 300 个租户共享一个 PostgreSQL 实例和一套应用服务器。大部分租户是 10-50 人的小团队,但有一个客户是做电商的,每天下午 3 点会集中导出数据(几十万条记录)。

翻车:这个客户的导出任务触发大量慢查询,长时间占用数据库连接池。其他租户的正常操作(创建任务、更新状态)开始超时,工单量激增。运维团队一开始以为是数据库性能问题,扩容了实例规格,但导出任务的数据量也跟着增长,问题没有根治。

分析:这是典型的嘈杂邻居问题。微软在 Azure Architecture Center 里总结过,嘈杂邻居有两种触发方式:一种是单个租户用量超过系统容量;另一种是多个租户的用量叠加, collectively 超过容量。这个案例属于前者。

修复方案

  1. 限流:在 API 网关层实施按租户限流,使用令牌桶算法
// ❌ 坏做法:全局限流,无法区分租户
const rateLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 1000, // 所有租户共享 1000 次/分钟
})
 
// ✅ 好做法:按租户独立限流
const tenantRateLimiter = rateLimit({
  windowMs: 60 * 1000,
  // 每个租户独立配额
  keyGenerator: (req) => req.headers['x-tenant-id'],
  max: (req) => {
    // 根据租户套餐返回不同配额
    const plan = getTenantPlan(req.headers['x-tenant-id'])
    return PLAN_LIMITS[plan].requestsPerMinute
  },
  handler: (req, res) => {
    res.status(429).json({
      error: 'RATE_LIMITED',
      retryAfter: 60,
      message: '请求频率超出套餐限额,请稍后重试',
    })
  },
})
  1. 异步化重操作:大数据导出改为异步任务,放到独立的队列中处理,不占用主库连接池
// ❌ 坏做法:同步导出,长时间占用连接池
app.get('/api/export/orders', async (req, res) => {
  const orders = await db.query(
    'SELECT * FROM orders WHERE tenant_id = $1',
    [req.tenantId]
  )
  res.json(orders) // 数据量大时这个请求会卡几十秒
})
 
// ✅ 好做法:异步导出,不阻塞主流程
app.post('/api/export/orders', async (req, res) => {
  const job = await exportQueue.add('export-orders', {
    tenantId: req.tenantId,
    filters: req.body.filters,
  }, {
    priority: getTenantPriority(req.tenantId),
  })
 
  res.status(202).json({
    jobId: job.id,
    statusUrl: `/api/export/status/${job.id}`,
  })
})
  1. 租户级资源配额:为每个租户设置数据库连接数上限和查询超时时间
防护手段实施位置效果
API 限流API 网关防止单租户请求频率过高
查询超时数据库连接层防止慢查询长时间占用连接
连接池隔离应用层防止单租户耗尽数据库连接
异步化重操作任务队列避免导出/报表拖垮在线服务
优先级队列任务调度高付费租户的操作优先执行

案例三:从共享表迁移到独立 Schema 的阵痛

场景:一个 HR SaaS 产品,早期用共享表模式服务中小客户。随着客户增长,有几家大型企业客户提出了数据隔离的合规要求。团队决定将大客户迁移到独立 Schema 模式。

翻车:迁移过程中发现三个问题。第一,应用代码里大量原生 SQL 直接写死了表名(如 FROM users),没有 Schema 前缀,切换 Schema 后全部报错。第二,数据库迁移工具(Flyway)默认只管理 public Schema,不支持按租户执行迁移脚本。第三,跨租户的后台管理功能(如运营数据看板)需要查询所有租户的数据,但 Schema 隔离后这些查询全部失效。

修复方案

  1. 引入 Schema 路由中间件:在数据库连接层根据租户配置动态切换 search_path
// ✅ 在连接建立时设置 Schema 搜索路径
function createTenantConnection(tenantId: string, schemaName: string) {
  const conn = pool.connect()
  // PostgreSQL: 设置当前连接的 Schema 搜索路径
  await conn.query(`SET search_path TO ${schemaName}, public`)
  return conn
}
 
// 请求级别:从租户配置获取 Schema 名
app.use(async (req, res, next) => {
  const tenant = await getTenantConfig(req.tenantId)
  if (tenant.isolationLevel === 'schema') {
    req.db = await createTenantConnection(tenant.id, tenant.schemaName)
  } else {
    req.db = pool // 共享表模式的租户走默认连接
  }
  next()
})
  1. 迁移工具适配:编写脚本遍历所有租户 Schema 执行 DDL
// 迁移脚本:对所有租户 Schema 执行相同的 DDL 变更
async function migrateAllTenants(ddl: string) {
  const tenants = await db.query(
    "SELECT schema_name FROM tenants WHERE isolation_level = 'schema'"
  )
 
  for (const tenant of tenants.rows) {
    // 幂等设计:每个 Schema 独立执行,失败可重试
    await db.query(`SET search_path TO ${tenant.schema_name}`)
    await db.query(ddl)
    console.log(`Migrated schema: ${tenant.schema_name}`)
  }
}
  1. 跨租户查询走专用连接:后台运营看板使用不带 Schema 限制的专用数据库账号
// 跨租户查询使用专用连接,不走租户路由
const adminPool = new Pool({
  connectionString: config.adminDbUrl,
  // 不设置 search_path,可以跨 Schema 查询
})
 
// 运营看板:汇总所有租户数据
async function getPlatformStats() {
  const result = await adminPool.query(`
    SELECT t.name, COUNT(u.id) as user_count
    FROM tenants t
    JOIN ${t.schema_name}.users u ON u.tenant_id = t.id
    GROUP BY t.name
  `)
  return result.rows
}

迁移完成后,团队整理了一份隔离级别升级的决策表:

判断条件建议方案
客户数 < 100,无合规要求共享表
客户有数据隔离要求,但数据量不大独立 Schema
客户数据量巨大或有数据驻留要求独立数据库
需要为不同客户定制数据模型独立数据库
团队 < 5 人,运维能力有限共享表 + 严格中间件

配置模型:从功能开关到套餐体系

不同租户有不同的功能需求。初期可以硬编码,但到了一定规模就需要一套可配置的套餐体系。关键是不要过早引入复杂规则引擎,也不要一直停留在硬编码阶段。

❌ 坏做法:在代码里散布租户判断

// 每个功能点都写 if-else,维护成本随租户数量线性增长
function getDashboardFeatures(tenantId: string) {
  const features = ['basic-chart']
  if (tenantId === 'tenant-premium-a' || tenantId === 'tenant-enterprise-b') {
    features.push('advanced-analytics')
  }
  if (tenantId === 'tenant-enterprise-b') {
    features.push('custom-branding', 'api-access')
  }
  return features
}

✅ 好做法:套餐 + 功能开关分离

// 套餐定义功能集合,功能开关控制灰度和实验
// 套餐是产品层面的概念,功能开关是技术层面的概念
 
// 套餐配置(存在数据库或配置中心)
const PLAN_FEATURES = {
  free: ['basic-chart', 'up-to-5-users'],
  pro: ['basic-chart', 'advanced-analytics', 'up-to-50-users', 'api-access'],
  enterprise: ['basic-chart', 'advanced-analytics', 'unlimited-users', 'api-access', 'custom-branding', 'sso'],
}
 
// 运行时查询:从套餐解析功能列表
function getTenantFeatures(tenantId: string): string[] {
  const plan = getTenantPlan(tenantId) // 从缓存或数据库获取
  return PLAN_FEATURES[plan] ?? PLAN_FEATURES.free
}
 
// 功能判断统一入口
function hasFeature(tenantId: string, feature: string): boolean {
  return getTenantFeatures(tenantId).includes(feature)
}

这种设计的好处是产品团队调整套餐内容时只需要修改配置,不需要改代码和重新部署。功能开关(Feature Flag)可以用来做灰度发布和 A/B 测试,和套餐体系解耦。

运维可观测性:按租户维度观测系统

多租户系统的监控如果只看全局指标,会漏掉很多关键问题。全局 P99 延迟正常,但某个租户的 P99 可能已经爆表;全局错误率 0.1%,但某个租户的错误率可能是 5%。

❌ 坏做法:只监控全局指标

// 只知道系统整体慢了多少,不知道是哪个租户引起的
metrics.histogram('request_duration', duration)
metrics.counter('request_errors', 1)

✅ 好做法:所有关键指标按租户维度拆分

// 每个指标都带 tenant_id 标签,支持按租户聚合和告警
metrics.histogram('request_duration', duration, {
  tenant_id: req.tenantId,
  endpoint: req.path,
})
 
metrics.counter('request_errors', 1, {
  tenant_id: req.tenantId,
  error_code: err.code,
})
 
// 可以设置按租户的告警规则
// 例:某个租户的 P95 延迟超过 2 秒时触发告警

需要关注的租户级指标包括:

指标类别具体指标告警阈值示例
请求量每分钟请求数超过套餐配额的 80%
延迟P50 / P95 / P99 延迟P95 > 2s
错误率5xx 错误占比> 1%
数据库慢查询数量、连接池使用率慢查询 > 10/min
存储存储空间使用量超过配额 90%
队列任务堆积数量堆积 > 1000

上线前检查清单

多租户架构涉及面广,下面按阶段整理了一份检查清单,可以在架构评审和上线前逐项核对。

设计阶段

  1. 明确隔离级别:根据客户规模、合规要求和运维能力,选择池化 / 桥接 / 独立模型
  2. 租户上下文传递方案:确定 tenant_id 从 API 入口到数据库、缓存、队列、日志的完整传递路径
  3. 套餐和功能模型:定义套餐结构和功能开关体系,避免硬编码
  4. 限流和配额策略:确定每个套餐的请求频率、数据量、并发数限制

实现阶段

  1. 租户过滤中间件:数据库查询层必须有自动注入 tenant_id 的机制,不能依赖开发者手动添加
  2. 缓存键租户隔离:所有缓存键必须包含租户维度,防止跨租户数据污染
  3. 文件存储租户隔离:对象存储的 key 路径包含租户目录,Bucket Policy 做访问控制
  4. 租户上下文安全:tenant_id 从 JWT 或 Session 中提取,禁止从请求体中获取

测试阶段

  1. 租户隔离测试:编写专项测试用例,验证租户 A 无法访问租户 B 的数据(包括正常请求和篡改请求)
  2. 嘈杂邻居测试:模拟单租户高并发、大查询、大批量操作,验证对其他租户的影响
  3. 迁移兼容性测试:验证数据库 Schema 变更能正确应用到所有租户

运维阶段

  1. 租户级监控告警:核心指标(延迟、错误率、请求量)按租户拆分,配置独立告警规则
  2. 租户级限流生效验证:上线后验证限流策略按租户独立生效
  3. 租户数据生命周期管理:确定租户注销后的数据保留和清理策略,满足 GDPR 等合规要求

总结

多租户架构没有「最佳方案」,只有「当前阶段最合适的方案」。早期产品用共享表就够了,不要过早引入独立 Schema 或独立数据库的运维负担。但随着客户增长和合规要求提升,架构必须具备演进能力——从共享表升级到独立 Schema 或独立数据库时,中间件、迁移工具和监控系统都要能平滑过渡。

三个核心原则:

  1. 租户边界是全链路约束,不只是数据库查询里加个 WHERE tenant_id = ?。API、缓存、队列、日志、文件存储都要带上租户维度。
  2. 限流和配额是防御性基础设施,不是等出了问题再补的救火措施。从第一个租户开始就要有。
  3. 可观测性要按租户拆分,全局指标正常不代表每个租户都正常。嘈杂邻居问题只有通过租户级监控才能及时发现。

参考资料

评论 0

0 / 1000