ThinkPHP 8 多租户数据隔离:全局作用域、缓存前缀与队列上下文的三道坎

2026-10-15 0 868

去年底做一个 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 的全局作用域开始改——那是最立竿见影的一步。改完之后可以打开日志,看看那些原本该带条件却漏了的查询,一次性全部暴露出来。

ThinkPHP 8 多租户数据隔离:全局作用域、缓存前缀与队列上下文的三道坎
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP 8 多租户数据隔离:全局作用域、缓存前缀与队列上下文的三道坎 https://www.taomawang.com/server/thinkphp/2943.html

常见问题

相关文章

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

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