分页必须在租户专属db实例上执行,禁用全局db;游标分页优于offset分页;总数统计与列表查询需拆分为两次独立带租户条件的查询;preload关联必须显式加租户条件。

分页必须在租户专属 DB 实例上执行
你不能拿全局 db *gorm.DB 做分页,哪怕中间件已把租户 ID 注入 context.Context。GORM 不会自动把上下文里的租户信息透传到 SQL 生成环节——它只认你手头这个 *gorm.DB 实例连的是哪个库。常见错误是:中间件查出 tenant_abc 的连接并存进 ctx,但 handler 里仍调用 globalDB.Offset(...).Limit(...),结果查的还是默认库。
正确做法是:在中间件完成租户校验后,从 sync.Map 缓存中取出该租户对应的 *gorm.DB 实例,并显式传给 DAO 层或直接用于查询:
- 缓存 key 用租户 ID(如
"tenant_abc"),value 是初始化好的*gorm.DB,首次访问时调用gorm.Open并设置连接池参数 - PostgreSQL 租户若走 schema 隔离,必须在
gorm.Open后立刻执行db.Exec("SET search_path TO tenant_abc"),不能依赖后续查询自动切换 - MySQL 租户必须分库,DSN 中数据库名写死(如
/tenant_abc?...),否则USE tenant_abc在连接复用下不可靠
游标分页比 Offset 分页更适配多租户场景
当租户数据量大、写入频繁(比如 SaaS 后台日志表),Offset 分页会暴露两个问题:一是跨租户数据变动导致同一页重复/漏记录;二是 OFFSET 100000 在百万级表上直接拖垮 PG/MySQL。而游标分页(如 WHERE id > ? ORDER BY id LIMIT 20)天然规避了这些问题——它不依赖“第几页”,只依赖上一页最后一条记录的排序字段值。
在多租户下,游标值(如 last_id)必须和租户 ID 绑定校验:不能允许租户 A 传来的 last_id=500 去查租户 B 的数据。建议结构体定义为:
type CursorPage struct {
TenantID string `json:"tenant_id"`
LastID uint64 `json:"last_id,omitempty"`
Limit int `json:"limit"`
}
DAO 查询时强制加租户条件:db.Where("tenant_id = ? AND id > ?", tenantID, lastID).Order("id ASC").Limit(limit).Find(&items)。
开箱即用的技能链路由引擎。13 条预定义链覆盖搜索、开发、审查、MLOps、法律、创意等场景,三层路由架构(触发词→SAD反馈→DAG编排),recall@10=96.97%。配置驱动(chains.yaml),零代码扩展。pip install skill-weave-chains 一键安装。
总数统计要带租户条件,且不能复用主查询链
前端分页控件需要总条数来渲染页码,但 db.Model(&User{}).Count(&total) 如果没加租户过滤,就会统计全库——这是越权漏洞。更隐蔽的问题是:如果你写成 db.Where("tenant_id = ?", tenantID).Model(&User{}).Count(&total),再接着调 .Offset().Limit(),GORM v2 会复用同一个 *gorm.DB 实例的状态,导致 WHERE 条件污染后续查询(尤其 Preload 关联时)。
安全做法是拆开两次独立查询:
- 总数查询:用干净的租户专属
*gorm.DB实例,显式加租户条件,例如tenantDB.Model(&User{}).Where("tenant_id = ?", tenantID).Count(&total) - 列表查询:同样用该实例,但重新构造链式调用,不复用上一步的
Where状态,例如tenantDB.Where("tenant_id = ?", tenantID).Order("id ASC").Limit(limit).Offset(offset).Find(&users) - 避免在无索引字段上 COUNT,比如
tenant_id = ? AND status = 'pending'必须有联合索引(tenant_id, status)
Preload 关联查询必须显式加租户条件
这是多租户分页中最容易被忽略的越权点。tenantDB.Preload("Orders").Find(&users) 看似安全,实则加载的是全库订单——GORM 的 Preload 默认不继承外层 Where("tenant_id = ?") 条件。即使你用了游标分页查出 20 个用户,Preload 可能拉回几千条其他租户的订单。
解决方案只有两种:
- 用 schema 隔离(PostgreSQL):Preload 自动落在当前 schema,无需额外处理
- 用字段隔离(MySQL/PG):每个
Preload必须手动加租户条件,例如tenantDB.Preload("Orders", func(db *gorm.DB) *gorm.DB { return db.Where("tenant_id = ?", tenantID) }) - 禁止裸写
db.Preload("Orders"),所有 DAO 方法签名必须显式接收tenantID string参数,而不是从ctx.Value拿——防止 handler 忘记注入
游标分页 + 租户专属 DB 实例 + 显式 Preload 条件,这三者缺一不可。少一个,就可能在某次上线后突然发现租户数据互相可见。










