100家店的数据如何互不偷看:多租户隔离深度解析
上一篇我们聊了架构演进,这篇进入硬核环节——多租户隔离的具体实现。
多租户隔离是SaaS系统的生命线。餐饮行业的数据尤其敏感:菜谱是商业机密,营业额是核心数据,客户信息受法律保护。一旦隔离出问题,轻则客户流失,重则面临法律诉讼。
我们的隔离方案是四层防线:JWT层 → 数据库RLS层 → ORM层 → 缓存层。任何一层出问题,其他层都能兜底。
第一层:JWT——租户身份的通行证
每个请求进入系统,首先要确认"你是谁、属于哪个租户"。我们用JWT(JSON Web Token)的RS256算法签发Token,Token的payload里包含关键信息:
type Claims struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"` // platform_admin / merchant_admin / store_admin / staff
TenantID int64 `json:"tenant_id"`
StoreID int64 `json:"store_id"`
MustChangePwd bool `json:"must_change_pwd"`
jwt.RegisteredClaims
}
为什么用RS256而不是HS256?因为RS256是非对称加密——私钥签发Token,公钥验证Token。在微服务架构下,每个服务只需要公钥就能验证Token,不需要共享签名密钥,降低了密钥泄露风险。
Token的验证在chi中间件里完成:
func JWTAuth(rdb *redis.Client, publicKeyPath string) func(http.Handler) http.Handler {
pubKeyData, _ := os.ReadFile(publicKeyPath)
pubKey, _ := jwt.ParseRSAPublicKeyFromPEM(pubKeyData)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := extractToken(r.Header.Get("Authorization"))
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
return pubKey, nil
})
if err != nil || !token.Valid {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Token黑名单检查:已注销的Token立即失效
if claims.ID != "" && rdb != nil {
blacklistKey := "token:blacklist:" + claims.ID
exists, err := rdb.Exists(r.Context(), blacklistKey).Result()
if err == nil && exists > 0 {
http.Error(w, "Token已注销", http.StatusUnauthorized)
return
}
}
// 把租户信息塞进context
ctx := context.WithValue(r.Context(), CtxKeyTenantID, claims.TenantID)
ctx = context.WithValue(ctx, CtxKeyUserID, claims.UserID)
ctx = context.WithValue(ctx, CtxKeyRole, claims.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
关键点:租户信息从Token中提取后,存入请求上下文(Context),后续所有层都从Context中读取,而不是让前端再次传递。这避免了篡改风险。
第二层:PostgreSQL RLS——数据库层的铁门
JWT验证通过了,只能说明"这个请求属于租户A"。但如果应用层代码有Bug,写了一条不带tenant_id条件的SQL呢?
-- 程序员手滑,忘了加 WHERE tenant_id = ?
SELECT * FROM orders WHERE status = 'pending';
这条SQL会返回所有租户的待处理订单。在V1里,这种Bug真的发生过。
V2用PostgreSQL的RLS彻底杜绝了这种问题。RLS就像每层楼有自己的门禁卡——即使你进了大楼(连上了数据库),没有对应楼层的门禁卡,也进不了别人的办公室。
RLS配置流程
第一步:启用RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE dishes ENABLE ROW LEVEL SECURITY;
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
-- 所有业务表都启用
第二步:通过GORM Callback自动设置租户ID
V2没有使用独立的SetTenantID函数手动调用,而是通过GORM的Callback机制,在每次数据库操作前后自动设置和重置PostgreSQL会话变量:
func RegisterCallbacks(db *gorm.DB) {
_ = db.Callback().Query().Before("gorm:query").Register("tenant_scope:set_rls_query", setRLSContext)
_ = db.Callback().Create().Before("gorm:create").Register("tenant_scope:set_rls_create", setRLSContext)
_ = db.Callback().Update().Before("gorm:update").Register("tenant_scope:set_rls_update", setRLSContext)
_ = db.Callback().Delete().Before("gorm:delete").Register("tenant_scope:set_rls_delete", setRLSContext)
_ = db.Callback().Row().Before("gorm:row").Register("tenant_scope:set_rls_row", setRLSContext)
// 操作完成后重置,避免连接池复用时污染
_ = db.Callback().Query().After("gorm:query").Register("tenant_scope:reset_rls_query", resetRLSContext)
_ = db.Callback().Create().After("gorm:create").Register("tenant_scope:reset_rls_create", resetRLSContext)
_ = db.Callback().Update().After("gorm:update").Register("tenant_scope:reset_rls_update", resetRLSContext)
_ = db.Callback().Delete().After("gorm:delete").Register("tenant_scope:reset_rls_delete", resetRLSContext)
_ = db.Callback().Row().After("gorm:row").Register("tenant_scope:reset_rls_row", resetRLSContext)
}
setRLSContext从请求Context中提取tenant_id,执行SET app.current_tenant_id = ?;resetRLSContext在操作完成后执行RESET app.current_tenant_id,确保连接归还到连接池时不会携带上一次请求的租户信息。
第三步:创建RLS策略
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::BIGINT);
CREATE POLICY tenant_isolation ON dishes
USING (tenant_id = current_setting('app.current_tenant_id')::BIGINT);
USING子句定义了可见性条件:只有当行的tenant_id等于当前会话设置的租户ID时,这行数据才可见。INSERT、UPDATE、DELETE同样受RLS约束。
第四步:超管绕过RLS
平台管理员(platform_admin)需要看到所有租户的数据来做运维和统计。PostgreSQL的RLS允许特定角色绕过策略:
-- platform_admin角色不受RLS约束
ALTER ROLE platform_admin BYPASSRLS;
这比在应用层做例外处理安全得多——绕过RLS是数据库层面的权限,不是代码里的if判断。
RLS的性能影响
RLS会在查询执行时自动附加条件,相当于数据库帮你加了WHERE tenant_id = ?。在tenant_id上有索引的情况下,性能影响可以忽略:
-- 务必在tenant_id上创建索引
CREATE INDEX idx_orders_tenant_id ON orders(tenant_id);
我们实测过,RLS带来的额外开销在1%以内,完全值得。
第三层:GORM TenantScope——ORM层的防护网
RLS是数据库层的最后防线,但我们在ORM层也加了一层防护——GORM的TenantScope。这层防护的目的不是替代RLS,而是让开发者不需要每次都手动传tenant_id,减少出错概率。
// TenantScope 自动为查询添加 tenant_id 条件
func TenantScope(tenantID int64) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if tenantID > 0 {
return db.Where("tenant_id = ?", tenantID)
}
return db
}
}
// 使用示例
func (r *OrderRepo) ListOrders(ctx context.Context, status string) ([]Order, error) {
tenantID := ctx.Value(CtxKeyTenantID).(int64)
var orders []Order
err := r.db.WithContext(ctx).
Scopes(TenantScope(tenantID)).
Where("status = ?", status).
Find(&orders).Error
return orders, err
}
同时,在创建记录时自动填充tenant_id:
// GORM Hook:创建前自动填充 tenant_id
func (o *Order) BeforeCreate(tx *gorm.DB) error {
if o.TenantID == 0 {
tenantID := tx.Statement.Context.Value(CtxKeyTenantID)
if id, ok := tenantID.(int64); ok {
o.TenantID = id
}
}
return nil
}
这样即使开发者忘了设置tenant_id,GORM也会自动从Context中获取并填充。
三层防护的冗余设计:
| 层级 | 机制 | 防护场景 |
|---|---|---|
| GORM TenantScope | 自动附加WHERE条件 | 防止应用层漏写tenant_id |
| GORM Hook | 自动填充tenant_id | 防止创建时漏填tenant_id |
| PostgreSQL RLS | 数据库强制隔离 | 防止任何绕过应用层的访问 |
三层防护,任何一层出问题都有兜底。这就是"纵深防御"。
第四层:Redis缓存key前缀隔离
缓存层是最容易被忽略的隔离点。如果两个租户的缓存key相同,就会出现"租户A读到了租户B的缓存数据"这种诡异Bug。
我们的方案是所有缓存key必须包含租户和门店前缀:
func cacheKey(prefix string, tenantID int64, storeID int64, identifier string) string {
return fmt.Sprintf("%s:%d:%d:%s", prefix, tenantID, storeID, identifier)
}
// 示例
// 租户1门店0的菜品列表缓存: dish:1:0:list
// 租户1门店5的菜品列表缓存: dish:1:5:list
// 租户2门店0的域名缓存: tenant:2:0:domain
封装一个统一的缓存操作层,开发者不需要手动拼接前缀:
type CacheService struct {
rdb *redis.Client
}
func (s *CacheService) Get(ctx context.Context, prefix string, identifier string) (string, error) {
tenantID := ctx.Value(CtxKeyTenantID).(int64)
storeID := ctx.Value(CtxKeyStoreID).(int64)
fullKey := cacheKey(prefix, tenantID, storeID, identifier)
return s.rdb.Get(ctx, fullKey).Result()
}
func (s *CacheService) Set(ctx context.Context, prefix string, identifier string, value interface{}, ttl time.Duration) error {
tenantID := ctx.Value(CtxKeyTenantID).(int64)
storeID := ctx.Value(CtxKeyStoreID).(int64)
fullKey := cacheKey(prefix, tenantID, storeID, identifier)
return s.rdb.Set(ctx, fullKey, value, ttl).Err()
}
这样即使开发者直接用key操作缓存,也不会出现跨租户数据泄露。
四级RBAC:权限的精细控制
多租户隔离解决的是"不同商户之间的数据隔离",但同一个商户内部,不同角色的权限也需要精细控制。
龙讯餐饮的RBAC分为四个层级:
| 角色 | 英文标识 | 权限范围 | 典型场景 |
|---|---|---|---|
| 平台管理员 | platform_admin | 所有租户数据 | 平台运维、数据分析 |
| 商户管理员 | merchant_admin | 本商户所有门店 | 老板、运营总监 |
| 门店管理员 | store_admin | 本门店数据 | 店长 |
| 普通员工 | staff | 限定功能 | 收银员、服务员 |
权限检查在中间件层完成:
func RequireRole(roles ...string) func(http.Handler) http.Handler {
roleSet := make(map[string]bool)
for _, r := range roles {
roleSet[r] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role := r.Context().Value(CtxKeyRole).(string)
if !roleSet[role] {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// 路由配置
r.Route("/api/admin", func(r chi.Router) {
r.Use(RequireRole("platform_admin"))
r.Get("/tenants", listTenants) // 只有平台管理员能访问
r.Get("/stats", platformStats) // 全平台统计
})
r.Route("/api/merchant", func(r chi.Router) {
r.Use(RequireRole("merchant_admin", "platform_admin"))
r.Get("/stores", listStores) // 商户管理员和平台管理员都能访问
r.Post("/dishes", createDish) // 菜品管理
})
权限包含关系
四级角色不是树状继承的,而是角色集合包含式——高权限路由的角色集合包含低权限角色。比如商户管理路由允许merchant_admin和platform_admin,门店管理路由允许store_admin、merchant_admin和platform_admin:
func RequireRole(roles ...string) func(http.Handler) http.Handler {
roleSet := make(map[string]bool, len(roles))
for _, r := range roles {
roleSet[r] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role := r.Context().Value(CtxKeyRole).(string)
if role == "" || !roleSet[role] {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireMerchantAdmin 允许 merchant_admin 和 platform_admin
func RequireMerchantAdmin() func(http.Handler) http.Handler {
return RequireRole("merchant_admin", "platform_admin")
}
// RequireStoreAdmin 允许 store_admin, merchant_admin, platform_admin
func RequireStoreAdmin() func(http.Handler) http.Handler {
return RequireRole("store_admin", "merchant_admin", "platform_admin")
}
隔离验证:如何确保隔离有效?
光有代码不够,我们还需要验证隔离是否真正有效。我们写了一套隔离测试:
func TestTenantIsolation(t *testing.T) {
// 租户A创建订单
ctxA := context.WithValue(context.Background(), CtxKeyTenantID, int64(1))
orderA := &Order{TenantID: 1, Amount: 9900}
db.WithContext(ctxA).Create(orderA)
// 租户B创建订单
ctxB := context.WithValue(context.Background(), CtxKeyTenantID, int64(2))
orderB := &Order{TenantID: 2, Amount: 8800}
db.WithContext(ctxB).Create(orderB)
// 验证:租户A只能看到自己的订单
var ordersA []Order
db.WithContext(ctxA).Scopes(TenantScope(1)).Find(&ordersA)
assert.Len(t, ordersA, 1)
assert.Equal(t, int64(1), ordersA[0].TenantID)
// 验证:租户B只能看到自己的订单
var ordersB []Order
db.WithContext(ctxB).Scopes(TenantScope(2)).Find(&ordersB)
assert.Len(t, ordersB, 1)
assert.Equal(t, int64(2), ordersB[0].TenantID)
}
每次发版前,这套测试都会跑一遍,确保隔离没有被破坏。
常见陷阱
在实现多租户隔离的过程中,我们踩过几个坑:
陷阱1:跨租户的关联查询
-- 错误:JOIN时只过滤了主表的tenant_id
SELECT o.*, d.name FROM orders o JOIN dishes d ON o.dish_id = d.id
WHERE o.tenant_id = 1;
如果dishes表没有RLS策略,这条查询可能返回其他租户的菜品名称。解决方案:所有参与JOIN的表都启用RLS。
陷阱2:缓存清除时忘记租户前缀
// 错误:清除了所有租户的缓存
rdb.Del(ctx, "dish:list")
// 正确:只清除当前租户门店的缓存
rdb.Del(ctx, cacheKey("dish", tenantID, storeID, "list"))
陷阱3:后台任务没有设置租户上下文
定时任务、消息队列消费者等后台进程,没有HTTP请求的Context,容易忘记设置租户ID。解决方案:后台任务启动时,显式设置租户上下文。
小结
四层隔离防线,从请求入口到数据存储,层层把关:
- JWT层:确认"你是谁",提取tenant_id
- RLS层:数据库强制隔离,应用层Bug也无法绕过
- GORM层:自动附加条件,减少人为错误
- 缓存层:key前缀隔离,防止缓存串数据
加上四级RBAC的权限控制,龙讯餐饮SaaS的多租户隔离做到了"纵深防御、层层兜底"。
下一篇,我们聊一个更刺激的话题——支付密钥的加密。你的支付密钥,我们连自己都看不到。
系列导航:[龙讯餐饮SaaS架构实战]
← 上一篇:从单体到多租户:餐饮SaaS架构演进之路 | 下一篇 →你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
- 从单体到多租户:餐饮SaaS架构演进之路
- 100家店的数据如何互不偷看:多租户隔离深度解析
- 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
- 3秒出单200ms响应:餐饮收银系统性能密码
- 从1家店到100家店代码一行不用改:多门店架构设计
- 退款5年可追溯:餐饮SaaS合规体系构建
- 微信扫码→下单→支付→出票,30秒全链路
- Go + PostgreSQL + Redis:餐饮SaaS后端架构全景
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 jaytp@qq.com