postgresql schema隔离是go多租户架构中真正可落地、可运维的默认方案;字段级隔离(tenant_id)仅适用于租户数

PostgreSQL schema 隔离是 Go 多租户架构中真正可落地、可运维的默认方案;字段级隔离(tenant_id)只适合验证期或租户数少于 50 且无安全审计要求的场景。
为什么不能靠 context.Context + tenant_id 字段过滤兜底
这不是代码写得不够勤快的问题,而是运行时根本不可控:GORM 的 Count()、Preload()、Raw()、Unscoped() 全部绕过自定义 Scopes;中间件往 ctx 塞了 tenant_id,DAO 层用 ctx.Value(tenantKey{}) 取值,但类型断言失败就 panic,异步任务里 ctx 还可能为空;更致命的是,测试常 mock DB 层,漏条件的 SQL 永远不会在单元测试里暴露。
实际故障现象包括:
-
db.Unscoped().Where("id = ?", 123).Find(&u)直接返回其他租户数据 -
db.Preload("Orders").First(&user)加载出全库所有订单,而非当前租户的 - 日志里查不到
tenant_id字段,排查时无法定位数据归属
PostgreSQL 下必须用 search_path + 独立 *sql.DB 实例
别信“连上 PG 后执行一次 SET search_path TO tenant_abc 就能复用连接池”——database/sql 不保证同连接复用,连接归还池后状态丢失,下个 goroutine 拿到就继承旧 schema,必然串租户。
正确做法是为每个租户缓存专属 *sql.DB 实例:
- 用
sync.Map缓存:tenantID → *sql.DB - 首次访问时调用
sql.Open,立刻执行db.Exec("SET search_path TO " + schemaName) - 必须设置连接池参数:
db.SetMaxOpenConns(5)和db.SetConnMaxLifetime(5 * time.Minute),否则 1000 租户 × 默认 100 连接 = 10 万连接,PG 直接 OOM - SQL 中不手写
"tenant_abc.users"拼表名——破坏 GORM 钩子(如BeforeCreate),也难测
MySQL 下字段隔离是唯一可行路径,但必须加 RLS 替代方案
MySQL 没有 search_path,也没有原生 schema 权限隔离能力。“分库”看似隔离,实则导致每个租户一个 *sql.DB 实例,连接池无法复用,K8s 下扩缩容时健康检查、空闲连接回收全部指数级恶化。
若坚持用 MySQL,只能走字段隔离,但必须补强:
- 所有查询强制走封装函数:
TenantDB.Where("tenant_id = ? AND deleted_at IS NULL", tenantID).Find(),禁用裸db.Where() - 每张表的
tenant_id字段必须建联合索引,例如(tenant_id, email),否则慢查询秒级起步 - 数据库层加 CHECK 约束:
ALTER TABLE orders ADD CONSTRAINT chk_tenant_id_nonempty CHECK (tenant_id IS NOT NULL) - 应用层禁止拼接 DSN 中的 database 名,避免租户 SQL 注入后触发
multiStatements跨库执行
GORM 的 Preload 和 Count 怎么强制走租户隔离
它们默认不继承主查询的 Scopes,这是最常被忽略的越权点。你写了 db.Scopes(ByTenant(tenantID)).Preload("Orders"),GORM 仍会生成无 WHERE tenant_id = ? 的关联查询。
解决方式只有两个,且必须二选一:
- 显式为每个关联指定 scope:
db.Preload("Orders", func(db *gorm.DB) *gorm.DB { return db.Where("tenant_id = ?", tenantID) }) - 改用
Joins()+ 手动SELECT,把关联逻辑收口到 DAO 层,避免依赖 GORM 自动推导 -
Count()必须重写:var count int64; db.Where("tenant_id = ?", tenantID).Model(&User{}).Count(&count),不能直接db.Count()
复杂点在于:Preload 的嵌套层级越深,漏加条件的概率越高;而每次手动传参又极易和上下文里的 tenant_id 不一致——所以干脆别依赖上下文取值,所有 DAO 方法签名都强制带 tenantID string 参数,类型安全,无隐式风险。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











