引言
SaaS 的核心工程问题之一就是多租户:一套代码、一套部署,服务成千上万个互相看不见的客户。听起来只是「每张表加个 tenant_id」,真做起来却处处是坑——忘了加全局作用域就跨租户泄漏数据、队列里的 Job 拿着错误的租户上下文、缓存 key 没隔离导致 A 客户看到 B 客户的报表。本文把隔离策略、租户识别、数据作用域、连接切换与计费限流讲透。
目录
- 1. 多租户与 SaaS 的隔离维度
- 2. 三种隔离策略
- 3. 租户识别与路由
- 4. 全局作用域与数据隔离
- 5. 连接切换与动态数据库
- 6. 队列与缓存的租户隔离
- 7. 迁移、备份与数据导出
- 8. 按租户限流与计费
- 9. 安全与常见陷阱
- 10. 速查表与一句话记忆
- 延伸阅读
1. 多租户与 SaaS 的隔离维度
1.1 什么是多租户
多租户(Multi-tenancy)= 单一应用实例服务多个租户(tenant),租户之间数据与配置完全隔离、互不可见。租户通常对应一个客户组织,其下再挂多个用户。
1.2 需要隔离的不止数据
| 维度 | 隔离内容 | 泄漏后果 |
|---|---|---|
| 数据 | 业务表按租户划分 | 客户看到他人数据 |
| 缓存 | 缓存 key 带租户前缀 | 脏数据串味 |
| 队列 | Job 携带租户上下文 | 处理错租户任务 |
| 文件 | 存储路径含租户 id | 越权下载文件 |
| 配置 | 每个租户独立设置 | 配置互相覆盖 |
| 配额 | 限流/用量按租户计 | 一家拖垮全部 |
只隔离数据库是远远不够的——缓存、队列、文件任何一处漏了前缀,都会变成生产事故。
1.3 一个租户模型
中央库里的 tenants 表通常含 id, name, domain, plan, status, created_at 等字段,是所有租户的「总目录」。
记忆:多租户 = 一套实例服务多租户,租户间完全隔离;要隔离的不止数据——缓存 key、队列 Job、文件路径、配置、配额都要带租户维度。
2. 三种隔离策略
2.1 对比
| 策略 | 结构 | 隔离性 | 成本 | 迁移 | 适合 |
|---|---|---|---|---|---|
| 库级 | 每租户一个数据库 | 最强 | 高 | 逐库执行 | 大客户/合规要求 |
| Schema 级 | 每租户一个 schema | 强 | 中 | 逐 schema | PostgreSQL 中大型 |
| 行级 | 共享表 + tenant_id | 弱(靠代码) | 低 | 一次执行 | 大量小租户 |
2.2 行级隔离的骨架
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
amount INT NOT NULL,
KEY idx_tenant (tenant_id) -- 所有查询都以 tenant_id 起头
);
关键:每张业务表都要有 tenant_id 列并建索引,且所有查询都必须带上它。行级隔离的性能与安全,全押在「作用域不漏」上。
2.3 混合策略
成熟 SaaS 常用混合:默认行级隔离,对「数据量巨大 / 有合规要求」的大客户升级为独立库。这要求数据访问层能在两种模式间透明切换(见 §5)。
记忆:库级隔离最强最贵、行级最便宜但靠代码兜底、schema 级居中;成熟做法是「默认行级 + 大客户独立库」的混合策略。
3. 租户识别与路由
3.1 四种识别方式
| 方式 | 例子 | 特点 |
|---|---|---|
| 子域名 | acme.app.com | 最常见,易做白标 |
| 路径前缀 | app.com/acme/… | 无需泛域名证书 |
| 请求头 | X-Tenant: acme | 适合 API |
| 令牌声明 | JWT 里的 tenant_id | 最安全(不可伪造) |
3.2 中间件实现
final class IdentifyTenant
{
public function handle(Request $request, Closure $next)
{
$sub = explode('.', $request->getHost())[0]; // acme.app.com → acme
$tenant = Tenant::where('domain', $sub)->where('status', 'active')->first();
if (!$tenant) {
abort(404, '租户不存在或已停用');
}
app()->instance('tenant', $tenant); // 存入容器,后续全程可用
return $next($request);
}
}
3.3 为什么令牌声明最安全
子域名/请求头都是用户可控的——攻击者改个 Host 头就可能探到别的租户。最稳妥的做法是把 tenant_id 写进登录令牌,服务端只信令牌里的租户,前端传来的子域名仅用于路由。
记忆:租户识别四种方式——子域名/路径/请求头/令牌声明;前三种用户可控,最安全的是把 tenant_id 写进 JWT,服务端只信令牌。
4. 全局作用域与数据隔离
4.1 BelongsToTenant 特征
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $q) { // 查询自动加条件
if ($id = app('tenant')?->id) { $q->where('tenant_id', $id); }
});
static::creating(function ($model) { // 写入自动填充
$model->tenant_id ??= app('tenant')?->id;
});
}
}
class Order extends Model
{
use BelongsToTenant;
}
4.2 会绕过作用域的五种写法
| 写法 | 是否绕过作用域 |
|---|---|
Order::where(...) | 否(受保护) |
Order::withoutGlobalScope('tenant') | 是 |
DB::table('orders') | 是 |
Order::whereRaw(...) | 否,但需自己拼 tenant |
| 原生 SQL / 存储过程 | 是 |
结论:全局作用域只能保护 Eloquent 查询,任何绕过 ORM 的路径都要手工加 tenant_id,并在代码审查中重点盯防。
4.3 唯一约束要带 tenant_id
-- 错误:邮箱全局唯一 → B 租户无法用 A 租户已注册的邮箱
UNIQUE (email)
-- 正确:租户内唯一
UNIQUE (tenant_id, email)
记忆:行级隔离靠 BelongsToTenant——全局作用域管读、creating 钩子管写;但 withoutGlobalScope / DB::table / 原生 SQL 会绕过它;唯一约束务必带上 tenant_id。
5. 连接切换与动态数据库
5.1 动态注册连接
function switchToTenant(Tenant $tenant): void
{
config(['database.connections.tenant' => [
'driver' => 'mysql', 'host' => $tenant->db_host, 'database' => $tenant->db_name,
'username' => $tenant->db_user, 'password' => decrypt($tenant->db_password),
]]);
DB::purge('tenant'); // 丢弃旧连接
DB::setDefaultConnection('tenant'); // 后续查询都走它
}
5.2 生命周期
请求进入 → IdentifyTenant 中间件 → switchToTenant()
→ 业务查询走租户连接
→ 请求结束(注意:常驻进程如 Octane 必须显式还原)
常驻内存的坑:在 Octane/Swoole 下,连接与「当前租户」都是进程级状态——必须用中间件的 terminate 或 finally 还原默认连接,否则下一个请求会「继承」上一个租户的数据库。
5.3 抽象成仓储
更好的做法是把「当前租户」封装进仓储(如 OrderRepository 内部通过 ConnectionResolver 取当前连接),让业务代码完全不感知连接切换。
记忆:库级隔离靠「动态注册连接 + DB::purge + setDefaultConnection」;常驻进程下当前租户是进程级状态,请求结束必须还原,否则会串库。
6. 队列与缓存的租户隔离
6.1 队列:Job 必须携带租户
后台任务脱离了 HTTP 请求,中间件不会运行——Job 里必须自己初始化租户上下文:
final class SendInvoice implements ShouldQueue
{
public function __construct(
public int $tenantId,
public int $invoiceId,
) {}
public function handle(): void
{
tenancy()->initialize(Tenant::findOrFail($this->tenantId)); // 切到该租户
try {
Invoice::find($this->invoiceId)->send();
} finally {
tenancy()->end(); // 无论成败都还原
}
}
}
把 tenant_id 放进构造函数参数(而非从 app('tenant') 读)是关键——队列是异步的,执行时请求上下文早已消失。
6.2 缓存:key 必须带前缀
// 错误:不同租户读到同一份缓存
Cache::put('dashboard_stats', $stats, 300);
// 正确:前缀隔离
Cache::put("tenant:{$tenantId}:dashboard_stats", $stats, 300);
更省心的做法是在初始化租户时动态改 cache prefix:
config(['cache.prefix' => 'tenant_' . $tenant->id . '_']);
6.3 文件存储
对象存储路径同样要隔离:s3://bucket/tenants/{tenant_id}/invoices/xxx.pdf。上传与下载都要校验路径中的 tenant_id 与当前租户一致,防止越权访问。
记忆:队列 Job 的 tenant_id 放构造函数、handle 里 initialize + finally end;缓存 key 或 prefix 必须带租户;文件路径要含 tenant_id 并在下载时校验。
7. 迁移、备份与数据导出
7.1 迁移:逐租户执行
行级隔离只需跑一次迁移;库级/schema 级要遍历所有租户:
// 自定义命令:为每个租户跑迁移
foreach (Tenant::where('status', 'active')->cursor() as $tenant) {
tenancy()->initialize($tenant);
Artisan::call('migrate', ['--force' => true]);
tenancy()->end();
}
版本一致性:要能随时回答「哪些租户的迁移版本落后了」,否则一次失败就会留下数据结构的「断层」。
7.2 备份与恢复
| 策略 | 做法 |
|---|---|
| 库级 | 每库独立备份,可单租户恢复 |
| 行级 | 全库备份 + 逻辑导出按 tenant_id 切分 |
| 导出 | 提供「导出我的全部数据」功能(合规要求) |
行级隔离的痛点是单租户恢复难——你没法只从全库备份里还原一个客户。常见折中是「全库备份 + 定期逻辑导出 + 大客户独立库」。
7.3 数据导出与删除
GDPR 类合规要求「数据可携带」与「被遗忘权」:租户注销时,行级隔离下要跨所有表按 tenant_id 清理,且要覆盖备份、缓存、日志、搜索索引。
记忆:库级/schema 级要逐租户迁移并追踪版本一致性;备份上库级可单租户恢复、行级只能全库备份 + 逻辑导出;注销时要跨表 + 缓存 + 日志 + 索引彻底清理。
8. 按租户限流与计费
8.1 按租户限流
// 每个租户每分钟 600 次
RateLimiter::for('api', function (Request $request) {
$tenant = app('tenant');
return Limit::perMinute($tenant->plan->rate_limit)->by($tenant->id);
});
按租户而非按 IP/用户限流,才能防止「一家客户刷爆整个服务」。
8.2 用量计量
计费的前提是计量。把「可计费事件」写进一张计量表:
Usage::create([
'tenant_id' => app('tenant')->id,
'metric' => 'api_calls', // api_calls / storage_mb / seats
'quantity' => 1,
'occurred_at' => now(),
]);
8.3 从用量到账单
| 环节 | 做法 |
|---|---|
| 采集 | 事件流写用量表(异步,避免拖慢请求) |
| 聚合 | 定时任务按租户 + 指标汇总到日/月 |
| 定价 | 套餐 + 超额单价(阶梯) |
| 出账 | 生成账单,对接支付网关(见支付专题) |
| 对账 | 用量与账单可追溯、可申诉 |
计量要幂等:同一个请求被重试不能计两次,用请求 id 做唯一约束。
记忆:限流按 tenant_id 而非 IP;计费先计量——可计费事件异步写用量表(幂等),定时聚合到日/月,按套餐 + 超额单价出账,最终对账可追溯。
9. 安全与常见陷阱
9.1 五个高危陷阱
| 陷阱 | 后果 | 对策 |
|---|---|---|
| 忘加全局作用域 | 跨租户读数据 | 模型统一 use trait + 测试覆盖 |
| 缓存 key 未隔离 | 脏数据串味 | key/prefix 带租户 |
| Job 用错租户上下文 | 处理错租户任务 | tenant_id 进构造函数 |
| 越权访问他人资源 | 横向越权 | 资源级授权(Policy)校验 tenant |
| 文件路径可枚举 | 越权下载 | 路径含 tenant_id + 签名 URL |
9.2 一个真实的越权场景
// 危险:路由模型绑定只按 id 查,未校验租户
Route::get('/invoices/{invoice}', fn (Invoice $invoice) => $invoice);
// 安全:Policy 里校验「该发票属于当前租户」
public function view(User $user, Invoice $invoice): bool
{
return $invoice->tenant_id === app('tenant')->id;
}
路由模型绑定默认不做租户校验——攻击者把 URL 里的 id 改成别家的,就能读到别人数据。所有资源级操作都要过 Policy。
9.3 测试要覆盖隔离
把「隔离」写成测试:租户 A 的 token 访问租户 B 的资源必须返回 403/404;缓存、队列、导出路径都要有对应断言。隔离不是写一次就完,是要持续被测试守住的。
记忆:五大陷阱——忘作用域、缓存未隔离、Job 错上下文、横向越权、文件可枚举;路由模型绑定默认不校验租户,资源级操作必须过 Policy;隔离要有测试守住。
10. 速查表与一句话记忆
| 需求 | 做法 |
|---|---|
| 隔离策略 | 行级(默认)/ schema / 库级(大客户) |
| 租户识别 | 子域名路由 + tenant_id 进 JWT |
| 读隔离 | Eloquent 全局作用域 |
| 写隔离 | creating 钩子自动填 tenant_id |
| 唯一约束 | 一律带 tenant_id |
| 连接切换 | 动态注册连接 + purge + setDefaultConnection |
| 队列 | tenant_id 进构造函数 + initialize/end |
| 缓存 | key 或 prefix 带租户 |
| 文件 | 路径含 tenant_id + 签名 URL |
| 限流 | 按 tenant_id 而非 IP |
| 计费 | 用量表(幂等)→ 聚合 → 账单 |
| 越权 | 资源级 Policy 校验 tenant |
一句话记忆:PHP 多租户 = 隔离策略三选一(默认行级 tenant_id、大客户独立库/schema)+ 租户识别(子域名路由、tenant_id 进 JWT)+ 数据隔离(全局作用域管读、creating 钩子管写、唯一约束带 tenant_id)+ 上下文贯穿(连接切换、队列 Job 带 tenant_id、缓存 prefix、文件路径)+ 按租户限流计费 + Policy 防横向越权——任何绕过 ORM 的路径都要手工补 tenant_id,并用测试把隔离钉死。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。