多租户这个东西,第一次做的时候特别容易想简单。脑子里想的是「每条数据加个 tenant_id 不就好了」,写起来确实也是这么写的。等你上线半年,某天业务方突然来信说 A 客户看到了 B 客户的数据,你回头翻代码才发现,有八个地方忘了 where('tenant_id', ...)。
这篇文章不聊理论和选型对比(共享库和分库各自有利弊,不是一句两句能说清的),就聚焦一件事:在共享库共表这个方案下,怎么让「忘记写租户条件」这件事尽可能不会发生。方案是 ThinkPHP 8 的全局查询作用域加上租户中间件。
先把场景定清楚
假设我们做一个工单 SaaS,多租户共表。每张业务表都有一个 tenant_id 字段。要求是:
- 绝大部分查询和写入,都应该自动带上当前租户 ID,业务代码不用管。
- 少数需要跨租户的操作(比如平台管理员后台),要能显式关掉这个限制。
- 有人写了原生 SQL 绕过 ORM 的时候,也要有兜底。
- 队列任务和缓存也要跟着租户走。
表结构大概是这个样子:
CREATE TABLE `ticket` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`tenant_id` int unsigned NOT NULL,
`user_id` int unsigned NOT NULL,
`title` varchar(200) NOT NULL,
`status` tinyint NOT NULL DEFAULT 0,
`created_at` int unsigned NOT NULL,
`updated_at` int unsigned NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_tenant_status` (`tenant_id`, `status`)
);
idx_tenant_status 这个联合索引是必须的。因为几乎所有查询都带 tenant_id,单列索引并不能让 MySQL 高效利用它后面的 status 条件。这个坑很多人在数据量上来之后才发现,加索引要停写,代价很大。
租户上下文:一个进程内的单例
整件事的前提是有一个地方存「当前是哪个租户」。在 ThinkPHP 里用容器绑定最顺。
<?php
namespace apptenant;
class TenantContext
{
private ?int $tenantId = null;
private bool $strict = true;
public function set(int $tenantId): void
{
$this->tenantId = $tenantId;
}
public function id(): ?int
{
return $this->tenantId;
}
/**
* 在当前租户上跑一段逻辑,跑完恢复原来的上下文
*/
public function runAs(int $tenantId, callable $fn): mixed
{
$previous = $this->tenantId;
$this->tenantId = $tenantId;
try {
return $fn();
} finally {
$this->tenantId = $previous;
}
}
/**
* 关闭严格模式,用于平台级查询
*/
public function withoutScope(callable $fn): mixed
{
$previous = $this->strict;
$this->strict = false;
try {
return $fn();
} finally {
$this->strict = $previous;
}
}
public function isStrict(): bool
{
return $this->strict;
}
}
runAs 和 withoutScope 都用 finally 恢复状态。这个很关键——如果被调用的闭包中途抛异常,没有 finally 就会把上下文污染掉,接下来整个请求的处理都会用错的租户。这种 bug 极难查,因为表现为「偶尔」数据错乱。
这套东西不要放在 request 里,直接绑定到容器单例,app()->make(TenantContext::class) 拿到的就是同一个。
中间件:解析并注入租户
请求进来的时候,把 token 里的租户信息解析出来,塞进上下文。同时也要防止用户伪造,所以 tenant_id 不能从 URL 里读,必须从 token 里读,而且要跟用户所属租户校验一致。
<?php
namespace appmiddleware;
use apptenantTenantContext;
use thinkfacadeDb;
use thinkRequest;
use thinkResponse;
use Throwable;
class TenantMiddleware
{
public function __construct(private TenantContext $context)
{
}
public function handle(Request $request, callable $next): Response
{
$tenantId = $request->tenantId ?? null;
if (!$tenantId || !is_int($tenantId)) {
return json(['code' => 401, 'msg' => '缺少租户标识']);
}
// 校验租户是否有效,比如是否已被停用
$exists = Db::name('tenant')
->where('id', $tenantId)
->where('status', 1)
->count();
if ($exists === 0) {
return json(['code' => 403, 'msg' => '租户不可用']);
}
$this->context->set($tenantId);
try {
return $next($request);
} finally {
// 请求结束清掉,避免长驻进程串租户
$this->context->set(0);
}
}
}
最后那个 finally 里的重置不是给 FPM 准备的(FPM 每个请求都是新进程),是为了兼容 Swoole、RoadRunner 这类常驻内存的运行时。如果你以后打算切换到这些环境,现在就把这个习惯养好,能省掉一堆诡异的串租户问题。
$request->tenantId 是从哪来的?通常在前一个 JWT 校验中间件里解析 token 后 $request->tenantId = $payload['tid']。中间件的执行顺序要保证 JWT 校验在租户中间件之前,这个在 route/app.php 或者全局中间件配置里可以指定。
模型基类:用全局作用域过滤
ThinkPHP 6 开始就有全局查询作用域(global scope),写在模型里,所有基于这个模型的查询都会自动套用。
<?php
namespace appmodel;
use apptenantTenantContext;
use thinkModel;
use thinkdbQuery;
abstract class TenantModel extends Model
{
protected function globalScope(Query $query): void
{
$context = app(TenantContext::class);
$tenantId = $context->id();
// 没有绑定租户时,直接返回空结果集,避免数据泄露
if (!$tenantId) {
$query->whereRaw('1 = 0');
return;
}
if ($context->isStrict()) {
$query->where($this->getTable() . '.tenant_id', $tenantId);
}
// 非严格模式下不加条件,故意让平台管理后台能查全量
}
protected static function init(): void
{
// 写入时自动补 tenant_id
static::beforeInsert(function ($model) {
if (empty($model->tenant_id)) {
$tenantId = app(TenantContext::class)->id();
if (!$tenantId) {
throw new RuntimeException('未绑定租户,禁止写入');
}
$model->tenant_id = $tenantId;
}
});
}
}
有几点要特别说明:
字段要加上表名。 $this->getTable() . '.tenant_id' 不写成裸的 tenant_id,是因为涉及联表查询时,tenant_id 这个字段名很容易在两张表里都出现。MySQL 会报 ambiguous column,或者不加报错就取错了,这取决于你的 SQL 模式设置。加上表名最稳妥。
没有绑定租户时应当返回空集。 这一点非常重要,属于「失败安全」的设计。有些实现是「没有租户就不过滤」,那一旦中间件因为某种原因没执行、或者某个命令行走到了这个模型,你猜会发生什么?全量数据暴露。宁可返回空,也不要「默认放行」。
写入兜底。 读的时候全局作用域管得住,写的时候不管。如果业务代码自己组装数据,很容易写了别的租户的 ID 或者干脆没写。用 beforeInsert 兜一把,同时要注意——如果业务明确允许跨租户写入(比如平台管理员给某个租户导入数据),那就得在 runAs 里操持,不要试图绕过这个校验。
更新和删除也要兜底。 globalScope 在 update 和 delete 上也会生效,这点比很多人想得要靠谱。但只限于通过模型发起的操作。如果用了 Db::table('ticket')->update(),作用域完全不生效,后面专门讲。
业务模型写起来干净得像单租户
有了上面的基类,具体模型可以写得很清爽:
<?php
namespace appmodel;
class Ticket extends TenantModel
{
protected $name = 'ticket';
protected $autoWriteTimestamp = true;
public function scopeClosed($query): void
{
$query->where('status', 2);
}
}
业务代码里调用是这样的:
// 自动带 tenant_id 条件
$list = Ticket::where('status', 0)->select();
// 自动补 tenant_id 字段
$ticket = Ticket::create([
'user_id' => 123,
'title' => '无法登录',
]);
// 更新和删除也自动带租户条件
Ticket::where('id', $id)->update(['status' => 1]);
注意这里没有一处出现 tenant_id,但每一句 SQL 都会自动带上条件。这就是把这套方案落地的意义——让常见路径「不易写错」。
平台管理后台:显式跳过作用域
总有需要跨租户的场景,比如平台的运营后台要看所有租户的工单,或者做数据统计。这时候要能明确、有仪式感地关掉作用域,而不是随手写个 Db::table 绕过。
<?php
namespace appcontrolleradmin;
use appmodelTicket;
use apptenantTenantContext;
class TicketReport
{
public function index(TenantContext $context)
{
// 平台级查询,明确声明意图
return $context->withoutScope(function () {
$rows = Ticket::field('tenant_id, count(*) as num')
->group('tenant_id')
->select();
return json(['code' => 0, 'data' => $rows]);
});
}
}
这种方式的好处是:代码审查的时候一眼就能看出「这段是跨租户操作」,而不是隐藏在某一行 SQL 里。也可以配合权限中间件,只有平台管理员的角色才能进入 withoutScope 的路径。
绕不过去的地方:原生查询和 Db 门面
Db::table('ticket') 完全不走模型,全局作用域对它无效。这不是缺陷,是设计定位——门面本来就不管你。但现实项目里这类代码到处都是:初始化脚本、报表导出、迁移任务、第三方工具调用。
这里能做的防护不多,主要是三条:
第一条,约定优先用模型。 项目文档里明确写「业务表读写一律走模型,Db::table 只能出现在数据迁移和离线统计里」。这条规矩看着啰嗦,但它能让绝大多数偷懒的写法无处遁形。
第二条,加个调试钩子。 在开发环境里监听 SQL 执行事件,凡是命中业务表又没有 tenant_id 条件的,往日志里打一条警告。
<?php
namespace appevent;
use apptenantTenantContext;
use thinkfacadeLog;
class SqlWatcher
{
private const WATCH_TABLES = ['ticket', 'ticket_reply', 'customer'];
public function handle(string $sql): void
{
if (app()->isDebug() === false) {
return;
}
if (!app(TenantContext::class)->isStrict()) {
return;
}
foreach (self::WATCH_TABLES as $table) {
if (!str_contains($sql, $table)) {
continue;
}
if (!preg_match('/btenant_idb/i', $sql)) {
Log::warning('[租户隔离] 可能的越权 SQL: ' . $sql);
}
}
}
}
挂在 event.php 里的 Db::listen() 上就行。开发阶段每个人的日志里都会冒警告,谁写的谁自己补。
第三条,加一条 CI 规则。 用 grep 之类的工具扫一下仓库,业务控制器里出现 Db::table('ticket') 的提交直接拒绝。粗糙但有效。
队列和缓存也得跟着租户走
一个请求扔进队列里就结束了,上下文也跟着请求走了。消费者执行任务的时候 TenantContext 里什么都没有,模型会直接返回空集,或者 beforeInsert 抛异常。这个问题如果不处理,秒杀之后你的异步任务就全失败了。
<?php
namespace appjob;
use apptenantTenantContext;
use thinkqueueJob;
class SendNotification
{
public function fire(Job $job, array $data): void
{
$tenantId = (int) $data['tenant_id'];
app(TenantContext::class)->runAs($tenantId, function () use ($data, $job) {
// 这里面所有模型操作都自动绑定到对应的租户
$this->doSend($data);
$job->delete();
});
}
private function doSend(array $data): void
{
// 具体业务逻辑
}
}
关键是推送任务的时候要把 tenant_id 放进 payload。别偷懒去读当前上下文——消费者跟生产者可能不在同一个进程,读到的是空的。
缓存这边的问题更隐蔽。缓存 key 如果不带租户前缀,A 租户把结果存进 ticket_stats,B 租户读出来就是 A 的数据。这种 bug 测不出来,只有用户投诉才会暴露。
private function cacheKey(string $name, int $tenantId): string
{
return 'tenant:' . $tenantId . ':' . $name;
}
// 使用
$key = $this->cacheKey('ticket_stats', app(TenantContext::class)->id());
$stats = Cache::remember($key, fn () => $this->computeStats(), 300);
更省事的做法是在 Cache 上再包一层,自动读上下文补前缀。这个封装不难,几十行代码,收益很高。
测试怎么写
多租户代码最应该测的,是「A 租户是否真的看不到 B 租户的数据」。用 ThinkPHP 的测试套件跑一遍:
<?php
namespace tests;
use appmodelTicket;
use apptenantTenantContext;
use PHPUnitFrameworkTestCase;
class TenantIsolationTest extends TestCase
{
public function testTenantCannotSeeOthersTickets(): void
{
$context = app(TenantContext::class);
$context->runAs(1, function () {
Ticket::create(['user_id' => 1, 'title' => 'A 的工单']);
});
$context->runAs(2, function () {
Ticket::create(['user_id' => 2, 'title' => 'B 的工单']);
});
$context->runAs(1, function () {
$titles = Ticket::column('title');
self::assertContains('A 的工单', $titles);
self::assertNotContains('B 的工单', $titles);
});
}
public function testNoTenantReturnsEmpty(): void
{
$context = app(TenantContext::class);
// 不清空上下文之外的情况,模拟漏掉中间件的场景
$context->set(0);
self::assertSame(0, Ticket::count());
}
public function testWithoutScopeSeesAllTenants(): void
{
$context = app(TenantContext::class);
$context->runAs(1, function () use ($context) {
$count = $context->withoutScope(function () {
return Ticket::count();
});
self::assertGreaterThanOrEqual(2, $count);
});
}
}
这几条测试应该放在 CI 里常驻。只要有人不小心删掉了 globalScope 或者改了 beforeInsert 的逻辑,立刻就能发现。
再补一条:一定要测「跨租户写 ID」这种场景。比如 A 租户的用户提交一个带 id=999 的更新请求,而这个 id 属于 B 租户。要求是更新影响行数为 0,而不是真的改到 B 租户的数据。这条如果没测,光靠人工评审很难保证不漏。
几个真踩过的坑
关联查询隐藏了租户问题。 比如 Ticket::with('replies'),replies 的模型如果不是 TenantModel 的子类,它就不会自动加租户条件。所有关联模型必须继承同一个基类,这条在架构设计阶段就要定死。
软删除和全局作用域叠加时要注意。 ThinkPHP 的软删除本身也是一个全局作用域。两个作用域同时工作,SQL 里的条件会比较长,容易看花眼。但逻辑上没问题,只是 debug 的时候要有耐心。
租户 ID 从 0 开始会导致误判。 上面代码里到处用 if (!$tenantId) 来判定「未绑定」,如果哪个租户的 ID 真是 0,就会一直被当成未绑定。所以租户表的自增要从 1 开始,或者用 null 表示未绑定,tenantId() 返回 ?int 而不是 int。我用的是后者。
Supervisor 重启队列消费者的时候残留上下文。 用 Swoole 或者常驻模式跑队列,消费者进程是复用的。runAs 的 finally 保证每个任务结束都会恢复,但你还是要在消费循环的最外层加一个「清空上下文」的保险动作,防止某个任务抛了半截异常留下脏状态。
迁移脚本千万别用模型。 数据迁移、字段修复这类脚本,本身就是跨租户的。用模型会处处碰壁,直接用 Db::table 就好。但要记得这类脚本走的是「危险路径」,最好单独放在一个目录里,跟业务代码分开管理,跑的时候人工确认。
落地的顺序建议
如果你想在一个已经跑了一段时间的项目上补这套东西,别想着一次全改完。建议的顺序是:
第一步,先把 TenantContext 和中间件加上,所有请求都能拿到租户 ID。这一步几乎是零风险的,因为不改业务逻辑。
第二步,新建一个 TenantModel 基类,先让新写的模型继承它。旧模型保持原样,等有新需求碰触的时候再顺手迁移。
第三步,开 SQL 监听日志,扫一遍当前项目里哪些地方在裸写 SQL。列一个清单慢慢改。
第四步,把队列和缓存的租户隔离补上。这个是渐进的,哪条队列出问题了改哪条。
最后一步才是上强制校验,把「未绑定租户返回空集」这种硬约束打开。这一步会有破坏性——某些不该跑的平台查询会返回空,你能借此发现所有隐藏的裸查询。
整个过程可能持续几周,但每一步都是可回滚的。系统越往后越安全,而不是压在某个大版本上一次性上线。

