ThinkPHP 8 多租户隔离实战:全局作用域 + 中间件落地 SaaS 数据安全

2026-10-01 0 216

多租户这个东西,第一次做的时候特别容易想简单。脑子里想的是「每条数据加个 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。列一个清单慢慢改。

第四步,把队列和缓存的租户隔离补上。这个是渐进的,哪条队列出问题了改哪条。

最后一步才是上强制校验,把「未绑定租户返回空集」这种硬约束打开。这一步会有破坏性——某些不该跑的平台查询会返回空,你能借此发现所有隐藏的裸查询。

整个过程可能持续几周,但每一步都是可回滚的。系统越往后越安全,而不是压在某个大版本上一次性上线。

ThinkPHP 8 多租户隔离实战:全局作用域 + 中间件落地 SaaS 数据安全
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 多租户隔离实战:全局作用域 + 中间件落地 SaaS 数据安全 https://www.taomawang.com/server/thinkphp/2848.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务