商城首页欢迎来到中国正版软件门户

您的位置: 首页 > 文章列表 > 编程开发 > Laravel 多租户 SaaS 架构设计思路【干货】

Laravel 多租户 SaaS 架构设计思路【干货】

  发布于2026-07-11 阅读(0)

扫一扫,手机访问

多租户系统里,最基础也最容易被忽视的,就是索引。一个没加索引的 tenant_id,在中等规模数据下,比如单表到了50万行,一个简单的 WHERE tenant_id = ? 查询,响应时间就会从200毫秒飙升到3秒。这可不是理论推导,是线上真实踩过的坑——某次订单列表页直接卡死,查到最后,罪魁祸首就是 tenant_id 缺了索引,高并发时数据库连接池瞬间被榨干。

Lara vel 多租户 SaaS 架构设计思路【干货】

tenant_id 字段必须加索引,否则查询会拖垮整个系统

实操建议其实很简单,但必须严格执行:

  • 所有包含 tenant_id 的表,在迁移文件里必须显式加上索引:$table->index('tenant_id');。别想着靠ORM自动处理,那不是它的活儿。
  • 遇到复合查询场景,比如按租户+状态+时间查订单,优先建联合索引,并且把 tenant_id 放在最左边:$table->index(['tenant_id', 'status', 'created_at']);。这能最大化索引效率。
  • 部署前,用 php artisan tinker 连进生产库,执行 DB::select("SHOW INDEX FROM orders WHERE Key_name LIKE 'tenant_id%'");,亲眼确认索引真实存在。这一步,比看十遍文档都管用。

全局作用域不能只靠 Trait 自动注册,必须手动验证是否生效

很多团队写了个 BelongsToTenant Trait,在 boot() 里调用 static::addGlobalScope(),然后就觉得万事大吉了。但谁也没想到,上线后出现了跨租户数据泄露——不是代码逻辑写错了,而是模型压根没被自动加载,全局作用域根本没挂上去。

验证方法其实很简单,但大家往往懒得做:

  • 在模型里加个日志:static::addGlobalScope('tenant', function (Builder $builder) { Log::debug('tenant scope applied'); $builder->where('tenant_id', TenantContext::id()); });
  • 用 Tinker 执行 App\Models\Order::all();,看日志里有没有那行 debug 输出。有,则说明作用域生效了;没有,就得赶紧排查模型加载问题。
  • 更直接的办法:用 toSql() 看生成的 SQL 语句是否包含 WHERE tenant_id = ?。例如 App\Models\Order::query()->toSql(),一眼就能看出问题。

中间件 EnsureTenantAccess 必须放在路由组最外层,且不能被其他中间件绕过

EnsureTenantAccess 这个中间件,如果放到了某个子中间件之后,或者被 auth:sanctum 之类的认证中间件“提前拦截”,那后果很严重:TenantContext::set() 根本没机会执行,后续所有全局作用域都失效了。因为 TenantContext::id() 返回的是 null,WHERE tenant_id = NULL 这条过滤条件,等于没写。

常见的错误配置,我见过不少:

  • 在控制器方法里手动调用 TenantContext::set() —— 请求生命周期已经过半,作用域注册早完成了,调了也白调。
  • 把中间件加在 apiweb 路由组内部,而不是整个路由文件的最顶层。
  • 用了 Lara vel Octane 后没重载中间件顺序,导致缓存里的中间件顺序还是旧的。

正确的做法是:在 routes/web.php 开头就直接写 Route::middleware(['web', 'ensure.tenant.access'])->group(...),确保它是第一个被执行的业务中间件。这才能保证租户上下文在请求的早期就被正确设置。

测试必须覆盖跨租户边界,不能只测单租户逻辑

只测“租户 A 能查到自己数据”,这毫无意义。真正要卡住的是“租户 A 的请求,会不会意外拿到租户 B 的数据”。这需要构造明确的跨租户断言,而不是模拟登录然后查个列表就完事。

一个最小可行的测试结构,应该包含以下四步:

  • 创建两个租户:A(ID=1)和 B(ID=2)。
  • 分别插入一条同名记录:例如 Order::create(['tenant_id' => 1, 'order_no' => 'ORD-001'])Order::create(['tenant_id' => 2, 'order_no' => 'ORD-001'])
  • 用租户 A 的认证上下文发起 GET /api/orders 请求,断言响应里只有 tenant_id === 1 的记录,且总数恰好为 1。
  • 关键一步:去掉认证或伪造一个不存在的租户 ID,确认接口返回 403 状态码或空结果,而不是报错后泄露数据。

如果测试没覆盖这四步,那它就是个摆设。线上出问题,从来不是“查不到”,而是“查多了”——这才是最可怕的。

本文转载于:https://www.php.cn/faq/2798608.html 如有侵犯,请联系zhengruancom@outlook.com删除。
免责声明:正软商城发布此文仅为传递信息,不代表正软商城认同其观点或证实其描述。

热门关注