租户隔离必须从数据层强制实现,dao 层所有函数须显式接收 tenantid 并注入 where tenant_id = ? 条件,禁用全局变量和 context.value 透传;shared-database + separate-schema 在 go 生态中因连接池与迁移工具限制难以落地。

租户隔离必须从数据层开始,不能只靠中间件拦截
很多团队以为在 HTTP 中间件里加个 tenant_id 解析、往 context 里塞一下就完成了多租户,结果上线后出现跨租户数据泄露。根本问题在于:Go 的数据库操作(比如 sqlx 或 gorm)默认不感知租户上下文,SELECT * FROM orders 这种语句会扫出全量数据。
真正有效的做法是把租户标识下沉到查询构建环节:
- 所有 DAO 层函数必须显式接收
tenantID string参数,禁止使用全局变量或 context.Value 透传(易被忽略或漏传) - 用
WHERE tenant_id = ?强制拼入每个查询,哪怕看起来“没必要”的单条查询(如GetUserByID)也要加——因为 ID 在不同租户下可能重复 - 对 GORM,禁用
db.Unscoped();启用db.Session(&gorm.Session{Context: ctx})并在BeforeFind回调中自动注入tenant_id条件(但需确保回调不被绕过)
schema 隔离选 shared-database + shared-schema 还是 shared-database + separate-schema?
Go 生态里没有原生的 “动态 schema 切换” 支持,pgx 和 mysql 驱动都不允许运行时改 search_path 或 USE database 后复用连接。所以 separate-schema(每个租户一个 PostgreSQL schema)看着干净,实际落地极难:
- 连接池无法复用:切换 schema 需要执行
SET search_path TO tenant_123,而 pgx 的ConnPool不保证同连接复用,一旦连接被归还,下一次取到的连接 schema 是不确定的 - 迁移工具(如
golang-migrate)不支持按 schema 批量执行,你得自己遍历租户列表,挨个执行migrate -path ./migrations -database "postgres://...?options=-c+search_path%3Dtenant_abc" - 监控和慢查分析变复杂:pg_stat_statements 按 query text 聚合,相同 SQL 在不同 schema 下无法合并统计
结论:中小规模 SaaS(租户数 shared-database + shared-schema,靠 tenant_id 字段 + 唯一索引(如 UNIQUE (tenant_id, email))兜底;超大规模再考虑分库或 Citus 等扩展。
Go 的 context.Context 不能直接存租户 ID,得封装一层
直接写 ctx = context.WithValue(ctx, "tenant_id", "t-789") 是反模式。问题不止是类型不安全——context.Value 返回 interface{},下游代码必须做类型断言,一旦断言失败就 panic;更麻烦的是,HTTP 中间件、gRPC 拦截器、异步任务(如 time.AfterFunc)之间容易丢失上下文链。
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
推荐做法是定义强类型租户上下文:
type TenantCtxKey struct{}
func WithTenant(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, TenantCtxKey{}, id)
}
func TenantFromCtx(ctx context.Context) (string, bool) {
v, ok := ctx.Value(TenantCtxKey{}).(string)
return v, ok
}
并在关键入口(HTTP handler、gRPC method、消息消费函数)统一提取并校验 tenant_id 是否存在且合法(比如查缓存确认租户未停用),之后才往下传。别省这一步——线上最头疼的 bug 往往来自某个异步 goroutine 忘了传 context。
租户配置不能硬编码,得支持热加载和租户粒度覆盖
比如支付渠道配置、邮件模板、功能开关,如果写死在 config.yaml 里,每次新租户上线都要发版。Go 没有 Java Spring 那样的 @RefreshScope,得自己搭轻量机制:
- 用 Redis Hash 存租户配置:
HGETALL tenant:t-123:config,key 设 TTL(如 5 分钟),避免配置变更延迟太久 - 在 DAO 层封装
GetTenantConfig(tenantID string) (*TenantConfig, error),内部带本地 LRU 缓存(如lru.Cache),避免每请求都打 Redis - 提供管理端 API 允许运营人员修改单个租户的配置,调用后主动清 Redis 和本地缓存(用 Pub/Sub 或直接 DEL)
特别注意:不要把租户配置塞进 JWT token。token 一旦签发就不可变,租户开关调整后,已签发的 token 仍能绕过新策略。
租户 ID 的生成、校验、透传、存储、索引,这五个点只要漏掉一个,系统就不是多租户,只是“假装多租户”。尤其要注意异步场景——Go 的 goroutine 和 timer 容易让 context 断链,那里才是最常出问题的地方。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










