PostgreSQL schema隔离下分页天然安全,字段隔离下必须显式加tenant_id条件于Count()、Offset()和Preload()中,否则导致跨租户越界;联合索引(tenant_id, created_at)为硬性要求。

PostgreSQL schema 隔离下,GORM 的 Limit/Offset 和 Pagination(如 gorm.Paginator)天然安全;字段隔离下必须手动加固分页逻辑,否则 OFFSET 跨租户偏移、COUNT 统计全库、Preload 关联加载失控——三者叠加等于裸奔。
分页 COUNT() 为什么总返回全库总数
Count() 完全绕过 GORM 的 Scopes 和上下文透传,哪怕主查询加了 ByTenant(tenantID),db.Model(&User{}).Count(&total) 仍会查全表。这不是配置问题,是 GORM v2+ 的设计事实。
- 必须显式把
tenant_id条件写进Count()前的链式调用里:db.Where("tenant_id = ?", tenantID).Model(&User{}).Count(&total) - 若带软删除,条件必须同级:
Where("tenant_id = ? AND deleted_at IS NULL", tenantID),否则Unscoped()一调就破防 - 禁止封装成“通用 Count 方法”并默认忽略 tenant_id——漏一次就是 P0
OFFSET 分页在字段隔离下极易越界
当租户 A 有 100 条数据、租户 B 有 50 条,执行 OFFSET 80 LIMIT 10 时,字段隔离若没加 WHERE tenant_id = ?,结果会混入租户 B 的前 10 条——因为数据库按全局顺序偏移,不感知租户边界。
- 所有分页入口必须强制先过滤:
db.Where("tenant_id = ?", tenantID).Order("created_at DESC").Limit(10).Offset(80).Find(&users) - 别信
Scopes(ByTenant(tenantID))能保底:它对Count()、Preload()、Raw()全无效 - 联合索引
(tenant_id, created_at)是硬性要求,否则ORDER BY + OFFSET在大数据量下直接秒级延迟
Preload 关联分页怎么不出错
Preload("Orders") 默认加载全库订单,和主查询的 tenant_id 完全无关。即使你对 User 加了租户过滤,Orders 仍是全量。
- 必须显式传参约束关联表:
db.Preload("Orders", func(db *gorm.DB) *gorm.DB { return db.Where("tenant_id = ?", tenantID) }) - 若 Orders 还要分页,得用嵌套子查询或两次查询:先查出用户 ID 列表,再用
IN+tenant_id查订单并分页 - 别尝试
Preload("Orders").Limit(5)——GORM 不支持对 Preload 结果分页,该写法静默失效
schema 隔离下分页可省心但初始化不能偷懒
PostgreSQL 的 search_path 方案让分页回归本质:租户数据物理隔离,Count、Offset、Preload 全部自动生效。但前提是每个租户的 *sql.DB 实例已正确绑定 schema。
- 不能靠中间件里临时
db.Exec("SET search_path TO tenant_abc")——连接池复用后状态丢失,下个请求可能落到 public - 必须用
sync.Map缓存租户专属实例,首次初始化时立即执行db.Exec("SET search_path TO " + safeSchemaName) -
safeSchemaName必须白名单校验(如正则^[a-z][a-z0-9_]{2,30}$),禁止拼接用户输入 - 每个实例必须设
db.SetMaxOpenConns(5),否则 1000 租户 × 默认 100 连接 = PG 直接 OOM
最易被忽略的点:字段隔离下,tenant_id 字段本身必须建联合索引;schema 隔离下,search_path 绑定必须发生在 sql.Open 后立刻执行,且不能依赖任何后续中间件干预——连接池不认“逻辑上该怎样”,只认“物理连接上此刻是什么”。


















