В предыдущей статье мы затронули тему размещения аккаунтов на отдельных поддоменах, а так же способ реализации публичного API в обычном приложении Django (с учетом того, что серверная сторона уже реализована в виде WebAPI для SPA-приложения).
Сегодня мы поговорим о шардинге баз данных и о том, как его можно применять при разработке SaaS продуктов. Частично о шардинге мы уже рассказывали ранее в серии статей о проектировании высоконагруженных веб-приложений: База данных. Рекомендую обратиться к этому материалу, т.к. тема оптимизации работы с БД достаточно сложная.
Сегодня речь пойдет по большей части о практике. Мы наглядно покажем (с примерами кода), как можно реализовать шардинг базы данных в SaaS проекте на Django, Python и PostgreSQL/MySQL.
Прежде всего, давайте поймем, зачем нужно делать шардинг? Прежде всего затем, чтобы иметь возможность переносить какие-то части одной базы данных на разные сервера, тем самым снижая нагрузку. Это имеет смысл делать в следующих случаях:
- когда данные прирастают с большой скоростью и приходится масштабировать сервер, на котором работает БД;
- когда данные на аккаунтах распределены неравномерно.
Скажем, у нас есть 99 зарегистрированных аккаунтов с примерно одинаково небольшим объемом данных, и еще один, который в сотни раз больше остальных. Нагрузка между ними будет распределена очень неравномерно и это будет создавать проблемы. Хочется иметь возможность вынести базу данных этого большого аккаунта на отдельный сервер.
В SaaS-системах обычно создаются аккаунты, в которых работают пользователи. Каждый аккаунт "изолирован" от остальных, т.е. оперирует только теми данными, которые связаны с ним. Это означает, что мы можем относительно легко сделать так, чтобы для каждого аккаунта у нас создавалась отдельная база данных.
Такой подход позволяет существенно оптимизировать работу с базой данных. Мы впервые реализовали такой вид шардинга, когда работали (и продолжаем работать) над SaaS продуктом для заказчика из Соединенных Штатов. В проекте накапливается очень большой объем данных, на текущий момент с сервисом работает более двухсот тысяч пользователей, а такой вид шардинга позволил нам эффективно масштабировать нагрузку.
Более того, разделив все аккаунты по разным базам данных, мы существенно повышаем безопасность сервиса. У пользователей одного аккаунта физически нет возможности получить доступ к данным другого.
Еще одно преимущество от разделения баз данных — это очищение кода проекта и запросов. Можно убрать некоторые избыточные проверки. Когда данные всех аккаунтов находятся в одной БД, то в каждом запросе нужно передавать
account_id
, чтобы выборка шла по нужному индексу.
То есть мы хотим иметь одну общую базу данных, в которой будем хранить информацию о зарегистрированных аккаунтах, сессии и другие системные данные Django, и отдельные базы данных под каждый аккаунт. Код веб-приложения должен быть адаптирован под работу с такой архитектурой.
Погружение
Итак, давайте начнем с того, что вспомним, как устроена работа с базами данных в Django.
Django "из коробки" предоставляет несколько отличных возможностей для реализации работы с несколькими базами данных. Все они нам пригодятся.
- Database definitions, т.е. тот словарь
DATABASES
, который задается вsettings.py
; - Database routers, т.е. "умные" маршрутизаторы запросов, которые принимают на вход данные о модели и определяют, какую базу данных нужно использовать;
- Возможность использования параметра
--database
при выполнении миграций; using()
,using=
для явного указания, к какой базе данных нужно выполнить запрос.
Теперь сформулируем требования для решения, которое будет управлять базами данных.
- Должен быть простой способ указать для модели, в какой базе данных она должна размещаться: в общей или базе данных аккаунта;
- Не должно быть явных указаний базы данных при выполнении запросов;
- Должно корректно выбирать базу данных для конкретной модели аккаунта;
- Должно работать в асинхронных задачах Celery;
Хак Решение
Модели, которые должны размещаться в общей базе данных, будем определять по наличию специального атрибута
is_global
.class Account(models.Model): ... is_global = True ... class AccountUser(models.Model): ... is_global = False ...
Таким образом, информация об аккаунтах хранится в общей базе данных, а пользователи — в отдельных. Теперь нужно реализовать Database router, который будет определять нужную базу данных для запроса.
class MyCustomDbRouter(object): def _is_internal(self, model): return model.__module__.startswith('my_app') def _is_global(self, model): return hasattr(model, 'is_global') and model.is_global def _get_db(self, model): if self._is_internal(model): if self._is_global(model): return 'default' else: context_account = get_context_account() if context_account: return context_account.get_database_name() return None return 'default' def db_for_read(self, model, **hints): return self._get_db(model) def db_for_write(self, model, **hint): return self._get_db(model) def allow_relation(self, obj1, obj2, **hints): return obj1._state.db == obj2._state.db def allow_migrate(self, db, app_label, model_name=None, **hints): if db == 'default': if app_label == 'app' and model_name != 'account': return False if app_label in ['auth']: return False else: if app_label == 'app' and model_name == 'account': return False if app_label in ['sites', 'sessions']: return False return True
Пропишем этот роутер в
settings.py
.DATABASE_ROUTERS = ['path.to.MyCustomDbRouter']
В приведенном выше коде можно было заметить вызовы функций
get_context_account
и get_database_name
. Последняя возвращает имя базы данных для конкретного аккаунта. О ее реализации мы поговорим позднее. Первая должна сообщать с каким аккаунтом в данный момент происходит работа. Это важный момент. Нам потребуется реализовать модуль, решающий эту задачу. Хранить контекстный аккаунт будем в threading.local
.import threading env = threading.local() def get_context_account(): if hasattr(env, 'context_account'): return env.context_account return None
Теперь добавим новый компонент в слой Middleware.
class EnvironmentMiddleware(object): def process_request(self, request): account = None if 'account_id' in request.session: account = Account.objects.get(id=request.session['account_id']) env.__dict__.update({ 'context_account': account })
Отлично. Теперь в каждом запросе мы будем знать с каким аккаунтом идет работа. Остается настроить маршрутизацию.
Давайте взглянем на то, как определяются базы данных в Django.
DATABASES = { 'default': { 'NAME': ..., 'ENGINE': ..., 'USER': ..., 'PASSWORD': ... }, 'second_db': { ... } }
Этот словарь кешируется в памяти Django.
Что мы хотим получить?
DATABASES
, который будет способен расширяться по ходу работы веб-приложения.MAIN_DB = { 'ENGINE': ..., 'NAME': ..., 'USER': ..., 'PASSWORD': ..., ... } DATABASES = DynamicDatabaseMap(MAIN_DB)
Реализация может выглядеть следующим образом.
class DynamicDatabaseMap(object): def __init__(self, default): self.data = { 'default': default } def __getitem__(self, item): if item not in self.data: self._load_db(item) return self.data[item] def __contains__(self, item): return item in self.data def __iter__(self): return iter(self.data) def keys(self): return self.data.keys() def _load_db(self, name): self.data[name] = { 'NAME': name, 'ENGINE': self.data['default']['ENGINE'], 'USER': self.data['default']['USER'], 'PASSWORD': self.data['default']['PASSWORD'], 'HOST': self.data['default']['HOST'], 'PORT': self.data['default']['PORT'] }
Большая часть работы позади. Теперь Django может автоматически определять в какую базу данных нужно отправлять запросы. Осталось реализовать создание базы данных при создании аккаунта.
class Account(models.Model):
... def get_database_name(self): return 'account_%d' % self.id def _get_internal_db_connection(self): connection_params = { 'host': settings.MAIN_DB['HOST'], 'user': settings.MAIN_DB['USER'], 'password': settings.MAIN_DB['PASSWORD'] } connection = psycopg2.connect(**connection_params) connection.set_isolation_level( psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT ) return connection def migrate_database(self): settings.DATABASES.load_db(self.get_database_name()) call_command('migrate', database=self.get_database_name(), interactive=False, verbosity=0) def create_database(self): connection = self._get_internal_db_connection() cursor = connection.cursor() cursor.execute( 'CREATE DATABASE "%s" OWNER "%s"' % ( self.get_database_name(), settings.MAIN_DB['USER'] ) ) self.migrate_database() def drop_database(self): connection = self._get_internal_db_connection() cursor = connection.cursor() cursor.execute( 'DROP DATABASE IF EXISTS "%s"' % self.get_database_name() )
Регистрация новых аккаунтов будет выглядеть примерно следующим образом.
class SignupApiView(NoAuthApiView): ... def form_valid(self, form): ... account = create_account(form) ... account.create_database() ... self.request.session.flush() self.request.session['account_id'] = account.id ... login(self.request, user) ...
Отлично. Для каждого нового аккаунта будет создавать отдельная база данных, а все запросы связанные с определенным аккаунтом будут автоматически направляться к нужному источнику.
Поговорим теперь о том, как быть с Celery. Ведь асинхронные задачи запускаются вне контекста Django и, соответственно,
get_context_account
будет всегда возвращать None
. Решить эту проблему можно через собственный менеджер контекста.class use_account(object): def __init__(self, account_id): self.account = Account.objects.get(id=account_id) self.current_account = None def __enter__(self): if hasattr(env, 'context_account'): self.current_account = env.context_account env.context_account = self.account def __exit__(self, exc_type, exc_val, exc_tb): env.context_account = self.current_account
Таким образом, мы можем явно указать с каким аккаунтом сейчас выполняется работа.
@periodic_task(run_every=timedelta(minutes=60)) def run_for_all_accounts(): for account in Account.objects.all(): with use_account(account.id): ...
Остался последний момент. Применение миграций к базам данных аккаунтов. Если мы запустим
python manage.py migrate
, то миграции применятся только к основной базе данных. Нам нужно написать команду, которая запустит миграции для остальных баз данных.Подробнее о том, как создавать свои команды для Django: https://docs.djangoproject.com/en/1.11/howto/custom-management-commands/.
class Command(BaseCommand): help = 'Run migrations for accounts databases' def handle(self, *args, **options): self.stdout.write('Starting migrations...') for account in Account.objects.all(): self.stdout.write('Migrating %s...' % account.get_database_name()) with use_account(account.id): account.migrate_database() self.stdout.write('Migrations finished.')
На этом все. Ваше приложение теперь полностью готово к тому, чтобы работать в условиях, когда данные каждого аккаунта находятся в разных базах данных.
За рамками этой статьи осталось еще много тем, касательно работы со многими базами данных, поддержки большого количества подключений к ним и т.д. Постараемся о них рассказать в дальнейшем.
А еще посты на тему saas планируются?
ОтветитьУдалитьОтличное решение! Возьму на вооружение, спасибо.
ОтветитьУдалить