使用 PostgreSQL 實現多租戶:通過一個簡單的真實例子來學習
學習如何通過真實世界的例子,使用 PostgreSQL 的行級安全性(RLS)和數據庫角色實現多租戶架構,以實現租戶間的安全數據隔離。
在我們的一些先前文章中,我們深入探討了多租戶的概念及其在產品和現實業務場景中的應用。
在這篇文章中,我們將從技術角度來探索如何為你的應用程序實現多租戶架構,使用 PostgreSQL。
什麼是單租戶架構?
單租戶架構是指每位客戶擁有應用程序和數據庫的專用實例的軟件架構。
在此架構中,每個租戶的數據和資源都與其他租戶完全隔離。
什麼是多租戶架構?
多租戶架構是一種軟件架構,允許多個客戶(租戶)共享同一應用程序實例和基礎設施,同時保持數據隔離。在此架構中,一個軟件實例服務多個租戶,每個租戶的數據通過不同的隔離機制與其他租戶保持分離。
單租戶架構與多租戶架構
單租戶架構和多租戶架構在數據隔離、資源利用、可擴展性、管理和維護以及安全性等方面有所不同。
在單租戶架構中,每位客戶擁有獨立的數據空間,導致較低的資源利用但相對簡單的定制。通常,單租戶軟件是為特定客戶需求而量身定制的,如為特定紡織品供應商設計的庫存系統或個人博客網站應用。它們的共同點在於,每位客戶佔據一個獨立的應用服務實例,方便按照具體要求進行定制。
在多租戶架構中,許多租戶共享相同的基礎資源,提高了資源利用率。然而,確保數據隔離和安全性是至關重要的。
當服務提供者向不同客戶提供標準化服務時,多租戶架構往往是首選的軟件架構。這些服務通常具有低定製化程度,所有客戶共享同一應用實例。當應用需要更新時,更新一個應用實例等同於更新所有客戶的應用。例如,CRM(客戶關係管理)是一個標準化的需求。這些系統通常使用多租戶架構為所有租戶提供相同的服務。
多租戶架構中的租戶數據隔離策略
在多租戶架構中,所有租戶共享相同的底層資源,使得租戶間的資源隔離成為關鍵。這種隔離不一定是物理上的;它只需確保租戶間的資源互不可見即可。
在架構設計中,可以實現不同程度的租戶間資源隔離:
通常,共享資源越多,系統迭代和維護成本越低。反之,則成本越高。
開始使用真實例子進行多租戶實現
在本文中,我們會以 CRM 系統為例,介紹一個簡單而實用的多租戶架構。
我們認識到所有租戶使用的是相同的標準服務,因此我們決定讓所有租戶共享相同的基本資源,並將使用 PostgreSQL 的行級安全性在數據庫層面進行不同租戶間的數據隔離。
此外,我們會為每個租戶創建一個獨立的數據連接,以便更好地管理租戶權限。
接下來,我們將介紹如何實現此多租戶架構。
如何使用 PostgreSQL 實現多租戶架構
為所有資源添加租戶標識符
在 CRM 系統中,我們會擁有許多資源,並且它們存儲在不同的表中。譬如,客戶信息存儲在 customers
表中。
在實現多租戶之前,這些資源與任何租戶都沒有關聯:
為了區分不同租戶擁有的不同資源,我們引入了一個 tenants
表來存儲租戶信息(其中 db_user
和 db_user_password
用於存儲每個租戶的數據庫連接信息,將在後文詳述)。此外,我們在每個資源中添加了一個 tenant_id
字段以標識其所屬的租戶:
現在,每個資源都與一個 tenant_id
相關聯,理論上我們可以在所有查詢中添加一個 where
子句以限制對每個租戶資源的訪問:
乍看之下,這似乎簡單且可行。然而,它會帶來以下問題:
- 幾乎每個查詢都會包含這個
where
子句,這使得代碼雜亂且難以維護,尤其是在撰寫複雜的連接語句時。 - 對於代碼庫的新手來說,可能很容易忘記添加這個
where
子句。 - 不同租戶之間的數據不是真正隔離的,因為每個租戶仍然有權訪問屬於其他租戶的數據。
因此,我們不會採用這種方法。相反,我們將使用 PostgreSQL 的行級安全性來解決這些問題。但是,在此之前,我們會為每個租戶創建一個專用的數據庫帳戶來訪問這個共享的數據庫。
為租戶設置數據庫角色
為能夠連接到數據庫的每個用戶分配一個數據庫角色是一個好習慣。這樣可以更好地控制每個用戶對數據庫的訪問,促進不同用戶之間操作的隔離,進而提高系統的穩定性和安全性。
由於所有租戶擁有相同的數據庫操作權限,我們可以創建一個基礎角色來管理這些權限:
然後,為使每個租戶角色不同,會在創建租戶時將一個從基礎角色繼承的角色分配給每個租戶:
接下來,對於每個租戶的數據庫連接信息將被存儲在 tenants
表中:
id | db_user | db_user_password |
---|---|---|
x2euic | crm_tenant_x2euic | pa55w0rd |
此機制為每個租戶提供了自己的數據庫角色,這些角色共享 crm_tenant
角色授予的權限。
然後,我們可以使用 crm_tenant
角色定義租戶的許可範圍:
- 租戶應擁有對所有 CRM 系統資源表的 CRUD 訪問權限。
- 與 CRM 系統資源無關的表應對租戶不可見(假設只有
systems
表)。 - 租戶不應能夠修改
tenants
表,且只有id
和db_user
字段應對其可見,以方便他們在執行數據庫操作時查詢自己的租戶 ID。
設置好租戶的角色後,當租戶請求訪問服務時,我們可以使用代表該租戶的數據庫角色來與數據庫交互:
使用 PostgreSQL 行級安全性保障租戶資料
到目前為止,我們已經建立了對應於租戶的數據庫角色,但這並未限制租戶之間的數據訪問。接下來,我們將利用 PostgreSQL 的行級安全性功能來限制每個租戶只能訪問自己的數據。
在 PostgreSQL 中,表可以具備行安全策略,該策略能控制查詢或數據操作命令可訪問的行。在很多情況下,這一特性也被稱為 RLS(行級安全性)。
默認情況下,表沒有行安全策略。要使用 RLS,你需要為表啟用該功能並創建訪問每次表時執行的安全策略。
以 CRM 系統中的 customers
表為例,我們將啟用 RLS 並創建一個安全策略來限制每個租戶只能訪問其自己的客戶數據:
在創建安全策略的語句中:
for all
(選填)表示此安全策略將用於select
、insert
、update
和delete
操作。你可以使用for
後跟命令關鍵字來為特定操作指定一個安全策略。to crm_tenant
表示此策略應用於擁有crm_tenant
數據庫角色的用戶,也就是所有的租戶。as restrictive
指定策略的執行模式,表明訪問應該被嚴格限制。默認情況下,表可以有多個策略,多個permissive
(寬鬆)的策略將以「或」關係進行組合。在此場景中,我們將該策略聲明為「restrictive」(嚴格)是因為我們希望此策略檢查對於 CRM 系統租戶來說是強制的。using
表達式定義實際訪問的條件,限制當前查詢的數據庫用戶只能查看屬於其租戶的數據。此限制適用於所有指令(select
、update
或delete
)所選擇的行。with check
表達式定義在修改數據行(insert
或update
)時所需的限制,保證租戶只能添加或更新自身的記錄。
使用 RLS 來約束租戶訪問我們的資源表具有幾個好處:
- 此策略有效地將
where tenant_id = (select id from tenants where db_user = current_user)
加到所有查詢指令(select
、update
或delete
)中。 例如,當你執行select * from customers
,等同於執行select * from customers where tenant_id = (select id from tenants where db_user = current_user)
。這使得我們在應用程序中不需明確地添加where
條件,簡化了代碼並減少了錯誤的可能性。 - 它能在數據庫層面集中控制不同租戶間的數據訪問權限,降低了應用程序脆弱性或不一致性的風險,從而提高系統安全性。
然而,需注意的一些要點是:
- RLS 策略會為每一行數據執行。如果 RLS 策略內的查詢條件過於複雜,可能會顯著影響系統性能。所幸,我們的租戶數據檢查查詢足夠簡單,不會影響性能。如果你計畫於日後使用 RLS 實現其他功能,可以參考 Supabase 的行級安全性性能建議 優化 RLS 性能。
- 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 組織的簡單指南——構建多租戶應用以獲取更多的見解。