第五章 多租户与多应用设计
多租户设计的目标,不是简单给每张表加一个 tenant_id。真正的目标是:同一套平台能服务多个组织、多个产品、多个环境,同时保证数据隔离、配置独立、权限清晰和成本可控。
产品矩阵平台中,多租户和多应用是两个不同维度:
| 维度 | 含义 | 示例 |
|---|---|---|
| Tenant | 使用平台的主体 | 企业、品牌、客户、开发者 |
| App | 租户下的具体产品 | 小程序、Web 站、管理后台 |
| Namespace | 逻辑命名空间 | dev、prod、活动专区 |
5.1 多租户隔离策略
常见隔离方式有三种:
| 模式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 共享库共享表 | 成本低、运维简单 | 隔离弱、查询必须严谨 | 早期 SaaS、长尾租户 |
| 共享库独立表 | 部分隔离、迁移灵活 | 表数量膨胀 | 中型租户、半独立业务 |
| 独立库 | 隔离强、合规友好 | 运维成本高 | 大客户、金融医疗 |
平台可以采用混合策略:默认共享库共享表,大客户升级为独立库,审计、账单等平台公共数据仍保留在中心库。
5.2 Tenant、App 与 Namespace 模型
建议模型关系:
erDiagram
TENANT ||--o{ APP : owns
APP ||--o{ APP_ENV : has
APP ||--o{ APP_CONFIG : configures
TENANT ||--o{ MEMBERSHIP : has
USER ||--o{ MEMBERSHIP : joins
核心字段:
| 表 | 关键字段 |
|---|---|
tenants | id、name、plan、status |
apps | id、tenant_id、type、name、status |
app_envs | app_id、namespace、domain、secret |
memberships | tenant_id、user_id、role |
AppID 和 AppSecret 不应直接等同于数据库主键。它们是对外凭证,应可轮换、可禁用、可审计。
5.3 租户上下文注入
租户上下文应在请求入口统一解析。
解析来源可以按优先级排列:
- 自定义域名,如
acme.example.com; - Header,如
X-App-ID; - JWT Claims,如
tenant_id、app_id; - 路径前缀,如
/t/{tenant_slug}; - 开放平台签名参数。
解析完成后写入 context.Context,后续 Service、Repository、日志、审计都从上下文读取。
5.4 数据表 Schema 设计
共享表模式下,业务表至少要有:
| 字段 | 说明 |
|---|---|
tenant_id | 租户隔离 |
app_id | 应用隔离或来源 |
namespace | 环境或空间 |
created_by | 创建人 |
updated_by | 更新人 |
deleted_at | 软删除 |
索引不能只按业务字段建。例如内容表常见查询是“某租户某 App 下按状态分页”,索引应考虑:
CREATE INDEX idx_contents_tenant_app_status_time
ON contents (tenant_id, app_id, status, published_at);
缺少 tenant_id 前缀的索引,在多租户数据量变大后会非常昂贵。
5.5 应用注册与生命周期管理
App 不是一条配置记录,而是有生命周期的对象。
created -> configured -> verified -> active -> suspended -> archived
| 状态 | 含义 |
|---|---|
| created | 已创建但未配置凭证 |
| configured | 已配置域名、回调、密钥 |
| verified | 域名或平台审核通过 |
| active | 可正常访问 |
| suspended | 欠费、违规或管理员暂停 |
| archived | 归档,不再对外服务 |
生命周期变化必须写审计日志,尤其是暂停、恢复、密钥轮换和删除。
5.6 App 配置分层
配置继承顺序建议为:
platform default
-> tenant default
-> app config
-> namespace config
-> runtime override
读取配置时要返回“最终值”和“来源”。运维排障时,知道某个开关为什么是 true,往往比值本身更重要。
5.7 SaaS 层级设计
平台可支持三种 SaaS 形态:
| 形态 | 说明 | 示例 |
|---|---|---|
| 单体 SaaS | 一个租户使用标准产品 | 中小企业订阅 CRM |
| 子租户 SaaS | 租户下继续管理客户 | 代理商管理多个门店 |
| 白标 SaaS | 租户拥有品牌和域名 | 给行业客户输出独立品牌 |
白标 SaaS 需要更强的主题、域名、邮件发件人、支付主体和备案支持,不能只换 Logo。
5.8 API 网关级多租户路由
网关应在转发前完成租户识别和基础校验。
| 路由方式 | 例子 | 优点 |
|---|---|---|
| 独立域名 | api.acme.com | 品牌独立、隔离清晰 |
| 子域名 | acme.platform.com | 管理方便 |
| Header | X-App-ID | SDK 和服务调用友好 |
| 路径 | /tenant/acme/api | 开发简单 |
生产环境优先推荐域名或 Header,路径模式适合开发、测试和内部工具。
5.9 常见风险
| 风险 | 后果 | 防护 |
|---|---|---|
查询漏加 tenant_id | 数据串租户 | ORM Scope、测试、审计 |
| 后台超级权限滥用 | 合规事故 | 审批、双人复核、审计 |
| 配置覆盖不透明 | 线上行为不可解释 | 配置来源追踪 |
| 租户删除过于直接 | 数据不可恢复 | 冷归档、延迟删除 |
| 密钥不可轮换 | 泄露后无法止血 | 多密钥版本 |
多租户不是一个功能点,而是贯穿身份、数据、配置、日志、计费和运维的系统约束。
5.10 代码实践:GORM 多租户 Hook 与 Query Scope
一、自动注入租户上下文的 Middleware
package middleware
import (
"context"
"net/http"
"strings"
)
func TenantContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenant := &TenantContext{
TenantID: "default",
AppID: "",
}
// 优先级:自定义域名 > Header > JWT > 路径
host := r.Host
if strings.HasSuffix(host, ".acme.com") {
tenant.TenantID = strings.TrimSuffix(host, ".acme.com")
} else if appID := r.Header.Get("X-App-ID"); appID != "" {
tenant.AppID = appID
// 从缓存或数据库解析 tenant_id
tenant.TenantID = resolveTenantByAppID(appID)
}
ctx := context.WithValue(r.Context(), tenantKey, tenant)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// 从上下文中读取租户信息
type tenantKey struct{}
type TenantContext struct {
TenantID string
AppID string
}
func GetTenant(ctx context.Context) *TenantContext {
if v := ctx.Value(tenantKey); v != nil {
return v.(*TenantContext)
}
return nil
}
二、GORM Hook 自动附加 tenant_id
package database
import (
"context"
"gorm.io/gorm"
)
// TenantIDModel 嵌入到所有共享 model 中
type TenantIDModel struct {
TenantID string `gorm:"index;not null" json:"tenant_id"`
AppID string `gorm:"index;not null" json:"app_id"`
}
// RegisterTenantHooks 在数据库初始化时调用
func RegisterTenantHooks(db *gorm.DB) {
// Create 时自动注入 tenant_id 和 app_id
db.Callback().Create().Before("gorm:create").Register("auto_tenant", func(db *gorm.DB) {
if tenant := GetTenant(db.Statement.Context); tenant != nil {
if db.Statement.Schema != nil {
if field := db.Statement.Schema.LookUpField("TenantID"); field != nil {
_ = db.Statement.SetColumn("TenantID", tenant.TenantID)
}
if field := db.Statement.Schema.LookUpField("AppID"); field != nil {
_ = db.Statement.SetColumn("AppID", tenant.AppID)
}
}
}
})
// 软删除默认使用 DeletedAt,已有 gorm 支持
// 审计字段通过另一个 Hook 注入
db.Callback().Create().Before("gorm:create").Register("auto_audit", func(db *gorm.DB) {
if db.Statement.Schema != nil {
if field := db.Statement.Schema.LookUpField("CreatedBy"); field != nil {
if userID := GetUserID(db.Statement.Context); userID != "" {
_ = db.Statement.SetColumn("CreatedBy", userID)
}
}
}
})
}
三、Query Scope 防止漏加 tenant_id
package database
import (
"context"
"gorm.io/gorm"
)
// ScopeTenant 自动过滤当前租户数据
func ScopeTenant(ctx context.Context) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if tenant := GetTenant(ctx); tenant != nil {
return db.Where("tenant_id = ?", tenant.TenantID)
}
return db
}
}
// ScopeTenantApp 同时过滤租户和应用
func ScopeTenantApp(ctx context.Context) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if tenant := GetTenant(ctx); tenant != nil {
return db.Where("tenant_id = ? AND app_id = ?", tenant.TenantID, tenant.AppID)
}
return db
}
}
// 在 Repository 中使用 Scope 封装(零侵入业务查询)
type ContentRepository struct {
db *gorm.DB
}
func (r *ContentRepository) ListByStatus(ctx context.Context, status string) ([]Content, error) {
var contents []Content
// ScopeTenant 自动附加 WHERE tenant_id = ?
err := r.db.WithContext(ctx).
Scopes(ScopeTenant(ctx)).
Where("status = ?", status).
Order("published_at DESC").
Find(&contents).Error
return contents, err
}
func (r *ContentRepository) GetByID(ctx context.Context, id string) (*Content, error) {
var content Content
err := r.db.WithContext(ctx).
Scopes(ScopeTenant(ctx)).
First(&content, "id = ?", id).Error
return &content, err
}
四、多租户 Redis Key 命名规范
package cache
import (
"context"
"fmt"
"time"
)
// TenantKey 生成带租户前缀的 Redis key
// 格式:tenant:{tenant_id}:app:{app_id}:{entity}:{id}:v{version}
func TenantKey(ctx context.Context, entity, id string, version int) string {
tenant := GetTenant(ctx)
if tenant == nil {
return fmt.Sprintf("%s:%s:v%d", entity, id, version)
}
return fmt.Sprintf("tenant:%s:app:%s:%s:%s:v%d",
tenant.TenantID, tenant.AppID, entity, id, version)
}
// 使用示例:缓存商品详情
func CacheProductDetail(ctx context.Context, productID string, data []byte, ttl time.Duration) error {
key := TenantKey(ctx, "product", productID, 1) // v1 表示当前版本
return redisClient.Set(ctx, key, data, ttl).Err()
}
// 批量失效同一租户下的缓存(例如配置变更后)
func InvalidateTenantCache(ctx context.Context, pattern string) error {
tenant := GetTenant(ctx)
if tenant == nil {
return fmt.Errorf("no tenant in context")
}
fullPattern := fmt.Sprintf("tenant:%s:%s", tenant.TenantID, pattern)
// 注意:生产环境 SCAN + DEL 更稳健
return redisClient.Eval(ctx, redisDelByPattern, []string{fullPattern}).Err()
}
五、单元测试:验证多租户隔离
package database_test
import (
"context"
"testing"
)
func TestTenantIsolation(t *testing.T) {
db := setupTestDB()
RegisterTenantHooks(db)
ctxA := context.WithValue(context.Background(), tenantKey,
&TenantContext{TenantID: "tenant_a", AppID: "app_1"})
ctxB := context.WithValue(context.Background(), tenantKey,
&TenantContext{TenantID: "tenant_b", AppID: "app_1"})
// tenant_a 创建内容
contentA := Content{Title: "A 的文章"}
err := db.WithContext(ctxA).Create(&contentA).Error
if err != nil {
t.Fatal(err)
}
// tenant_b 查询时应该看不到 tenant_a 的数据
var contents []Content
err = db.WithContext(ctxB).Scopes(ScopeTenant(ctxB)).Find(&contents).Error
if err != nil {
t.Fatal(err)
}
if len(contents) != 0 {
t.Fatalf("tenant_b 不应看到 tenant_a 的数据,实际返回 %d 条", len(contents))
}
}
本章小结
本章从 Tenant/App/Namespace 三层模型出发,设计了适用于产品矩阵平台的多租户隔离策略(共享库共享表/独立表/独立库)、租户上下文自动注入、应用生命周期管理与配置分层体系。关键判断是:多租户不是单一功能点,而是贯穿身份、数据、配置、日志与运维的系统约束。
延伸阅读
- 上一章:业务域服务体系 — DDD 划分的八大业务域边界与协作设计。
- 下一章:可扩展性服务框架 — Goravel 模块化、插件系统、Wire DI 与热插拔架构。
关联专题
| 专题 | 关联内容 | 链接 |
|---|---|---|
| PostgreSQL | 多租户数据库Schema设计 | /posts/postgresql/ |
| Golang | Goravel多租户Hook实现 | /golang/ |
| TypeScript | 多应用配置的类型化设计 | /posts/typescript/ |
| Cloudflare | CDN与租户域名路由 | /posts/cloudflare/ |
| Next.js | 白标SaaS前端部署方案 | /posts/nextjs/ |
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。