共享数据库+tenant_id字段隔离是最可行方案,因schema隔离抬高连接池、迁移与监控成本;gorm scopes和withcontext无法自动注入租户条件,必须封装租户感知查询器或显式调用tenantscope,且软删除需与tenant_id同级约束。

共享数据库 + tenant_id 字段隔离是当前 Go 微服务多租户架构中最可行、最易落地的选择;Schema 隔离虽更安全,但会显著抬高连接池管理、迁移和监控成本,仅当租户数长期稳定在百级以内且有 DBA 全程支持时才值得考虑。
为什么不能直接用 GORM 的 Scopes 或全局 WithContext 做租户过滤
因为 GORM 的 Scopes 是静态的,无法感知运行时从 context.Context 动态提取的 tenant_id;而 WithContext 只传递上下文,不自动注入 WHERE 条件。两者叠加仍需手动调用 Where("tenant_id = ?", tenantID),漏一次就全盘越权。
- 真正安全的做法是封装一个带租户感知的查询器,例如
ent.Client.WithTenant(ctx),内部从ctx.Value(tenantKey{})提取 ID 并自动挂载到所有Where、Update、Delete操作中 - 如果用 GORM,必须定义私有
tenantScope函数,且每个 DAO 方法都显式调用它,不能依赖中间件“统一加”——中间件加不了Unscoped()后的查询 - 软删除字段(如
deleted_at)必须和tenant_id同级约束,否则Unscoped().Where("id = ?", x)会绕过租户检查
HTTP 中间件里怎么安全提取并注入 tenant_id
关键不是“能不能从 header 读”,而是“谁有权决定这个值有效”。靠 X-Tenant-ID 直接透传等于把租户边界交给了不可信输入。
- 正确路径是:JWT 校验通过后,从 payload 解出已签名的
tenant_id和tenant_role,再写入context.Context - 必须用私有未导出类型作 key,例如
type tenantCtxKey struct{},避免与其他包 key 冲突 - 禁止在中间件里做 DB 切换或连接初始化——那是数据访问层的事;Context 只负责安全透传标识
- 子域名识别(如
acme.api.example.com)比 header 更可靠,适合对外 SaaS;内部服务调用优先走 JWT 声明
缓存 key 为什么必须硬编码包含 tenant_id
漏掉 tenant_id 不是并发问题,是设计缺陷。多个租户查同名资源(比如都叫 config),Redis 里就会变成“谁先写谁赢”,后续请求直接命中脏数据。
- 所有缓存 key 必须形如
tenant:{tenant_id}:config或tenant:{tenant_id}:user:{id},不用 struct hash,避免序列化差异引入错乱 - 缓存 miss 后,必须把带
tenant_id的结果回填,否则下个租户会拿到前一个租户的数据 - groupcache 或 ristrett 这类本地缓存也一样——租户上下文不隔离,缓存就不可信
gRPC 场景下 tenant_id 怎么透传才不丢
HTTP 中间件对 gRPC 无效,metadata 不是 header,context.WithValue 在客户端塞进去,服务端不拦截就收不到。
- 客户端必须用
metadata.Pairs("tenant-id", "t-123")显式传,不能只靠context.WithValue - 服务端必须注册
grpc.UnaryInterceptor,用metadata.FromIncomingContext提取,并立即写入新 context - 拦截器要放在鉴权之前,确保所有 handler 拿到的 ctx 都含可信
tenant_id - 别在 handler 里重复解析 metadata——那会导致竞态,且无法复用中间件逻辑
真正难的从来不是“怎么让代码跑起来”,而是当租户合并、数据迁移或权限策略变更时,tenant_id 是否始终作为刚性约束贯穿整个请求生命周期——从 HTTP/gRPC 入口、到缓存键生成、再到 SQL 执行和日志打点,漏掉任意一环,隔离就形同虚设。











