基于共享数据库与逻辑隔离的多租户重构实践
在 SaaS 和企业级应用中,多租户(Multi-Tenancy)架构需要在数据隔离(防止越权与互相干扰)和资源成本之间做权衡。
本文介绍如何在单数据库实例中,通过逻辑隔离(共享数据库与tenant_id过滤)实现低成本的多租户隔离。
一、多租户数据隔离选型
多租户方案通常有以下三种:
- 独立数据库 (Database-per-Tenant):每个租户使用独立的数据库实例。隔离性最好,但租户变多时,数据库维护和硬件成本很高。
- 独立模式 (Schema-per-Tenant):租户共享一个数据库实例,但使用独立的模式(Schema)。这种方式降低了硬件开销,但每次修改表结构(DDL 迁移)都需要对所有 Schema 进行,维护成本依然偏高。
- 共享数据库 (Shared Database):所有租户共享同一个数据库和同一批表,通过
tenant_id过滤数据。这种方案维护成本最低,但代码层必须确保隔离逻辑万无一失。
在租户规模未达到万级之前,共享数据库是性价比最高的选择。
二、租户上下文拦截与行级隔离
在共享数据库中,为了防止由于开发人员疏忽导致越权查询(如租户 A 查到租户 B 的数据),不能依赖在每条 SQL 里手动添加WHERE tenant_id = xxx。更好的做法是在请求入口处通过拦截器解析租户身份,并将其与数据库连接或会话绑定,结合行级安全(Row-Level Security, RLS)自动过滤数据。
租户请求的处理流程如下:
sequenceDiagram autonumber actor Client as 租户客户端 participant Gateway as 网关/拦截器 participant Context as 请求上下文 participant Service as 业务逻辑层 participant DB as 数据库 (PostgreSQL) Client->>Gateway: 1. 发送请求 (携带租户 ID) activate Gateway Gateway->>Gateway: 2. 鉴权并提取租户 ID Gateway->>Context: 3. 将租户 ID 写入上下文 Gateway->>Service: 4. 调用业务方法 deactivate Gateway activate Service Service->>Context: 5. 获取租户 ID Context-->>Service: 6. 返回租户 ID Service->>DB: 7. 绑定会话变量并执行查询 activate DB DB->>DB: 8. 执行行级安全过滤 (RLS) DB-->>Service: 9. 返回过滤后的数据 deactivate DB Service-->>Client: 10. 返回响应数据 deactivate Service三、行级隔离的 Go 语言实现
为了在应用框架中实现全自动的租户隔离,我们可以在数据库客户端拦截器中拦截 SQL,自动从上下文中读取当前租户 ID,并在执行查询前注入数据库会话变量。
下面是使用 Go 语言实现的逻辑行隔离拦截器代码:
package main import ( "context" "database/sql" "errors" "fmt" ) type contextKey string const TenantIDKey contextKey = "tenant_id" // TenantContextMiddleware 从 Context 中提取或设置租户 ID func TenantContextMiddleware(ctx context.Context, tenantID string) context.Context { return context.WithValue(ctx, TenantIDKey, tenantID) } // MultiTenantRepository 数据库访问层 type MultiTenantRepository struct { db *sql.DB } // QueryTenantData 查询租户数据,并绑定租户 ID func (r *MultiTenantRepository) QueryTenantData(ctx context.Context, itemID string) (string, error) { // 1. 获取当前请求的租户 ID tenantID, ok := ctx.Value(TenantIDKey).(string) if !ok || tenantID == "" { return "", errors.New("security breach: tenant_id not found") } // 2. 开启事务,并在事务中绑定租户会话变量 tx, err := r.db.BeginTx(ctx, nil) if err != nil { return "", fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() // 注入 PostgreSQL 会话变量,激活行级安全过滤 _, err = tx.ExecContext(ctx, "SET LOCAL app.current_tenant = ?", tenantID) if err != nil { return "", fmt.Errorf("failed to set session variable: %w", err) } // 3. 执行核心业务 SQL。数据库将利用会话变量自动进行过滤 var description string query := "SELECT description FROM items WHERE id = ? AND tenant_id = current_setting('app.current_tenant')" err = tx.QueryRowContext(ctx, query, itemID).Scan(&description) if err != nil { if errors.Is(err, sql.ErrNoRows) { return "", errors.New("resource not found or access denied") } return "", fmt.Errorf("query failed: %w", err) } if err := tx.Commit(); err != nil { return "", fmt.Errorf("failed to commit transaction: %w", err) } return description, nil } func main() { fmt.Println("initialized") }四、共享数据库方案的局限与应对
尽管共享数据库方案成本较低,但同样存在以下限制:
- 邻居干扰 (Noisy Neighbors):所有租户共享 CPU 和存储资源。如果某个租户的流量暴涨或存在慢查询,会拖慢其他租户的响应。因此,通常需要在网关层限制单个租户的并发数和请求频率。
- 数据备份与恢复困难:所有数据混在同一张表里,如果某个租户误删了数据,无法直接通过物理备份回滚,否则会覆盖其他租户的数据。针对这种情况,通常需要在业务层实现逻辑删除(Soft Delete),或者记录详细的操作日志(Activity Logs)以供手动恢复。
- 数据库变更 (DDL) 风险:随着数据量增大,在大表上修改表结构(如增加字段、建索引)可能会导致数据库锁表,影响在线业务。
五、总结
共享数据库结合逻辑行隔离是早期 SaaS 系统的常用方案。它通过拦截器和数据库会话变量,在应用层建立了安全边界,同时将基础设施成本降到最低。在项目初期,这种方案能帮团队快速验证业务,后续可以根据业务规模再演进到更复杂的物理隔离架构。