探索 Python/Django 支援分散式多租戶資料庫,如 Postgres+Citus

為少發表於2022-05-13

image

在 確定分佈策略 中,我們討論了在多租戶用例中使用 Citus 所需的與框架無關的資料庫更改。 在這裡,我們專門研究如何藉助 django-multitenant 庫將多租戶 Django
用程式遷移到 Citus 儲存後端。

此過程將分為 5 個步驟:

  • 將租戶列介紹給我們想要分發的缺少它的模型
  • 更改分散式表的主鍵以包含租戶列
  • 更新模型以使用 TenantModelMixin
  • 分發資料
  • Django 應用程式更新為範圍查詢

準備橫向擴充套件多租戶應用程式

最初,您將從放置在單個資料庫節點上的所有租戶開始。 為了能夠擴充套件 django,必須對模型進行一些簡單的更改。

讓我們考慮這個簡化的模型:

from django.utils import timezone
from django.db import models

class Country(models.Model):
    name = models.CharField(max_length=255)

class Account(models.Model):
    name = models.CharField(max_length=255)
    domain = models.CharField(max_length=255)
    subdomain = models.CharField(max_length=255)
    country = models.ForeignKey(Country, on_delete=models.SET_NULL)

class Manager(models.Model):
    name = models.CharField(max_length=255)
    account = models.ForeignKey(Account, on_delete=models.CASCADE,
                                related_name='managers')

class Project(models.Model):
    name = models.CharField(max_length=255)
    account = models.ForeignKey(Account, related_name='projects',
                                on_delete=models.CASCADE)
    managers = models.ManyToManyField(Manager)

class Task(models.Model):
    name = models.CharField(max_length=255)
    project = models.ForeignKey(Project, on_delete=models.CASCADE,
                                related_name='tasks')

這種模式的棘手之處在於,為了找到一個帳戶的所有任務,您必須首先查詢一個帳戶的所有專案。 一旦您開始分片資料,這就會成為一個問題,特別是當您對巢狀模型(如本例中的任務)執行 UPDATEDELETE 查詢時。

1. 將租戶列引入屬於帳戶的模型

1.1 向屬於某個帳戶的模型引入該列

為了擴充套件多租戶模型,查詢必須快速定位屬於一個帳戶的所有記錄。考慮一個 ORM 呼叫,例如:

Project.objects.filter(account_id=1).prefetch_related('tasks')

它生成這些底層 SQL 查詢:

SELECT *
FROM myapp_project
WHERE account_id = 1;

SELECT *
FROM myapp_task
WHERE project_id IN (1, 2, 3);

但是,使用額外的過濾器,第二個查詢會更快:

-- the AND clause identifies the tenant
SELECT *
FROM myapp_task
WHERE project_id IN (1, 2, 3)
      AND account_id = 1;

這樣您就可以輕鬆查詢屬於一個帳戶的任務。 實現這一點的最簡單方法是在屬於帳戶的每個物件上簡單地新增一個 account_id 列。

在我們的例子中:

class Task(models.Model):
    name = models.CharField(max_length=255)
    project = models.ForeignKey(Project, on_delete=models.CASCADE,
                                related_name='tasks')
    account = models.ForeignKey(Account, related_name='tasks',
                                on_delete=models.CASCADE)

建立遷移以反映更改:python manage.py makemigrations

1.2 在屬於一個帳戶的每個 ManyToMany 模型上為 account_id 引入一個列

目標與之前相同。我們希望能夠將 ORM 呼叫和查詢路由到一個帳戶。我們還希望能夠在 account_id 上分發與帳戶相關的多對多關係。

所以產生的呼叫:

Project.objects.filter(account_id=1).prefetch_related('managers')

可以在他們的 WHERE 子句中包含這樣的 account_id:

SELECT *
FROM "myapp_project" WHERE "myapp_project"."account_id" = 1;

SELECT *
FROM myapp_manager manager
INNER JOIN myapp_projectmanager projectmanager
ON (manager.id = projectmanager.manager_id
AND  projectmanager.account_id = manager.account_id)
WHERE projectmanager.project_id IN (1, 2, 3)
AND manager.account_id = 1;

為此,我們需要引入 through 模型。 在我們的例子中:

class Project(models.Model):
    name = models.CharField(max_length=255)
    account = models.ForeignKey(Account, related_name='projects',
                                on_delete=models.CASCADE)
    managers = models.ManyToManyField(Manager, through='ProjectManager')

class ProjectManager(models.Model):
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
    manager = models.ForeignKey(Manager, on_delete=models.CASCADE)
    account = models.ForeignKey(Account, on_delete=models.CASCADE)

建立遷移以反映更改:python manage.py makemigrations

2. 在所有主鍵和唯一約束中包含 account_id

2.1 將 account_id 包含到主鍵中

Django 會自動在模型上建立一個簡單的 “id” 主鍵,因此我們需要通過自己的自定義遷移來規避這種行為。 執行 python manage.py makemigrations appname --empty --name remove_simple_pk, 並將結果編輯為如下所示:

from django.db import migrations

class Migration(migrations.Migration):

  dependencies = [
    # leave this as it was generated
  ]

  operations = [
    # Django considers "id" the primary key of these tables, but
    # we want the primary key to be (account_id, id)
    migrations.RunSQL("""
      ALTER TABLE myapp_manager
      DROP CONSTRAINT myapp_manager_pkey CASCADE;

      ALTER TABLE myapp_manager
      ADD CONSTRAINT myapp_manager_pkey
      PRIMARY KEY (account_id, id);
    """),

    migrations.RunSQL("""
      ALTER TABLE myapp_project
      DROP CONSTRAINT myapp_project_pkey CASCADE;

      ALTER TABLE myapp_project
      ADD CONSTRAINT myapp_product_pkey
      PRIMARY KEY (account_id, id);
    """),

    migrations.RunSQL("""
      ALTER TABLE myapp_task
      DROP CONSTRAINT myapp_task_pkey CASCADE;

      ALTER TABLE myapp_task
      ADD CONSTRAINT myapp_task_pkey
      PRIMARY KEY (account_id, id);
    """),

    migrations.RunSQL("""
      ALTER TABLE myapp_projectmanager
      DROP CONSTRAINT myapp_projectmanager_pkey CASCADE;

      ALTER TABLE myapp_projectmanager
      ADD CONSTRAINT myapp_projectmanager_pkey PRIMARY KEY (account_id, id);
    """),
  ]

2.2 將 account_id 包含到唯一約束中

UNIQUE 約束也需要做同樣的事情。 您可以使用 unique=Trueunique_together 在模型中設定顯式約束,例如:

class Project(models.Model):
    name = models.CharField(max_length=255, unique=True)
    account = models.ForeignKey(Account, related_name='projects',
                                on_delete=models.CASCADE)
    managers = models.ManyToManyField(Manager, through='ProjectManager')

class Task(models.Model):
    name = models.CharField(max_length=255)
    project = models.ForeignKey(Project, on_delete=models.CASCADE,
                                related_name='tasks')
    account = models.ForeignKey(Account, related_name='tasks',
                                on_delete=models.CASCADE)

    class Meta:
        unique_together = [('name', 'project')]

對於這些約束,您可以簡單地在模型中更改約束:

class Project(models.Model):
    name = models.CharField(max_length=255)
    account = models.ForeignKey(Account, related_name='projects',
                                on_delete=models.CASCADE)
    managers = models.ManyToManyField(Manager, through='ProjectManager')

    class Meta:
        unique_together = [('account', 'name')]

class Task(models.Model):
    name = models.CharField(max_length=255)
    project = models.ForeignKey(Project, on_delete=models.CASCADE,
                                related_name='tasks')
    account = models.ForeignKey(Account, related_name='tasks',
                                on_delete=models.CASCADE)

    class Meta:
        unique_together = [('account', 'name', 'project')]

然後使用以下命令生成遷移:

python manage.py makemigrations

一些 UNIQUE 約束是由 ORM 建立的,您需要顯式刪除它們。 OneToOneFieldManyToMany 欄位就是這種情況。

對於這些情況,您需要: 1. 找到約束 2. 進行遷移以刪除它們 3. 重新建立約束,包括 account_id 欄位

要查詢約束,請使用 psql 連線到您的資料庫並執行 \d+ myapp_projectmanager 你將看到 ManyToMany (或 OneToOneField )約束:

"myapp_projectmanager" UNIQUE CONSTRAINT myapp_projectman_project_id_manager_id_bc477b48_uniq,
btree (project_id, manager_id)

在遷移中刪除此約束:

from django.db import migrations

class Migration(migrations.Migration):

  dependencies = [
    # leave this as it was generated
  ]

  operations = [
    migrations.RunSQL("""
      DROP CONSTRAINT myapp_projectman_project_id_manager_id_bc477b48_uniq;
    """),

然後改變你的模型有一個 unique_together 包括 account_id

class ProjectManager(models.Model):
    project = models.ForeignKey(Project, on_delete=models.CASCADE)
    manager = models.ForeignKey(Manager, on_delete=models.CASCADE)
    account = models.ForeignKey(Account, on_delete=models.CASCADE)

    class Meta:
        unique_together=(('account', 'project', 'manager'))

最後通過建立新遷移來應用更改以生成這些約束:

python manage.py makemigrations

3. 更新模型以使用 TenantModelMixin 和 TenantForeignKey

接下來,我們將使用 django-multitenant 庫將 account_id 新增到外來鍵中,以便以後更輕鬆地查詢應用程式。

Django 應用程式的 requirements.txt 中,新增

django_multitenant>=2.0.0, <3

執行 pip install -r requirements.txt

settings.py 中,將資料庫引擎改為 django-multitenant 提供的自定義引擎:

'ENGINE': 'django_multitenant.backends.postgresql'

3.1 介紹 TenantModelMixin 和 TenantManager

模型現在不僅繼承自 models.Model,還繼承自 TenantModelMixin

要在你的 models.py 檔案中做到這一點,你需要執行以下匯入

from django_multitenant.mixins import *

以前我們的示例模型僅繼承自 models.Model,但現在我們需要將它們更改為也繼承自 TenantModelMixin。 實際專案中的模型也可能繼承自其他 mixin,例如 django.contrib.gis.db,這很好。

此時,您還將引入 tenant_id 來定義哪一列是分佈列。

class TenantManager(TenantManagerMixin, models.Manager):
    pass

class Account(TenantModelMixin, models.Model):
    ...
    tenant_id = 'id'
    objects = TenantManager()

class Manager(TenantModelMixin, models.Model):
    ...
    tenant_id = 'account_id'
    objects = TenantManager()

class Project(TenantModelMixin, models.Model):
    ...
    tenant_id = 'account_id'
    objects = TenantManager()

class Task(TenantModelMixin, models.Model):
    ...
    tenant_id = 'account_id'
    objects = TenantManager()

class ProjectManager(TenantModelMixin, models.Model):
    ...
    tenant_id = 'account_id'
    objects = TenantManager()

3.2 處理外來鍵約束

對於 ForeignKeyOneToOneField 約束,我們有幾種不同的情況:

  • 分散式表之間的外來鍵(或一對一),您應該使用 TenantForeignKey (或 TenantOneToOneField)。
  • 分散式表和引用表之間的外來鍵不需要更改。
  • 分散式表和本地表之間的外來鍵,需要使用 models.ForeignKey(MyModel, on_delete=models.CASCADE, db_constraint=False) 來刪除約束。

最後你的模型應該是這樣的:

from django.db import models
from django_multitenant.fields import TenantForeignKey
from django_multitenant.mixins import *

class Country(models.Model):  # This table is a reference table
  name = models.CharField(max_length=255)

class TenantManager(TenantManagerMixin, models.Manager):
    pass

class Account(TenantModelMixin, models.Model):
    name = models.CharField(max_length=255)
    domain = models.CharField(max_length=255)
    subdomain = models.CharField(max_length=255)
    country = models.ForeignKey(Country, on_delete=models.SET_NULL)  # No changes needed

    tenant_id = 'id'
    objects = TenantManager()

class Manager(TenantModelMixin, models.Model):
    name = models.CharField(max_length=255)
    account = models.ForeignKey(Account, related_name='managers',
                                on_delete=models.CASCADE)
    tenant_id = 'account_id'
    objects = TenantManager()

class Project(TenantModelMixin, models.Model):
    account = models.ForeignKey(Account, related_name='projects',
                                on_delete=models.CASCADE)
    managers = models.ManyToManyField(Manager, through='ProjectManager')
    tenant_id = 'account_id'
    objects = TenantManager()

class Task(TenantModelMixin, models.Model):
    name = models.CharField(max_length=255)
    project = TenantForeignKey(Project, on_delete=models.CASCADE,
                             related_name='tasks')
    account = models.ForeignKey(Account, on_delete=models.CASCADE)

    tenant_id = 'account_id'
    objects = TenantManager()

class ProjectManager(TenantModelMixin, models.Model):
    project = TenantForeignKey(Project, on_delete=models.CASCADE)
    manager = TenantForeignKey(Manager, on_delete=models.CASCADE)
    account = models.ForeignKey(Account, on_delete=models.CASCADE)

    tenant_id = 'account_id'
    objects = TenantManager()

3.3 處理多對多約束

在本文的第二部分,我們介紹了在 citus 中, ManyToMany 關係需要一個帶有租戶列的 through 模型。 這就是為什麼我們有這個模型:

class ProjectManager(TenantModelMixin, models.Model):
    project = TenantForeignKey(Project, on_delete=models.CASCADE)
    manager = TenantForeignKey(Manager, on_delete=models.CASCADE)
    account = models.ForeignKey(Account, on_delete=models.CASCADE)

    tenant_id = 'account_id'
    objects = TenantManager()

安裝庫、更改引擎和更新模型後,執行 python manage.py makemigrations。這將產生一個遷移,以便在必要時合成外來鍵。

4. 在 Citus 中分發資料

我們需要最後一次遷移來告訴 Citus 標記要分發的表。 建立一個新的遷移 python manage.py makemigrations appname --empty --name Distribute_tables。 編輯結果如下所示:

from django.db import migrations
from django_multitenant.db import migrations as tenant_migrations

class Migration(migrations.Migration):
  dependencies = [
    # leave this as it was generated
  ]

  operations = [
    tenant_migrations.Distribute('Country', reference=True),
    tenant_migrations.Distribute('Account'),
    tenant_migrations.Distribute('Manager'),
    tenant_migrations.Distribute('Project'),
    tenant_migrations.Distribute('ProjectManager'),
    tenant_migrations.Distribute('Task'),
  ]

從到目前為止的步驟中建立的所有遷移,使用 python manage.py migrate 將它們應用到資料庫。

此時,Django 應用程式模型已準備好與 Citus 後端一起工作。 您可以繼續將資料匯入新系統並根據需要修改檢視以處理模型更改。

將 Django 應用程式更新為範圍查詢

上一節討論的 django-multitenant 庫不僅對遷移有用,而且對簡化應用程式查詢也很有用。 該庫允許應用程式程式碼輕鬆地將查詢範圍限定為單個租戶。 它會自動將正確的 SQL 過濾器新增到所有語句中,包括通過關係獲取物件。

例如,在一個檢視中只需 set_current_tenant,之後的所有查詢或連線都將包含一個過濾器,以將結果範圍限定為單個租戶。

# set the current tenant to the first account
s = Account.objects.first()
set_current_tenant(s)

# now this count query applies only to Project for that account
Project.objects.count()

# Find tasks for very important projects in the current account
Task.objects.filter(project__name='Very important project')

在應用程式檢視的上下文中,當前租戶物件可以在使用者登入時儲存為 SESSION 變數, 並且檢視操作可以 set_current_tenant 到該值。 有關更多示例,請參閱 django-multitenant 中的 README

set_current_tenant 函式也可以接受一個物件陣列,比如

set_current_tenant([s1, s2, s3])

它使用類似於 tenant_id IN (a,b,c) 的過濾器更新內部 SQL 查詢。

使用中介軟體自動化

而不是在每個檢視中呼叫 set_current_tenant(), 您可以在 Django 應用程式中建立並安裝一個新的 middleware 類來自動完成。

# src/appname/middleware.py

from django_multitenant.utils import set_current_tenant

class MultitenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.user and not request.user.is_anonymous:
            set_current_tenant(request.user.employee.company)
        response = self.get_response(request)
        return response

通過更新 src/appname/settings/base.py 中的 MIDDLEWARE 陣列來啟用中介軟體:

MIDDLEWARE = [
    # ...
    # existing items
    # ...

    'appname.middleware.MultitenantMiddleware'
]

更多

相關文章