PostgreSQL下必须用schema隔离+sync.Map缓存租户专属*sql.DB实例,字段级隔离(tenant_id)仅适用于租户数<50且无安全审计要求的验证期场景。

PostgreSQL 下必须用 schema 隔离 + sync.Map 缓存租户专属 *sql.DB 实例,字段级隔离(tenant_id)只适合租户数
为什么不能靠 GORM.Scopes 或中间件设 search_path
Scopes 被 Count()、Preload()、Raw()、Unscoped() 全部绕过,漏一次就是跨租户读取全量数据。而中间件里执行 db.Exec("SET search_path TO tenant_abc") 是 session 级操作,连接归还池后状态不重置,下次取出时仍沿用旧 search_path——现象是查 A 租户时返回 B 租户记录。
根本原因是:database/sql 连接池不保证同物理连接复用,GORM.Session() 和 WithContext() 也不控制底层连接绑定,事务中甚至可能跨连接失效。
PostgreSQL:如何安全初始化租户专属 *sql.DB 实例
核心是“一租户一实例 + 初始化即绑定”,用 sync.Map 缓存,避免每次请求重建连接:
-
safeSchemaName必须白名单校验,例如正则^[a-z][a-z0-9_]{2,30}$,禁止拼接用户输入 - DSN 不带
dbname=(PostgreSQL 下可省略),连接后立即执行db.Exec("SET search_path TO " + safeSchemaName) - 每个实例调用
db.SetMaxOpenConns(5)和db.SetConnMaxLifetime(5 * time.Minute),防连接数爆炸(1000 租户 × 默认 100 = 10 万连接) - 缓存 key 用租户 ID 字符串,value 是
*sql.DB,首次访问时 lazy 初始化
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 下字段隔离的硬性补救措施
MySQL 没有 search_path,所谓 “schema 隔离” 实际是分库(dbname=tenant_123),但这会导致每个租户一个 *sql.DB 实例,连接池无法复用——1000 租户 ≈ 1000 个独立连接池,K8s 下健康检查、空闲连接回收、OOM 风险指数级恶化。
若坚持用 MySQL,字段隔离是唯一可行路径,但必须补强:
- 所有 DAO 方法签名强制接收
tenantID string参数,禁止从context.Value取值(类型断言失败会 panic,异步任务里还可能为空) - 禁用裸
db.Where().Find(),改用封装方法如FindWithTenant(tenantID)或统一Scopes(ByTenant(tenantID)) - 每张业务表的
tenant_id字段必须加联合索引,例如(tenant_id, email)或(tenant_id, created_at),否则查询一慢就是秒级延迟 - 考虑用 MySQL 8.0+ 的 Row-Level Security(RLS)替代纯应用层过滤,但需 DBA 配合开启策略
最常被忽略的点是:Preload 关联查询永远不继承主查询的租户条件,哪怕你写了十层嵌套 Scopes,只要没在 Preload 里显式传参,就等于把所有租户的数据都拉进内存再本地过滤——性能差、内存爆、还越权。


















