Этот пост из серии Витесс от Square. Посмотрите предыдущие сообщения здесь, здесь и здесь.

Внимание, мы переехали! Если вы хотите и дальше следить за последними техническими новостями Square, посетите наш новый дом https://developer.squareup.com/blog

База данных Sharding Cash с Витессом была масштабным начинанием, которое настраивало нас на будущее, но это было только начало пути. Стремительный рост нашего трафика потребовал сосредоточить внимание на максимально быстром разделении данных на несколько машин. Переход от традиционной базы данных к распределенной требует некоторого внимательного рассмотрения, поскольку при изменении ваших шаблонов доступа к данным становятся очевидными тонкие эффекты. Отсутствие учета некоторых из этих эффектов фактически ухудшит производительность по мере добавления дополнительного оборудования под капотом.

Модель группы сущностей

Кэш организует данные в Vitess вокруг концепции групп сущностей. Группа сущностей - это просто строка данных в одной таблице (корень группы сущностей) и все строки, связанные с этой строкой (дочерние элементы группы сущностей). Все дочерние элементы группы сущностей хранят идентификатор корня группы сущностей, поэтому вы можете отделить свои данные от этого идентификатора, тем самым гарантируя, что все, что связано с этой группой сущностей, будет находиться в одном сегменте.

Vitess находится между вашим приложением и несколькими экземплярами MySQL, представляя то, что кажется обычным интерфейсом MySQL для уровня приложения. По мере прохождения трафика запроса он может проверять предложения WHERE в поисках подсказок относительно того, в каком сегменте на самом деле находятся ваши данные. Таким образом, если вы сегментировали свою базу данных динозавров по идентификатору и должны были сделать что-то вроде:

SELECT * FROM dinosaurs WHERE dino_id = 32

Vitess может посмотреть на этот dino_id = 32 и определить, что он находится в сегменте B, который, согласно внутренней топологической карте Vitess, живет в базе данных MySQL 002. Таким образом, Vitess прозрачно перенаправит ваш запрос в эту базу данных, и вы сможете продолжить свою работу.

Предположим, у вас есть еще одна таблица яиц, где каждое яйцо принадлежит определенному динозавру. В модели Entity Group динозавры, таким образом, являются корнями группы сущностей, а яйца - дочерними элементами группы сущностей. Таким образом, в каждом ряду яиц будет dino_id, представляющий своего динозавра.

В устаревшем коде есть точечные запросы, и это проблема

Прелесть Vitess в том, что он делает массивно распределенную базу данных похожей на простой старый экземпляр MySQL для вашего уровня приложения, но на самом деле вы все еще общаетесь с массивно распределенной базой данных. Если вы начинаете думать о шардинге с первого дня (как делает Миск), с этими проблемами довольно просто справиться; однако, когда вы на лету отказываетесь от традиционной топологии базы данных, некоторые из ваших предположений придется изменить.

В системе с одной базой данных вы можете искать конкретное яйцо по идентификатору:

SELECT * FROM eggs WHERE egg_id = 24

Это будет нормально: вы берете яичный ряд и продолжаете свои дела. Однако популярность базы данных динозавров стремительно растет, и теперь у вас слишком много яиц, чтобы их можно было надежно обслуживать из одной базы данных. Вы настраиваете Vitess, делаете кучу шардов и продолжаете отгрузку функций. Проблема, однако, в том, что WHERE egg_id = 24 не дает ни малейшего представления о том, к какому сегменту вы пытаетесь получить доступ. Поэтому у Витесса нет другого выбора, кроме как искать все ваши осколки. Под капотом промежуточное ПО будет разветвлять ваш запрос на все известные ему осколки динозавров, а затем собирать выходные данные в один набор результатов: классический шаблон рассеивания-сбора.

Это, конечно, то, о чем мы знали до того, как приступили к работе по сегментированию базы данных. Однако это будут делать только запросы, не содержащие идентификатор корня группы сущностей. Таких запросов было меньшинство, поэтому разделение нагрузки на два, четыре или даже восемь осколков все еще является улучшением. После выполнения разделения, которое удваивает ваши шарды, если каждый из ваших шардов забирает 70% трафика, который принимали предыдущие шарды, это все равно победа. Но по мере увеличения количества сегментов эти запросы с разбросом становятся все дороже и дороже. Один из способов подумать об этом заключается в том, что как ваши сегменты данных, ваши запросы с разбросом не работают - если один экземпляр базы данных получал 10000 запросов в секунду от разброса до сегментирования, оба новых осколка будут получать 10000 запросов в секунду из этих разбросов. Ваши базы данных тратят время на обработку всех этих разветвленных запросов, что означает, что у них меньше места для обработки других запросов.

Так как же решить эту проблему?

Измерение пульса

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

К счастью, Vitess предоставляет статистику решений, принятых при фильтрации SQL через планировщик запросов. Запросы, имеющие отдельную подпись, кэшируются и подсчитываются¹ и доступны на очень удобной странице отладки. Таким образом, легко проверить, что SELECT * FROM eggs WHERE egg_id = :val был вызван 45 000 раз, и в результате будет получено SelectScatter, что, по словам Витесса, означает «мне пришлось везде искать эти данные».

Мы написали несколько довольно простых инструментов для анализа страницы статистики плана запросов Vitess и выявления наиболее серьезных нарушителей. Результаты были довольно отрезвляющими: 25% трафика нашей базы данных было результатом рассеянных запросов. Ой!

Это глупое использование большого количества оборудования. Как мы сделали это лучше?

Создание кода, ориентированного на Витесс

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

SELECT * FROM eggs WHERE dino_id=32 AND egg_id=24

Очевидно, для этого необходимо заранее знать идентификатор динозавра, но на практике такие строки часто лениво загружаются нашим ORM (Hibernate), поэтому идентификатор не используется в коде. Немного поработав, может быть довольно просто добавить дополнительный параметр в сгенерированный запрос.

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

SELECT * FROM eggs WHERE (dino_id, egg_id) IN ((32, 24), (34, 12))

Однако на самом деле он разлетится!

Чтобы исправить это, требуется дополнительное заклинание, которое существует только для того, чтобы дать Витесс намек на то, где искать:

SELECT * FROM eggs WHERE dino_id IN (32, 34) AND (dino_id, egg_id) IN ((32, 24), (34, 12))

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

Использование содержания

Есть еще один класс проблем, которые не могут быть устранены приложением. Предположим, у вас есть список идентификаторов яиц, и вы хотите найти динозавров-хозяев. Очевидно, что если вы пытаетесь найти динозавров, вы не знаете их идентификаторов! К счастью, у Vitess есть функция для решения этой проблемы: поиск VIndexes².

VIndex - это концепция распределенного индекса Витесса. Подобно тому, как индекс базы данных использует информацию в запросе для эффективного поиска строки, которую вы ищете, VIndex использует ту же информацию для поиска правильного осколка. Lookup VIndex - это специальный VIndex, поддерживаемый таблицей базы данных и предназначенный для решения этой самой проблемы. Это работает довольно просто: таблица яиц будет настроена на уровне Vitess, чтобы иметь Lookup VIndex, назовем его eggs_egg_id_lookup. Это будет соответствовать таблице, в которой есть столбцы egg_id и dino_id. Теперь всякий раз, когда таблица яиц изменяется, таблица поиска будет обновляться, чтобы иметь соответствующую пару полей. Теперь, когда вы выполняете свой запрос, Витесс знает, что в столбце egg_id таблицы яиц есть VIndex, и будет обращаться к нему, чтобы найти dino_id, связанный с ним.

При использовании этого метода следует учитывать несколько моментов. Обращение к поиску - это дополнительный обход базы данных туда и обратно, который добавит немного задержки к общему запросу. Однако это определенно предпочтительнее, чем поражение каждого отдельного осколка! Побочным эффектом этого метода является то, что поиск необходимо обновлять перед данными. Запись поиска и данные имеют разные ключи сегментирования, поэтому их необходимо обновлять в отдельных транзакциях³. Если поиск обновился во второй транзакции и завершился ошибкой, не было бы строки, действующей как сопоставление этого поля с идентификатором шарда. Если бы вы затем запросили по полю, Vitess проверил бы поиск, ничего не нашел и не вернул бы результатов. Нехорошо. Вместо этого происходит обновление Lookup до внесения каких-либо изменений в данные, и если вторая транзакция завершится неудачно, в Lookup останутся строки, которые ни на что не указывают. На самом деле это не большая проблема, потому что поиск только намекает, что что-то есть в осколке, но не на самом деле. Пока поиск не будет очищен (обычно с перестроением⁴), мы будем тратить время на поиск данных, которых нет, но это намного предпочтительнее, чем полностью ошибаться.

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

Еще одна интересная проблема заключалась в том, что индексы не поддерживали поля NULL. Это во многом связано с тем фактом, что NULL - это особое значение в MySQL; NULL никогда ничему не равен, поэтому большая часть логики сравнения в Lookup выходит за рамки окна. Однако на практике, когда вы работаете с незащищенной базой данных, довольно часто используются поля NULL. Тогда поиски по этим полям станут одинаково обычными. Чтобы обрабатывать существующие запросы, подобные этому, мы добавили поддержку таких полей в поисковые индексы. Изменения заключались в том, чтобы отображать поле NULL вообще без осколка, и Витесс согласился с этим. Это имеет побочный эффект, заключающийся в том, что поиск строк, в которых поле имеет значение NULL, вероятно, не даст вам ничего полезного без каких-либо других подсказок, но я лично считаю, что это скорее особенность: альтернатива - всегда разбрасывать такие запросов, поскольку вы можете практически гарантировать, что каждый шард будет иметь хотя бы одну такую ​​строку.

Разумное использование наших ресурсов

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

Этот пост является частью Витессской серии Square.

¹ Это само по себе является причиной того, что затраты ЦП на выполнение планирования запросов перед передачей запроса в базу данных для следующего цикла планирования запросов не являются разрушительными.

² Vitess использует шаблон именования, добавляя букву «V» перед общими компонентами базы данных. Просто катайтесь с этим.

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

⁴ Когда мы запускаем новый поиск, его необходимо заполнить из исходной таблицы, и самый простой способ удалить ненужные строки из поискового запроса - начать все заново. Раньше это задание выполнялось приложением, но недавно мы добавили в Vitess функцию, позволяющую заполнять собственные поисковые запросы. Теперь проблема в том, что слишком легко выполнить поиск. Часто бывает лучше написать что-нибудь более элегантное, что сделает работу без дополнительных накладных расходов. Не самая страшная проблема.