去年底做一个 SaaS 后台,上线两周后收到一封用户邮件:登录 A 公司账号,在订单列表里看到了一条 B 公司的订单。附了截图,订单号是真的,金额是真的,就是不该出现。
排查的过程比想象中顺利。问题出在一个”导出 Excel”接口上,那个接口为了性能绕开了模型,直接用 Db::name('order')->select() 拉数据。模型里的租户条件被绕过了,接口只带了时间范围筛选。上线后没人测过导出功能,就这么漏到了线上。
那之后我把整个项目的租户隔离梳理了一遍,才发现这类漏点藏得比想象中深。今天把 ThinkPHP 8 下的做法和踩过的坑整理出来。
先想清楚隔离层级
多租户的数据隔离有三种常见形态,选型决定了后面所有的写法。
第一种是独立库。每个租户一个数据库,连接参数从配置中心动态读。隔离最彻底,但跨租户统计、账号体系、公共字典表这些会很别扭。适合租户数量少、每个租户数据量大的场景。
第二种是共享库、共享表、每行带 tenant_id。最常见的一种,也是本文要展开的。开发成本最低,但隔离完全依赖代码自觉,一旦某个查询忘了带条件就是事故。
第三种是共享库、按 schema 隔离。PostgreSQL 里很自然,MySQL 得靠切库实现,比较少见。
绝大多数中小型 SaaS 选第二种就行,问题在于”怎么让漏掉条件的查询变得难写”。这才是本文想解决的事。
第一步:中间件识别租户并绑定到容器
租户从哪来?两种主流做法:子域名解析,或者登录态里读。子域名解析更清爽,路径里看不到租户标识,也方便 CDN 做通配符证书。
namespace apptenantmiddleware;
use apptenantmodelTenant;
use Closure;
use thinkfacadeCache;
use thinkRequest;
use thinkResponse;
class ResolveTenant
{
public function handle(Request $request, Closure $next): Response
{
$host = $request->host();
$sub = strstr($host, '.', true);
if (!$sub || in_array($sub, ['www', 'api', 'admin'])) {
return json(['code' => 404, 'msg' => '租户不存在'], 404);
}
// 缓存一下,避免每次请求都查库
$tenant = Cache::remember("tenant:host:{$sub}", function () use ($sub) {
return Tenant::where('subdomain', $sub)
->where('status', 1)
->find();
}, 600);
if (!$tenant) {
return json(['code' => 404, 'msg' => '租户不存在'], 404);
}
// 绑定到容器,后续任何地方都能通过 app('tenant') 拿到
app()->bind('tenant', $tenant);
// 顺便把 id 绑成一个独立的键,取值时更直接
app()->bind('tenant_id', $tenant->id);
return $next($request);
}
}
几个细节。
不要写死子域名列表。www、api、admin 这类通用子域名要提前排除掉,不然有人访问 api 主域名会拿到一个奇怪的租户。
绑定用 bind 而不是 instance。bind 每次都返回你传入的那个对象,instance 会覆盖容器里的单例并做更严格的类型校验。这里不需要后者。
租户缓存加了 600 秒。租户基本不会变,缓存能省掉几乎所有的租户查询。但要注意:租户被禁用或删除时,这个缓存得手动清,不然最长十分钟内旧租户还能访问。
第二步:BaseModel 自动注入租户条件
整个隔离方案的核心是把租户条件”藏”进模型里,业务代码不知不觉就会带上。ThinkPHP 的全局作用域正好干这个。
namespace appcommonmodel;
use thinkModel;
abstract class TenantModel extends Model
{
// 运行时不检查这个字段是否存在,只能是约定
protected $tenantField = 'tenant_id';
// 允许单次查询临时关闭租户过滤
protected static bool $tenantSuspended = false;
protected static function boot()
{
parent::boot();
// 查询前自动加租户条件
static::beforeQuery(function ($query) {
if (static::$tenantSuspended) {
return;
}
$tid = app()->get('tenant_id');
if (!$tid) {
return;
}
$query->where(static::makeTablePrefix() . 'tenant_id', $tid);
});
// 写入前自动补租户字段
static::beforeInsert(function ($model) {
if (static::$tenantSuspended) {
return;
}
$tid = app()->get('tenant_id');
if ($tid && empty($model->tenant_id)) {
$model->tenant_id = $tid;
}
});
}
protected static function makeTablePrefix(): string
{
$table = (new static)->getTable();
return $table . '.';
}
/**
* 临时关闭租户过滤,用于后台管理跨租户查询
* 用法:TenantModel::withoutTenant(fn () => Order::select())
*/
public static function withoutTenant(callable $callback)
{
static::$tenantSuspended = true;
try {
return $callback();
} finally {
static::$tenantSuspended = false;
}
}
}
这段代码里有几处很关键,容易写错。
为什么要带表名前缀
不带前缀的 where('tenant_id', $tid) 在单表查询时没问题,但只要有关联查询,生成的 SQL 里会出现 tenant_id 字段名歧义。举例:
Order::with('user')->select();
生成 SQL 时 order 表和 user 表都有 tenant_id 字段,数据库会报 “Column ‘tenant_id’ in where clause is ambiguous”。加上表名前缀之后,条件明确指向主表,问题消除。
要注意 getTable() 返回的可能已经带了前缀(取决于项目配置),拼接时得先确认一下,否则会得到 prefix_order.tenant_id 这种东西。稳妥的做法是写一个统一的工具方法,把表名规范化。
为什么用静态属性而不是实例变量
全局作用域在模型上是一次性的——同一个请求里可能调用多次模型,每次都要重新判断是否跳过。用实例属性的话,一处调用关掉之后整个请求都关掉了,非常危险。用静态属性配合 try/finally 保证作用域精准,退出块之后自动恢复,这是唯一安全的方式。
但静态属性有个在协程环境下的隐患:常驻内存模式下,一次请求改的静态值在同一个 worker 里会被下一次请求看到。Workerman、Swoole 跑 ThinkPHP 时这块要特别小心,finally 是必须的,而且不能有异常路径绕过它。
写入时的自动补全
beforeInsert 里只补 tenant_id 字段,不做校验。校验是多余的吗?不是。应该改成:如果外部传了 tenant_id 且跟当前租户不一致,直接抛异常。
static::beforeInsert(function ($model) {
if (static::$tenantSuspended) {
return;
}
$tid = app()->get('tenant_id');
if (!$tid) {
return;
}
if (!empty($model->tenant_id) && $model->tenant_id != $tid) {
throw new RuntimeException('禁止跨租户写入');
}
$model->tenant_id = $tid;
});
这一条是防事故的关键。曾经有一个接口因为参数泄漏,被传了别的租户 id 进去,数据真的写进了对方租户。加了这层校验之后,这类攻击直接失败。
第三步:留一个”逃生舱”
后台运营、财务报表、数据统计这些场景需要跨租户查询,如果每个地方都去绕 Db::name() 写 SQL,等于把隔离体系废了。正确做法是留一条明确的、可审计的逃生通道。
// 只查某个租户,但当前上下文是超级管理员
Order::withoutTenant(fn () => Order::where('tenant_id', $targetTid)->select());
// 跨租户统计
Order::withoutTenant(function () {
return Order::field('tenant_id, count(*) as cnt')
->group('tenant_id')
->select();
});
这里有两个设计上的用心。
函数而不是标志位。如果提供的是 withoutTenant(true) 这种开关式的 API,很容易有人调用之后忘了恢复。try/finally 保证异常时也能恢复,把”忘记”这个错误从代码里消灭掉。
名字里带 without。Code Review 的时候一眼就能扫出来,比 ignoreTenant 之类的中性词更醒目。有时候一个命名能比一整篇规范文档有效。
再配一条团队约定:任何 withoutTenant 调用,必须紧跟着显式的 tenant_id 条件。写不出条件的场景,说明这个功能设计有问题,应该用独立的超级管理入口,而不是在业务代码里开洞。
第四步:绕过模型的那些调用
前面那次事故的根源就在这里。Global scope 只对模型生效,Db::name()、Db::table()、Db::raw() 全都绕过。
三种处理方式。
第一,用模型的事件代理。可以写一个 TenantDb 门面,包装 Db::name() 调用并自动追加条件:
namespace appcommonfacade;
use thinkfacadeDb;
class TenantDb
{
public static function name(string $table)
{
$query = Db::name($table);
$tid = app()->get('tenant_id');
if ($tid) {
$query->where('tenant_id', $tid);
}
return $query;
}
}
但这样只是加了一个”推荐写法”,阻止不了有人直接调 Db::name。
第二,用静态分析兜住。在 CI 里加一条规则:Db::name(、Db::table( 只能出现在 appcommonrepository 目录下,其他目录一旦出现直接构建失败。这条规则的实现很小——用 PHP_CodeSniffer 自定义一个 sniff,或者干脆写个 grep 脚本。
第三,走 Repository 模式。所有数据访问收拢到 Repository,业务代码不碰 SQL。这个改动大,但一次到位。
我倾向第二条,投入产出比最高。工具的约束比人的记性可靠,尤其是半年后再看这个项目的时候。
第五步:关联查询的坑
模型里加了全局作用域,关联查询会怎样?答案是——关联表不会自动加租户条件。
看一个例子:
class Order extends TenantModel
{
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
}
// 查询:当前租户的订单,以及对应的用户
Order::with('user')->select();
生成的 SQL 大概是这样:
SELECT * FROM prefix_order WHERE prefix_order.tenant_id = 1;
SELECT * FROM prefix_user WHERE id IN (1, 2, 3);
第二条 SQL 完全没有租户条件。看起来没问题——因为订单已经是当前租户的了,用户也就是当前租户的。但如果 user 表和 order 表的 tenant_id 不是严格一致的(比如历史数据迁移留下的脏数据),或者关联关系被破坏,第二条 SQL 会捞出别的租户的用户。
更隐蔽的一种是反向关联:
User::with('orders')->select();
用户表带了租户条件(因为 User 也继承 TenantModel),订单表则在预加载时也会走自己的全局作用域,看起来应该是安全的。但如果是 hasManyThrough 或者带 whereHas 的复杂关联,条件可能会丢。
最稳妥的做法是在关联方法里显式写条件:
public function user()
{
return $this->belongsTo(User::class, 'user_id')
->where('user.tenant_id', app()->get('tenant_id'));
}
看起来啰嗦,但每一条关联查询都经得起审计。项目里关联方法不多的话,写一遍一劳永逸。
第六步:缓存键必须带租户前缀
数据隔离做好了,缓存忘了加前缀,一样会串数据。这是我在第二个项目里踩的坑。
ThinkPHP 的缓存门面默认不带租户概念。所有业务缓存都要自己在 key 里加 tenant:{id}: 前缀。
namespace appcommonsupport;
use thinkfacadeCache as BaseCache;
class TenantCache
{
public static function key(string $key): string
{
$tid = app()->get('tenant_id', 0);
return "tenant:{$tid}:{$key}";
}
public static function remember(string $key, callable $callback, int $ttl = 3600)
{
return BaseCache::remember(self::key($key), $callback, $ttl);
}
public static function rm(string $key): bool
{
return BaseCache::delete(self::key($key));
}
}
用起来:
TenantCache::remember('order:stats', function () {
return Order::where('status', 'paid')->count();
}, 300);
这里有一个特别容易漏的地方:清理缓存的时候也必须用同样的前缀。曾经有人在后台改配置后调 Cache::delete('config') 想清配置缓存,结果清的是当前租户的,其他租户的配置缓存纹丝不动,改配置变成了”只对自己生效”的诡异 bug。
还有一种批量清理的场景,比如”清空某租户所有缓存”。这时候需要一套统一的 key 规范,加上 Cache::tag() 做分组:
BaseCache::tag("tenant:{$tid}")->remember('order:stats', $callback, 300);
// 批量清理
BaseCache::tag("tenant:{$tid}")->clear();
标签功能依赖具体的缓存驱动,Redis 原生支持,文件驱动部分支持。生产环境用 Redis 的话直接上标签更省心。
第七步:队列任务里租户上下文会丢
think-queue 是异步的,任务投递出去之后,等到消费者执行时,当前请求早就结束了,容器里的 tenant 绑定也就没了。
结果就是:在队列任务里调 TenantModel,app()->get('tenant_id') 返回 null,全局作用域静默跳过,条件不加,直接出事故。
解决办法是把租户 id 一起投递进队列,然后在任务执行时重新绑定:
namespace appjob;
use appcommonmodelTenantModel;
use thinkqueueJob;
class SyncOrderStats
{
public function fire(Job $job, array $data)
{
$tenantId = $data['tenant_id'] ?? null;
$orderId = $data['order_id'] ?? null;
if (!$tenantId || !$orderId) {
$job->delete();
return;
}
// 关键一步:把租户 id 重新绑定进容器
app()->bind('tenant_id', $tenantId);
try {
$order = apptenantmodelOrder::find($orderId);
if ($order) {
// 处理业务
}
$job->delete();
} catch (Throwable $e) {
if ($job->attempts() > 3) {
$job->delete();
// 记录日志
} else {
$job->release(10);
}
}
}
}
投递的时候:
use thinkfacadeQueue;
Queue::push(SyncOrderStats::class, [
'tenant_id' => app()->get('tenant_id'),
'order_id' => $order->id,
], 'order');
更好的做法是包一层 Job 基类,把”恢复租户上下文”这件事做成统一的前置逻辑:
abstract class TenantJob
{
public function fire(Job $job, array $data)
{
$tid = $data['_tenant_id'] ?? null;
if ($tid) {
app()->bind('tenant_id', $tid);
}
try {
$this->handle($job, $data);
} catch (Throwable $e) {
// 统一异常处理
$this->fail($job, $e);
}
}
abstract protected function handle(Job $job, array $data);
}
业务任务只继承 TenantJob 并实现 handle,租户上下文自动恢复,不用每个任务里都记得写一遍。
还有一个隐藏的坑:任务重试时也会重新执行 fire 方法,如果 $_tenant_id 在 $data 里还好,恢复逻辑会自动跑一遍;如果是放在 $job->payload() 的其他位置,就得注意数据结构的读取方式是不是稳定的。
第八步:文件路径也得隔开
用户上传的文件如果放在一起,即使数据库层面隔离了,一旦有人猜到了文件 URL 就能直接访问到别的租户的文件。这个比数据库泄露更直接,因为没有权限校验可拦。
做法很简单:按租户分子目录。
$savePath = 'tenant/' . app()->get('tenant_id') . '/uploads/' . date('Ymd');
$file = $request->file('file');
$info = $file->move($savePath);
$url = '/storage/' . str_replace('\', '/', $info->getSaveName());
存到数据库里的就是完整 URL,访问时走 Nginx 直接返回静态文件,性能也好。
但光靠长路径防不住有心人。真正的安全方案是走一个带权限校验的下载接口,或者用对象存储的签名 URL。小项目里把路径做得越难猜越好——比如加上随机段——作为一道软性防线,别当硬防线用。
回归测试:怎么确信没有漏点
上面这些都做了之后,最大的风险依然存在:有一天某个新人写了一个 Db::name,或者某个关联方法忘了加条件。代码层面挡不住的事情,靠测试兜底。
最简单有效的方式是写一个跨租户渗透测试:
public function testTenantIsolation()
{
// 准备两个租户的数据
$this->seedTenantA();
$this->seedTenantB();
// 模拟以租户 A 的身份访问所有列表接口
foreach ($this->listEndpoints() as $endpoint) {
$res = $this->withTenant('A')->get($endpoint);
$body = json_decode($res->getContent(), true);
$ids = collect($body['data'] ?? [])
->pluck('tenant_id')
->unique();
// 所有返回的数据必须都属于租户 A
$this->assertEquals(['A'], $ids->all());
}
}
这个测试的核心是遍历所有列表接口,而不是只测某一个。可以手动维护接口清单,更好的做法是从路由表里自动提取 GET 接口,遍历执行一遍。
测试跑在 CI 里,每次提交都跑。哪怕有人真的漏了条件,最迟在合并前就会被拦下来。
写在最后
多租户隔离这件事,代码写对不难,难的是让别人也写对,而且一年之后还能保持写对。中间件 + BaseModel 全局作用域解决的是”默认安全”,逃生舱解决的是”必要时可控”,CI 静态检查和渗透测试解决的是”长期不腐化”。三层缺一不可。
回头看那次事故,如果当时有 TenantDb 门面和 CI 静态检查,导出接口那个 Db::name 早就被拦下来了。代码层面的约束,永远比”记住要加条件”这种靠自觉的方式可靠。
如果项目里现在已经在做多租户,可以先从 BaseModel 的全局作用域开始改——那是最立竿见影的一步。改完之后可以打开日志,看看那些原本该带条件却漏了的查询,一次性全部暴露出来。

