четверг, 6 июля 2017 г.

Разработка SaaS на Django и Python. Часть 2. Шардинг базы данных (multi-tenant)



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

На этом все. Ваше приложение теперь полностью готово к тому, чтобы работать в условиях, когда данные каждого аккаунта находятся в разных базах данных.

За рамками этой статьи осталось еще много тем, касательно работы со многими базами данных, поддержки большого количества подключений к ним и т.д. Постараемся о них рассказать в дальнейшем.

2 комментария: