100家店的数据如何互不偷看:多租户隔离深度解析

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_adminplatform_admin,门店管理路由允许store_adminmerchant_adminplatform_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。解决方案:后台任务启动时,显式设置租户上下文

小结

四层隔离防线,从请求入口到数据存储,层层把关:

  1. JWT层:确认"你是谁",提取tenant_id
  2. RLS层:数据库强制隔离,应用层Bug也无法绕过
  3. GORM层:自动附加条件,减少人为错误
  4. 缓存层:key前缀隔离,防止缓存串数据

加上四级RBAC的权限控制,龙讯餐饮SaaS的多租户隔离做到了"纵深防御、层层兜底"。

下一篇,我们聊一个更刺激的话题——支付密钥的加密。你的支付密钥,我们连自己都看不到。


系列导航:[龙讯餐饮SaaS架构实战]

← 上一篇:从单体到多租户:餐饮SaaS架构演进之路 | 下一篇 →你的支付密钥我们连自己都看不到:AES-256-GCM加密实战

  1. 从单体到多租户:餐饮SaaS架构演进之路
  2. 100家店的数据如何互不偷看:多租户隔离深度解析
  3. 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
  4. 3秒出单200ms响应:餐饮收银系统性能密码
  5. 从1家店到100家店代码一行不用改:多门店架构设计
  6. 退款5年可追溯:餐饮SaaS合规体系构建
  7. 微信扫码→下单→支付→出票,30秒全链路
  8. Go + PostgreSQL + Redis:餐饮SaaS后端架构全景