当微型博客从「单个社区」演进为「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 命名,让隔离成为系统默认行为,而不是后期补救。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。