租户标识必须在请求最早可干预点解析校验,来源仅限x-tenant-id头、jwt tenant_id声明或子域名;中间件只负责提取、校验并c.set("tenant_id"),严禁越界操作,dao层须显式接收tenantid参数。

租户标识从哪来,中间件就得在哪解析
租户 ID 不能靠 handler 里临时猜,必须在请求生命周期最早可干预点提取并校验。常见来源只有三类:X-Tenant-ID header、JWT claim 中的 tenant_id 字段、或子域名(如 acme.example.com)。选错位置,后续所有隔离都白搭。
用 header 就在最外层中间件做:c.Request().Header.Get("X-Tenant-ID"),为空或格式非法直接返回 400 Bad Request;用 JWT 必须等鉴权中间件跑完、c.Get("user_claims") 可取到后才提取 claims["tenant_id"];子域名则需在 echo.New() 后立即注册 Host 路由匹配,或用 net/http.ServeMux 前置分发——别指望 Echo 默认路由自动拆解 Host 头。
- 漏掉校验会导致 DAO 层拿到空
tenant_id,GORM 查询变成全表扫描 - 把解析逻辑拖到 service 或 DAO 层,等于放弃连接池隔离,
sql.DB实例会被多个租户复用 - 测试时若只 mock
echo.Context而不注入真实 header/JWT,这个中间件根本不会执行,隐患无法暴露
中间件里只做识别和透传,绝不碰数据库或缓存
这个中间件唯一职责是:解析出 tenant_id、校验合法性、调用 c.Set("tenant_id", tid) 存入上下文。它不该初始化 DB、不查 Redis、不调 Preload、不拼 SQL —— 那些都是 DAO 层该干的事。一旦越界,就破坏了「识别」和「隔离」的职责分离。
典型错误包括:c.Get("tenant_id") 后直接传给 gorm.Where(),或者在中间件里用 redisClient.Get(ctx, "user:"+id)。这些操作在异步 goroutine、GORM Raw()、单元测试中全部失效,c.Get() 返回 nil 导致 panic 或空 key 查询。
- 异步任务(如
go func() { ... }())中echo.Context已不可用,c.Get()永远是nil - GORM 的
Count()、Unscoped()、Raw()完全不读上下文,中间件塞的值对它们无效 - 日志中间件若只写
c.Get("tenant_id"),而 handler 漏了调用c.Set(),日志里就是空字段,线上排查无从下手
DAO 层必须显式接收 tenant_id 参数,禁止隐式读取 Context
所有数据访问函数签名第一参数必须是 tenantID string,例如:func (r *UserRepo) FindByID(ctx context.Context, tenantID string, id int64) (*User, error)。这是防泄漏的底线——漏传编译报错,比运行时跨租户查数据强一万倍。
Echo框架 5.1.0 版本源码包下载,适合关注 RealIP 行为变化、StartConfig.Listener、NewDefaultFS 和观测性中间件入口的开发团队。
封装 GORM 查询时,强制写 db.Where("tenant_id = ?", tenantID),别用 Scopes。因为 Preload("Orders") 不继承 scope 条件,软删除也得和 tenant_id 同级:Where("tenant_id = ? AND deleted_at IS NULL", tenantID)。
- 缓存 key 必须含
tenant_id前缀,如redisClient.Get(ctx, "tenant:"+tenantID+":user:"+strconv.FormatInt(id, 10)) - 文件路径、配置中心 key、消息队列 topic 名,只要涉及租户边界,就必须显式拼接
tenantID - PostgreSQL schema 隔离下,绝不能手写表名如
"tenant_abc.users"——会绕过 GORM 钩子,且无法测试
租户专属 DB 实例必须按 ID 缓存,且首次访问即 SET search_path
Echo 请求生命周期太短,中间件里执行 db.Exec("SET search_path TO tenant_abc") 没用:连接归还池后状态丢失,下一个请求可能串库。正确做法是用 sync.Map 缓存租户专属 *sql.DB 实例,键为 tenantID,值为已执行过 SET search_path 的连接池。
首次访问某租户时,调用 sql.Open() 创建新 *sql.DB,立刻执行 db.Exec("SET search_path TO " + tenantID),再设连接池参数:db.SetMaxOpenConns(5)、db.SetConnMaxLifetime(5 * time.Minute)。handler 中通过 tenantID 查 sync.Map 拿对应 *sql.DB,再传给 GORM 初始化。
- 全局声明一个
var db *sql.DB然后 runtime 拼 DSN 切换,是高危操作——sql.DB不是连接,是连接池抽象 - 每个租户的
*sql.DB必须单独调用Close(),否则连接泄漏,尤其服务 shutdown 阶段 - 不要用
context.WithValue透传*sql.DB,handler 里直接从sync.Map查更可靠
租户隔离最易被忽略的点,是「所有租户边界操作都必须显式绑定 ID」——不是靠中间件塞进 Context 就万事大吉。DAO、缓存、文件、DB 连接池、甚至日志字段,漏一处,数据就跨出去。










