简体中文
  • saas
  • multi-tenancy
  • postgres
  • 行级安全
  • rls
  • 触发器函数
  • 多租户架构
  • 单租户架构

使用 PostgreSQL 实现多租户:通过简单的真实示例学习

了解如何通过一个真实示例使用 PostgreSQL 行级安全性(RLS)和数据库角色实现多租户架构,以实现租户之间的安全数据隔离。

Yijun
Yijun
Developer

在我们的一些先前文章中,我们深入探讨了多租户的概念及其在产品和真实商业场景中的应用。

在这篇文章中,我们将从技术层面探讨如何为你的应用程序实现一种多租户架构,使用 PostgreSQL。

什么是单租户架构?

单租户架构指的是一种软件架构,其中每个客户都有自己专用的应用实例和数据库。

在这种架构中,每个租户的数据和资源与其他租户完全隔离。

单租户

什么是多租户架构?

多租户架构是一种软件架构,其中多个客户(租户)共享同一个应用实例和基础设施,同时维护数据隔离。在这种架构中,单个软件实例为多个租户服务,通过各种隔离机制将每个租户的数据与其他租户分开。

多租户

单租户架构与多租户架构的比较

单租户架构和多租户架构在数据隔离、资源利用率、可扩展性、管理和维护以及安全性等方面存在差异。

在单租户架构中,每个客户都有一个独立的数据空间,这导致资源利用率较低,但相对容易实现定制。通常,单租户软件是针对特定客户需求定制的,如特定面料供应商的库存系统或个人博客网页应用。它们的共同点是每个客户占据一个独立的应用服务实例,便于根据特定要求进行定制。

在多租户架构中,多个租户共享相同的底层资源,从而提高了资源利用率。然而,必须确保数据的隔离和安全。

多租户架构通常是服务提供商向不同客户提供标准化服务时的首选软件架构。这些服务通常具有低级别的定制化,所有客户共享同一个应用实例。当需要更新应用程序时,更新一个应用实例相当于为所有客户更新应用。例如,CRM(客户关系管理)是一种标准化需求。这些系统通常使用多租户架构为所有租户提供相同的服务。

多租户架构中租户数据隔离策略

在多租户架构中,所有租户共享相同的底层资源,使得租户之间资源的隔离变得至关重要。这种隔离不一定需要是物理的,只需确保租户之间的资源不相互可见即可。

在架构设计中,可以实现各种程度的租户之间资源隔离:

由隔离到共享

一般来说,租户之间共享的资源越多,系统迭代和维护的成本就越低。反之,共享的资源越少,成本就越高。

开始使用真实示例实施多租户

在这篇文章中,我们将使用一个 CRM 系统作为示例,介绍一个简单但实用的多租户架构。

我们了解到所有租户使用相同的标准服务,所以我们决定让所有租户共享相同的基本资源,并在数据库级别使用 PostgreSQL 的行级安全实现不同租户之间的数据隔离。

此外,我们将为每个租户创建一个单独的数据连接,以便更好地管理租户权限。

接下来,我们将介绍如何实现这种多租户架构。

如何使用 PostgreSQL 实现多租户架构

为所有资源添加租户标识符

在一个 CRM 系统中,我们将拥有许多资源,并将它们存储在不同的表中。例如,客户信息存储在 customers 表中。

在实现多租户之前,这些资源不与任何租户相关联:

为了区分拥有不同资源的租户,我们引入一个 tenants 表来存储租户信息(其中 db_userdb_user_password 用于存储每个租户的数据库连接信息,详细信息将在下文中介绍)。此外,我们向每个资源添加一个 tenant_id 字段以识别其属于哪个租户:

现在,每个资源都与一个 tenant_id 相关联,理论上我们可以向所有查询添加一个 where 子句,以限制每个租户对资源的访问:

乍一看,这似乎简单可行。然而,它会存在以下问题:

  • 几乎每个查询将包含这个 where 子句,这会使代码变得杂乱无章,并且难以维护,尤其是在编写复杂的连接语句时。
  • 新加入的代码库成员可能很容易忘记添加这个 where 子句。
  • 不同租户之间的数据并未真正隔离,因为每个租户仍然有权限访问属于其他租户的数据。

因此,我们不会采用这种方法。相反,我们将使用 PostgreSQL 的行级安全来解决这些问题。然而,在进行之前,我们将为每个租户创建一个专用的数据库帐户,以访问这个共享的数据库。

为租户设置 DB 角色

为每个可以连接到数据库的用户分配数据库角色是一种良好的实践。这允许我们更好地控制每个用户对数据库的访问,有助于隔离不同用户之间的操作,提高系统稳定性和安全性。

由于所有租户具有相同的数据库操作权限,我们可以创建一个基础角色来管理这些权限:

然后,为了区别每个租户角色,在创建租户时,将继承基础角色的角色分配给每个租户:

接下来,每个租户的数据库连接信息将存储在 tenants 表中:

iddb_userdb_user_password
x2euiccrm_tenant_x2euicpa55w0rd

这种机制为每个租户提供了自己的数据库角色,并且这些角色共享授予 crm_tenant 角色的权限。

接着,我们可以使用 crm_tenant 角色定义租户的权限范围:

  • 租户应该拥有对所有 CRM 系统资源表的 CRUD 访问权限。
  • 与 CRM 系统资源无关的表对租户不可见(假设只有 systems 表)。
  • 租户不应能够修改 tenants 表,并且只有在执行数据库操作时允许他们查询自己的租户 id,且只能查看 iddb_user 字段。

一旦设定了租户的角色,当租户请求访问服务时,我们可以使用代表该租户的数据库角色与数据库交互:

使用 PostgreSQL 行级安全确保租户数据安全

到目前为止,我们已经为租户建立了对应的数据库角色,但这并未限制租户之间的数据访问。接下来,我们将利用 PostgreSQL 的行级安全功能来限制每个租户对其自身数据的访问。

在 PostgreSQL 中,表可以具有控制哪些行可以被查询或修改的_行安全政策_。这个功能也被称为 RLS(行级安全)。

通常情况下,表没有行安全政策。要使用 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 来约束我们资源表的租户访问有几个好处:

  • 这个策略实际上为所有查询操作(selectupdatedelete)添加了 where tenant_id = (select id from tenants where db_user = current_user)。 例如,当你执行 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 组织的简单指南 - 构建多租户应用程序 获取更多见解。