Django 是一个流行的框架,您可以选择它来为您的公司开发应用程序。但是,如果您想创建一个供多个客户端使用的 SaaS 应用程序,该怎么办?您应该选择什么架构?让我们看看如何完成这项任务。
最直接的方法是为您拥有的每个客户端创建一个单独的实例。假设我们有一个 Django 应用程序和一个数据库。然后,对于每个客户端,我们需要运行其自己的数据库和应用程序实例。这意味着每个应用程序实例只有一个租户。
这种方法很容易实现:您只需启动您拥有的每个服务的新实例。但同时,它可能会导致一个问题:每个客户端都会显着增加基础设施的成本。如果您计划只有几个客户端或者每个实例都很小,这可能不是什么大问题。
然而,假设我们正在建立一家向 100,000 个组织提供企业通讯工具的大公司。想象一下,为每个新客户复制整个基础设施的成本有多大!而且,当我们需要更新应用程序版本时,我们需要为每个客户端进行部署,因此部署速度也会变慢。
当我们的应用程序有很多客户端时,还有另一种方法可以提供帮助:多租户架构。这意味着我们有多个客户端,我们称之为租户,但它们都只使用应用程序的一个实例。
这种架构虽然解决了每个客户端专用实例成本高昂的问题,但也带来了一个新问题:如何确保客户端的数据与其他客户端安全隔离?
我们将讨论以下方法:
使用共享数据库和共享数据库模式:我们可以通过需要添加到每个数据库表的外键来识别哪个租户拥有数据。
使用共享数据库,但使用单独的数据库模式:这样,我们不需要维护多个数据库实例,但可以获得良好的租户数据隔离级别。
使用单独的数据库:它看起来与单租户示例类似,但并不相同,因为我们仍将使用共享应用程序实例并通过检查租户来选择要使用的数据库。
让我们更深入地研究这些想法,看看如何将它们与 Django 应用程序集成。
这个选项可能是第一个想到的:将外键添加到表中,并使用它为每个租户选择适当的数据。然而,它有一个巨大的缺点:租户的数据根本不是隔离的,因此一个小的编程错误就足以将租户的数据泄露给错误的客户端。
让我们以Django 文档中的数据库结构为例:
from django.db import models class Question(models.Model): question_text = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") class Choice(models.Model): question = models.ForeignKey(Question, on_delete=models.CASCADE) choice_text = models.CharField(max_length=200) votes = models.IntegerField(default=0)
我们需要确定哪些记录属于哪个租户。因此,我们需要在每个现有表中添加一个Tenant
表和一个外键:
class Tenant(models.Model): name = models.CharField(max_length=200) class Question(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE) question_text = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") class Choice(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE) question = models.ForeignKey(Question, on_delete=models.CASCADE) choice_text = models.CharField(max_length=200) votes = models.IntegerField(default=0)
为了稍微简化代码,我们可以创建一个抽象基本模型,该模型将在我们创建的每个其他模型中重用。
class Tenant(models.Model): name = models.CharField(max_length=200) class BaseModel(models.Model): tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE) class Meta: abstract = True class Question(BaseModel): question_text = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") class Choice(BaseModel): question = models.ForeignKey(Question, on_delete=models.CASCADE) choice_text = models.CharField(max_length=200) votes = models.IntegerField(default=0)
正如您所看到的,这里至少存在两个主要风险:开发人员可能忘记将租户字段添加到新模型中,或者开发人员在过滤数据时可能忘记使用该字段。
此示例的源代码可以在 GitHub 上找到:https: //github.com/bp72/django-multitenancy-examples/tree/main/01_shared_database_shared_schema 。
记住共享模式的风险,让我们考虑另一种选择:数据库仍然是共享的,但我们将为每个租户创建一个专用模式。对于实现,我们可以查看一个流行的库django-tenants (文档)。
让我们将django-tenants
添加到我们的小项目中(官方安装步骤可以在这里找到)。
第一步是通过pip
安装库:
pip install django-tenants
更改模型: Tenant
模型现在将位于单独的应用程序中, Question
和Choice
模型将不再与租户建立连接。由于不同租户的数据将位于不同的模式中,因此我们不再需要将各个记录与租户行链接起来。
文件租户/models.py
from django.db import models from django_tenants.models import TenantMixin, DomainMixin class Tenant(TenantMixin): name = models.CharField(max_length=200) # default true, schema will be automatically created and synced when it is saved auto_create_schema = True class Domain(DomainMixin): # a required table for django-tenants too ...
文件polls/models.py
from django.db import models class Question(models.Model): question_text = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") class Choice(models.Model): question = models.ForeignKey(Question, on_delete=models.CASCADE) choice_text = models.CharField(max_length=200) votes = models.IntegerField(default=0)
请注意,问题和选择不再有租户的外键!
另一件改变的事情是租户现在位于一个单独的应用程序中:它不仅用于分隔域,而且也很重要,因为我们需要将tenants
表存储在共享模式中,并且将为每个租户创建polls
表架构。
更改settings.py
文件以支持多个架构和租户:
DATABASES = { 'default': { 'ENGINE': 'django_tenants.postgresql_backend', # .. } } DATABASE_ROUTERS = ( 'django_tenants.routers.TenantSyncRouter', ) MIDDLEWARE = ( 'django_tenants.middleware.main.TenantMainMiddleware', #... ) TEMPLATES = [ { #... 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.request', #... ], }, }, ] SHARED_APPS = ( 'django_tenants', # mandatory 'tenants', # you must list the app where your tenant model resides in 'django.contrib.contenttypes', # everything below here is optional 'django.contrib.auth', 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', 'django.contrib.admin', ) TENANT_APPS = ( # your tenant-specific apps 'polls', ) INSTALLED_APPS = list(SHARED_APPS) + [app for app in TENANT_APPS if app not in SHARED_APPS] TENANT_MODEL = "tenants.Tenant" TENANT_DOMAIN_MODEL = "tenants.Domain"
接下来,让我们创建并应用迁移:
python manage.py makemigrations
python manage.py migrate_schemas --shared
结果,我们将看到公共模式将被创建并且仅包含共享表。
我们需要为public
模式创建一个默认租户:
python manage.py create_tenant --domain-domain=default.com --schema_name=public --name=default_tenant
如果询问,请将is_primary
设置为True
。
然后,我们可以开始创建服务的真正租户:
python manage.py create_tenant --domain-domain=tenant1.com --schema_name=tenant1 --name=tenant_1 python manage.py create_tenant --domain-domain=tenant2.com --schema_name=tenant2 --name=tenant_2
请注意,数据库中现在还有 2 个包含polls
表的模式:
现在,当您在为租户设置的域上调用 API 时,您将获得来自不同模式的问题和选择 - 一切都完成了!
尽管设置看起来更复杂,如果迁移现有应用程序可能会更困难,但该方法本身仍然具有很多优点,例如数据的安全性。
该示例的代码可以在这里找到。
我们今天要讨论的最后一种方法更进一步,为租户提供单独的数据库。
这次,我们将有一些数据库:
我们将存储共享数据,例如租户到default_db
中数据库名称的映射,并为每个租户创建一个单独的数据库。
然后我们需要在settings.py中设置数据库配置:
DATABASES = { 'default': { 'NAME': 'default_db', ... }, 'tenant_1': { 'NAME': 'tenant_1', ... }, 'tenant_2': { 'NAME': 'tenant_2', ... }, }
现在,我们将能够通过using
QuerySet 方法调用来获取每个租户的数据:
Questions.objects.using('tenant_1')…
该方法的缺点是您需要使用以下方法在每个数据库上应用所有迁移:
python manage.py migrate --database=tenant_1
与使用django-tenants
或仅使用外键(如共享模式方法)相比,为每个租户创建新数据库也可能不太方便。
另一方面,租户数据的隔离确实很好:数据库可以在物理上分开。另一个优点是,我们不会因为django-tenants
的要求而仅限于使用 Postgresql,我们可以选择任何适合我们需求的引擎。
有关多数据库主题的更多信息可以在 Django文档中找到。
| 单租户 | 具有共享模式的 MT | 具有单独模式的 MT | 具有独立数据库的 MT |
---|---|---|---|---|
数据隔离 | ✅高 | ❌最低 | ✅高 | ✅高 |
数据意外泄露的风险 | ✅低 | ❌高 | ✅低 | ✅低 |
基础设施成本 | ❌每个租户的收益更高 | ✅降低 | ✅降低 | ✅❌ 低于单租户 |
部署速度 | ❌与每个租户一起降低 | ✅ | ✅❌ 迁移会比较慢,因为需要针对每个模式执行迁移 | ✅❌ 迁移会比较慢,因为需要针对每个数据库执行迁移 |
易于实施 | ✅ | ❌ 如果服务已经作为单租户应用程序实现,则需要进行大量更改 | ✅ | ✅ |
综上所述,似乎没有解决问题的灵丹妙药,每种方法都有其优点和缺点,因此由开发人员决定他们可以进行哪些权衡。
单独的数据库为租户的数据提供了最好的隔离,并且实现起来很简单,但是维护成本较高:n 数据库需要更新,数据库连接数更高。
具有单独模式的共享数据库实施起来有点复杂,并且可能会出现一些迁移问题。
单租户是最容易实现的,但它会导致资源过度消耗,因为每个租户都有一份完整的服务副本。