每个租户必须使用独立gorm.DB实例,不可复用或切换同一变量;因gorm.DB封装连接池、回调链、日志器等不可共享组件,复用会导致连接泄漏、事务失效、日志污染;须用sync.Map缓存tenantID→*gorm.DB映射,首次访问时gorm.Open初始化并设search_path或库名,显式配置连接池参数。

每个租户必须用独立 *gorm.DB 实例,不能复用或“切换”同一个变量
GORM 的 *gorm.DB 不是轻量上下文,它内部封装了 *sql.DB 连接池、回调链、日志器、插件(如 prometheus)、事务管理器等。两个不同 DSN(哪怕只是库名或 schema 不同)的数据库,必须调用两次 gorm.Open,分别赋值给不同变量(如 tenantDB1、tenantDB2)。复用同一变量会导致:
- 前一个租户的连接池未关闭,引发连接泄漏
- 事务中混用不同实例,事务完全失效(
tenantDB1.Transaction()里调tenantDB2.Create()就游离在外) - 日志和钩子互相污染,比如 A 租户的 SQL 日志打到 B 租户的监控指标里
驱动导入漏下划线(如 import _ "gorm.io/driver/postgres" 少了 _)时,gorm.Open 不报错,但首次查询卡死或返回 "invalid connection"。
用 sync.Map 缓存租户 ID 到 *gorm.DB 的映射
避免每次请求都新建连接,也防止无限创建实例导致内存/连接数爆炸。缓存结构为 tenantID → *gorm.DB,推荐 key 用子域名(如 acme),不是 UUID 或随机字符串。
- 首次访问某租户时,调
gorm.Open初始化专属实例,并立刻执行db.Exec("SET search_path TO tenant_acme")(PostgreSQL)或使用带库名的 DSN(MySQL) - 必须显式设置连接池参数:
db.SetMaxOpenConns(5)和db.SetConnMaxLifetime(5 * time.Minute);否则 1000 租户 × 默认 100 连接 = 10 万连接,PostgreSQL 直接拒绝新连接 - DSN 中特殊字符(如密码含
/或@)需用url.QueryEscape编码,否则报"pq: password authentication failed"且难定位
入口中间件校验租户有效性并注入 context.Context
租户识别不能跳过主库校验。典型流程:
- 从
r.Host安全提取子域名(用net.SplitHostPort剥离端口),再切分主域名 - 查主库(如
tenants表)确认租户存在且状态为active,绝不 fallback 到默认库 - 校验失败直接返回
404或403,不继续往下走 - 把租户 ID 和对应
*gorm.DB实例一起塞进ctx,例如ctx = context.WithValue(ctx, tenantDBKey, db)
Handler 必须显式取值:db := ctx.Value(tenantDBKey).(*gorm.DB),禁止 fallback 到全局变量或隐式依赖。异步任务、定时器、消息消费器里 context.Value 可能为空,所以 DAO 层函数签名应强制接收 tenantID string 参数,而非从 ctx 里取。
Preload、Count、Unscoped 等方法不自动感知租户,必须收口防护
GORM 所有内置方法(包括 Preload、Count、FirstOrInit、Updates)默认都不带租户条件。一旦调用 db.Unscoped().Where("id = ?", 123).Find(&u),就彻底绕过所有隔离逻辑。
- 禁用裸
db.Where().Find()调用,统一封装成Scopes(ByTenant(tenantID)),把WHERE tenant_id = ?收口到一个函数里 - 软删除字段
deleted_at必须和tenant_id同级约束:Where("tenant_id = ? AND deleted_at IS NULL", tenantID) - 每张表的
tenant_id字段必须建联合索引,如(tenant_id, email)或(tenant_id, created_at),否则分页查询秒级延迟 - 测试时强制覆盖所有 DAO 路径,验证生成的每条 SQL 是否含
tenant_id = ?
最常被忽略的点:PostgreSQL 的 search_path 是 session 级变量,连接池复用后状态不重置——上个请求设的 tenant_abc,下个请求可能还在用,而你根本没察觉。











