多租户架构设计:数据隔离、资源管理与安全策略

全面解析多租户架构的三种隔离模式(独立数据库、共享数据库独立Schema、共享数据库共享表),深入讲解租户识别、数据隔离、资源配额、安全防护等核心技术与实战案例。

引言

多租户架构是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(&quota).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
}

总结

多租户架构的核心是在资源共享和数据隔离之间找到平衡:

  1. 独立数据库:适合对隔离性要求高的大客户
  2. 共享数据库独立Schema:平衡隔离性和资源利用率
  3. 共享数据库共享表:适合大量小租户,成本最低

选择哪种模式需要考虑:数据隔离要求、租户规模、运维复杂度、成本预算等因素。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页