租户标识来源决定切换时机:http header、jwt claim或子域名,需在对应中间件解析并校验;每个租户应持独立*sql.db实例,按id缓存并单独close;框架中通过context透传db,禁止跨租户事务。

租户标识从哪来,决定了切换时机
Golang 多租户数据库切换不是在 ORM 层“猜”租户,而是必须明确租户上下文来源。常见来源有三类:HTTP header(如 X-Tenant-ID)、JWT claim(如 tenant_id 字段)、子域名(如 tenant1.example.com)。选哪种,直接决定中间件拦截和解析逻辑的位置。
- 若用
header,需在路由入口中间件里提取并校验,失败直接返回400 Bad Request或401 Unauthorized - 若用
JWT,必须在鉴权中间件之后、业务 handler 之前完成解析;注意 JWT 过期时间与租户权限是否强绑定 - 若用子域名,需在 HTTP server 启动时配置
Host匹配,或用net/http.ServeMux前置路由分发,不能依赖框架默认路由
漏掉校验或延迟到 DAO 层才取租户 ID,会导致连接池复用混乱、SQL 注入风险(比如把租户名拼进 USE 语句)。
database/sql 的 *sql.DB 不能跨租户复用
<em>sql.DB</em> 是连接池抽象,不是单个连接。每个租户应持有独立的 sql.DB 实例,否则 SetMaxOpenConns、SetConnMaxLifetime 等配置会互相干扰,且 driver.Valuer 或自定义 Scanner 行为可能污染其他租户查询。
- 不要全局声明一个
var db <em>sql.DB</em>然后 runtime 拼接 DSN 切换——sql.Open返回的新sql.DB才是隔离单元 - 推荐按租户 ID 缓存
*sql.DB,用sync.Map或带 TTL 的内存缓存(如github.com/bluele/gcache),避免高频建连 - 每个租户
*sql.DB应单独调用Close()(例如在服务 shutdown 阶段),否则连接泄漏
示例伪代码:
func GetDB(tenantID string) (*sql.DB, error) {
if db, ok := dbCache.Load(tenantID); ok {
return db.(*sql.DB), nil
}
dsn := fmt.Sprintf("user:pass@tcp(127.0.0.1:3306)/%s?parseTime=true", tenantID)
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(20)
dbCache.Store(tenantID, db)
return db, nil
}
GIN / Echo 中间件如何安全透传租户 DB 实例
框架中间件本身不持有 DB 实例,但必须把租户级 *sql.DB 注入到请求生命周期中。关键点在于:不能存在中间件里存全局变量,也不能在 handler 里重复查缓存。
- GIN:用
c.Set("db", db)写入 context,handler 中用c.Value("db").(<em>sql.DB)</em>取;注意类型断言失败 panic,建议封装GetTenantDB(c gin.Context) (*sql.DB, bool) - Echo:同理,用
c.Set("db", db),再通过c.Get("db")获取;Echo 的echo.Context不支持泛型,务必做if db, ok := c.Get("db").(*sql.DB)判断 - 切忌在中间件里调用
db.QueryRow或执行任何 SQL——此时事务尚未开启,且连接可能被复用到其他请求
另一个坑:日志中间件若打印了 c.Request.URL 但没过滤敏感路径参数(如 /api/tenant123/orders),可能意外暴露租户 ID。
事务跨租户会静默失败
Golang 标准库没有“跨数据库事务”,MySQL XA 或 PostgreSQL 两阶段提交在多租户场景下基本不可用。一旦业务需要操作多个租户数据(比如平台运营后台同步修改两个租户配置),必须放弃 ACID,改用最终一致性。
- 单租户内事务仍可用:
tx, _ := db.Begin()→tx.Commit(),没问题 - 但若误写成:
db1.Begin()和db2.Begin()分别提交,任一失败不会回滚另一个,也没有原子性保障 - 更隐蔽的问题:ORM 如
gorm的Session(&gorm.Session{Context: ctx})如果 ctx 里混入了不同租户的 DB 实例,Create可能写到错误库表
真正需要跨租户协调的场景,得靠消息队列 + 幂等 + 补偿事务,而不是试图在 database/sql 层解决。
租户隔离最易被忽略的不是技术实现,而是租户元数据(比如租户状态、DB 连接串加密)的存储位置——它本身就不能放在某个租户库中,而必须由平台库单独管理,且访问该库的凭证要严格隔离。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











