Preload 和 Count 总漏 tenant_id 条件,因 GORM 不自动透传租户上下文到子查询;必须显式为 Preload 加 Where("tenant_id = ?", tenantID),Count 也需同级过滤,且所有查询须将 tenant_id 与 deleted_at IS NULL 同层约束。

Preload 和 Count 为什么总漏 tenant_id 条件
GORM 的 Preload 和 Count 方法默认不继承主查询的 Where 条件或 Scopes,哪怕你写了 db.Scopes(ByTenant(tenantID)).Preload("Orders").Find(&u),Orders 关联加载的仍是全库数据。这不是 bug,是设计使然——GORM 不会自动把租户上下文透传到子查询里。
常见错误现象包括:分页接口返回其他租户的订单、后台导出统计总数远超预期、管理员误删跨租户记录。
- 必须显式为每个
Preload加条件:Preload("Orders", func(db *gorm.DB) *gorm.DB { return db.Where("tenant_id = ?", tenantID) }) -
Count()必须走同级过滤:db.Where("tenant_id = ? AND status = ?", tenantID, "active").Model(&Order{}).Count(&count),不能依赖主查询 scope - 禁用裸
db.Unscoped().Where("id = ?", x).Find()—— 它会直接绕过所有租户和软删除约束
分页查询如何避免跨租户慢查和越权
分页本身不带租户意识,db.Offset(0).Limit(20).Find(&users) 若没加 tenant_id 条件,就是全表扫描。更危险的是,一旦漏建索引,ORDER BY created_at LIMIT 20 可能触发秒级延迟甚至超时。
性能影响很直接:没有 tenant_id 的联合索引,PostgreSQL 无法高效剪枝,即使只查 20 条也要扫完整张表。
- 每张业务表的
tenant_id字段必须建联合索引,例如:(tenant_id, created_at)或(tenant_id, email) - 分页前强制校验
tenant_id存在且合法,空值或非法格式直接http.StatusUnauthorized - 不要用
db.Scopes(ByTenant(tenantID)).Order("created_at DESC").Limit(20).Offset(0).Find()这类写法——Order和Limit不保证作用于过滤后的结果集,仍可能被优化器绕过
软删除与 tenant_id 必须同级约束
deleted_at IS NULL 如果单独写在 Scopes 里,而 tenant_id = ? 写在主查询中,GORM 在生成 SQL 时可能把两者拆到不同子句,导致 Unscoped() 一调就全暴露——不仅绕过软删除,还绕过租户隔离。
典型错误写法:db.Unscoped().Where("id = ?", 123).Find(&u),它会查出所有租户中 ID=123 的记录,不管是否已软删除、也不管属于哪个租户。
- 所有查询必须把
tenant_id和deleted_at IS NULL放在同一层Where中:Where("tenant_id = ? AND deleted_at IS NULL", tenantID) - 禁止封装成两个独立 scope,比如
ByTenant()和NotDeleted()分开调用 - DAO 方法签名必须显式接收
tenantID string,不从context.Value取——异步任务或定时器中容易 panic 或取错值
为什么不能靠 Scopes 收口租户过滤
Scopes 看似整洁,但 GORM 的 Raw、Joins、Count、Preload、Unscoped 全部绕过它。线上 P0 故障往往不是逻辑错,而是某次临时调试用了 db.Raw("SELECT * FROM users"),忘了加 WHERE tenant_id = ?。
测试也难覆盖:单元测试常 mock *gorm.DB,根本不会执行真实 SQL,漏条件问题直到压测或上线才暴露。
- 禁用任何裸
db.Where()调用,统一走 DAO 层封装方法,如userRepo.FindByID(ctx, tenantID, id) - 所有 DAO 方法参数强制含
tenantID string,类型系统帮你守住第一道防线 - 如果坚持字段隔离,就接受“每个关联、每个聚合、每个裸查都得手动补 tenant_id”这个事实——没有银弹,只有显式
分页和关联查询是多租户最易失守的环节,因为它们天然脱离主查询上下文;真正兜底的方式不是靠框架机制,而是让 tenantID 成为每个 DAO 方法不可省略的参数,连测试用例都得填上它。


















