行级隔离分页必须显式为count()和find()均添加tenant_id条件,preload需手动限定子查询tenant_id,软删除须与tenant_id同级约束,索引需覆盖tenant_id、deleted_at等字段。

行级隔离(tenant_id字段)做分页,必须显式传参 + 强制约束条件,否则 Limit/Offset 或 Scopes 一绕就跨租户查全表——这是线上 P0 故障最高发的路径之一。
分页时 Count() 和 Find() 必须各自加 tenant_id 条件
Count() 和 Find() 是两个独立 SQL 请求,GORM 不会自动复用主查询的 scope;哪怕你封装了 ByTenant(tenantID),Count() 仍默认走无条件统计。
- 错误写法:
db.Scopes(ByTenant(tenantID)).Model(&User{}).Count(&count)→ 实际执行SELECT count(*) FROM users,漏WHERE tenant_id = ? - 正确写法:
db.Where("tenant_id = ?", tenantID).Model(&User{}).Count(&count) -
Find()同理:必须写成db.Where("tenant_id = ?", tenantID).Limit(limit).Offset(offset).Find(&users),不能依赖 Scopes 透传 - 若用
Paginate类库(如gorm.io/plugin/pagination),确认它是否支持传入预置*gorm.DB实例——否则它内部调用的Count()仍是裸查
Preload 关联分页极易越权,必须手动限定子查询
Preload 的子查询完全不继承父查询的 tenant_id 条件,db.Preload("Orders").Where("tenant_id = ?", tID).Find(&users) 只保证 users 行安全,Orders 仍加载全库数据。
- 正确写法:
db.Preload("Orders", func(db *gorm.DB) *gorm.DB { return db.Where("tenant_id = ?", tenantID) }) - 若关联表也有分页需求(如只查每个用户最近 3 笔订单),需嵌套:
db.Preload("Orders", func(db *gorm.DB) *gorm.DB { return db.Where("tenant_id = ?", tenantID).Order("created_at DESC").Limit(3) }) - 避免用
Joins替代Preload:JOIN 后 WHERE 放错位置(如写在主表条件后)会导致笛卡尔积或漏过滤
软删除 + 分页组合必须同级约束 tenant_id
Unscoped() 会直接跳过所有 scope,包括软删除逻辑;如果 deleted_at IS NULL 和 tenant_id = ? 不在同一个 WHERE 层级,Unscoped().Where("id = ?", x) 就能读到其他租户已软删的数据。
- 错误写法:
db.Unscoped().Where("id = ?", id).First(&u)→ 完全绕过租户和软删 - 安全写法(分页也适用):
db.Where("tenant_id = ? AND deleted_at IS NULL", tenantID).Limit(limit).Offset(offset).Find(&users) - 不要依赖 GORM 的
SoftDelete插件自动加条件——它不与tenant_id绑定,必须手写联合 WHERE - 索引要覆盖:
CREATE INDEX idx_users_tenant_deleted ON users (tenant_id, deleted_at, created_at),否则分页ORDER BY created_at LIMIT会全表扫描
最危险的不是不会写分页,而是写了一半——比如 Find 加了 tenant_id,但忘了 Count 或 Preload。只要一个环节漏掉,分页就变成全量数据导出接口。











