ThinkPHP 8 多租户实战:全局作用域 + 请求上下文做数据隔离

2026-09-25 0 774

去年接手一个 SaaS 项目,原本是给一家客户定制的,后来业务部门谈下来另外五家,要求”同一套代码,各看各的数据”。当时团队里的第一反应是给每个租户建一套数据库,但运维那边不同意——五家还凑合,谈到二十家就不是人干的活了。

最后定的方案是共享数据库、共享表,用 tenant_id 字段区分数据。这个选择的理由很简单:部署简单、备份简单、加一个新租户就是插一行数据。代价也很清楚——任何一条漏写 tenant_id 的查询,都可能把别人家的数据读出来。这是一个谁都不想面对的线上事故类型。

所以真正要解决的问题不是”怎么加 tenant_id”,而是”怎么让开发者在日常写业务代码的时候,想忘都忘不掉”。这篇文章复盘一下我们最后落地的方案。

一、方案选型,先想清楚要防什么

多租户在数据库层面通常有三种做法。独立数据库隔离最彻底,但迁移、连接池、备份都要按租户数量翻倍,运维成本高。共享数据库独立 Schema 是中间选择,PostgreSQL 用起来顺手,MySQL 里基本等价于独立数据库。共享数据库共享表靠 tenant_id 做逻辑隔离,成本最低,风险最高。

我们选第三种,不是因为成本——虽然成本确实重要——而是因为业务里有相当一部分功能是需要跨租户统计的,比如运营后台要看整体活跃度。独立数据库的方案在这一点上会变得很别扭。

既然选了共享表,那接下来的所有设计都围绕一个原则:把 tenant_id 的读写做成开发流程里绕不开的一步。手动加、靠代码评审发现问题,这种方案我们试过,第一个月就出事了。

二、环境与目录约定

本文基于 ThinkPHP 8.x,PHP 8.1 以上。项目结构沿用框架默认的多应用模式,但为了示例简单,只讨论单应用。

app/
├── middleware/
│   └── TenantMiddleware.php
├── model/
│   ├── TenantModel.php
│   └── Order.php
├── service/
│   └── TenantContext.php
└── common.php

数据库层面,所有业务表统一加三个字段:tenant_id、created_at、updated_at。tenant_id 建议用 int 并建立联合索引,索引的第一个字段就是它。

三、租户识别中间件

租户从哪里来?我们约定每个请求必须带 X-Tenant-Code 请求头,网关层已经校验过它的合法性。中间件只负责两件事:把 code 换成租户信息,然后把信息放进本次请求的上下文。

namespace appmiddleware;

use appmodelTenant;
use appserviceTenantContext;
use thinkfacadeCache;
use thinkRequest;

class TenantMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        $code = $request->header('X-Tenant-Code');
        if (empty($code)) {
            return json(['code' => 4001, 'msg' => '缺少租户标识']);
        }

        $tenant = Cache::get('tenant:info:' . $code);
        if (!$tenant) {
            $row = Tenant::where('code', $code)->find();
            if (!$row) {
                return json(['code' => 4002, 'msg' => '租户不存在或已停用']);
            }
            $tenant = [
                'id'   => $row->id,
                'code' => $row->code,
                'name' => $row->name,
            ];
            Cache::set('tenant:info:' . $code, $tenant, 300);
        }

        // 关键一步:放进容器,绑定到本次请求
        app()->instance(
            TenantContext::class,
            new TenantContext($tenant['id'], $tenant['code'], $tenant['name'])
        );

        return $next($request);
    }
}

中间件的注册放在 app/middleware.php,全局生效。如果你用的是多应用模式,则可以只在业务应用里注册,管理后台走另一套。

return [
    appmiddlewareTenantMiddleware::class,
];

为什么不用静态属性保存租户信息

团队里有人提出过简单方案:定义 TenantContext::$current 静态属性,在中间件里赋值,模型里读。看上去省事,但我们否决了。

原因有两个。第一,静态属性在单次 FPM 请求里没问题,但一旦项目迁到 Swoole 或 FrankenPHP 这种常驻内存的运行时,静态属性的生命周期就跟进程一样长,A 租户的请求覆盖了它,B 租户的请求就有可能读到 A 的值。第二,即使在 FPM 下,单元测试里没有中间件执行时,静态属性会是空值,模型行为会静默出错。

容器绑定则不一样。它跟着应用实例走,应用实例响应完一个请求之后可以被清理,生命周期边界清晰。这是 ThinkPHP 提供请求级容器的用意所在。

namespace appservice;

class TenantContext
{
    public function __construct(
        public readonly int $id,
        public readonly string $code,
        public readonly string $name,
    ) {}
}

用 readonly 是刻意的。一旦创建就不允许修改,避免某个角落里有人偷偷改了 id 导致后续所有查询都串到别的租户去。

四、模型基类:查询自动过滤,写入自动填充

这是整个方案里最核心的一块。所有业务表都要继承统一基类,基类负责两件事。

namespace appmodel;

use appserviceTenantContext;
use thinkModel;

abstract class TenantModel extends Model
{
    // 全局作用域,内置方法会自动应用
    protected $globalScope = ['tenant'];

    public function scopeTenant($query)
    {
        $ctx = app(TenantContext::class);
        $query->where($this->getTable() . '.tenant_id', $ctx->id);
    }

    // 写入前自动填充 tenant_id
    protected static function onBeforeInsert($model)
    {
        $ctx = app(TenantContext::class);
        $model->tenant_id = $ctx->id;
    }
}

这段代码有三个细节值得展开。

第一个细节:getTable 不等同于数据表名

用 $this->getTable() 拼字段前缀是为了避免多表联查时 tenant_id 有歧义。假如有一张 order 表和一张 order_item 表联查,两张表都有 tenant_id,写 where('tenant_id', 1) 会让数据库报 ambiguous column。加上表名之后就明确了。

注意 getTable() 返回的是不含数据库前缀的完整表名,比如 prefix_order,实际上在 SQL 里可以直接使用。

第二个细节:全局作用域不影响 Db 查询

这是很多人第一次用会踩的坑。globalScope 只在模型查询上生效,下面这段代码完全不受保护:

// 全局作用域对这条查询无效
Db::table('order')->select();

所以项目里定了一条硬规矩:业务查询一律走模型,禁止直接 Db::table()。如果确实需要写复杂 SQL,也必须显式带上 where('tenant_id', ...),并且在代码评审里标注说明。

我们的做法是在部署脚本里加了一个静态检查,扫描 app/ 目录下所有 Db::table( 或 Db::name( 的调用,除了白名单文件之外全部告警。这条规则看着死板,但确实挡住了几次事故。

第三个细节:更新和删除的隔离

模型更新默认会应用全局作用域吗?答案是会,但前提是走模型的 save() 方法并且对象是通过查询得到的。如果直接 Order::where('id', $id)->update(['status' => 1]),全局作用域同样生效,因为 update 会走查询构造器。

但下面这种写法就危险了:

Order::destroy($id); // 单个删除
Order::destroy([$id1, $id2, $id3]); // 批量删除

ThinkPHP 的 destroy 内部会构造查询,但它是否应用全局作用域取决于版本实现。我们在本地实测过,ThinkPHP 8 下的 destroy 会应用全局作用域。但为了保险,涉及删除的接口都改成了先 find() 再 delete(),多一次查询换一个心安。

$order = Order::find($id);
if (!$order) {
    throw new thinkexceptionHttpException(404, '订单不存在');
}
$order->delete();

五、缓存键的隔离

数据隔离解决了数据库,但缓存层的坑同样深。项目里有一堆 Cache::get('order:' . $id) 这样的调用,需要全部加上租户前缀。

我们没有全局替换字符串(字符串替换容易误伤),而是加了一个辅助函数:

if (!function_exists('tenant_cache_key')) {
    function tenant_cache_key(string $key): string
    {
        $ctx = app(appserviceTenantContext::class);
        return 't:' . $ctx->code . ':' . $key;
    }
}

所有缓存调用改成 Cache::set(tenant_cache_key('order:' . $id), $data, 300)。这个改动量大,但是必要的。当时有一处订单详情缓存忘了加前缀,上线第二天就被用户发现了——A 租户看到 B 租户的订单截图。幸好只是测试环境。

顺带一提,Cache::tag() 也可以用来做租户级清理,比如退出登录时清理某个租户的所有缓存。tag 的好处是不用拼字符串,坏处是它对底层缓存驱动有要求(file 驱动不支持 tag,必须 redis)。

六、六个实际踩过的坑

坑一:中间件顺序搞反

租户识别中间件必须排在身份认证中间件之前。因为认证通常要查用户表,用户表本身也带 tenant_id,如果那时上下文还没建立,模型层会直接抛异常或读到错误数据。

// app/middleware.php
return [
    appmiddlewareTenantMiddleware::class,   // 先租户
    appmiddlewareAuthMiddleware::class,      // 后认证
];

ThinkPHP 中间件按数组顺序自上而下执行,这一点和 Laravel 相反,从 Laravel 迁过来的同事一开始很不习惯。

坑二:命令行任务里没有中间件

项目里有几个定时任务,用 php think 命令跑。这些脚本不经过中间件,容器里根本没有 TenantContext,模型一查询就报”找不到 TenantContext”。

解决办法是在命令入口手动建立上下文:

class SyncOrderCommand extends Command
{
    public function handle()
    {
        $tenants = Tenant::where('status', 1)->select();

        foreach ($tenants as $tenant) {
            app()->instance(
                appserviceTenantContext::class,
                new appserviceTenantContext($tenant->id, $tenant->code, $tenant->name)
            );

            $this->syncForTenant();
        }
    }
}

这样处理之后,模型层依然会自动隔离,不需要给每个任务单独写过滤条件。这个设计在多个命令之间共享,写起来很干净。

坑三:唯一索引没加 tenant_id

这是个数据库层面的问题,但影响很大。项目里订单号本来设计的是全局唯一,加多租户之后必须改成 UNIQUE(tenant_id, order_no)。如果不改,A 租户生成的订单号会挡住 B 租户生成相同号码的操作,报主键冲突。

同样的推理适用于用户名、邮箱、手机号这些字段。改索引这一步拖到后面改很麻烦,建议在方案确定的时候一起做。

坑四:关联查询里的漏网之鱼

模型定义了关联之后,通过关联查数据不会自动应用被关联模型的全局作用域。看这段:

$order = Order::with('items')->find($id);
// $order->items 里可能有别的租户的数据

真正稳妥的做法是在 OrderItem 模型上也继承 TenantModel,并且在使用 with 时确认它走的是模型查询路径(而不是裸 join)。ThinkPHP 的 with 底层走的是 Model 查询,全局作用域是生效的,但只有在被关联模型自己也定义了作用域的前提下。

我们项目里所有带 tenant_id 的表对应的模型,无一例外都继承了 TenantModel。这是强制规范,写进代码模板里。

坑五:软删除和租户过滤的顺序

ThinkPHP 的软删除是通过 SoftDelete trait 实现的,它本质上也是一种全局作用域。两个作用域叠加的时候没什么问题,但要注意先写哪个在生成的 SQL 里就是先过滤哪个,对索引的影响略有不同。

我们约定 tenant_id 作为联合索引的第一个字段,所以希望它在 SQL 里出现得早一些,命中复合索引的概率更高。如果性能上遇到瓶颈,可以展开 SQL 看一下实际顺序。

坑六:容器在多应用模式下的作用范围

项目里有 admin 和 api 两个应用,admin 应用里的租户识别逻辑和 api 不一样(admin 允许运营人员跨租户查询)。第一次写的时候把 TenantContext 注册在全局容器里,结果两个应用的中间件互相覆盖,导致 admin 登录之后 api 也跟着串了租户。

正确的做法是中间件只在对应的应用里注册,注册的实例也只在该应用的请求上下文里生效。ThinkPHP 的多应用本质上是一次请求内切换应用,容器实例是按请求而非应用划分的,所以必须靠中间件注册范围来控制。

七、一个完整的请求流量回顾

把上面这些串起来,一条普通请求的完整流程大致是这样:

客户端带 X-Tenant-Code: acme 请求 /api/order/123。网关层先校验 token,然后转发到 PHP-FPM。

框架启动,执行全局中间件。TenantMiddleware 查缓存拿到 acme 的 id 是 7,把 new TenantContext(7, 'acme', '杭州某科技') 注册到容器。下游的 AuthMiddleware 开始查用户,用户模型继承 TenantModel,全局作用域自动加上 where tenant_id = 7,只查到 7 号租户下的用户。

请求进入控制器,控制器调用 Service 层。Service 里写 Order::find(123),全局作用域继续生效,生成的 SQL 是 SELECT * FROM tp_order WHERE tenant_id = 7 AND id = 123。如果 123 号订单属于别的租户,查询直接返回 null,控制器转成 404,什么都不会泄露。

如果这个接口需要缓存,Service 层里写 tenant_cache_key('order:123'),实际 key 是 t:acme:order:123。多租户之间互不干扰。

整个链路里,开发者只需要正常使用模型,不用手动加过滤条件。这是这套方案唯一的价值,也是最容易做错的地方——一旦某个环节漏了,隔离就形同虚设。

八、怎么验证隔离真的生效了

写完代码不等于做完事。多租户方案必须要有配套的验证手段,否则”看起来没问题”会一直持续到线上。

我推荐三个做法。

第一个是单元测试。写一个测试用例,在容器里人为注入一个 TenantContext(1),然后调用各业务查询,断言返回的数据 tenant_id 全部等于 1。这个测试覆盖的模型越多越好,因为它同时也在测试”模型有没有继承对基类”。

第二个是数据库触发器或查询日志。在测试环境里开启 SQL 慢日志,跑完整业务流程之后扫描日志,找出所有对带 tenant_id 字段的表执行、但没有带上 tenant_id 条件的 SQL。这一类要么是模型没继承基类,要么是有人用了 Db::table。我们内部把这套扫描做成了一个 artisan 风格的命令行工具,每周跑一次。

第三个是生产环境的兜底。在 TenantMiddleware 里注册一个请求结束的回调,如果本次请求访问了带 tenant_id 的表但上下文从未被读取过,就记录一条警告日志。这个日志不代表一定出错,但值得看一眼——它往往意味着某段代码在走不该走的路。

九、写在最后

多租户这个话题听起来偏应用层,其实核心是一个架构问题:哪些东西应该由框架强制保证,哪些可以依赖开发者自觉。

把所有希望都押在”写的时候记得加 tenant_id”上,就是选择了开发者自觉;把租户过滤做成模型层的默认行为,就是选择了框架强制。这两种选择在我们的实践里,事故率差了不止一个数量级。

ThinkPHP 的全局作用域和容器绑定,恰好能撑住后一种选择。用起来没有想象中复杂,关键是要在设计阶段就把几个易错点(命令行任务、裸 SQL、缓存键、唯一索引)想清楚,然后用静态检查或者团队规矩把它们兜住。

另外提一句,这套方案对 Swoole 之类的常驻内存运行时也是适用的。只要你用的是容器绑定而不是静态属性,请求之间的隔离天然成立。这一点在迁移的时候会省下不少事。

ThinkPHP 8 多租户实战:全局作用域 + 请求上下文做数据隔离
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP 8 多租户实战:全局作用域 + 请求上下文做数据隔离 https://www.taomawang.com/server/thinkphp/2817.html

常见问题

相关文章

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

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