字段级隔离最实用但仅适合中小SaaS;Schema级更安全可扩展,是Go微服务推荐默认方案;DB级因连接池无法复用、运维成本高而基本不用。

多租户数据隔离该选哪一层:DB、Schema 还是字段?
直接结论:**字段级隔离(tenant_id)最实用,但只适合中小规模 SaaS;Schema 级隔离更安全、可扩展,是 Go 微服务中推荐的默认选择**。DB 级隔离运维成本高、连接池难复用,Go 里基本没人真用。
原因很简单:database/sql 的连接池是按 *sql.DB 实例维护的,DB 级意味着每个租户要持有一个独立 *sql.DB,连接数爆炸、健康检查复杂、K8s 下扩缩容反模式。而 Schema 级(如 PostgreSQL 的 public 换成 tenant_123)能共用连接池,只要在 sql.Open 后动态 SET search_path 或拼接表名前缀即可。
- 字段隔离:所有租户数据混在一张表,靠
WHERE tenant_id = ?过滤 —— 容易漏加条件,ORM 自动生成 SQL 时尤其危险 - Schema 隔离:每租户一个 schema,建表语句带
CREATE TABLE tenant_abc.users,查询时显式指定 schema 或设search_path—— Go 里用pgx.Conn.Exec("SET search_path TO tenant_123")即可切换 - DB 隔离:每个租户一个 PostgreSQL database ——
sql.Open("postgres", "host=... dbname=tenant_123")要动态构造,连接池无法共享,sql.DB实例数 ≈ 租户数,OOM 风险高
Go 中如何安全地动态切换租户 Schema?
不能靠全局变量存当前租户,也不能把 tenant_id 塞进 context 并一路透传到 DAO 层再拼 SQL —— 这样容易漏、难测试、ORM(如 GORM)不友好。
推荐做法:**封装一个租户感知的 *sql.DB 代理,基于 sql.Driver 或中间件拦截,让 Exec/Query 自动注入 schema 上下文**。但更轻量的是在 service 层初始化时,为每个租户创建专属 *sql.DB 实例(注意不是每个请求都新建),并缓存到 map 中:
立即学习“go语言免费学习笔记(深入)”;
// 全局缓存:tenant_id → *sql.DB
var tenantDBs sync.Map // key: string, value: *sql.DB
func GetTenantDB(tenantID string) (*sql.DB, error) {
if db, ok := tenantDBs.Load(tenantID); ok {
return db.(*sql.DB), nil
}
// 构造带 tenant schema 的 DSN,例如:dbname=myapp user=... sslmode=...
dsn := fmt.Sprintf("host=pg port=5432 user=app password=xxx dbname=myapp sslmode=require")
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
// 设置 search_path,后续所有查询自动落在该 schema
_, _ = db.Exec("SET search_path TO " + pgx.Identifier{tenantID}.Sanitize())
tenantDBs.Store(tenantID, db)
return db, nil
}
注意:pgx.Identifier{tenantID}.Sanitize() 是必须的,否则租户 ID 若含破折号或大小写,会触发 PostgreSQL 错误 ERROR: invalid schema name。
GORM 怎么适配多租户 Schema?
GORM v2 默认不支持运行时切换 schema,硬拼表名(db.Table("tenant_123.users"))会导致关联失效、预加载崩掉。
可行方案只有两个:
- 用
Session绑定租户上下文:db.Session(&gorm.Session{Context: ctx}).Table("tenant_123.users").Find(&users)—— 但所有调用点都要显式写Table,DAO 层没法复用 - 改用
FullSaveAssociations: true+ 自定义Namer接口,在TableName方法里读取 context 中的租户信息 —— 更干净,但要求所有 model 实现TableName() string,且必须确保 context 不丢
更稳的做法是:**放弃 GORM 的自动迁移和复杂关联,用 pgx 原生执行 + sqlc 生成类型安全查询**。schema 切换由 sqlc 的 --schema 参数控制,不同租户用不同生成目录,编译期隔离,零 runtime 风险。
租户路由和中间件最容易踩的坑
常见错误是把租户识别逻辑(比如从 subdomain 或 JWT claim 提取 tenant_id)写在 HTTP middleware 里,然后塞进 context.WithValue,最后在 handler 里取出来做 DB 切换 —— 这看似合理,但问题在于:**Go 的 http.Request.Context() 是 request-scoped,一旦启动 goroutine(比如发异步消息、调下游服务),context 就丢了,tenant_id 变成空**。
真正可靠的方案只有两个:
- 租户标识必须作为参数显式传递,比如
UserService.CreateUser(ctx, tenantID, req),禁止隐式依赖 context - 如果必须用 context,得用
context.WithValue+ 所有异步操作都手动ctx = context.WithValue(parentCtx, tenantKey, tenantID)—— 但极易遗漏,不推荐
另一个隐形坑:租户 ID 来源不可信。别直接拿 subdomain 当 tenant_id 去查 DB,先校验是否已激活、是否被冻结。否则攻击者随便访问 hacker.tenant.com 就可能触发未授权 schema 创建或查询。










