「产品矩阵平台」多租户与多应用设计

多租户与多应用设计:Tenant/App/Namespace 模型、数据隔离策略、域名路由、配置继承与 SaaS 层级设计方案。

第五章 多租户与多应用设计

多租户设计的目标,不是简单给每张表加一个 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

核心字段:

关键字段
tenantsidnameplanstatus
appsidtenant_idtypenamestatus
app_envsapp_idnamespacedomainsecret
membershipstenant_iduser_idrole

AppIDAppSecret 不应直接等同于数据库主键。它们是对外凭证,应可轮换、可禁用、可审计。

5.3 租户上下文注入

租户上下文应在请求入口统一解析。

解析来源可以按优先级排列:

  1. 自定义域名,如 acme.example.com
  2. Header,如 X-App-ID
  3. JWT Claims,如 tenant_idapp_id
  4. 路径前缀,如 /t/{tenant_slug}
  5. 开放平台签名参数。

解析完成后写入 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管理方便
HeaderX-App-IDSDK 和服务调用友好
路径/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 三层模型出发,设计了适用于产品矩阵平台的多租户隔离策略(共享库共享表/独立表/独立库)、租户上下文自动注入、应用生命周期管理与配置分层体系。关键判断是:多租户不是单一功能点,而是贯穿身份、数据、配置、日志与运维的系统约束。


延伸阅读


关联专题

专题关联内容链接
PostgreSQL多租户数据库Schema设计/posts/postgresql/
GolangGoravel多租户Hook实现/golang/
TypeScript多应用配置的类型化设计/posts/typescript/
CloudflareCDN与租户域名路由/posts/cloudflare/
Next.js白标SaaS前端部署方案/posts/nextjs/

继续阅读

探索更多技术文章

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

全部文章 返回首页

「SaaS」更多文章

  1. 「产品矩阵平台」未来演进方向
  2. 「产品矩阵平台」运维与成本优化
  3. 「产品矩阵平台」安全与合规体系