gorm事务本身不保障租户隔离,必须显式注入tenantid并全程校验每条sql含tenant_id或正确schema。禁用unscoped()、raw()需手动拼tenant_id、preload/joins须显式加租户条件,事务嵌套需重建租户专属*sql.db实例。

GORM事务中如何确保租户隔离不被绕过
事务本身不解决租户隔离——它只保证原子性。一旦事务内用了Unscoped()、Raw()或漏写tenant_id条件,跨租户读写立刻发生。GORM 的事务对象(*gorm.DB)不会自动继承租户上下文,必须显式注入。
- 所有事务入口必须带
tenantID string参数,禁止从context.Context里取值:异步任务、定时器、消息消费等场景下ctx.Value可能为空或错位,直接panic或越权 - 开启事务前先用租户专属
*sql.DB实例初始化*gorm.DB:若走schema隔离,该*sql.DB已绑定search_path;若走字段隔离,需立即挂载ByTenant(tenantID)scope - 禁止在事务中调用
db.Unscoped():哪怕只查一条记录,Unscoped().Where("id = ?", 123)也会绕过所有租户过滤。软删除字段(如deleted_at)必须和tenant_id同级约束:Where("tenant_id = ? AND deleted_at IS NULL", tenantID) -
Raw()语句必须手动拼入tenant_id占位符:例如db.Raw("SELECT * FROM users WHERE tenant_id = ? AND status = ?", tenantID, "active"),不能依赖外部变量插值,防止SQL注入
Preload和Joins在事务里为何更危险
事务内做关联加载时,Preload()和Joins()默认完全不感知事务外的租户条件。现象是:主查询锁定了租户 A 的订单,但Preload("Items")却把租户 B 的商品也拉进来了——这不是并发问题,是GORM设计使然。
-
Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Where("tenant_id = ?", tenantID) })必须显式传参,scope 不会透传 -
Joins("JOIN items ON orders.id = items.order_id AND items.tenant_id = ?", tenantID)里的tenant_id条件不能省,否则JOIN结果集跨租户 - 避免在事务里用
FindInBatches()或Count()裸调:它们绕过所有scope,必须写成db.Where("tenant_id = ?", tenantID).Model(&Item{}).Count(&count)
事务嵌套时租户上下文怎么传递
GORM 的Session()或WithContext()不控制底层连接绑定,事务嵌套(比如service A调用service B)时,若B误用全局*gorm.DB实例,就会复用非当前租户的连接或schema上下文。
- 每个服务方法签名强制含
tenantID string,内部新开事务时用db.Session(&gorm.Session{NewDB: true}).Begin(),并立即应用租户scope或切换schema - 不要复用上层传入的
*gorm.DB:即使它已带租户条件,嵌套后可能被Unscoped()污染,应基于原始租户*sql.DB重建干净实例 - 事务结束前务必校验最终SQL:开启
GORM.Config.Logger,确认每条INSERT/UPDATE/SELECT都含tenant_id = 'xxx'或落在正确schema下,尤其注意子查询和CTE
PostgreSQL schema隔离下事务的坑
很多人以为SET search_path后开事务就万事大吉,但database/sql连接池不保证事务全程使用同一物理连接——BEGIN可能在连接A,COMMIT却落到连接B,而B的search_path还是public。
- 必须为每个租户缓存独立
*sql.DB实例(用sync.Map),且每个实例只服务一个租户:这样事务生命周期内所有操作都在同一session上下文 - 初始化时用DSN追加
options=-c+search_path%3Dtenant_abc,比运行时Exec("SET search_path")更可靠 - 事务中禁用
db.Session(&gorm.Session{PrepareStmt: true}):预编译语句会跨连接复用,可能把tenant_abc的schema上下文带到tenant_xyz的连接上
事务安全的关键不在“开了没”,而在“每条SQL是否真落在目标租户边界内”。任何绕过DAO层直连、任何未校验的Raw()、任何漏掉tenant_id的Preload,都会让事务变成越权通道。











