PostgreSQL下GORM多租户必须用schema隔离而非tenant_id字段隔离;因search_path是session级变量,连接池复用会导致租户串访,须为每个租户缓存独立*sql.DB实例并初始化即绑定search_path,且Preload/Count等需显式传tenant_id。

PostgreSQL 下用 GORM 做多租户,schema 隔离比 tenant_id 字段隔离更可靠;但必须为每个租户缓存独立 *sql.DB 实例并绑定 search_path,否则并发下必然串租户。
为什么不能靠 db.Exec("SET search_path TO ...") 动态切换
PostgreSQL 的 search_path 是 session 级变量,而 database/sql 连接池不保证同连接复用——上一个请求设的 search_path 可能被下一个租户继承。现象是:查 A 租户数据时,偶尔返回 B 租户的记录。
- 连接归还池后状态不重置,下次取出时仍沿用旧
search_path -
GORM.Session()或WithContext()不控制底层连接绑定,事务中可能跨连接失效 - 哪怕只调一次
db.Exec,也无法保证后续所有Query/Exec都落在目标 schema
如何安全初始化租户专属 *sql.DB 实例
核心是“一租户一实例 + 初始化即绑定”,用 sync.Map 缓存,避免每次请求都重建连接。
- DSN 不带
dbname=(PostgreSQL 下可省略),连接后立即执行db.Exec("SET search_path TO " + safeSchemaName) -
safeSchemaName必须白名单校验,例如正则^[a-z][a-z0-9_]{2,30}$,禁止拼接用户输入 - 每个实例必须调用
db.SetMaxOpenConns(5)和db.SetConnMaxLifetime(5 * time.Minute),防连接数爆炸 - 缓存 key 用租户 ID 字符串,value 是
*sql.DB,首次访问时 lazy 初始化
GORM.Preload 和 Count 为何必须显式传参
GORM 的 Preload、Count、Raw、Unscoped 全部绕过 Scopes,哪怕主查询加了 TenantScope,关联查询也默认加载全库数据。
-
db.Preload("Orders").First(&user)→ 加载所有租户的订单,不是当前租户的 - 正确写法:
db.Preload("Orders", func(db *gorm.DB) *gorm.DB { return db.Where("tenant_id = ?", tenantID) }) -
Count()同样漏条件:db.Where("tenant_id = ?", tenantID).Model(&User{}).Count(&count) - 软删除字段如
deleted_at必须和tenant_id同级约束:Where("tenant_id = ? AND deleted_at IS NULL", tenantID)
缓存与文件路径必须硬编码包含 tenant_id
缓存键漏掉 tenant_id 是导致数据错乱的高频原因,尤其当多个租户查同一资源名(比如都叫 config)时,Redis 里存的就变成“谁先写谁赢”。
- 禁止依赖 struct hash 或通用 key 生成器,必须显式拼接
"tenant_abc:config" - 文件系统操作必须校验路径归属:
/data/tenant_abc/uploads/xxx.png,且上传前重写文件名为 UUID -
os.Stat和os.Chmod前必须先调用validateTenantPath(tenantID, path),否则元数据操作可能越权
真正落地的难点不在代码怎么写,而在于连接池参数是否真设了 SetMaxOpenConns(5)、schema 名是否真做了白名单校验、缓存键是否每一处都硬编码了租户前缀——这些地方只要漏一处,压测或审计时就会暴雷。


















