Как работает изоляция данных между схемами, что такое shared apps и как хранить общие данные.
Понимание того, как данные разделяются между тенантами и что хранится в общей схеме — ключ к правильной архитектуре мультитенантного приложения.
В основе изоляции лежит механизм PostgreSQL search_path:
-- При запросе на acme.example.com django-tenants выполняет:
SET search_path TO acme, public;
-- Теперь все запросы выполняются в схеме acme
SELECT * FROM projects_project; -- acme.projects_project
SELECT * FROM billing_plan; -- public.billing_plan (shared)Что это означает:
┌─────────────────────────────────────────────────────────┐
│ Database: saas_platform │
├─────────────────────────────────────────────────────────┤
│ Schema: public │
│ ├── django_migrations (shared) │
│ ├── auth_user (shared) │
│ └── billing_plan (shared) ← Видят все тенанты │
├─────────────────────────────────────────────────────────┤
│ Schema: acme │
│ ├── projects_project ← Видит только Acme │
│ └── tasks_task ← Видит только Acme │
├─────────────────────────────────────────────────────────┤
│ Schema: globex │
│ ├── projects_project ← Видит только Globex │
│ └── tasks_task ← Видит только Globex │
└─────────────────────────────────────────────────────────┘
Подходит для shared:
Не подходит для shared:
# billing/models.py
from django_tenants.models import SharedModel
class Plan(SharedModel):
"""Тарифный план — общий для всех тенантов."""
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
max_projects = models.IntegerField(default=10)
max_users = models.IntegerField(default=5)
features = models.JSONField(default=list)
class Meta:
app_label = 'billing'
ordering = ['price']
def __str__(self):
return self.nameВсе тенанты видят одни и те же тарифы:
# Запрос в любом тенанте вернёт одинаковый результат
plans = Plan.objects.all()
# [<Plan: Free>, <Plan: Pro>, <Plan: Enterprise>]# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django_tenants.models import SharedModel
class User(SharedModel, AbstractUser):
"""
Пользователи общие для всех тенантов.
Один пользователь может работать с несколькими тенантами.
"""
phone = models.CharField(max_length=20, blank=True)
avatar = models.ImageField(upload_to='avatars/', null=True)
class Meta:
app_label = 'accounts'Подходит для tenant:
# projects/models.py
from django_tenants.models import TenantModel
class Project(TenantModel):
"""Проект принадлежит конкретному тенанту."""
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
owner = models.ForeignKey('accounts.User', on_delete=models.CASCADE)
plan = models.ForeignKey('billing.Plan', on_delete=models.PROTECT)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
app_label = 'projects'
ordering = ['-created_at']
def __str__(self):
return self.nameДанные изолированы:
# В контексте тенанта 'acme'
with schema_context('acme'):
projects = Project.objects.all() # Только проекты Acme
# В контексте тенанта 'globex'
with schema_context('globex'):
projects = Project.objects.all() # Только проекты Globexclass Project(TenantModel):
# Связь с shared моделью — работает отлично
owner = models.ForeignKey('accounts.User', on_delete=models.CASCADE)
plan = models.ForeignKey('billing.Plan', on_delete=models.PROTECT)Почему работает: public schema видна из всех tenant схем через search_path.
# НЕПРАВИЛЬНО — не будет работать!
class Plan(SharedModel):
# Ошибка: нельзя ссылаться на tenant модель из shared
default_project = models.ForeignKey('projects.Project', on_delete=models.SET_NULL)Почему не работает: shared таблица в public не может ссылаться на таблицу в tenant схеме (которых много).
Если нужно связать shared и tenant данные, делайте связь от tenant к shared:
# billing/models.py
class Plan(SharedModel):
name = models.CharField(max_length=100)
# Нет связей к tenant моделям
# projects/models.py
class Project(TenantModel):
name = models.CharField(max_length=200)
# Связь от tenant к shared
plan = models.ForeignKey('billing.Plan', on_delete=models.PROTECT, related_name='projects')
# Использование
plan = Plan.objects.get(slug='pro')
# Получение всех проектов с этим планом (из всех тенантов!)
from django_tenants.utils import schema_context
for tenant in Client.objects.all():
with schema_context(tenant.schema_name):
tenant_projects = plan.projects.all()По умолчанию запросы выполняются только в текущей схеме:
# В контексте 'acme'
Project.objects.all() # Видит только проекты AcmeИногда нужно прочитать данные другого тенанта (админка, аналитика):
from django_tenants.utils import schema_context
# Прочитать проекты Globex из контекста Acme
with schema_context('globex'):
globex_projects = Project.objects.all()from django_tenants.utils import get_tenant_model, schema_context
Tenant = get_tenant_model()
total_projects = 0
for tenant in Tenant.objects.filter(active=True):
with schema_context(tenant.schema_name):
count = Project.objects.count()
total_projects += count
print(f"{tenant.schema_name}: {count} проектов")
print(f"Всего проектов: {total_projects}")Важно: Такие операции дорогие — не используйте в запросах пользователя.
# projects/models.py
from django.core.exceptions import ValidationError
from django_tenants.utils import get_current_schema_name
class Project(TenantModel):
name = models.CharField(max_length=200)
owner = models.ForeignKey('accounts.User', on_delete=models.CASCADE)
def clean(self):
# Проверка квоты проектов
from billing.models import Plan
from customers.models import Client
# Получить план текущего тенанта
schema = get_current_schema_name()
client = Client.objects.get(schema_name=schema)
# Предположим, у клиента есть связь с Plan
max_projects = client.plan.max_projects
# Проверить квоту
if not self.pk: # Только при создании
current_count = Project.objects.count()
if current_count >= max_projects:
raise ValidationError(f'Достигнут лимит проектов: {max_projects}')Для производительности храните счётчики в модели тенанта:
# customers/models.py
class Client(TenantMixin):
name = models.CharField(max_length=100)
project_count = models.IntegerField(default=0)
user_count = models.IntegerField(default=0)
def check_project_limit(self):
return self.project_count < self.plan.max_projectsСигналы работают в контексте текущей схемы:
# projects/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Project
@receiver(post_save, sender=Project)
def on_project_created(sender, instance, created, **kwargs):
if created:
# Увеличить счётчик в контексте текущего тенанта
from customers.models import Client
from django_tenants.utils import get_current_schema_name
schema = get_current_schema_name()
client = Client.objects.get(schema_name=schema)
client.project_count += 1
client.save()Избегайте сигналов, которые обращаются к данным других схем — это нарушает изоляцию.
# Неправильно — запрос в public schema, а не в tenant
def get_projects(request):
projects = Project.objects.all() # Ошибка! Пустой результат
return render(request, 'projects.html', {'projects': projects})
# Правильно — middleware автоматически переключит схему
# или используйте schema_context явно# Неправильно
class Document(SharedModel): # Документы общие для всех!
content = models.TextField()
# Правильно
class Document(TenantModel): # Документы изолированы
content = models.TextField()# Неправильно — прямой SQL может обойти search_path
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("SELECT * FROM projects_project") # Может вернуть не то
# Правильно — использовать ORM или явно установить search_path
with connection.cursor() as cursor:
cursor.execute("SET search_path TO %s", [schema_name])
cursor.execute("SELECT * FROM projects_project")# Неправильно — кэш общий для всех тенантов!
from django.core.cache import cache
def get_dashboard_data():
data = cache.get('dashboard') # Общие данные для всех!
if not data:
data = calculate_dashboard()
cache.set('dashboard', data)
return data
# Правильно — ключ кэша включает схему
def get_dashboard_data():
from django_tenants.utils import get_current_schema_name
schema = get_current_schema_name()
cache_key = f'{schema}:dashboard'
data = cache.get(cache_key)
if not data:
data = calculate_dashboard()
cache.set(cache_key, data)
return dataТеперь вы понимаете, как работает изоляция данных. В следующей теме вы узнаете о production-аспектах: статика, производительность, бэкапы.
Переходите к следующей теме → Production-аспекты
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.