PHP 多租户 SaaS 架构:隔离策略、数据作用域与按租户计费

PHP 多租户 SaaS 架构实战:库级/schema 级/行级三种隔离策略对比、子域名与请求头识别租户、全局作用域与 BelongsToTenant 数据隔离、连接切换与动态数据库、队列与缓存的租户上下文、按租户迁移与备份、限流与用量计费、跨租户泄漏的常见陷阱。

引言

SaaS 的核心工程问题之一就是多租户:一套代码、一套部署,服务成千上万个互相看不见的客户。听起来只是「每张表加个 tenant_id」,真做起来却处处是坑——忘了加全局作用域就跨租户泄漏数据、队列里的 Job 拿着错误的租户上下文、缓存 key 没隔离导致 A 客户看到 B 客户的报表。本文把隔离策略、租户识别、数据作用域、连接切换与计费限流讲透。

前置:安全加固、数据库迁移治理、认证与授权。


目录


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强中逐 schemaPostgreSQL 中大型
行级共享表 + 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,并用测试把隔离钉死。


延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「php」更多文章

  1. PHP 的 CQRS 与事件溯源:命令总线、事件存储与投影
  2. PHP 支付集成实战:Stripe、支付宝与微信支付的状态机与回调
  3. Serverless PHP 与 Bref:Lambda 运行时、事件驱动与 Laravel Octane