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,企业大客户走独立数据库。根据客户付费意愿和数据规模动态分配资源,既控制成本又满足高端客户的合规需求。