在聊具体实现之前,咱们先把基础概念理清楚。想象一下你住的公寓楼:一栋楼(你的应用),很多住户(你的客户)。大家都用同一个电梯,共用水电,但每家的门都锁得好好的,互相看不到对方家里的东西。多租户就是这个意思。
这篇文章会讲清楚几个问题:租户到底是什么,为什么这么多团队选择多租户架构,三种常见的数据隔离方式,还有一套可以直接拿来用的 PHP 代码,让你的应用从一开始就不会把不同客户的数据搞混。
租户就是你应用里的一个客户,可能是一家公司、一所学校、或者一个商店。
目标就是让这些客户共用你的代码和服务器,但绝对不能看到彼此的数据。
说白了就是三个好处:
所有客户共用一套表,每行数据都标记属于哪个客户(tenant_id
)。
好处是简单,坏处是你得时刻记住加过滤条件,不然客户 A 就能看到客户 B 的数据了。
还有个要注意的:邮箱这种"唯一"字段,应该是在客户内部唯一,不是全局唯一。也就是说,客户 A 和客户 B 可以有相同的邮箱地址。
一个数据库,但给每个客户建独立的模式:客户甲.users
、客户乙.users
。
好处是隔离性更强,坏处是每次数据库升级都得跑很多遍,应用也得会切换模式。
最彻底的隔离方式,每个客户都有自己的数据库。
安全性最高,但运维工作量也最大。
刚开始用行级隔离,简单快速。等客户多了,重要客户可以"升级"到独立模式或独立数据库。这就像一个旋钮,可以根据需要调节隔离程度。
先建个小表记录客户信息和隔离方式,以后想换隔离方式也不用重写代码:
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 的例子,通过子域名识别客户:
// 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 的全局作用域,让每个查询都自动加上客户过滤:
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:
CREATE UNIQUE INDEX users_email_per_tenant
ON users (tenant_id, lower(email));
MySQL:
CREATE UNIQUE INDEX users_email_per_tenant
ON users (tenant_id, email);
$prefix = app('tenant')->id . ':';
// 缓存加前缀
Cache::put($prefix.'user:'.$userId, $user, 300);
// 队列任务也要分开
SendInvoice::dispatch($invoiceId)->onQueue('tenant:'.$prefix);
如果用 PostgreSQL,可以开启行级安全策略,即使代码里忘了加过滤条件,数据库也会拦截:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id::text = current_setting('app.tenant_id'));
连接数据库后设置当前客户:
DB::statement('SET app.tenant_id = ?', [app('tenant')->id]);
忘记加客户过滤 解决:用全局作用域或者查询包装器,让过滤自动化。
邮箱等字段全局冲突 解决:建复合唯一索引 (tenant_id, email)
。
缓存串数据 解决:所有缓存键都加客户 ID 前缀。
后台任务搞混客户 解决:任务开始时就设置好客户上下文。
生产环境乱改数据库 解决:先做向后兼容的改动,数据库结构变更放在低峰期。
tenants
表,填入测试数据tenant_id
字段和索引有了上面这套基础设施:
这样后面讨论更复杂的话题——比如成本控制、性能优化、无缝迁移——就有了坚实的基础。