打造 SaaS 帝國:Laravel 多租戶架構核心抉擇
您的 Laravel 專案即將升級成 SaaS 服務嗎?從單一用戶邁向多租戶(Multi-tenant)是架構上的生死抉擇。本文深入剖析最關鍵的資料庫隔離策略:是採用簡便但高風險的「單一資料庫+Global Scope」,還是選擇資安極致的「獨立資料庫」?我們不僅提供實戰 Trait 範例,更揭露快取污染和佇列身份識別的隱藏地雷。別讓一個疏忽造成數據洩漏的災難!立即掌握 Laravel SaaS 最佳實踐,確保您的系統穩固且可擴展,並探索如何利用專業套件避免重複造輪子!
SaaS 開發者必讀:Laravel 多租戶 (Multi-tenant) 資料庫隔離策略與實戰設計
嗨,我是 Eric,浪花科技的資深工程師。如果你正在看這篇文章,八成是你手上的 Laravel 專案被老闆要求:「欸,我們這個系統很棒,下個月能不能改成 SaaS 賣給其他一百家公司用?」
這時候你的內心可能正在崩潰。從「單一用戶」變成「多租戶(Multi-tenant)」,絕對不是複製貼上程式碼那麼簡單。這不僅僅是加一個 tenant_id 的問題,更牽涉到資料庫架構、快取隔離、佇列(Queue)處理,甚至是最敏感的資安邊界。
別擔心,這條路我也走過不少冤枉路。今天我們就來深入探討 Laravel 多租戶系統(Multi-tenant)設計 的核心邏輯,我會直接攤開最血淋淋的架構選擇題,告訴你該選「單一資料庫」還是「多資料庫」,以及如何優雅地用 Laravel 實作。
什麼是多租戶架構(Multi-tenancy)?
簡單來說,多租戶架構就是讓「一套程式碼(Codebase)」同時服務「多個客戶(Tenants)」。每個客戶都覺得自己擁有獨立的系統,數據互不干擾。
在 Laravel 開發中,實現多租戶通常有三種主流策略,這裡我們必須先做個生死抉擇,因為選錯了,後面的技術債會還到你哭出來:
- 共享資料庫,共享 Schema (Shared Database, Shared Schema): 所有租戶的資料都在同一個 Table 裡,靠
tenant_id欄位區分。 - 共享資料庫,獨立 Schema (Shared Database, Separate Schemas): 使用 Postgres 的 Schema 功能,但在 MySQL 中較少見(通常直接對應到多資料庫)。
- 獨立資料庫 (Database per Tenant): 最極致的隔離,每個租戶擁有自己的資料庫檔案。
策略對決:單一資料庫 vs. 獨立資料庫
這是在設計 Laravel SaaS 時最常被問到的問題。Eric 在這裡幫大家整理個比較表,別再憑感覺選了。
1. 單一資料庫 (Single Database)
這是最簡單的實作方式。你在每個 Table (例如 users, orders) 都加上一個 tenant_id 外鍵。
- 優點:
- 成本最低(只需要維護一個 DB 實體)。
- 部署容易,Migration 跑一次就好。
- 跨租戶的數據分析(例如:計算全平台總營業額)非常容易。
- 缺點:
- 開發風險高: 工程師只要忘記在
where條件加tenant_id,A 客戶就會看到 B 客戶的訂單,這是絕對的災難(Data Leak)。 - 備份困難: 如果某個大客戶要求「我要回朔到昨天下午兩點的狀態」,你很難只還原他的資料而不影響其他人。
- 開發風險高: 工程師只要忘記在
2. 獨立資料庫 (Database per Tenant)
每個租戶註冊時,系統自動建立一個新的 Database。
- 優點:
- 安全性最高: 物理層面的隔離,程式碼寫錯也不容易跨庫撈到資料。
- 備份靈活: 可以針對付費VIP客戶提供獨立備份還原服務。
- 擴展性: 可以將大客戶的 DB 搬到獨立的伺服器上。
- 缺點:
- 維運成本高: 你有 1000 個租戶,就要跑 1000 次 Migration。
- 連線數限制: 資料庫伺服器的 Connection 消耗較快。
Eric 的建議: 如果你是做 B2C 或小型 B2B,且租戶數量預期會破萬,選「單一資料庫」。如果你是做中大型 B2B,客戶很在意資安且付得起錢,請務必選「獨立資料庫」。
實戰:在 Laravel 中實作「單一資料庫」隔離
如果你選擇單一資料庫,為了避免工程師「忘記加 where」導致的資安慘劇,我們必須利用 Laravel 的 Global Scopes 機制來自動過濾。
Step 1: 建立 TenantScope
這個 Scope 會自動將所有的查詢加上 where('tenant_id', $currentTenantId)。
<?php
namespace App\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use App\Services\TenantManager;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model)
{
// 假設你有一個 Service 負責管理當前租戶
$tenantId = app(TenantManager::class)->getTenantId();
if ($tenantId) {
$builder->where('tenant_id', $tenantId);
}
}
}
Step 2: 建立 Tenantable Trait
為了方便在多個 Model 中重複使用,我們寫一個 Trait。這樣你建立新資料時,也會自動填入 tenant_id。
<?php
namespace App\Traits;
use App\Scopes\TenantScope;
use App\Services\TenantManager;
trait Tenantable
{
protected static function booted()
{
static::addGlobalScope(new TenantScope);
static::creating(function ($model) {
$tenantId = app(TenantManager::class)->getTenantId();
if ($tenantId) {
$model->tenant_id = $tenantId;
}
});
}
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
}
Step 3: 使用 Trait
現在,你只需要在 Product 或 Order 模型中引入這個 Trait,Laravel 就會自動幫你處理隔離了。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Traits\Tenantable;
class Product extends Model
{
use Tenantable;
// ... 其他程式碼
}
這樣一來,當你寫 Product::all() 時,SQL 其實會執行 select * from products where tenant_id = 1。這就是 Laravel 優雅的地方。
多租戶系統的隱形地雷:佇列與快取
很多人搞定了資料庫,卻死在 Redis 上。請記住,資料庫隔離了,但 Redis 預設是共享的!
1. Cache 污染
假設 A 租戶快取了一個 key 叫 dashboard_stats,B 租戶登入後讀取同一個 key,就會看到 A 租戶的營業額。這會讓你被告到脫褲子。
解法: 修改 Cache Key,或是使用 Tag。例如:Cache::tags(['tenant_1'])->put('key', 'value');,或者在底層覆寫 Cache Prefix。
2. Queue Job 的身份識別
當你把一個 Job 丟到 Queue 裡執行時,背景的 Worker 並不知道現在是哪個租戶。你必須在 Job 的 Payload 裡帶上 tenant_id,並在 Job 開始執行時「切換環境」。
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Models\Tenant;
use App\Services\TenantManager;
class ProcessOrder implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $tenant;
public function __construct(Tenant $tenant)
{
$this->tenant = $tenant;
}
public function handle(TenantManager $manager)
{
// 在 Job 執行前,手動設定環境
$manager->setTenant($this->tenant);
// 執行你的邏輯...
}
}
總結:不要重複造輪子
雖然上面我示範了手刻的邏輯,主要是為了讓你理解原理。但在實際的大型專案中,我強烈建議使用成熟的套件,例如 stancl/tenancy 或 spatie/laravel-multitenancy。
這些套件已經幫你處理了最頭痛的問題:
- 自動切換資料庫連線 (Database Switching)。
- Tenant 專屬的 Filesystem (S3 資料夾隔離)。
- 域名識別 (Subdomain Identification)。
- Redis Prefix 自動注入。
SaaS 開發是一場馬拉松,架構設計得好,後期維護是享受;架構沒弄好,每天都在修 Data Leak 的 Bug。希望這篇文章能成為你打造 SaaS 帝國的第一塊穩固基石。
延伸閱讀
如果你對 SaaS 架構與 Laravel 進階開發有興趣,這幾篇也是我不藏私的推薦:
- 你的 Laravel 專案是下一個技術債地雷?資深工程師的『防爆架構圖』,從零打造可傳承的程式碼帝國
- 你的 Laravel API 正在裸奔嗎?終極驗證 (Validation) 與中介層 (Middleware) 客製化聖經
- 你的 Laravel 網站還在同步等回應?解鎖 Scheduler 與 Queue,打造非同步火箭
你的企業 SaaS 系統正面臨擴展瓶頸,或是資料庫效能卡關嗎?浪花科技擁有豐富的 Laravel 企業級架構經驗。
常見問題 (FAQ)
Q1: 使用獨立資料庫模式 (Database per Tenant) 會不會很難維護 Migration?
確實會增加維護成本。當你有 500 個租戶時,部署新功能需要對 500 個資料庫執行 Migration。這通常需要搭配自動化腳本或使用專門的套件(如 stancl/tenancy)提供的指令來並行處理,絕對不能依賴手動操作。
Q2: 多租戶系統的檔案上傳 (如 S3) 該如何隔離?
如果所有租戶共用同一個 S3 Bucket,建議在路徑上做區隔。例如 `bucket-name/tenant-1/avatar.jpg` 和 `bucket-name/tenant-2/avatar.jpg`。這可以透過 Laravel 的 Filesystem 設定,動態修改 Root Path 來達成,確保 A 租戶永遠無法存取到 B 租戶的資料夾。
Q3: 單一資料庫模式下,如何確保 tenant_id 的唯一性與安全性?
除了在程式碼層面使用 Global Scopes 之外,建議在資料庫層面設定 Foreign Key Constraint 雖然較難(因為 tenant 也是資料),但至少要設定複合索引(Composite Index),例如 `(tenant_id, user_email)` 設為 Unique,防止資料錯亂。同時,務必撰寫自動化測試(Feature Tests)來模擬跨租戶存取,確保系統會回傳 403 或 404。






