真正能落地的方案只有字段级隔离(中小规模)和schema级隔离(推荐默认),且必须从连接池、dao层、日志、缓存四路同步约束;postgresql schema隔离须为每个租户缓存独立*sql.db实例并白名单校验schema名,字段隔离则要求dao方法签名显式传tenantid、禁用unscoped()、软删除与tenant_id同级约束、每表加联合索引。

Go 微服务系统里做多租户数据隔离,**不能靠 ORM 的 Scopes 或中间件自动注入 tenant_id 来兜底**——只要漏一次 Where("tenant_id = ?"),或调一次 Unscoped()、Raw()、Count(),就直接越权读全量数据。真正能落地的方案只有两个:字段级隔离(中小规模)和 schema 级隔离(推荐默认),且都必须从连接池、DAO 层、日志、缓存四路同步约束。
PostgreSQL schema 隔离:为什么必须为每个租户缓存独立 *sql.DB
复用同一个 *sql.DB 实例 + SET search_path TO tenant_abc 是并发不安全的。连接池中连接会被多个 goroutine 复用,上一个请求设的 search_path 可能被下一个租户继承,查到别人 schema 里的表。
- 正确做法是用
sync.Map缓存租户 ID 到专属*sql.DB实例的映射:tenantDBs sync.Map // key: string, value: *sql.DB - 首次获取时构造 DSN(不含
dbname=后缀),连接后立即执行db.Exec("SET search_path TO " + safeSchemaName) -
safeSchemaName必须白名单校验,例如正则^[a-z][a-z0-9_]{2,30}$,禁止拼接用户输入 - 每个
*sql.DB实例必须调用SetMaxOpenConns(5)和SetConnMaxLifetime(5 * time.Minute),否则 1000 租户 × 默认 100 连接 = 10 万连接,PG 直接被打崩
GORM 字段隔离:Scopes 不可靠,必须封装 DAO 方法签名
Scope 在 Preload()、Count()、Joins()、Raw() 场景下全部失效。开发者调一次 db.Unscoped().Where("id = ?", x) 就绕过所有租户过滤。
- DAO 方法签名必须显式接收
tenantID string参数,例如:func (d *UserDAO) FindByID(ctx context.Context, tenantID string, id int64) (*User, error) - 内部实现强制拼接
WHERE tenant_id = ?,不依赖任何外部上下文或全局变量 - 软删除字段(如
deleted_at)必须与tenant_id同级约束:Where("tenant_id = ? AND deleted_at IS NULL", tenantID) - 每张表的
tenant_id字段必须加联合索引,例如(tenant_id, created_at),否则分页查询延迟飙升
文件与缓存操作:租户前缀不是可选,而是硬性边界
Go 标准库完全不感知租户。os.Open("/data/" + r.URL.Path) 或 redisClient.Set(ctx, "user:1001", ...) 都是高危操作——前者路径遍历,后者键名冲突。
- 文件路径必须强制以租户 ID 为第一级目录:
filepath.Join("/data", tenantID, strings.TrimPrefix(r.URL.Path, "/")),且调用filepath.Clean()后校验是否仍以/data/xxx/开头 - Redis 键名必须带租户前缀:
"tenant:" + tenantID + ":user:1001",不能靠客户端传参动态拼接 - 上传文件名必须重写为 UUID 或哈希,禁止保留原始名——防止
../../../etc/passwd类恶意片段 -
os.Stat、os.Chmod前必须先调用validateTenantPath(tenantID, path),否则元数据操作可能跨租户
Context 透传租户 ID:私有 struct key 是底线,不是可选项
用 context.WithValue(r.Context(), "tenant_id", id) 是典型错误——string 类型 key 易冲突,且无法类型安全校验。
- 必须定义未导出 struct 作 key:
type tenantKey struct{},避免与其他包 key 冲突 - 中间件中从
X-Tenant-ID、子域名或 JWT claim 提取 tenantID,空值直接返回http.StatusUnauthorized - 租户 ID 仅用于透传,**DAO 层不得直接从 context 取值拼 SQL**;它只该用来查
sync.Map拿租户专属*sql.DB,或校验缓存/文件路径合法性 - gRPC 场景需用拦截器从
metadata.FromIncomingContext提取,再用context.WithValue注入新 context,客户端必须调用metadata.AppendToOutgoingContext,否则服务端收不到
最易被忽略的是:数据库行级安全策略(RLS)、文件锁(flock)、临时目录(os.TempDir())和当前工作目录(os.Getwd())——这些 API 完全不感知租户,必须在入口 middleware 中用 chdir 切换,或全部改用绝对路径构造逻辑。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











