В предыдущей статье мы затронули тему размещения аккаунтов на отдельных поддоменах, а так же способ реализации публичного 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 планируются?
ОтветитьУдалитьОтличное решение! Возьму на вооружение, спасибо.
ОтветитьУдалить