租户id提取必须在中间件完成,不能拖到dao层;需通过net.splithostport拆端口再切分子域名、校验jwt或header,校验后存入c.set("tenant_id", id),所有下游调用必须显式携带该id。

租户ID提取必须在中间件完成,不能拖到DAO层
租户上下文一旦延迟到数据库操作时才解析,就等于把路由逻辑和数据访问混在一起,后果是连接池复用错乱、SQL注入风险上升(比如拼接 USE tenant_db)、缓存键漏 tenant_id 导致静默污染。中间件是唯一能统一拦截并校验的位置。
常见错误现象包括:同一请求查出其他租户的数据、sql.Open 频繁新建连接、context.WithValue 传参后 handler 里取不到值。
- HTTP header 方式:检查
X-Tenant-ID是否存在且非空,缺失直接c.AbortWithStatusJSON(400, ...) - JWT 方式:必须在鉴权中间件之后调用,从
c.Get("jwt_payload")取tenant_id字段,不建议再发一次 JWT 解析 - 子域名方式:先用
net.SplitHostPort(c.Request.Host)拆端口,再strings.SplitN(host, ".", 2)提前缀;localhost或127.0.0.1请求应跳过或走默认租户
中间件里不能存全局 *sql.DB,必须透传到 context
Gin 中间件本身不持有状态,*sql.DB 是带连接池、超时、最大连接数等配置的重量级对象,跨租户复用会互相干扰。中间件只负责解析租户 ID 并注入 context,后续所有 DB 操作都靠 c.MustGet("tenant_id") 查缓存获取对应实例。
错误做法是声明一个全局 var db *sql.DB,然后在中间件里根据租户动态重赋值——这会导致并发下连接池被覆盖、SetMaxOpenConns 失效、甚至 panic。
- 推荐用
sync.Map缓存租户 ID →*sql.DB映射,key 是 string,value 是*sql.DB - 缓存未命中时调用
sql.Open,设置db.SetMaxOpenConns(20)和db.SetConnMaxLifetime(1h)后再存入 - 中间件内调用
c.Set("tenant_db", db),handler 里用db, ok := c.Get("tenant_db").(*sql.DB)取用
路由组不能动态注册,所有租户共用一套路径
Gin 的 router.Group() 是构建期行为,r.Run() 启动后路由树就冻结了。试图写 v1 := router.Group(tenantID + "/api") 是无效的,运行时不会生效。
真实可行的是“一套路由、多套上下文”:所有租户都走 /api/users,但每个 handler 开头立刻从 context 提取 tenant_id,再传给 DAO 层构造查询条件、拼接缓存键、初始化限流器。
- 禁止用
switch tenantID写分支逻辑,应封装成方法,例如tenantDB(tenantID).Users().List() -
c.Param("id")和c.Query("page")不受影响,照常取用,但所有下游调用必须显式携带tenant_id - Redis 缓存键必须含租户前缀,如
"tenant:" + tenantID + ":user:" + userID,硬编码比序列化 struct 更可靠
shutdown 阶段必须显式 Close 每个租户的 *sql.DB
每个租户的 *sql.DB 实例都需要单独 Close(),否则连接泄漏、MySQL 报 Too many connections。这不是可选动作,而是资源清理硬要求。
容易被忽略的是:Gin 本身不提供 shutdown hook,得自己注册 os.Interrupt 或用第三方库(如 github.com/oklog/run)管理生命周期。
- 服务启动时收集所有已创建的
*sql.DB实例,存进全局 slice 或 map - 监听
os.Interrupt或syscall.SIGTERM,遍历调用每个db.Close() - 不要依赖 GC 自动回收 ——
*sql.DB的底层连接池不会被 GC 回收











