引言
多租户架构是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
}
总结
多租户架构的核心是在资源共享和数据隔离之间找到平衡:
- 独立数据库:适合对隔离性要求高的大客户
- 共享数据库独立Schema:平衡隔离性和资源利用率
- 共享数据库共享表:适合大量小租户,成本最低
选择哪种模式需要考虑:数据隔离要求、租户规模、运维复杂度、成本预算等因素。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。