06 октября 2025 г.
Рост баз данных: от единиц запросов к тысячам
Всем привет, меня зовут Андрей Колнооченко, я разрабатываю бэкенд Яндекс Диска и отвечаю за ядро файловой системы. Мы решаем задачу хранения методанных о миллиардах файлов сотен миллионов пользователей. Часто бывает так, что в ходе работы над базой данных не продумывают то, как она будет расти и развиваться. Это ведет к деградации времени запросов и к неожиданному поведению при росте нагрузки.
Рассмотрим на практических примерах те детали, на которые мы обращаем внимание при проектировании баз данных и разработке кода для работы с базой данных. Для начала посмотрим на первый этап развития баз данных. Когда мы только-только начали проект, радостно пишем свежий код для обработки пользовательских сценарий. Постараемся предугадать заранее, где могут быть проблемы и подстелить солонку, чтобы было не больно, когда проект запустится и начнет расти по количеству запросов и по количеству данных. В зависимости от того, насколько вам дорогие данные пользователей и какой даунтайм можно допустить, нужно выбрать стратегию резервирования ваших данных.
Например, для Диска потерять информацию даже об одном файле недопустимо. И простои должны быть настолько небольшими, что для любого пользователя проблемы на клиенте должны решаться в рамках простого повторного запроса. Поэтому, помимо регулярных бэкапов, каждый инстанс базы данных реплицируется в два дополнительных дата-центра. При этом в репликации соблюдается принцип строгой консистентности записи, то есть запись не считается завершенной, пока две реплики из трёх не подтвердят запись данных на диск. Это делает запись не такой быстрый, но отлично подходит для нашего случая, когда изменение происходит сильно реже, чем чтение. Помимо этого, большая часть изменений является либо вставкой, либо удалением, а такие операции в современных СУБД исполняются очень быстро.
В наших инсталляциях мы размещаем три реплики в разных дата-центрах. И в обычных случаях картина выглядит следующим образом. Есть мастер. В одной из реплик он записал изменения синхронно. А в другую – асинхронно. Что будет, если дата-центры потеряют связность с друг с другом, и, например, мастер не сможет записать изменения ни в одну из реплик? Для защиты данных пользователя на такой запрос будет сгенерирована ошибка, и запись в старый мастер не будет сделана для защиты от ситуации split-brain. СУБД же запустит процесс смены мастера.
Допустим, реплики договорились, и реплика 1 теперь стала мастером, а в реплику 2 записи идут синхронно. Когда связанность сети восстанавливается, старый мастер становится репликой и начинает применять изменения, которые пропустил. Таким образом, его отставание от нового мастера будет уменьшаться, и когда он полностью догонит лог транзакций, то сможет тоже принимать синхронные изменения с мастера.
Мы рассмотрели резервирование данных и репликацию. Посмотрим, как происходит рост количества данных. Обычно первые пользователи дают нам информацию о примерном количестве данных в различных таблицах. И можно прогнозировать рост данных с ростом посещаемости. Первое, о чем стоит подумать, — насколько хорошо будут выполняться запросы при росте количества данных в таблицах. Если мы видим, что время запроса будет линейно расти в зависимости от количества пользователей или количества данных у них, стоит заранее озаботиться об индексах для запросов.
Конечно, чудес не бывает. На самом деле индекс — это небольшая вспомогательная табличка, которая позволяет обменять время изменений и занимаемое место на время чтения. Для разных типов сравнений используются разные индексы. Например, если вам нужно искать по полному совпадению, например, айдишника, то подойдет хэш-индекс. А если нужно искать like-ом по префиксу, то нужно создать B3-индекс. В идеале индексы должны помещаться в память на инстансе базы данных. Для оценки размера индекса мы можем использовать один из простых способов:
  • Первый способ — посмотреть на размер похожих индексов.
  • Второй способ — это склонировать какой-то инстанс базы данных, офлайн построить индекс на нем и примерно оценить, как размер индекса зависит от общего количества данных в таблице или от количества пользователей.
  • Конечно, нужно понимать, что использование индексов небесплатное. Так как помимо дополнительного места индекс требует вычислений на его подсчёт. И на каждое изменение нужно его пересчитывать.
    Существуют инструменты, которые могут указать на неиспользуемые индексы. Их удаление поможет сократить потребление памяти и ускорить добавление данных. Если появляются редкие, но очень тяжелые запросы, которые либо затрагивают данные многих пользователей, либо анализируют исторические данные, то стоит к таким запросам присмотреться. Возможно, строить индексы для них не очень рационально. Лучше будет данные для таких запросов переложить в какое-то OLAP-хранилище, такое как ClickHouse, и уже в нем выполнять запросы.
    Итак, мы рассмотрели рост количества данных в таблицах. Давайте же рассмотрим рост количества запросов. При росте количества запросов важную роль начинает играть консистентность данных, чтобы записи в разных таблицах соответствовали друг другу. Количество инстансов в приложении растет, вероятность сбоев в произвольный момент времени тоже растет, могут появляться частичные записи в случае возникновения ошибок. Также возникает проблема конкурирующих запросов, которые меняют данные одного пользователя или просто связанные данные.
    Для примера рассмотрим хранение папок в Диске. Для этого есть таблица с тремя полями. Первое поле — это id, второе — parent_id, указатель на родителя, и имя. Допустим, что мы хотим реализовать процедуру рекурсивного удаления элементов. То есть мы не можем взять и удалить какой-то узел без удаления его потомков.
    1directories:
    2    id: int
    3    parent_id: int
    4    name: str
    Поэтому вначале мы пишем примерно такой код: выбрать все выложенные объекты, их удалить и потом удалить корень. Однако временами мы начинаем замечать, что в базе данных бывают элементы с несуществующими parent_id. Из-за чего это произошло? Оказывается, мы не хотели делать локи на элементы, и поэтому параллельный процесс мог вставить элемент где-то во время удаления потомков. Что можно сделать от такой неприятности? Давайте создадим foreign key для parent_id. Таким образом, мы уже не сможем удалить элемент, если на него ссылается хотя бы один другой элемент. И операция delete упадет из-за нарушения целостности ключа.
    1def remove_element(directory_id):
    2    children = SQL('SELECT * FROM directories WHERE parent_id=:directory_id',
    3                   {'directory_id': directory_id})
    4    for child in children:
    5        remove_element(child.id)
    6    SQL('DELETE FROM directories WHERE id=:directory_id',
    7        {'directory_id': directory_id})
    Здесь мы добавляем новой foreign key и таблица выглядит следующим образом. Однако тогда получается задача не решена, раз операция удаления не удаляет всё как надо. Для гарантии полного удаления база данных дает нам специальный инструмент при создании foreign key. Это ON DELETE CASCASE. Она позволяет взять и удалить сразу все элементы, которые ссылаются на id удаляемого объекта.
    1directories:
    2    id: int
    3    parent_id: fk_directories_id[int]
    Наш код преобразуется к очень простому виду — это просто удаление корня. Этот механизм, с одной стороны, очень удобный. Во-первых, он гарантирует консистентность данных базы данных, то есть не будет висящих нод, у которых будут несуществующие parent_id. Второе, это избавляет нас от ООМ-ов на стороне кода. В то же время мы получаем и некоторые проблемы. Первая проблема — это то, что код теперь не контролирует время выполнения запроса. Так как сложность запроса O(N) от количества элементов в поддереве, Время транзакции не определено. Вторая проблема — это то, что обработка каждого элемента может требовать какую-то дополнительную работу. Например, отправку уведомлений в другие системы, записи в другие таблицы и прочее, о чем база данных не знает. Если все эти требования для вас важны, то лучше объединить код и проверку целостности со стороны базы данных.
    1SQL('DELETE FROM directories WHERE id=:directory_id',
    2    {'directory_id': directory_id})
    Здесь будет довольно большой сниппет кода, на который можно остановиться, рассмотреть повнимательнее, а я объясню, что на нем происходит. Сначала мы пробуем удалить корень директории. Если это у нас получилось, то можно запустить сайд-эффекты для этого элемента. Если же есть какие-то вложенные элементы, то мы получаем ошибку целостности ключа. И тогда мы можем выбрать все вложенные элементы и попытаться удалить их. Таким образом, мы гарантированно будем удалять все вложенные элементы, а только в конце удалим сам корень.
    1def remove_element(directory_id):
    2    while True:
    3        try:
    4            del_cnt = SQL('DELETE FROM directories WHERE id=:node_id',
    5                          {'directory_id': directory_id}).row_count
    6            if del_cnt:
    7                run_side_effects(directory_id)
    8        except IntegrityError:
    9            children = SQL('SELECT * FROM directories WHERE parent_id=:directory_id',
    10                           {'directory_id': directory_id})
    11            for child in children:
    12                remove_element(child.id)
    13        else:
    14            break
    Давайте подведем итоги. Во-первых, лучше сделать самого начала как можно больше ограничений на стороне базе данных, а уже потом их ослаблять, то есть пометить поля уникальными, добавить foreign key для тех данных, где они действительно нужны, добавить триггеры на вставку, если нужно контролировать значения в разных таблицах. Второе, нельзя допускать ситуацию, когда удаляется больше данных, чем ожидает код. И, конечно, делайте бекапы. Увидимся в следующих сериях.