多租户入门指南:开始之前先搞清楚基础

在聊具体实现之前,咱们先把基础概念理清楚。想象一下你住的公寓楼:一栋楼(你的应用),很多住户(你的客户)。大家都用同一个电梯,共用水电,但每家的门都锁得好好的,互相看不到对方家里的东西。多租户就是这个意思。

这篇文章会讲清楚几个问题:租户到底是什么,为什么这么多团队选择多租户架构,三种常见的数据隔离方式,还有一套可以直接拿来用的 PHP 代码,让你的应用从一开始就不会把不同客户的数据搞混。

什么是"租户"

租户就是你应用里的一个客户,可能是一家公司、一所学校、或者一个商店。

目标就是让这些客户共用你的代码和服务器,但绝对不能看到彼此的数据。

为什么选择多租户

说白了就是三个好处:

  • 省钱:一套服务器跑多个客户,成本分摊
  • 好维护:只需要维护一套代码,有 bug 修一次就行
  • 快上线:新客户来了,开个账号就能用,不用重新部署

三种数据隔离方式

行级隔离(最常用的起步方式)

所有客户共用一套表,每行数据都标记属于哪个客户(tenant_id)。

好处是简单,坏处是你得时刻记住加过滤条件,不然客户 A 就能看到客户 B 的数据了。

还有个要注意的:邮箱这种"唯一"字段,应该是在客户内部唯一,不是全局唯一。也就是说,客户 A 和客户 B 可以有相同的邮箱地址。

每客户一个数据库模式

一个数据库,但给每个客户建独立的模式:客户甲.users客户乙.users

好处是隔离性更强,坏处是每次数据库升级都得跑很多遍,应用也得会切换模式。

每客户一个数据库

最彻底的隔离方式,每个客户都有自己的数据库。

安全性最高,但运维工作量也最大。

建议的演进路径

刚开始用行级隔离,简单快速。等客户多了,重要客户可以"升级"到独立模式或独立数据库。这就像一个旋钮,可以根据需要调节隔离程度。

一张表搞定未来扩展

先建个小表记录客户信息和隔离方式,以后想换隔离方式也不用重写代码:

sql
CREATE TABLE tenants (
  id          CHAR(36) PRIMARY KEY,          -- 客户ID(UUID)
  slug        VARCHAR(100) UNIQUE NOT NULL,  -- 客户标识,比如子域名 "acme"
  isolation   ENUM('ROW','SCHEMA','DATABASE') NOT NULL DEFAULT 'ROW',
  schema_name VARCHAR(120),  -- 用独立模式时填这个
  dsn         TEXT,          -- 用独立数据库时填这个
  created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

实际代码(拿来就能用)

识别当前客户(中间件)

Laravel 的例子,通过子域名识别客户:

php
// app/Http/Middleware/ResolveTenant.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;

class ResolveTenant {
    public function handle($request, Closure $next) {
        // 从 "acme.example.com" 提取 "acme"
        $slug = Str::before($request->getHost(), '.');
        $tenant = DB::table('tenants')->where('slug', $slug)->first();

        if (!$tenant) {
            abort(404, '客户不存在');
        }

        // 把客户信息存到容器里,整个请求过程都能用
        app()->instance('tenant', $tenant);

        return $next($request);
    }
}

记得在路由里注册这个中间件。之后在任何地方都可以通过 app('tenant') 获取当前客户信息。

自动过滤数据

用 Eloquent 的全局作用域,让每个查询都自动加上客户过滤:

php
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class TenantScope implements Scope {
    public function apply(Builder $query, Model $model) {
        $tenantId = app('tenant')->id;
        $query->where($model->getTable().'.tenant_id', $tenantId);
    }
}

// 在需要按客户隔离的模型里加上:
protected static function booted() {
    static::addGlobalScope(new TenantScope());
}

如果不用 Laravel,就包装一下你的查询构建器,让它自动加 WHERE tenant_id = ?

处理唯一性约束

PostgreSQL:

sql
CREATE UNIQUE INDEX users_email_per_tenant
ON users (tenant_id, lower(email));

MySQL:

sql
CREATE UNIQUE INDEX users_email_per_tenant
ON users (tenant_id, email);

缓存和队列也要隔离

php
$prefix = app('tenant')->id . ':';

// 缓存加前缀
Cache::put($prefix.'user:'.$userId, $user, 300);

// 队列任务也要分开
SendInvoice::dispatch($invoiceId)->onQueue('tenant:'.$prefix);

额外保险(PostgreSQL 行级安全)

如果用 PostgreSQL,可以开启行级安全策略,即使代码里忘了加过滤条件,数据库也会拦截:

sql
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
USING (tenant_id::text = current_setting('app.tenant_id'));

连接数据库后设置当前客户:

php
DB::statement('SET app.tenant_id = ?', [app('tenant')->id]);

常见坑点

忘记加客户过滤 解决:用全局作用域或者查询包装器,让过滤自动化。

邮箱等字段全局冲突 解决:建复合唯一索引 (tenant_id, email)

缓存串数据 解决:所有缓存键都加客户 ID 前缀。

后台任务搞混客户 解决:任务开始时就设置好客户上下文。

生产环境乱改数据库 解决:先做向后兼容的改动,数据库结构变更放在低峰期。

快速检查清单

  • [ ] 建好 tenants 表,填入测试数据
  • [ ] 中间件能正确识别客户
  • [ ] 所有客户相关的表都有 tenant_id 字段和索引
  • [ ] 邮箱、用户名等字段用复合唯一约束
  • [ ] 模型自动过滤客户数据
  • [ ] 缓存、会话、队列都加了客户前缀
  • [ ] 写几个测试,确保忘记过滤时会报错
  • [ ] (可选)关键表开启行级安全

为什么要先搞清楚这些

有了上面这套基础设施:

  • 应用随时知道当前是哪个客户在访问
  • 数据查询默认就是安全的
  • 缓存和队列不会串数据
  • 以后想给大客户升级到独立数据库也很容易

这样后面讨论更复杂的话题——比如成本控制、性能优化、无缝迁移——就有了坚实的基础。

CatchAdmin
后端开发工程师,前端入门选手,略知相关服务器知识,偏爱❤️ Laravel & Vue
本作品采用《CC 协议》,转载必须注明作者和本文链接