多租户SaaS系统的架构与实践

4 阅读8分钟

在这篇博客中,分享如何设计一个支持多租户的SaaS系统,重点探讨租户数据隔离资源配额控制这两个核心问题的解决方案和代码实现。

一、多租户架构概述

多租户(Multi-Tenant)是一种软件架构模式,它允许单个系统实例同时服务于多个客户(即“租户”)。每个租户都认为自己独享了整个应用,但实际上,他们在底层共享着计算、存储等基础设施。

对于SaaS系统,多租户架构需要解决两大核心挑战:

  1. 数据隔离 (Data Isolation):必须从物理或逻辑上保证租户之间的数据绝对隔离,防止数据泄露,这是系统的生命线。
  2. 资源配额 (Resource Quota):必须有效控制每个租户可使用的系统资源(如API调用、存储容量等),防止“坏邻居”问题,保证系统的稳定性和公平性。

二、数据隔离方案对比与Go实现

常见的数据隔离方案有三种,它们在隔离性、成本和复杂性之间各有取舍。

隔离级别实现方式优点缺点适用场景
数据库级别每个租户使用独立的数据库实例隔离性最强,安全性高成本高,扩展和维护复杂对数据隔离要求极高的场景(如金融、医疗)
Schema级别所有租户共享数据库,但使用独立Schema隔离性较好,成本适中并非所有数据库都良好支持,管理仍有复杂度租户数量中等的B2B SaaS服务
行级别所有租户共享表,通过tenant_id列区分成本低,易于扩展和维护隔离性相对最弱,对代码侵入性高,需严防逻辑漏洞租户数量庞大的场景(如2C业务、小型团队工具)

(注:在MySQL中,Schema和Database是同义词,所以Schema级别隔离通常指为每个租户创建不同的Database。在PostgreSQL中,Schema是Database下的一个命名空间,更适合此模式。)

接下来,我们用Go来实现这几种方案。

1. 数据库级别隔离实现 (Database per Tenant)

架构设计: 为每个租户动态地连接到其专属的数据库。应用层需要一个机制来路由请求。

+-------------------+   +-------------------+   +-------------------+
|   租户A数据库     |   |   租户B数据库     |   |   租户N数据库     |
+-------------------+   +-------------------+   +-------------------+
|   users 表      |   |   users 表      |   |   users 表      |
|   orders 表     |   |   orders 表     |   |   orders 表     |
+-------------------+   +-------------------+   +-------------------+

核心Go代码实现(基于Context和Middleware动态切换数据源)

在Go中,我们不常用动态数据源的类,而是利用context.Context在请求链路中传递数据库连接(*sql.DB*gorm.DB)。

// tenant_context.go
package middleware

import (
	"context"
	"gorm.io/gorm"
)

type contextKey string

const tenantDBKey contextKey = "tenantDB"

// WithTenantDB 将租户的DB连接实例存入Context
func WithTenantDB(ctx context.Context, db *gorm.DB) context.Context {
	return context.WithValue(ctx, tenantDBKey, db)
}

// GetTenantDB 从Context中获取租户的DB连接实例
func GetTenantDB(ctx context.Context) (*gorm.DB, bool) {
	db, ok := ctx.Value(tenantDBKey).(*gorm.DB)
	return db, ok
}
// db_resolver.go
package middleware

import (
	"fmt"
	"net/http"
	"sync"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

// DBPool 管理所有租户的数据库连接
type DBPool struct {
	mu    sync.RWMutex
	conns map[string]*gorm.DB
}

func NewDBPool() *DBPool {
	return &DBPool{
		conns: make(map[string]*gorm.DB),
	}
}

// GetDB 获取或创建租户的数据库连接
func (p *DBPool) GetDB(tenantID string) (*gorm.DB, error) {
	p.mu.RLock()
	db, ok := p.conns[tenantID]
	p.mu.RUnlock()

	if ok {
		return db, nil
	}

	// 如果不存在,则创建新连接(实际场景中,租户信息和DB配置应从配置中心或元数据DB获取)
	p.mu.Lock()
	defer p.mu.Unlock()
	
    // 再次检查,防止并发创建
	if db, ok = p.conns[tenantID]; ok {
        return db, nil
    }

	dsn := fmt.Sprintf("user:pass@tcp(127.0.0.1:3306)/db_%s?charset=utf8mb4&parseTime=True&loc=Local", tenantID)
	newDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		return nil, err
	}
	p.conns[tenantID] = newDB
	return newDB, nil
}


// TenantDBResolverMiddleware 是一个HTTP中间件,用于解析租户并注入DB连接
func (p *DBPool) TenantDBResolverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 从JWT, Subdomain或Header中获取tenantID
		tenantID := r.Header.Get("X-Tenant-ID")
		if tenantID == "" {
			http.Error(w, "Tenant ID is missing", http.StatusBadRequest)
			return
		}

		db, err := p.GetDB(tenantID)
		if err != nil {
			http.Error(w, "Could not connect to tenant database", http.StatusInternalServerError)
			return
		}

		// 将DB连接注入到请求的Context中
		ctx := WithTenantDB(r.Context(), db)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

在业务Handler中,直接从Context获取DB连接即可,完全无需关心路由逻辑。

2. 行级别隔离实现 (Row per Tenant)

这是最常见也最考验代码规范的方案。所有租户共享同一个数据库和同一套表,通过tenant_id字段来区分数据。

架构设计

+---------------------------------+
|          共享数据库             |
+---------------------------------+
| users 表                        |
| + tenant_id (索引)              |
| + user_id                       |
| + username                      |
|---------------------------------|
| orders 表                       |
| + tenant_id (索引)              |
| + order_id                      |
| + amount                        |
+---------------------------------+

核心Go代码实现(基于GORM Scopes自动注入查询条件)

为防止开发人员忘记在每个查询中手动添加WHERE tenant_id = ?,我们可以使用GORM的Scopes功能来自动化这个过程。

// models.go
package models

// BaseEntity 包含所有模型共有的字段
type BaseEntity struct {
	TenantID string `gorm:"index;not null"`
}

type User struct {
	gorm.Model
	BaseEntity
	Name string
}

type Order struct {
	gorm.Model
	BaseEntity
	Amount float64
}
// tenant_scope.go
package scopes

import (
	"context"
	"errors"
	"gorm.io/gorm"
)

// TenantScope 返回一个GORM Scope,它会自动添加tenant_id查询条件
func TenantScope(ctx context.Context) func(db *gorm.DB) *gorm.DB {
	return func(db *gorm.DB) *gorm.DB {
		// 从Context中提取tenantID
		tenantID, ok := GetTenantIDFromContext(ctx) // 假设这个函数能从ctx中拿到租户ID
		if !ok {
            // 在Scope中返回错误会中断GORM操作
			db.AddError(errors.New("tenant ID not found in context"))
			return db
		}
		
        // 自动为INSERT操作设置TenantID
		if db.Statement.Schema != nil {
			for _, field := range db.Statement.Schema.Fields {
				if field.Name == "TenantID" {
					field.Set(db.Statement.Context, db.Statement.ReflectValue, tenantID)
				}
			}
		}

		// 为所有查询(SELECT, UPDATE, DELETE)自动添加WHERE条件
		return db.Where("tenant_id = ?", tenantID)
	}
}

// 在业务代码中使用
func GetUser(ctx context.Context, userID uint) (*User, error) {
    db, _ := GetTenantDB(ctx) // 假设这个DB是共享DB
    var user User
    // 应用TenantScope后,GORM会自动添加 WHERE tenant_id = '...'
    err := db.WithContext(ctx).Scopes(TenantScope(ctx)).First(&user, userID).Error
    return &user, err
}

三、资源配额控制方案

资源配额控制通常通过AOP(在Go中是Middleware)或在业务逻辑中显式检查来实现。

1. 资源配额管理模型

首先,定义一个TenantQuota模型来存储每个租户的配额和使用情况。

// tenant_quota.go
package models

import "time"

type TenantQuota struct {
	TenantID            string `gorm:"primaryKey"`
	StorageQuotaMB      int64  // 存储配额 (MB)
	StorageUsedMB       int64  // 已使用存储 (MB)
	ApiCallQuota        int64  // API调用次数配额 (例如, 每月)
	ApiCallsUsed        int64  // 已使用API调用次数
	QuotaResetAt        time.Time // 配额重置时间
}

2. 基于Middleware的配额控制实现

使用HTTP中间件来拦截API请求,检查并更新调用次数。

// quota_middleware.go
package middleware

import (
	"net/http"
	"yourapp/services" // 假设这是你的配额服务
)

// QuotaCheckMiddleware 检查API调用配额
func QuotaCheckMiddleware(quotaService *services.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.Header.Get("X-Tenant-ID")
			if tenantID == "" {
				http.Error(w, "Unauthorized", http.StatusUnauthorized)
				return
			}
			
			// 检查并消费配额
			allowed, err := quotaService.CheckAndConsume(r.Context(), tenantID, "api_call", 1)
			if err != nil {
				http.Error(w, "Internal Server Error", http.StatusInternalServerError)
				return
			}

			if !allowed {
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusTooManyRequests)
				w.Write([]byte(`{"error": "API call quota exceeded"}`))
				return
			}

			next.ServeHTTP(w, r)
		})
	}
}

3. 分布式环境下的配额控制 (Redis)

在分布式系统中,简单的数据库读写会产生并发问题。使用Redis的原子操作(如INCR或Lua脚本)是最佳实践。

// redis_quota_service.go
package services

import (
	"context"
	"fmt"
	"github.com/go-redis/redis/v8"
)

type RedisQuotaService struct {
	rdb *redis.Client
}

// CheckAndConsume 使用Lua脚本原子性地检查和消费配额
func (s *RedisQuotaService) CheckAndConsume(ctx context.Context, tenantID, resourceType string, amount int64) (bool, error) {
	usageKey := fmt.Sprintf("tenant:usage:%s:%s", tenantID, resourceType)
	quotaKey := fmt.Sprintf("tenant:quota:%s:%s", tenantID, resourceType)

	// Lua脚本:原子性地检查 (usage + amount <= quota),如果满足则增加usage
	script := `
        local usage = tonumber(redis.call('GET', KEYS[1])) or 0
        local quota = tonumber(redis.call('GET', KEYS[2]))
        
        if quota == nil or quota <= 0 then
            return 0 -- 配额未设置或无效
        end

        if usage + ARGV[1] > quota then
            return 0 -- 超出配额
        else
            redis.call('INCRBY', KEYS[1], ARGV[1])
            return 1 -- 成功
        end
    `
	
	result, err := s.rdb.Eval(ctx, script, []string{usageKey, quotaKey}, amount).Result()
	if err != nil {
		return false, err
	}

	return result.(int64) == 1, nil
}

四、多租户认证与权限

认证的核心是从请求中安全地识别出用户身份租户身份

1. 租户识别与认证 (JWT Middleware)

在用户登录时,颁发包含user_idtenant_id的JWT。后续每个请求都通过一个中间件来校验JWT并提取这些信息。

// jwt_middleware.go
package middleware

import (
    "context"
	"net/http"
	"strings"
	"github.com/golang-jwt/jwt/v4"
)

// Claims 定义了JWT中存储的数据
type Claims struct {
	UserID   string `json:"user_id"`
	TenantID string `json:"tenant_id"`
	jwt.RegisteredClaims
}

var jwtKey = []byte("your_secret_key")

// AuthMiddleware 校验JWT并注入用户信息到Context
func AuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		authHeader := r.Header.Get("Authorization")
		if authHeader == "" {
			http.Error(w, "Authorization header required", http.StatusUnauthorized)
			return
		}

		tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
		claims := &Claims{}

		token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
			return jwtKey, nil
		})

		if err != nil || !token.Valid {
			http.Error(w, "Invalid token", http.StatusUnauthorized)
			return
		}

		// 将租户和用户信息存入Context
		ctx := context.WithValue(r.Context(), "tenantID", claims.TenantID)
		ctx = context.WithValue(ctx, "userID", claims.UserID)
		
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

五、方案选择与最佳实践

  1. 数据隔离方案选择建议

    • 初创/小型SaaS:从行级别隔离开始,它成本最低,开发速度最快。使用GORM Scopes等机制来保证数据安全。
    • 成长/中型SaaS:当租户数量增多,或出现对隔离性要求更高的客户时,可以考虑迁移到Schema级别或为大客户提供数据库级别的增值服务。
    • 大型/企业级SaaS:通常采用混合模式。大部分租户使用行级别隔离,而VIP客户则使用独立的数据库,通过统一的应用层路由进行管理。
  2. 资源配额控制最佳实践

    • 分层控制:在API网关层(如Kong, Traefik)做速率限制,在应用层做更精细的业务配额控制。
    • 异步和最终一致性:对于非关键的配额统计(如存储用量),可以通过定时任务或消息队列异步更新,减少对主业务流程的性能影响。
    • 监控与告警:实时监控租户的资源使用情况,当接近配额阈值时,通过邮件或Webhook主动通知用户。

六、总结

在Go中构建一个高效、安全的多租户SaaS系统,关键在于深思熟虑的架构决策和严谨的代码实践:

  • 数据隔离:根据业务场景、成本和安全需求,在数据库、Schema、行级别隔离中做出选择。行级别隔离 + GORM Scopes 是Go社区中一个兼具效率和安全性的流行方案。
  • 资源配额:利用HTTP中间件Redis原子操作实现一个健壮、分布式的配额控制系统。
  • 上下文(Context)context.Context是Go中串联请求、传递租户信息、实现优雅控制的“神经系统”,必须熟练运用。