中小项目用tenant_id字段隔离够用,租户超50或有等保/SOC2要求必须上Schema隔离;前者易因ORM漏条件、缓存/日志缺tenant_id等导致跨租户,后者靠数据库原生能力兜底。

Go 微服务里做多租户,核心不是选“ fancy 的架构”,而是选**能扛住上线后第 37 次紧急修复、DBA 不骂你、审计不翻车**的方案。中小项目用 tenant_id 字段隔离够用;租户数超 50、有等保或 SOC2 要求,必须上 Schema 隔离——这不是“更高级”,是兜底能力的分水岭。
tenant_id 字段隔离为什么容易线上爆炸
看着简单:每张表加个 tenant_id,所有查询补 WHERE tenant_id = ?。但真实崩溃点藏在 ORM 和边界操作里:
-
GORM.FirstOrInit()、Count()、Preload()默认不带租户条件,漏一个就跨租户读数据 - 软删除字段
deleted_at若没和tenant_id组成联合约束,调Unscoped()直接绕过隔离 - 缓存 key 忘加
tenant_id,比如用"user:123"而不是"tenant:abc:user:123",A 租户写入后 B 租户直接命中脏数据 - DAO 层函数签名若不显式接收
tenantID string,靠ctx.Value()透传,极易漏传、类型错、key 冲突
PostgreSQL Schema 隔离怎么避免连接池失控
Schema 隔离本质是让每个租户拥有逻辑独立的命名空间(如 tenant_acme),但共用同一个数据库连接池。关键不是“建 schema”,而是连接怎么管:
- 别在每次 HTTP 请求里调
sql.Open()—— 连接泄漏、OOM、启动慢三连击 - 用
sync.Map缓存tenant_id → *sql.DB,首次请求时初始化并执行SET search_path TO tenant_acme - 每个
*sql.DB实例必须调db.SetMaxOpenConns(5)和db.SetConnMaxLifetime(30 * time.Minute),否则 K8s Pod 重启后连接堆积 - 迁移工具(如
golang-migrate)不支持动态 schema 名,得自己遍历租户列表,对每个tenant_xxx执行一次migrate.Up()
租户 ID 从哪来、怎么传才不被绕过
前端传 X-Tenant-ID 或 URL 参数?等于把租户边界交给不可信输入。真正可信的只有认证上下文里已验证的归属关系:
立即学习“go语言免费学习笔记(深入)”;
- 子域名(
acme.example.com)最稳:中间件用net.SplitHostPort(r.Host)剥离端口,再按主域名切分,本地localhost加白名单跳过 - JWT payload 中必须存
tenant_id和tenant_role,签名校验通过才解出,别在 middleware 里重新解析或转换 - HTTP 中间件注入
context.Context时,用私有 key 类型(如type tenantKey struct{}),禁用字符串 key 防冲突 - DAO 层函数签名必须是
func GetUser(ctx context.Context, id int64) (*User, error),而不是func GetUser(tenantID string, id int64)—— 后者调用方永远可能传错
缓存和日志里的 tenant_id 容易被忽略的硬伤
缓存 key 漏 tenant_id 是高频 P0 故障;日志里不打 tenant_id 则排查等于盲人摸象:
- Redis key 必须硬编码拼接,如
"tenant:acme:feature_flag:paywall",别用 struct hash——字段顺序一变就 miss - 缓存 miss 后查 DB,结果必须带
tenant_id回填,否则下个租户直接拿错数据 - 所有日志打点前加
log.With("tenant_id", ctx.Value(tenantKey{})),Kibana 里才能按租户过滤、对比行为 - gRPC 场景不能复用 HTTP 中间件,必须用拦截器从
metadata.MD提取X-Tenant-ID,再塞进 context
最麻烦的从来不是“怎么实现”,而是“怎么确保没人绕过”。Schema 隔离靠数据库原生能力兜底,哪怕 DAO 层手写 SQL 漏条件,也不会跨租户;而字段隔离全靠人盯代码、靠测试覆盖、靠 Code Review——上线后第 100 次迭代,你还敢信吗?


















