应优先选择 PostgreSQL 的 schema 隔离:tenant_id 字段隔离仅适用于租户数<20的轻量场景,超50租户或有审计要求时必须用 schema 隔离,因 search_path 能兜底防越权,而字段隔离易因漏加 WHERE 条件导致 P0 数据泄露。

Go 本身不提供多租户能力,架构成败取决于隔离策略是否选对、落地是否兜底——字段隔离(tenant_id)适合快速验证或租户数<20的轻量场景;一旦租户超 50、有审计要求或长期维护计划,必须用 schema 隔离,否则漏一次 WHERE tenant_id = ? 就是 P0 数据泄露。
该用 tenant_id 字段隔离,还是 PostgreSQL 的 schema 隔离?
结论很明确:中小 SaaS 可以从 tenant_id 字段隔离起步,但别长期依赖它。GORM 的 Preload、Count、Joins、Raw 查询都容易绕过手动加条件的逻辑,而数据库原生的 search_path 能兜底——哪怕 DAO 层写错 SQL,也不会跨 schema 读到其他租户数据。
MySQL 不支持 search_path,若非用不可,只能走分库(dbname=tenant_123),但连接池无法复用,1000 个租户 ≈ 1000 个 *sql.DB 实例,OOM 和连接耗尽风险极高。
-
tenant_id方案必须封装所有 DAO 方法,强制传入tenantID string,禁用裸db.Where().Find() - PostgreSQL
schema方案只需在连接初始化后执行SET search_path TO tenant_abc,连接池可复用 - 别手写表名拼接(如
"tenant_abc.users"),破坏 ORM 抽象、难测试、易出错
如何安全地动态切换租户 DB 连接(避免全局变量和 context 透传)?
把 tenantID 塞进 context.Context 再一路传到 DAO 层拼 SQL,等于把隔离责任甩给每个业务开发者——漏一次就是越权。正确做法是让连接“自己知道属于谁”。
用 sync.Map 缓存租户专属的 *sql.DB 实例,key 是 tenantID,value 是已设置好 search_path 的连接池:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
var tenantDBs sync.Map // key: string, value: *sql.DB
<p>func GetTenantDB(tenantID string) (<em>sql.DB, error) {
if db, ok := tenantDBs.Load(tenantID); ok {
return db.(</em>sql.DB), nil
}
dsn := "host=pg user=app password=xxx dbname=myapp sslmode=require"
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
<em>, </em> = db.Exec("SET search_path TO " + tenantID)
db.SetMaxOpenConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
tenantDBs.Store(tenantID, db)
return db, nil
}</p>
- 每次 HTTP 请求都调用
sql.Open?绝对不行——连接泄漏、OOM、冷启动慢全找上门 -
SetMaxOpenConns(5)必须设,否则默认 0(无上限),1000 租户 × 100 连接 = 10 万连接,直接打崩 PostgreSQL - 不要把数据库名或 schema 名写死在代码里,配置项应为
tenant_schema_mapJSON 字符串,由配置中心热更新
GORM 中怎么避免漏加 tenant_id 过滤?
别靠中间件往 context 写值再手动塞进每个查询——太容易遗漏。GORM 的 Scopes + Callbacks 才是可靠方案。
定义一个通用 scope:
func TenantScope(tenantID string) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("tenant_id = ?", tenantID)
}
}
<p>// 使用时
db.Scopes(TenantScope("acme")).Find(&users)
db.Scopes(TenantScope("acme")).Preload("Orders").Find(&users)</p>
-
SoftDelete字段(如deleted_at)也要和tenant_id同级约束,否则Unscoped().Where()可能绕过租户检查 - 禁止裸用
db.Table("users").Where(...),必须走 model 定义 + scope 注入 - 如果用 GORM v2,还可配合
Session创建带租户上下文的临时*gorm.DB实例
真正难的不是“怎么写代码”,而是租户生命周期操作:比如合并两个租户时,schema 隔离要导出再导入,字段隔离则要批量改 tenant_id 并校验外键一致性——这些没有现成 Go 库,得自己写带事务回滚和断点续传的迁移工具,且必须在低峰期灰度执行。










