tenant_id字段隔离必须从dao层函数签名强制约束,因gorm scopes是静态闭包、withcontext不自动注入sql条件,且preload/count/raw/unscoped等操作均绕过防护;需显式传参、禁用裸调用、所有缓存键和连接池也须按租户隔离。

tenant_id 字段隔离不是“能用就行”的方案,而是必须从 DAO 层函数签名开始强制约束——漏一次 Where、一次 Preload、一次缓存键拼接,就可能泄露全量数据。
为什么 GORM 的 Scopes 和 WithContext 都不能自动做租户过滤
GORM 的 Scopes 是静态闭包,无法读取运行时 context.Context 里的 tenant_id;WithContext 只是把上下文传下去,不自动注入 SQL 条件。两者叠加仍要手动写 Where("tenant_id = ?", tenantID)。
更危险的是:中间件里加了 Scopes(ByTenant(tenantID)),但开发者调 db.Unscoped().Where("id = ?", 123).Find(&u) 就直接绕过所有防护。
- 所有 DAO 方法签名必须显式带
tenantID string参数,禁止从ctx.Value()动态取——异步任务、定时器、消息消费里context往往为空或被覆盖 - 禁用裸
db.Where()、db.Table()、db.Raw()调用;统一走封装函数如tenantDB.User().WithTenant(tenantID).Find(&u) -
SoftDelete插件默认只加deleted_at IS NULL,必须在 scope 里显式写Where("tenant_id = ? AND deleted_at IS NULL", tenantID),否则Unscoped()一调就跨租户
Preload 和 Count 怎么避免绕过 tenant_id
Preload("Orders") 不继承主查询的 scope,Count() 也不感知租户上下文——这是线上 P0 故障最高发点。
例如:db.Scopes(ByTenant(tenantID)).Preload("Orders").Find(&user),Orders 关联查出来的仍是全库数据。
Go语言(Golang)1.26.0版本提供 Go 官方 Windows amd64 MSI 安装包下载入口,版本号 1.26.0,可用于旧项目维护、兼容性测试和指定版本开发环境配置。
- 不要依赖
Preload自动透传;改用封装方法:tenantDB.User().WithOrders(tenantID).Find(&u),内部对Orders表也手动加Where("tenant_id = ?", tenantID) -
Count()必须显式带条件:db.Where("tenant_id = ? AND status = ?", tenantID, "pending").Model(&Order{}).Count(&count),不能靠 scope 或全局 default - 聚合类操作(如
Sum、GroupBy)同理,一律手动拼Where,GORM 不做任何租户感知
PostgreSQL schema 隔离怎么不爆连接池
很多人试 db.Exec("SET search_path TO tenant_abc"),结果下个请求又回到 public——因为 *sql.DB 连接池不保证复用同一物理连接,session 级命令无法跨请求生效。
真正可行的做法是为每个租户缓存独立 *sql.DB 实例:
- 用
sync.Map缓存:tenantID → *sql.DB,key 是租户 ID,value 是已执行过SET search_path TO tenant_abc的连接池 - 初始化时用标准 DSN 打开连接,立刻执行
db.Exec("SET search_path TO " + schemaName),后续所有操作自动落在该 schema - 必须调
db.SetMaxOpenConns(5)和db.SetConnMaxLifetime(5 * time.Minute),否则 1000 租户 × 默认 100 连接 = PostgreSQL 直接被打崩 - 别手写
"tenant_abc.users"拼表名——破坏 ORM 抽象,GORM 的BeforeCreate等钩子全部失效
缓存键和 Redis 怎么不串租户
缓存键漏 tenant_id 不是并发问题,是设计缺陷。多个租户查同名资源(比如都叫 config),Redis 里存的就变成“谁先写谁赢”。
- 所有缓存 key 必须硬编码包含租户前缀,形如
tenant:{tenant_id}:user:{id}或tenant:{tenant_id}:feature_flag:paywall - 不用 struct hash 或 JSON 序列化生成 key——字段顺序、空值处理、时间格式差异都会导致键不一致
- Redis
Get返回nil后查 DB,必须把带tenant_id的结果回填,否则下一个租户会命中前一个租户的脏数据 - 本地缓存(如
groupcache、ristretto)同样需要租户上下文隔离,否则缓存不可信
真正难的不是写对一行 Where("tenant_id = ?"),而是让所有路径——包括日志埋点写 DB、第三方 SDK 回调、后台定时任务、甚至迁移脚本——全都显式携带 tenant_id。漏掉任意一个点,边界就塌了。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










