多租户隔离:数据模型、资源配额与安全边界

系统讲解微型博客 SaaS 化的多租户隔离设计:共享库/独立库数据模型对比、租户资源配额与限流、租户安全边界与越权防护、租户级监控与故障隔离。

当微型博客从「单个社区」演进为「SaaS 平台」——让企业、品牌、创作者各自运营独立的社区——多租户隔离就成了最高优先级的架构问题。租户之间既要共享平台能力与成本,又必须在数据、资源、安全上互不干扰。本文围绕数据模型、资源配额、安全边界、租户监控四个维度,给出多租户隔离的完整设计。

一、多租户模式概述

1.1 三种隔离模型

多租户隔离的经典三分法,按数据物理隔离程度排列:

模式数据存放隔离强度成本适用场景
共享库 (Shared Schema)同一库同一表,用 tenant_id 区分逻辑隔离最低海量小租户、免费/低客单
分 Schema (Shared DB, Per-Schema)同一库不同 Schema中中中型租户、需要一定隔离
独立库/独立实例 (DB Per Tenant)每租户独立数据库强高大客户、合规要求高

1.2 微型博客 SaaS 的典型形态

微型博客 SaaS 的场景通常是「平台 + 多个独立社区」:每个租户是一个社区(如某个品牌的粉丝社区、某个领域的创作者群),社区内部有独立的内容流、成员、审核规则。

平台层 (共享): 账号体系、支付、平台运营、推荐基建
    │
    ├── 租户 A (企业社区) ── 独立时间线 / 成员 / 审核策略
    ├── 租户 B (创作者社区)
    └── 租户 C (品牌社区)

选型决策树:

租户规模与数量
 ├─ 大量小租户 (免费/低客单) → 共享库 + tenant_id 软隔离
 ├─ 中型租户 (有独立运营诉求) → 分 Schema 或 共享库+扩展字段
 └─ 少量大客户 (合规/性能隔离) → 独立库/独立实例

二、数据模型设计

2.1 共享库模式:tenant_id 软隔离

共享库模式下,所有核心表都带 tenant_id 列,所有查询强制带 tenant_id 条件。这是最常见、成本最低的方案:

-- 短文表:每条记录归属租户
CREATE TABLE posts (
    id         BIGSERIAL,
    tenant_id  BIGINT NOT NULL,          -- 租户隔离键
    user_id    BIGINT NOT NULL,
    content    TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (tenant_id, id)
);

-- 复合主键 (tenant_id, id):让主键天然按租户分区
CREATE TABLE follows (
    tenant_id  BIGINT NOT NULL,
    follower_id BIGINT NOT NULL,
    following_id BIGINT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (tenant_id, follower_id, following_id)
);

-- 索引同样以 tenant_id 为前缀
CREATE INDEX idx_posts_tenant_time ON posts(tenant_id, created_at DESC);

主键设计是共享库模式的关键:把 tenant_id 放进复合主键,能让主键索引天然按租户聚集,单租户查询直接命中局部性好的索引段,同时防止跨租户主键冲突。

2.2 分 Schema 与独立库模式

分 Schema 模式:每个租户一套 Schema(tenant_1_posts、tenant_2_posts),数据库连接时动态切换 search_path:

-- PostgreSQL 分 Schema:按租户切换 search_path
SET search_path TO tenant_42, public;

-- 每个租户的 schema 中有独立的表结构,可支持租户定制字段
CREATE SCHEMA tenant_42;
CREATE TABLE tenant_42.posts ( /* 允许租户扩展列 */ );

独立库模式:每租户一个数据库,应用层通过连接路由(Tenant Routing)把请求打到对应库:

// Go 租户连接路由
package tenant

import (
	"database/sql"
	"fmt"
	"sync"
)

type Router struct {
	mu      sync.RWMutex
	conns   map[int64]*sql.DB // tenantID → 独立连接池
	dsnTpl  string            // "host=%s dbname=miniblog_tenant_%d"
}

// Conn 返回租户对应的数据库连接(独立库模式)
func (r *Router) Conn(tenantID int64) (*sql.DB, error) {
	r.mu.RLock()
	conn, ok := r.conns[tenantID]
	r.mu.RUnlock()
	if ok {
		return conn, nil
	}
	return r.open(tenantID) // 懒加载
}

2.3 三种模式对比

维度共享库分 Schema独立库
硬件成本最低中最高
数据隔离软隔离中物理隔离
租户定制难(字段需通用)中完全自由
备份恢复全库一起按 Schema按库
单租户放大效应会互相影响部分隔离完全隔离
运维复杂度低中高
适合租户数万级+百-千级十级

2.4 混合模式

成熟产品通常走「共享为主、大租户升级独立」的混合路线:

默认:共享库 (tenant_id 软隔离)
     ↓ 当租户触发升级条件:
     - 数据量 > 阈值
     - 合规要求物理隔离
     - 性能投诉
     ↓ 迁移为独立库
     (迁移期间提供 read-only 切换窗口)

数据模型基础(短文、时间线、关注关系的建模)见 https://plumephp.com/miniblog-architecture-design/,多租户是在其上叠加 tenant_id 维度的演进。

三、资源配额

3.1 配额维度

即使共享库,租户也不能无限占用资源。配额(Quota)从四个维度约束租户:

维度配额项超限行为
存储短文条数、媒体总量、图片空间暂停发帖、提示扩容
流量每日 API 请求量、出网带宽限流 429、降级
并发峰值 QPS、长连接数排队、拒绝新连接
功能成员数、管理员数、审核席位升级套餐解锁

3.2 配额实现:限流器与计数

// Go 租户级限流:基于 Redis 滑动窗口
package quota

import (
	"context"
	"github.com/redis/go-redis/v9"
	"time"
)

type QuotaService struct {
	redis *redis.Client
}

// CheckQuota 校验租户是否超过配额,窗口内超过 max 则拒绝
func (s *QuotaService) CheckQuota(ctx context.Context, tenantID int64, metric string, max int64, window time.Duration) (bool, error) {
	key := "quota:" + metric + ":" + strconv.FormatInt(tenantID, 10)
	now := time.Now().UnixNano()
	cutoff := now - int64(window)

	pipe := s.redis.TxPipeline()
	pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatInt(cutoff, 10))
	pipe.ZCard(ctx, key)
	pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: now})
	pipe.Expire(ctx, key, window*2)

	cmds, err := pipe.Exec(ctx)
	if err != nil {
		return false, err
	}
	count := cmds[1].(*redis.IntCmd).Val()
	return count <= max, nil
}

3.3 配额风暴保护

单租户突发流量不应拖垮其他租户:

  • 租户级限流优先于全局限流:先挡单个租户的峰值,再挡全站峰值
  • 资源池分级:把连接池、线程池按「保证配额 + 弹性配额」分级,保证级资源绝不让渡
  • 熔断租户:某租户持续超限时自动熔断一段时间,避免资源挤兑

四、安全边界

4.1 租户越权是最大的安全风险

多租户系统最危险的问题不是外部攻击,而是租户之间的横向越权——租户 A 的用户访问到租户 B 的数据。防护核心是「纵深防御」:

请求 → API 网关 (识别 tenant_id)
    → 应用层 (SQL 强制带 tenant_id 条件)
    → ORM 层 (租户作用域中间件)
    → 数据库层 (行级安全 RLS)

4.2 数据库行级安全 (RLS)

PostgreSQL 的 Row-Level Security 是共享库模式的杀手级防线——即便应用层漏加 tenant_id 条件,数据库也会强制过滤:

-- 启用行级安全
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE posts FORCE ROW LEVEL SECURITY;

-- 创建租户策略:只有匹配当前租户的行可见
CREATE POLICY tenant_isolation ON posts
    USING (tenant_id = current_setting('app.tenant_id')::BIGINT);

-- 应用连接后必须设置租户上下文
SELECT set_config('app.tenant_id', '42', true);
// Go ORM 层:每个请求绑定租户上下文
func WithTenant(ctx context.Context, tenantID int64) context.Context {
	return context.WithValue(ctx, tenantKey{}, tenantID)
}

// 查询时自动追加 tenant 条件,避免到处手写漏写
func ListPosts(ctx context.Context, db *sql.DB) ([]Post, error) {
	tid := ctx.Value(tenantKey{}).(int64)
	rows, err := db.QueryContext(ctx,
		`SELECT id, content, created_at
		 FROM posts
		 WHERE tenant_id = $1   -- 强制条件
		 ORDER BY created_at DESC LIMIT 50`, tid)
	// ...
}

4.3 完整安全边界清单

边界层措施
鉴权JWT 中包含 tenant_id,服务端校验租户归属
授权每个业务接口做「资源属于当前租户」校验
数据层RLS + 强制 tenant_id 条件双重防护
缓存Redis key 带 tenant 前缀(timeline:{tid}:{uid}),防止跨租户缓存串数据
消息队列消息体带 tenant_id,消费端同样做归属校验
外部资源对象存储 key 带 tenant 前缀({tid}/images/...),参考 https://plumephp.com/miniblog-object-storage-images/

缓存是容易遗漏的越权点:Redis 时间线 key 如果不带租户前缀,不同租户的同 id 用户可能读到对方数据。一切存储层的 key 都必须携带 tenant_id 前缀,这是多租户的黄金法则。

五、租户级监控

5.1 监控维度

多租户系统的监控既要看全局,也要能下钻到租户:

全局指标: 全站 QPS / 错误率 / 可用性
    ↓ 下钻
租户指标: 每租户 QPS / 错误率 / P99 延迟 / 配额使用率
    ↓ 下钻
单请求: 租户 A 的某次请求全链路 Trace

5.2 租户维度指标埋点

// Go Prometheus 租户级指标
package monitor

import "github.com/prometheus/client_golang/prometheus"

var (
	tenantQPS = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "tenant_api_requests_total",
			Help: "Per-tenant API request count",
		},
		[]string{"tenant_id", "method", "route", "status"},
	)
	tenantLatency = prometheus.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "tenant_api_latency_seconds",
			Help:    "Per-tenant API latency",
			Buckets: prometheus.DefBuckets,
		},
		[]string{"tenant_id", "route"},
	)
)

func init() {
	prometheus.MustRegister(tenantQPS, tenantLatency)
}

// 请求中间件:按租户打点
func TenantMetricsMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		tid := tenantIDFromContext(r.Context())
		start := time.Now()
		rec := &statusRecorder{ResponseWriter: w, status: 200}
		next.ServeHTTP(rec, r)

		tenantQPS.WithLabelValues(strconv.FormatInt(tid, 10),
			r.Method, r.URL.Path, strconv.Itoa(rec.status)).Inc()
		tenantLatency.WithLabelValues(strconv.FormatInt(tid, 10),
			r.URL.Path).Observe(time.Since(start).Seconds())
	})
}

5.3 租户健康度与故障隔离

租户级监控的终极目标是主动发现与快速隔离:

  • 租户健康度评分:综合错误率、延迟、配额使用率生成 0-100 分,低于阈值自动预警
  • 单租户故障自动隔离:某租户持续 5xx 时自动熔断该租户的流量,保护平台整体
  • 配额触顶预警:配额使用率达到 80% 时提前通知运营,避免硬拒导致流失
租户健康度评分模型
score = 100
    - 错误率加权扣分 (每 1% 扣 5)
    - P99 超预算扣分 (每超 20% 扣 3)
    - 配额使用率 > 80% 预警扣分
    - 熔断/降级事件记录扣分
score < 60 → 租户级告警 + 自动熔断

六、总结

多租户隔离的本质是在「共享经济」与「隔离安全」之间找到平衡。数据层面用「共享库 + 强制 tenant_id + RLS 兜底」支撑海量小租户,用「分 Schema / 独立库」承接大租户;资源层面用租户级配额与限流防止单租户挤兑;安全层面把 tenant_id 作为贯穿 API、缓存、存储、消息的通用维度,并以数据库 RLS 作为最后防线;监控层面用租户维度指标实现「全局健康、租户可下钻、故障可隔离」。

多租户是一条「牵一发而动全身」的横切关注点——一旦上线再补隔离,改造代价极高。建议从第一天起就把 tenant_id 纳入所有数据模型与 key 命名,让隔离成为系统默认行为,而不是后期补救。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「miniblog」更多文章

  1. 速率限制与防滥用:令牌桶、滑动窗口与分布式限流
  2. 通知系统:通知类型、聚合去重、多端同步与推送架构
  3. 评论与互动系统:评论树、@提及、点赞转发与互动计数一致性