PostgreSQL schema隔离是Go多租户默认方案;字段隔离仅适用于<50租户且无审计要求的验证期场景,因GORM的Raw/Count/Preload/Unscoped等方法绕过Scopes,context传tenant_id存在panic、丢失、测试难覆盖等运行时风险,必须用search_path+sync.Map缓存租户专属*sql.DB实例并限流连接池。

PostgreSQL schema 隔离是 Go 微服务中真正可落地、可运维的默认方案;字段级隔离(tenant_id)只适合验证期或租户数少于 50 且无安全审计要求的场景。
为什么不能靠 GORM Scopes 或 context 透传 tenant_id 做字段隔离
GORM Scopes 和手动从 context.Context 取 tenant_id 拼条件看似简单,但实际极易失效:
-
GORM的Raw()、Joins()、Count()、Unscoped()等方法会直接绕过Scopes,只要调用一次就可能跨租户读取 - 把
tenant_id塞进context.Context再一路传到 DAO 层拼 SQL,等于把隔离责任甩给每个开发者——中间任意一层漏传、错传或复用旧context,数据就裸奔 - 软删除字段(如
deleted_at)若没和tenant_id同级约束,Unscoped().Where("id = ?", x)可直接绕过租户检查 - 单元测试常 mock DB 层,无法暴露真实 SQL 漏条件问题,线上才爆 P0
PostgreSQL 下如何安全实现 schema 隔离
关键不是“建个 schema 就完事”,而是连接初始化时绑定上下文并复用连接池:
- 每个租户对应一个 PostgreSQL schema(如
tenant_abc),建表语句统一加CREATE TABLE tenant_abc.users - 不要手写表名拼接(如
"tenant_abc.users")——破坏 ORM 抽象、难测、易出错;应使用SET search_path TO tenant_abc让后续所有查询自动落在该 schema - 用
sync.Map缓存租户专属的*sql.DB实例:tenantID → *sql.DB,避免每次请求都重连 - 首次获取时调用
db.Exec("SET search_path TO " + tenantID),之后该连接所有Query/Exec自动生效 - 必须调
db.SetMaxOpenConns(5)和db.SetConnMaxLifetime(5 * time.Minute),否则 1000 租户 × 默认 100 连接 = 直接打崩 PG
MySQL 下为什么硬上 schema 隔离等于自废武功
MySQL 不支持 search_path,也没有原生 schema 级权限隔离能力。所谓“MySQL schema 隔离”实际只能靠库名(database name)区分,但这带来致命问题:
立即学习“go语言免费学习笔记(深入)”;
- 每个租户一个
database(如saas_tenant_123),就必须为每个租户新建一个*sql.DB实例 -
database/sql连接池按*sql.DB实例维护,无法跨库复用,1000 租户 ≈ 1000 个独立连接池 - K8s 下扩缩容反模式:健康检查、空闲连接回收、OOM 风险全部指数级放大
- 若仍想用 MySQL,唯一可行路径是字段隔离 + RLS 替代(如全局强制
WHERE tenant_id = ?+ 中间件校验)
GORM Preload 和 Count 为什么总绕过租户过滤
GORM 的 Preload("Orders") 和 Count() 默认不继承主查询的 Scopes,也不会自动透传 tenant_id 到关联查询或聚合语句里:
- 禁止裸调
db.Preload("Orders").Find(&u);改用封装方法:tenantDB.User().WithOrders(tenantID).Find(&u),内部对Orders关联也手动加WHERE tenant_id = ? -
Count()必须走同级过滤:db.Where("tenant_id = ? AND status = ?", tenantID, "pending").Model(&Order{}).Count(&count),不能依赖 scope 透传 -
deleted_at软删除字段必须和tenant_id同级约束:Where("tenant_id = ? AND deleted_at IS NULL", tenantID),否则Unscoped()直接绕过 - 每张业务表的
tenant_id字段必须加联合索引,比如(tenant_id, email)或(tenant_id, created_at),否则分页查慢成秒级延迟
真正容易被忽略的是连接池状态不可靠:即使你执行了 SET search_path,*sql.DB 归还连接后不保证下次复用同一物理连接,所以不能靠 session 变量临时切换——必须为每个租户缓存独立 *sql.DB 实例,并在初始化时就完成 search_path 绑定。


















