postgresql下schema隔离更可靠,但必须为每个租户缓存独立*sql.db实例并初始化即绑定search_path;因search_path是session级变量,连接池复用会导致租户串访,gorm的preload/count等方法绕过scopes,须显式传参tenant_id且软删除需与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 缓存,避免每次请求都重建连接。
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
- 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),否则Unscoped()可绕过
MySQL 下别硬上 schema 隔离
MySQL 没有 search_path,所谓 “schema 隔离” 实际是分库(dbname=tenant_123),但这会导致每个租户一个 *sql.DB 实例,连接池无法复用。
- 1000 租户 ≈ 1000 个独立连接池,K8s 下健康检查、空闲连接回收、OOM 风险指数级放大
- DSN 拼接必须校验
tenantID,防止tenant_123; DROP DATABASE类注入 - 连接字符串禁用
multiStatements=true,避免一条语句触发多库操作 - MySQL 唯一可行路径是字段隔离 + 数据库层强制
WHERE tenant_id = ?(如通过代理或 RLS 替代方案)
真正容易被忽略的点是:租户专属 *sql.DB 实例一旦缓存,就再也不能共享给其他租户;而 tenant_id 字段隔离哪怕封装得再严密,只要有一次 Raw() 或 Unscoped() 调用没加条件,就是 P0 泄露。安全不是靠“尽量不犯错”,而是靠架构设计让错误根本无法发生。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










