PostgreSQL schema隔离需为每个租户缓存独立*sql.DB实例并绑定固定search_path,否则并发下必然串库;GORM关联查询须显式挂载TenantScope,软删除需与tenant_id同级约束;租户ID透传禁用context拼SQL,应通过sync.Map查专属DB实例。

Go 微服务中多租户数据安全不能靠“加个 tenant_id 字段就完事”,字段隔离漏一次 WHERE 就全量泄露;真正可落地的方案是 PostgreSQL schema 隔离,但必须为每个租户缓存独立 *sql.DB 实例并绑定固定 search_path,否则并发下必然串库。
PostgreSQL schema 隔离为什么必须配专属 *sql.DB 实例
很多人试 db.Exec("SET search_path TO tenant_abc") 后发现下一次查询又回到 public —— 因为 database/sql 连接池会复用连接,session 变量不持久。pgx 或 lib/pq 都不保证同连接复用,更别说跨 goroutine。
- 每个租户必须初始化一个专属
*sql.DB,DSN 中不带dbname=(PostgreSQL 下允许省略),连接后立即执行db.Exec("SET search_path TO " + safeSchemaName) -
safeSchemaName必须白名单校验,例如正则^[a-z][a-z0-9_]{2,30}$,禁止拼接用户输入 - 用
sync.Map缓存:tenantDBs sync.Map // key: string, value: *sql.DB,避免每次请求都重连 - 每个实例必须调
db.SetMaxOpenConns(5)和db.SetConnMaxLifetime(5 * time.Minute),否则 1000 租户 × 默认 100 连接 = 直接打崩 PG
GORM 的 Preload 和 Count 为什么总绕过租户隔离
db.Scopes(TenantScope(tenantID)).Preload("Orders").Find(&user) 中,Preload 发起的子查询不会继承主查询的 scope,Count()、FirstOrInit()、Unscoped() 同理——它们默认不感知上下文,也不走你写的 TenantScope。
PostgreSQL 18.4 官方 Ubuntu 安装包现已发布,这是目前最新的稳定版本。推荐通过官方 APT 仓库安装:先执行 sudo apt update 更新索引,再运行 sudo apt install postgresql-18 即可完成部署。新版本引入了异步 I/O 子系统,在顺序扫描与 VACUUM 场景下性能提升显著,同时支持 UUID v7 原生生成函数与虚拟生成列。
- 所有关联查询必须显式挂载 scope:
db.Preload("Orders", TenantScope(tenantID)) - 禁用裸
db.Where().Count(),改用db.Scopes(TenantScope(tenantID)).Model(&Order{}).Count(&count) -
deleted_at软删除必须与tenant_id同级约束:db.Scopes(TenantScope(tenantID)).Where("deleted_at IS NULL"),不能只依赖 GORM 的SoftDelete插件 - 测试时要跑真实 SQL 日志(
db.Debug()),mock DB 层根本暴露不了漏条件问题
租户 ID 透传为什么不能依赖 context.Context 拼 SQL
把 tenantID 塞进 context.Context 再一路传到 DAO 层手动拼 WHERE tenant_id = ?,等于把隔离责任甩给每个开发者:中间任意一层漏传、错传、复用旧 context,数据就裸奔。
- 必须定义私有未导出类型作 key:
type tenantKey struct{},避免与其他包冲突 - 中间件里取
r.Header.Get("X-Tenant-ID")后,只做非空和格式校验(≤64 字符、仅含字母数字和短横线),不在此处建 DB 连接 - DAO 方法签名应接收
ctx context.Context,但查询逻辑不能从ctx.Value(tenantKey{})取值拼 SQL —— 类型断言失败会 panic,异步任务里还可能丢失 - 正确路径是:HTTP 中间件提取
tenantID→ 从sync.Map查专属*sql.DB→ 所有查询自动落在该 schema,无需再拼条件
最易被忽略的是:PostgreSQL 行级安全策略(RLS)虽强,但要求每次连接都 SET app.tenant_id = 't123',而 database/sql 连接池无法保证连接复用时该变量仍有效;所以 RLS 在 Go 微服务中实际不可靠,别把它当主力防线。

















