gorm分页(limit/offset/page)本身不破坏schema隔离,但必须基于租户专属*gorm.db实例,否则因search_path未生效或连接池共享导致跨租户数据泄露;正确做法是为每个租户缓存已绑定search_path的独立db实例。

PostgreSQL 下用 GORM 做 schema 隔离时,Limit/Offset 和 Scopes 本身不破坏隔离,但分页逻辑若混用全局 *gorm.DB 实例或漏设 search_path,会直接导致跨租户数据泄露——这不是分页写法的问题,而是底层连接归属没控住。
为什么 GORM 分页函数(Limit/Offset/Page)默认不串租户,但实际常出问题
因为 Limit、Offset、Page(来自 gorm.io/plugin/pagination)这些方法只操作 SQL 的 LIMIT/OFFSET 子句,不改变表名或 schema 上下文。它们本身是安全的,但前提是:所有后续查询必须落在已绑定 search_path 的租户专属 *sql.DB 实例上。
常见错误现象:
- 用同一个
*gorm.DB实例反复调用Session(&gorm.Session{Context: ctx})拼租户参数,但底层连接池仍是共享的 →search_path未生效,分页查到其他租户数据 - 在中间件里执行了
db.Exec("SET search_path TO tenant_abc"),但该语句只影响当前 goroutine 拿到的那个连接,下一次Find可能从池里取出另一个连接 →search_path重置为public - 用了
pagination.Paginate插件,但传入的是未绑定租户的原始db,插件内部的Count()调用绕过所有 scope,查全库总数
GORM 分页必须搭配租户专属 *gorm.DB 实例
不能复用一个 *gorm.DB 做多租户分页。正确路径是:每个租户对应一个缓存的 *gorm.DB,其底层 *sql.DB 已初始化并绑定 search_path。
实操建议:
- 用
sync.Map缓存:tenantID → *gorm.DB,key 是租户 ID 字符串,value 是调用gorm.Open()构建的实例 - 构建时 DSN 必须含 URL 编码后的
options=-c+search_path%3Dtenant_123,确保连接建立即生效,而非靠运行时Exec - 每个租户的
*gorm.DB必须调SetMaxOpenConns(5)和SetConnMaxLifetime(5 * time.Minute),避免连接数爆炸 - 分页调用必须基于该租户实例,例如:
tenantDB.Scopes(Paginate(page, pageSize)).Find(&users)
Paginate 插件的 Count() 必须走租户实例,否则全库统计
pagination.Paginate 内部会自动调 Count() 获取总条数。如果传给它的 *gorm.DB 没绑定租户 schema,这个 Count() 就会查整个数据库,返回错误总数,且可能暴露其他租户存在性。
关键点:
- 不要把全局
db直接塞进Paginate();必须传 tenantDB - 如果你封装了
ByTenant(tenantID)scope,它对Count()无效 ——Paginate不自动透传 scope,它只管自己构造的子查询 - 验证方式:打开 GORM 日志,确认生成的
SELECT COUNT(*) FROM users前有没有SET search_path TO tenant_123或是否命中对应 schema 表(如tenant_123.users)
手动分页(Limit/Offset)比 Paginate 更可控,但别省略租户实例
如果你不用插件,手写 db.Limit(pageSize).Offset((page-1)*pageSize).Find(&users),看起来更“干净”,但风险一点没少:只要 db 不是租户专属实例,Find 仍可能落在 public schema 或其他租户 schema 上。
所以重点不是选 Limit 还是 Paginate,而是:
- 所有 DB 操作必须基于
GetTenantDB(tenantID string) *gorm.DB返回的实例 - 该函数内部首次访问时 lazy 初始化,并校验
tenantID符合正则^[a-z][a-z0-9_]{2,30}$,禁止拼接用户输入 - 初始化后立刻执行
db.Exec("SET search_path TO " + safeSchemaName),再做任何查询 - 哪怕只分两页,也要走这套流程;少一次初始化,就多一分串租户风险
最易被忽略的一点:分页接口的 HTTP handler 里,你拿到的 tenantID 来自 header 或 JWT,但 GetTenantDB(tenantID) 返回的实例必须是线程安全的——sync.Map 能保证并发读写安全,但别在 handler 里临时 new 一个 *gorm.DB,那等于每次请求都重建连接池。











