第 35 章

SaaS 多租户方案

SaaS 多租户数据库设计

多租户(Multi-tenancy)是 SaaS 产品的核心架构特性:多个客户(租户)共享同一套软件基础设施,同时保证数据隔离、安全和性能。数据库层面的多租户设计直接影响 SaaS 产品的成本结构、扩展能力和安全合规。

三种多租户模型对比

模型 1:独立数据库(Database per Tenant)

每个租户拥有独立的 MySQL 数据库(或实例)。

✅ 优势:最强隔离,一个租户崩溃不影响其他人;可以独立备份/迁移;满足最严格的合规要求(GDPR、金融监管)

❌ 劣势:成本最高(每个租户独立资源);运维复杂度 O(N);Schema 变更需要同步 N 个数据库

**适用:**大客户、高价值租户、有严格数据主权要求的企业客户

模型 2:独立 Schema(Schema per Tenant)

共享 MySQL 实例,每个租户使用独立的 Database(MySQL 中 schema ≈ database)。

✅ 优势:较好隔离,Schema 层面隔离;较低成本(共享实例);可以独立备份特定租户

❌ 劣势:MySQL 单实例 Database 数量有限(建议不超过 1000);Schema 变更仍需同步 N 个 Database;连接池管理复杂

**适用:**中型 SaaS,租户数量 < 500

模型 3:共享 Schema(Shared Tables)

所有租户共享同一张表,通过 tenant_id 列区分数据。

✅ 优势:成本最低;Schema 变更只需一次;运维最简单;无限扩展租户数量

❌ 劣势:隔离最弱(代码 bug 可能跨租户泄露数据);噪音邻居问题(某租户大查询影响所有人);大租户和小租户数据混存

**适用:**SMB SaaS,租户数量大(1000+),租户数据量差异不大

共享 Schema 实现(最常用)

-- 每张业务表都包含 tenant_id
CREATE TABLE tenants (
    id          INT UNSIGNED NOT NULL AUTO_INCREMENT,
    slug        VARCHAR(50)  NOT NULL COMMENT '租户标识符(用于 subdomain)',
    name        VARCHAR(100) NOT NULL,
    plan        TINYINT      NOT NULL DEFAULT 1 COMMENT '1=Free 2=Pro 3=Enterprise',
    max_users   INT          NOT NULL DEFAULT 5,
    status      TINYINT      NOT NULL DEFAULT 1,
    created_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_slug (slug)
) ENGINE=InnoDB;

-- 用户表
CREATE TABLE users (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    tenant_id   INT UNSIGNED    NOT NULL,   -- 每张表必须有
    email       VARCHAR(200)    NOT NULL,
    name        VARCHAR(100)    NOT NULL,
    role        TINYINT         NOT NULL DEFAULT 1,
    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_tenant_email (tenant_id, email),  -- 租户内邮箱唯一
    INDEX idx_tenant_id (tenant_id)                  -- 必须建索引!
) ENGINE=InnoDB;

-- 项目表
CREATE TABLE projects (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    tenant_id   INT UNSIGNED    NOT NULL,
    name        VARCHAR(200)    NOT NULL,
    owner_id    BIGINT UNSIGNED NOT NULL,
    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    INDEX idx_tenant_owner (tenant_id, owner_id)
) ENGINE=InnoDB;

数据隔离机制

应用层隔离(最常用)

// Go 示例:中间件自动注入 tenant_id
func TenantMiddleware(db *sql.DB) gin.HandlerFunc {
    return func(c *gin.Context) {
        // 从 JWT 或 subdomain 解析 tenant_id
        tenantID := extractTenantID(c)
        if tenantID == 0 {
            c.AbortWithStatus(401)
            return
        }
        // 存入 Context
        ctx := context.WithValue(c.Request.Context(), "tenant_id", tenantID)
        c.Request = c.Request.WithContext(ctx)
        c.Next()
    }
}

// Repository 层强制加 tenant_id 条件
type UserRepository struct {
    db *sql.DB
}

func (r *UserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
    tenantID := ctx.Value("tenant_id").(int)
    // 永远带 tenant_id 条件
    row := r.db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = ? AND tenant_id = ?",
        id, tenantID)
    // ...
}

// 危险:永远不要写这种不带 tenant_id 的查询
// SELECT * FROM users WHERE id = ?  ← 可能泄露其他租户数据!

MySQL 行级安全(模拟 RLS)

MySQL 没有原生 RLS,但可以用视图 + 存储过程模拟:

-- 创建视图,自动过滤当前租户
CREATE VIEW v_users AS
    SELECT * FROM users
    WHERE tenant_id = (SELECT @current_tenant_id);

-- 应用连接时设置变量
SET @current_tenant_id = 42;
SELECT * FROM v_users;  -- 自动只返回 tenant_id=42 的数据

-- 使用 MySQL 8.0 的 SET ROLE 实现更细粒度权限
CREATE ROLE 'tenant_42_role';
GRANT SELECT ON mydb.v_users TO 'tenant_42_role';

-- 连接时激活角色
SET ROLE 'tenant_42_role';

租户路由

基于子域名的路由

// acme.saas.com → tenant_id=42
// globex.saas.com → tenant_id=57
func extractTenantFromSubdomain(c *gin.Context) int {
    host := c.Request.Host
    // host = "acme.saas.com"
    parts := strings.Split(host, ".")
    if len(parts) < 3 {
        return 0
    }
    slug := parts[0]  // "acme"

    // 查缓存(避免每次查 DB)
    var tenantID int
    cacheKey := "tenant:slug:" + slug
    if val, err := rdb.Get(ctx, cacheKey).Int(); err == nil {
        return val
    }
    db.QueryRow("SELECT id FROM tenants WHERE slug = ?", slug).Scan(&tenantID)
    rdb.Set(ctx, cacheKey, tenantID, 30*time.Minute)
    return tenantID
}

噪音邻居问题

共享 Schema 中,某个大租户的全表扫描会导致 InnoDB Buffer Pool 被污染,影响其他所有租户的查询性能。

-- 对大租户查询加执行时间限制
SELECT /*+ MAX_EXECUTION_TIME(5000) */ *
FROM reports WHERE tenant_id = 999;
-- 超过 5 秒自动 kill,防止拖垮数据库

-- 对大租户的批量导出走只读副本
-- 在应用层根据 tenant_plan 路由查询:
func GetDB(ctx context.Context) *sql.DB {
    tenant := ctx.Value("tenant").(*Tenant)
    if tenant.Plan == PlanFree && isBatchExport(ctx) {
        return replicaDB  // 免费用户批量查询走从库
    }
    return masterDB
}

-- 分区策略:按 tenant_id 范围分区减少单分区热点
ALTER TABLE users PARTITION BY RANGE (tenant_id) (
    PARTITION p_small  VALUES LESS THAN (1000),     -- 小租户
    PARTITION p_medium VALUES LESS THAN (5000),     -- 中型租户
    PARTITION p_large  VALUES LESS THAN MAXVALUE    -- 大租户
);

Schema 演进(多租户的最大挑战)

-- 共享 Schema 的优势:ALTER 只需执行一次
-- 使用 pt-osc 或 gh-ost 在线加列(不锁表)

gh-ost \
  --host=localhost --user=root --password=pass \
  --database=saas --table=users \
  --alter="ADD COLUMN preferences JSON" \
  --execute

-- 独立 Schema 的挑战:需要在所有租户 DB 上执行 ALTER
-- 使用 Flyway/Liquibase 管理多数据库 Schema 迁移
# flyway.url=jdbc:mysql://host/tenant_{tenant_id}
# flyway.locations=classpath:migrations
flyway migrate -url=jdbc:mysql://host/tenant_42

自定义字段(扩展性设计)

SaaS 客户往往需要自定义字段(如 CRM 客户的自定义属性),这是最复杂的多租户设计需求。

-- 方案 1: JSON 列(MySQL 5.7.8+,最简单)
ALTER TABLE contacts ADD COLUMN custom_fields JSON;

-- 插入自定义数据
UPDATE contacts
SET custom_fields = JSON_SET(custom_fields,
    '$.industry', '科技',
    '$.source', '展会')
WHERE id = 1 AND tenant_id = 42;

-- 查询自定义字段
SELECT name, custom_fields->>'$.industry' AS industry
FROM contacts
WHERE tenant_id = 42
  AND custom_fields->>'$.industry' = '科技';

-- 对常用自定义字段建函数索引
ALTER TABLE contacts
ADD INDEX idx_custom_industry ((custom_fields->>'$.industry'));

-- 方案 2: 自定义字段定义表(EAV 变体,支持类型校验)
CREATE TABLE custom_field_definitions (
    id          INT UNSIGNED NOT NULL AUTO_INCREMENT,
    tenant_id   INT UNSIGNED NOT NULL,
    entity_type VARCHAR(50)  NOT NULL COMMENT 'contact/deal/...',
    field_key   VARCHAR(50)  NOT NULL,
    field_label VARCHAR(100) NOT NULL,
    field_type  TINYINT      NOT NULL COMMENT '1=text 2=number 3=date 4=select',
    options     JSON         COMMENT '下拉选项列表',
    sort_order  INT          NOT NULL DEFAULT 0,
    PRIMARY KEY (id),
    UNIQUE KEY uk_tenant_key (tenant_id, entity_type, field_key)
) ENGINE=InnoDB;

多租户架构选型指南

因素 选独立数据库 选独立 Schema 选共享 Schema
租户数量 <50 50-500 >500
数据合规要求 最高(金融/医疗) 中等 基本隔离即可
每租户数据量 大(GB 级) 中等 小(MB 级)
价格敏感度 高价套餐 中价套餐 低价/免费套餐
定制化需求 高(专属配置) 低(标准产品)

混合策略:成熟的 SaaS(如 Salesforce、HubSpot)通常采用混合策略:免费/基础用户走共享 Schema,企业大客户走独立数据库。根据客户付费意愿和数据规模动态分配资源,既控制成本又满足高端客户的合规需求。

本章评分
4.8  / 5  (3 评分)

💬 留言讨论