繁體中文(台灣)
  • saas
  • multi-tenancy
  • postgres
  • row-level security
  • rls
  • trigger function
  • multi-tenant architecture
  • single-tenant architecture

使用 PostgreSQL 實現多租戶:透過簡單的實例學習

學習如何使用 PostgreSQL 行級安全性 (Row-Level Security, RLS) 和資料庫角色實現多租戶架構,透過真實世界的範例來實現租戶之間的安全資料隔離。

Yijun
Yijun
Developer

在我們之前的一些文章中,我們深入探討了多租戶的概念及其在產品和真實商業場景中的應用。

在本文中,我們將從技術角度探索如何為你的應用程式實現多租戶架構,使用 PostgreSQL。

什麼是單租戶架構?

單租戶架構指的是一種軟體架構,其中每個客戶都有自己的專用應用程式實例和資料庫。

在這種架構中,每個租戶的資料和資源完全與其他租戶隔離。

單租戶

什麼是多租戶架構?

多租戶架構是一種軟體架構,允許多個客戶(租戶)共享相同的應用程式實例和基礎設施,同時保持資料隔離。在這種架構中,單個軟體實例為多個租戶提供服務,每個租戶的資料通過各種隔離機制與其他租戶的資料分開。

多租戶

單租戶架構與多租戶架構的比較

單租戶架構和多租戶架構在資料隔離、資源利用、擴展性、管理和維護及安全性等方面有所不同。

在單租戶架構中,每個客戶具有獨立的資料空間,導致資源利用率較低,但相對於自定義化方面較簡單。通常,單租戶軟體會根據特定客戶需求進行定制,例如為特定紡織品供應商打造的庫存系統或個人博客 Web應用。這些應用的共性是每個客戶佔有一個單獨的應用服務實例,便於根據具體要求進行定制。

在多租戶架構中,則是多個租戶共享相同的基礎資源,從而資源利用率較高。然而,確保資料隔離和安全是至關重要的。

在服務提供商向不同客戶提供標準化服務時,多租戶架構通常是首選軟體架構。這些服務的自定義程度通常很低,所有客戶共享同一個應用實例。當一個應用需要更新時,更新一個應用實例相當於為所有客戶更新應用。例如,CRM(客戶關係管理)是標準化需求,這些系統通常使用多租戶架構為所有租戶提供相同服務。

多租戶架構中的租戶資料隔離策略

在多租戶架構中,所有租戶共享相同的底層資源,使得資源隔離成為租戶之間的重要考量。這種隔離不一定需要是物理上的,只需確保租戶間的資源不可見即可。

在架構設計中,可以實現不同程度的租戶間資源隔離:

從隔離到共享

一般來說,租戶共享的資源越多,系統迭代和維護成本越低;反之,共享的資源越少,成本越高。

透過實例開始多租戶實現

本文將用 CRM 系統作為範例,介紹一種簡單實用的多租戶架構。

我們承認所有租戶使用相同的標準服務,因此我們決定讓所有租戶共享相同的基本資源,並在資料庫層利用 PostgreSQL 的 行級安全性 在不同租戶之間實現資料隔離。

此外,我們將為每個租戶創建一個單獨的資料連接,以便更好地管理租戶權限。

接下來,我們將介紹如何實現這種多租戶架構。

如何使用 PostgreSQL 實現多租戶架構

為所有資源新增租戶標識符

在 CRM 系統中,我們會有很多資源並存儲在不同的資料表中。例如,客戶資訊存儲在 customers 資料表中。

在實現多租戶之前,這些資源沒有與任何租戶相關聯:

為了區分擁有不同資源的租戶,我們引入了一個 tenants 資料表來存儲租戶資訊(其中 db_userdb_user_password 用於存儲每個租戶的資料庫連接資訊,詳情將在下文說明)。另外,我們在每個資源中新增了一個 tenant_id 欄位,以識別其所屬的租戶:

現在,每個資源都與一個 tenant_id 相關聯,理論上使我們能夠在所有查詢中新增 where 子句,以限制每個租戶的資源存取:

乍看之下,這似乎既簡單又可行。然而,它將帶來以下問題:

  • 幾乎每個查詢都會包含這個 where 子句,會使代碼混亂,尤其在書寫複雜的 join 語句時,維護困難。
  • 代碼庫的新手很容易忘記添加這個 where 子句。
  • 不同租戶之間的資料並未真正隔離,因為每個租戶仍保有訪問其他租戶資料的權限。

因此,我們將不採用這種方式。而是使用 PostgreSQL 的行級安全性來解決這些顧慮。然而,在此之前,我們將為每個租戶創建一個專用的資料庫帳號來訪問這個共享的資料庫。

為租戶設置資料庫角色

為可以連接資料庫的每個用戶分配資料庫角色是一種良好的做法。這可以更好地控制每個用戶對資料庫的存取,有利於不同用戶之間操作的隔離,提升系統穩定性和安全性。

由於所有租戶都擁有相同的資料庫操作權限,我們可以創建一個基礎角色來管理這些權限:

然後,為區分每個租戶角色,會在創建租戶時為其分配一個從基礎角色繼承的角色:

接著,為每個租戶將資料庫連接資訊存儲在 tenants 資料表中:

iddb_userdb_user_password
x2euiccrm_tenant_x2euicpa55w0rd

這個機制為每個租戶提供了自己的資料庫角色,而且這些角色共享 crm_tenant 角色授予的權限。

然後,我們可以使用 crm_tenant 角色來定義租戶的權限範圍:

  • 租戶應有權限對所有 CRM 系統資源表進行 CRUD 操作。
  • 與 CRM 系統資源無關的資料表應對租戶不可見(假設只有 systems 表)。
  • 租戶不應能修改 tenants 表,且只允許租戶在進行資料庫操作時查詢自己的租戶 ID ,即 iddb_user 字段應對其可見。

一旦設置了租戶的角色,當租戶申請訪問服務時,我們可以使用代表該租戶的資料庫角色與資料庫進行互動:

使用 PostgreSQL 行級安全性保護租戶資料

到目前為止,我們已為租戶建立了相應的資料庫角色,但這並未限制租戶間的資料存取。接下來,我們將利用 PostgreSQL 的行級安全性特性來限制每個租戶訪問其自己的資料。

在 PostgreSQL 中,資料表可以具有_行安全策略_,用來控制詢問或資料操作命令能訪問的行。這個特性也稱為 RLS (_Row-Level Security_行級安全性)。

默認情況下,資料表沒有行安全策略。要利用 RLS,你需要為表啟用它,並創建安全策略,使它每次訪問表時執行。

以我們 CRM 系統中的 customers 表為例,我們將啟用 RLS,並創建一個安全策略來限制每個租戶只能存取他們自己的客戶資料:

在創建安全策略的語句中:

  • for all (可選)表示此訪問策略將用於對表的 selectinsertupdatedelete 操作。你可以使用 for 後的指令關鍵字為特定操作指定訪問策略。
  • to crm_tenant 表示該策略適用於具有 crm_tenant 資料庫角色的用戶,這也就是說適用於所有租戶。
  • as restrictive 指定策略的執行模式,表示存取應嚴格限制。在默認情況下,表可以有多個策略,多個 permissive 策略將以 OR 關係結合。在這種情況下,我們將此策略聲明為 restrictive,因為我們希望此策略檢查對於 CRM 系統租戶的用戶為必需。
  • using 表達式定義了實際存取的條件,限制當前查詢資料庫用戶只能查看屬於其對應租戶的資料。此約束適用於被命令查詢(selectupdatedelete)所選擇的行。
  • with check 表達式定義了在修改數據行(insertupdate)時必要的約束,保證租戶只能為自己添加或更新記錄。

使用 RLS 對我們的資源表的租戶訪問進行限制有以下幾個優勢:

  • 此策略有效地將 where tenant_id = (select id from tenants where db_user = current_user) 附加到所有查詢操作(selectupdatedelete)。 例如,當你執行 select * from customers 時,等同於執行 select * from customers where tenant_id = (select id from tenants where db_user = current_user)。這樣就不必在應用程式代碼中顯式加入 where 條件,簡化代碼並減少錯誤可能性。
  • 它集中在資料庫層面控制不同租戶之間的資料存取權限,降低應用中漏洞或架構不一致的風險,從而提高系統安全性。

然而,仍需注意以下幾點:

  • 行安全策略會對每一行資料執行。如果行安全策略中查詢條件過於複雜,可能會對系統性能產生顯著影響。好在,我們的租戶資料檢查查詢非常簡單,並不影響性能。如果你計劃使用 RLS 實現其他功能,可以遵循 Supabase 的行級安全性效能建議 來優化 RLS 效能。
  • 行級安全策略不會在 insert 操作中自動填寫 tenant_id。它們只限制租戶插入自身資料。因此,在插入資料時,我們仍然需要提供租戶 ID,這與查詢過程不一致,可能導致開發過程中混淆,增加錯誤可能性(這一問題將在後續步驟中解決)。

除了 customers 表之外,我們需要對所有 CRM 系統資源資料表進行相同操作(這個過程可能有點繁瑣,但可以編寫一個程式在表初始化過程中進行配置),從而隔離不同租戶的資料。

為資料插入創建觸發器函式

如前所述,行級安全性(RLS)允許我們無需擔心檢索時 tenant_id 是否存在,因為資料庫會自動處理它們。然而,在 insert 操作中,我們仍需手動指定對應的 tenant_id

為了實現與 RLS 一樣的插入資料便利性,我們需要在資料插入過程中讓資料庫自動處理 tenant_id

這有個顯而易見的好處:在應用開發層面,我們不再需考慮資料屬於哪個租戶,從而減少出錯可能性,並在開發多租戶應用程式時減輕我們的心理負擔。

幸運的是,PostgreSQL 提供了強大的觸發器功能。

觸發器是與資料表相關聯的特別函式,當對資料表進行特定操作(如插入、更新或刪除)時自動執行特定動作。這些操作可以在行級(對每行)或語句級(針對整個語句)觸發。透過觸發器,我們可以在特定資料庫操作之前或之後執行自定義邏輯,使我們能夠輕鬆地實現目標。

首先,我們創建一個在每次插入資料之前執行的觸發器函式 set_tenant_id

接下來,對 customers 表的插入操作將這個觸發器函式關聯(類似於啟用資料表的 RLS,這個觸發器函式需要與所有相關表關聯):

此觸發器確保插入的資料包含正確的 tenant_id。如果新資料已經包括了 tenant_id,則觸發器函式不做其他操作;否則,根據在 tenants 表中的當前用戶信息自動填充 tenant_id 欄位。

這樣,我們在租戶插入資料過程中,實現了在資料庫層自動處理 tenant_id

總結

本文中,我們深入探討了多租戶架構的實際應用,以 CRM 系統為例,演示了利用 PostgreSQL 資料庫的實際解決方案。

我們討論了資料庫角色管理、存取控制及 PostgreSQL 的行級安全性特性,以確保租戶之間的資料隔離。此外,我們利用觸發器函式來減輕開發者在管理不同租戶時的認知負擔。

這就是本文的全部內容。如果你想進一步提升你的多租戶應用程式,請參閱提供簡單指南與 Logto 組織一起構建多租戶應用以獲取更多啟發。