基于共享数据库与逻辑隔离的多租户重构实践在 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 系统的常用方案。它通过拦截器和数据库会话变量在应用层建立了安全边界同时将基础设施成本降到最低。在项目初期这种方案能帮团队快速验证业务后续可以根据业务规模再演进到更复杂的物理隔离架构。