1. 多租户 SaaS 基础概念
多租户(Multi-Tenancy)是 SaaS 架构最核心的隔离与共享模型。它允许一套软件实例同时服务多个组织(租户),每个租户在逻辑上拥有独立的数据空间与配置能力,但在物理层面共享计算、存储与网络资源。理解多租户的本质,是设计鉴权拦截链路的前提——因为所有身份与权限判断都必须先回答“当前请求属于哪个租户”。
1.1 什么是多租户
多租户并非简单的“多个用户登录同一系统”。它的核心约束是:租户之间必须实现数据隔离与配置隔离,同时保持应用代码与基础设施的高复用率。从部署形态看,多租户通常分为三种隔离级别:
| 隔离级别 | 描述 | 典型场景 | 安全强度 |
|---|---|---|---|
| 共享数据库-共享表 | 所有租户数据存放在同一数据库的同一张表中,通过 tenant_id 字段区分 | 轻量级 SaaS、内部工具、POC 阶段 | 低(依赖应用层过滤) |
| 共享数据库-独立 Schema | 每个租户拥有独立的 Schema(命名空间),表结构相同但数据物理分离 | 中型 SaaS、行业垂直应用 | 中(数据库层隔离) |
| 独立数据库 | 每个租户拥有完全独立的数据库实例 | 金融、医疗、高合规要求场景 | 高(物理/网络层隔离) |
架构决策提示: 隔离级别越高,鉴权拦截链路越简单(因为租户边界由基础设施保障),但运维成本与资源消耗也越高。多数 SaaS 产品在早期选择“共享表 + tenant_id”模式,后期再根据合规需求逐步升级。
1.2 SaaS 架构核心特征
SaaS 架构区别于传统软件交付的四个核心特征,直接决定了鉴权拦截链路的设计方向:
- 多租户隔离 —— 鉴权链路必须在第一层就识别租户身份,否则后续所有权限判断都会错位。
- 配置即代码 —— 租户级别的功能开关、路由规则、限流阈值等配置,需要动态注入鉴权中间件。
- 弹性伸缩 —— 鉴权组件必须无状态,支持水平扩展,不能将租户会话绑定到特定实例。
- 计量与计费 —— 鉴权拦截链路需要记录每个租户的请求量、活跃用户数等指标,为计费系统提供原始数据。
下图展示了 SaaS 架构中租户请求从入口到业务服务的完整链路,其中鉴权拦截处于网关与业务服务之间的关键位置:
1.3 租户与用户的区别
这是多租户鉴权中最容易被混淆的概念。简单来说:租户是组织单位,用户是自然人身份。一个租户下可以包含多个用户,一个用户也可能(在有限场景下)属于多个租户。下表从鉴权视角详细对比:
| 对比维度 | 租户(Tenant) | 用户(User) |
|---|---|---|
| 定义 | 组织、企业、团队或项目空间 | 自然人账号,拥有登录凭证 |
| 标识方式 | tenant_id(UUID 或业务编码) | user_id + 登录名/邮箱/手机号 |
| 生命周期 | 由 SaaS 运营方创建/销毁,通常长期稳定 | 可随时加入/离开租户,生命周期更短 |
| 鉴权作用域 | 决定“能访问哪个隔离空间” | 决定“在租户空间内能做什么操作” |
| 权限粒度 | 粗粒度(功能模块、API 分组) | 细粒度(角色、资源、数据行) |
| 典型存储 | tenants 表(id, name, plan, status) | users 表 + tenant_user 关联表 |
常见陷阱: 很多团队在设计初期将 tenant_id 直接作为 user 表的一个字段,导致一个用户只能属于一个租户。这在企业协作场景(如咨询顾问同时服务多家客户)中会引发严重的数据隔离漏洞。正确的做法是建立 多对多关联表,并在鉴权时同时校验 tenant_id 与 user_id 的关联关系。
下图展示了租户与用户之间的多对多关系,以及鉴权链路中需要同时验证的两个维度:
1.4 鉴权拦截链路中的租户上下文
在理解了租户与用户的区别后,我们需要在鉴权拦截链路中建立统一的租户上下文(Tenant Context)。这个上下文贯穿整个请求生命周期,包含以下关键信息:
- tenant_id —— 当前请求所属的租户标识,从请求头、JWT 或子域名中解析。
- tenant_plan —— 租户的套餐等级(免费版/专业版/企业版),用于功能权限的粗粒度过滤。
- tenant_status —— 租户状态(正常/冻结/过期),在鉴权第一层就拦截无效租户的请求。
- user_id + role —— 当前操作人的身份与角色,用于细粒度权限判定。
最佳实践: 在网关或第一个中间件中完成租户识别后,将租户上下文注入到请求的
Context(Go)或 ThreadLocal(Java)中,后续所有鉴权组件直接读取,避免重复解析。示例代码片段如下:
// Go 示例:在 HTTP 中间件中注入租户上下文
func TenantMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. 从请求头或 JWT 中提取 tenant_id
tenantID := r.Header.Get("X-Tenant-ID")
if tenantID == "" {
http.Error(w, "missing tenant id", http.StatusBadRequest)
return
}
// 2. 校验租户状态(查询缓存或数据库)
tenant := tenantService.GetByID(tenantID)
if tenant == nil || tenant.Status != "active" {
http.Error(w, "tenant not active", http.StatusForbidden)
return
}
// 3. 将租户上下文注入到 request context
ctx := context.WithValue(r.Context(), "tenant", tenant)
next.ServeHTTP(w, r.WithContext(ctx))
})
}