引言
多租户架构是SaaS系统的核心设计模式,允许多个租户共享同一套基础设施,同时保证数据隔离和安全性。合理的多租户设计能够显著降低运营成本,提升系统可扩展性。
本文将系统介绍多租户架构的三种核心模式及其实现细节。
多租户隔离模式
模式一:独立数据库(Database per Tenant)
每个租户拥有独立的数据库实例或逻辑数据库。
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Tenant A │ │ Tenant B │ │ Tenant C │
│ │ │ │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ Database A │ │ │ │ Database B │ │ │ │ Database C │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ Tables... │ │ │ │ Tables... │ │ │ │ Tables... │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
↓ ↓ ↓
┌─────────────────────────────────────────────────────┐
│ Shared Application Layer │
└─────────────────────────────────────────────────────┘
优点:
- 数据隔离性最强,满足合规要求
- 支持租户级别的备份恢复
- 可以针对不同租户定制配置
缺点:
- 资源利用率低,成本高
- 运维复杂度高
实现示例:
// 租户数据库连接池管理
type TenantDBManager struct {
pools map[string]*sql.DB
mutex sync.RWMutex
}
func (m *TenantDBManager) GetDB(tenantID string) (*sql.DB, error) {
m.mutex.RLock()
if pool, ok := m.pools[tenantID]; ok {
m.mutex.RUnlock()
return pool, nil
}
m.mutex.RUnlock()
// 创建新的数据库连接
m.mutex.Lock()
defer m.mutex.Unlock()
// 双重检查
if pool, ok := m.pools[tenantID]; ok {
return pool, nil
}
dsn := fmt.Sprintf("user:password@tcp(db-host:3306)/tenant_%s", tenantID)
pool, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
pool.SetMaxOpenConns(10)
pool.SetMaxIdleConns(5)
m.pools[tenantID] = pool
return pool, nil
}
// 中间件:从请求中提取租户ID并注入上下文
func TenantMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID := extractTenantID(r)
if tenantID == "" {
http.Error(w, "Tenant ID required", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "tenant_id", tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
模式二:共享数据库,独立Schema
所有租户共享数据库实例,但每个租户使用独立的Schema。
┌─────────────────────────────────────────────────────────┐
│ Database Instance │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Schema A │ │ Schema B │ │ Schema C │ │
│ │ │ │ │ │ │ │
│ │ Tenant A │ │ Tenant B │ │ Tenant C │ │
│ │ Tables... │ │ Tables... │ │ Tables... │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
优点:
- 数据隔离性较好
- 资源利用率高于独立数据库
- 支持租户级别的备份
缺点:
- 数据库连接数可能成为瓶颈
- Schema迁移复杂度高
PostgreSQL实现:
-- 创建租户Schema
CREATE SCHEMA tenant_a;
CREATE SCHEMA tenant_b;
-- 在Schema中创建表
CREATE TABLE tenant_a.users (
id SERIAL PRIMARY KEY,
username VARCHAR(100),
email VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW()
);
-- 设置搜索路径
SET search_path TO tenant_a, public;
// 动态切换Schema
func (r *Repository) WithTenantSchema(ctx context.Context, tenantID string, fn func(*sql.DB) error) error {
db := r.getDB()
schema := fmt.Sprintf("tenant_%s", tenantID)
// 设置当前会话的search_path
_, err := db.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s, public", schema))
if err != nil {
return err
}
// 执行操作
err = fn(db)
// 重置search_path
db.ExecContext(ctx, "SET search_path TO public")
return err
}
模式三:共享数据库,共享表
所有租户共享同一套表,通过tenant_id字段区分数据。
┌─────────────────────────────────────────────────────────┐
│ Shared Database │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Shared Tables │ │
│ │ │ │
│ │ users: │ │
│ │ id | tenant_id | username | email | created_at │ │
│ │ 1 | tenant_a | alice | ... | ... │ │
│ │ 2 | tenant_a | bob | ... | ... │ │
│ │ 3 | tenant_b | carol | ... | ... │ │
│ │ 4 | tenant_b | dave | ... | ... │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
优点:
- 资源利用率最高,成本最低
- 运维简单
- 适合大量小租户
缺点:
- 数据隔离性最弱
- 查询性能可能受影响
- 大租户可能影响其他租户
实现示例:
-- 表设计:所有表包含tenant_id字段
CREATE TABLE users (
id BIGINT PRIMARY KEY,
tenant_id VARCHAR(50) NOT NULL,
username VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
-- 复合索引
INDEX idx_tenant_created (tenant_id, created_at),
UNIQUE KEY uk_tenant_username (tenant_id, username)
);
-- Row Level Security(PostgreSQL)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant'));
// 自动注入tenant_id的Repository基类
type BaseRepository struct {
db *gorm.DB
}
func (r *BaseRepository) WithTenant(ctx context.Context) *gorm.DB {
tenantID := ctx.Value("tenant_id").(string)
// 自动添加tenant_id条件
return r.db.WithContext(ctx).Where("tenant_id = ?", tenantID)
}
func (r *BaseRepository) Create(ctx context.Context, model interface{}) error {
tenantID := ctx.Value("tenant_id").(string)
// 通过反射设置tenant_id字段
v := reflect.ValueOf(model).Elem()
tenantField := v.FieldByName("TenantID")
if tenantField.IsValid() && tenantField.CanSet() {
tenantField.SetString(tenantID)
}
return r.db.WithContext(ctx).Create(model).Error
}
func (r *BaseRepository) Find(ctx context.Context, id string, result interface{}) error {
return r.WithTenant(ctx).First(result, "id = ?", id).Error
}
func (r *BaseRepository) List(ctx context.Context, results interface{}) error {
return r.WithTenant(ctx).Find(results).Error
}
租户识别与上下文传递
多种租户识别策略
// 租户识别策略
type TenantResolver interface {
Resolve(r *http.Request) (string, error)
}
// 策略1:从子域名识别
type SubdomainResolver struct {
baseDomain string
}
func (r *SubdomainResolver) Resolve(req *http.Request) (string, error) {
host := req.Host
parts := strings.Split(host, ".")
if len(parts) < 3 || parts[len(parts)-2]+"."+parts[len(parts)-1] != r.baseDomain {
return "", errors.New("invalid subdomain")
}
tenantID := parts[0]
return tenantID, nil
}
// 策略2:从请求头识别
type HeaderResolver struct {
headerName string
}
func (r *HeaderResolver) Resolve(req *http.Request) (string, error) {
tenantID := req.Header.Get(r.headerName)
if tenantID == "" {
return "", errors.New("tenant header missing")
}
return tenantID, nil
}
// 策略3:从JWT Token识别
type JWTResolver struct{}
func (r *JWTResolver) Resolve(req *http.Request) (string, error) {
token := req.Header.Get("Authorization")
if token == "" {
return "", errors.New("authorization header missing")
}
claims, err := parseJWT(token)
if err != nil {
return "", err
}
tenantID, ok := claims["tenant_id"].(string)
if !ok {
return "", errors.New("tenant_id not in token")
}
return tenantID, nil
}
资源配额与限制
租户级别的资源限制
// 租户配额配置
type TenantQuota struct {
TenantID string
MaxUsers int
MaxStorage int64 // bytes
MaxAPIRequests int // per day
MaxConcurrent int // 并发请求数
}
// 配额检查中间件
func QuotaMiddleware(quotaService QuotaService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value("tenant_id").(string)
quota, err := quotaService.GetQuota(r.Context(), tenantID)
if err != nil {
http.Error(w, "Failed to get quota", http.StatusInternalServerError)
return
}
// 检查API请求配额
used, _ := quotaService.GetAPIUsage(r.Context(), tenantID)
if used >= quota.MaxAPIRequests {
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(quota.MaxAPIRequests))
w.Header().Set("X-RateLimit-Remaining", "0")
http.Error(w, "API quota exceeded", http.StatusTooManyRequests)
return
}
// 记录本次请求
quotaService.IncrementAPIUsage(r.Context(), tenantID)
next.ServeHTTP(w, r)
})
}
}
存储配额管理
// 存储配额检查
type StorageQuotaManager struct {
db *gorm.DB
}
func (m *StorageQuotaManager) CheckAndAllocate(ctx context.Context, tenantID string, size int64) error {
var quota TenantQuota
if err := m.db.Where("tenant_id = ?", tenantID).First("a).Error; err != nil {
return err
}
var used struct{ TotalSize int64 }
m.db.Model(&File{}).
Where("tenant_id = ?", tenantID).
Select("SUM(size) as total_size").
Scan(&used)
if used.TotalSize+size > quota.MaxStorage {
return errors.New("storage quota exceeded")
}
return nil
}
安全防护
数据隔离验证
// 数据访问验证器
type TenantDataValidator struct{}
func (v *TenantDataValidator) ValidateAccess(ctx context.Context, resource interface{}) error {
tenantID := ctx.Value("tenant_id").(string)
// 通过反射获取资源的tenant_id
rv := reflect.ValueOf(resource)
if rv.Kind() == reflect.Ptr {
rv = rv.Elem()
}
field := rv.FieldByName("TenantID")
if !field.IsValid() {
return errors.New("resource has no tenant_id field")
}
resourceTenantID := field.String()
if resourceTenantID != tenantID {
return errors.New("access denied: tenant mismatch")
}
return nil
}
防止租户数据泄露
// 日志脱敏中间件
func LogSanitizationMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value("tenant_id").(string)
// 记录日志时添加tenant_id上下文
logger := log.WithFields(log.Fields{
"tenant_id": tenantID,
"method": r.Method,
"path": r.URL.Path,
})
// 防止日志中包含其他租户的数据
ctx := context.WithValue(r.Context(), "logger", logger)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
租户迁移与升级
Schema版本管理
// 租户Schema迁移管理
type TenantMigrationManager struct {
db *gorm.DB
migrations []Migration
}
func (m *TenantMigrationManager) MigrateTenant(ctx context.Context, tenantID string, version int) error {
schema := fmt.Sprintf("tenant_%s", tenantID)
for _, migration := range m.migrations {
if migration.Version > version {
// 设置search_path
if err := m.db.Exec(fmt.Sprintf("SET search_path TO %s", schema)).Error; err != nil {
return err
}
// 执行迁移
if err := migration.Up(m.db); err != nil {
return err
}
// 记录迁移版本
m.recordMigrationVersion(tenantID, migration.Version)
}
}
return nil
}
// 批量迁移所有租户
func (m *TenantMigrationManager) MigrateAll(ctx context.Context, targetVersion int) error {
var tenants []Tenant
if err := m.db.Find(&tenants).Error; err != nil {
return err
}
for _, tenant := range tenants {
if err := m.MigrateTenant(ctx, tenant.ID, targetVersion); err != nil {
log.Errorf("Failed to migrate tenant %s: %v", tenant.ID, err)
// 继续迁移其他租户,记录错误
}
}
return nil
}
进阶议题
7. 租户级别的性能隔离
共享表模式下,需要防止"吵闹邻居"效应:
// 连接池隔离:大租户拥有独立连接池
type TenantConnectionPool struct {
defaultPool *sql.DB
vipPools map[string]*sql.DB // 大客户独立池
}
func (p *TenantConnectionPool) GetDB(tenantID string) *sql.DB {
if db, ok := p.vipPools[tenantID]; ok {
return db
}
return p.defaultPool
}
// 查询超时隔离
type TenantQueryTimeout struct {
timeouts map[string]time.Duration
}
func (t *TenantQueryTimeout) GetTimeout(tenantID string) time.Duration {
if timeout, ok := t.timeouts[tenantID]; ok {
return timeout
}
return 30 * time.Second // 默认超时
}
资源隔离策略矩阵:
| 资源 | 共享模式 | 隔离方式 |
|---|---|---|
| 数据库连接 | 共享池 | VIP 租户独立连接池 |
| CPU | 共享 | 容器 cgroup limit |
| 内存 | 共享 | Redis 内存上限 / 查询结果限制 |
| 存储 | 共享表空间 | 配额管理 + 自动告警 |
| API QPS | 全局共享 | 按租户令牌桶限流 |
| 带宽 | 共享 | CDN 边缘节点分离 |
8. 租户数据的合规与审计
多租户 SaaS 面临 GDPR、个保法等数据合规要求:
数据本地化:
- 欧盟租户数据必须存储在欧盟区域
- 中国租户个人信息境内存储
- 通过全局负载均衡器按租户路由到对应区域
数据删除权(Right to be Forgotten):
// 租户级联删除
type TenantDataDeletionService struct {
db *gorm.DB
}
func (s *TenantDataDeletionService) PurgeTenant(ctx context.Context, tenantID string) error {
// 标记删除(先软删除,30天后物理删除)
var tables = []string{"users", "orders", "logs", "files"} // ...所有租户相关表
for _, table := range tables {
if err := s.db.Table(table).
Where("tenant_id = ?", tenantID).
Update("deleted_at", time.Now()).Error; err != nil {
return err
}
}
// 记录删除审计日志
s.db.Create(&AuditLog{
Action: "tenant_purge",
TenantID: tenantID,
Timestamp: time.Now(),
})
return nil
}
// 异步物理删除(cron 任务)
func (s *TenantDataDeletionService) PhysicalDelete(ctx context.Context, before time.Time) error {
var tables = []string{"users", "orders", "logs"}
for _, table := range tables {
s.db.Table(table).Unscoped().
Where("deleted_at < ?", before).
Delete(nil)
}
return nil
}
审计追踪:
| 事件 | 记录内容 | 保留期 |
|---|---|---|
| 数据访问 | 租户ID、用户ID、时间、操作类型、IP | 2年 |
| 权限变更 | 修改人、修改内容、时间 | 永久 |
| 数据导出 | 导出人、导出范围、时间 | 2年 |
| 登录/登出 | 用户ID、时间、IP、设备信息 | 1年 |
9. 租户级别的数据库连接优化
// 动态连接池大小调整
type AdaptivePoolManager struct {
pools map[string]*sql.DB
mu sync.RWMutex
}
func (m *AdaptivePoolManager) AdjustPool(tenantID string, activeConnections int) {
m.mu.Lock()
defer m.mu.Unlock()
pool, ok := m.pools[tenantID]
if !ok {
return
}
// 根据活跃连接数动态调整
currentMax := pool.MaxOpenConns()
if activeConnections > currentMax*0.8 {
newMax := int(float64(currentMax) * 1.2)
if newMax <= 100 { // 上限
pool.SetMaxOpenConns(newMax)
}
} else if activeConnections < currentMax*0.3 {
newMax := int(float64(currentMax) * 0.8)
if newMax >= 5 { // 下限
pool.SetMaxOpenConns(newMax)
}
}
}
总结
多租户架构的核心是在资源共享和数据隔离之间找到平衡:
- 独立数据库:适合对隔离性要求高的大客户
- 共享数据库独立Schema:平衡隔离性和资源利用率
- 共享数据库共享表:适合大量小租户,成本最低
- 动态扩展:连接池自适应、资源配额管理、性能隔离
- 合规审计:数据本地化、删除权、操作审计
| 场景 | 推荐模式 | 关键考量 |
|---|---|---|
| 大企业客户为主 | 独立数据库 | 合规、隔离性 |
| 中小型 SaaS | Schema 隔离 | 平衡成本与隔离 |
| 海量免费用户 | 共享表 + RLS | 成本优先 |
| 混合模式 | 按租户等级选择 | 客户分层 |
选择哪种模式需要考虑:数据隔离要求、租户规模、运维复杂度、成本预算和合规需求。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。