mondayDB — это новый внутренний механизм данных, который мы создали в monday.com. Это изменило парадигму данных всей организации и, безусловно, является самым сложным и полезным проектом, над которым я имел удовольствие работать.

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

Слово о нашем мире

Давайте кратко рассмотрим, чем занимается monday.com. Если вы знакомы с нашей платформой, можете пропустить этот раздел.

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

Ключевым строительным блоком нашей платформы является «доска». По сути, это очень богатая таблица для управления любыми данными — от задач и проектов до сделок, маркетинговых кампаний и всего, что вам нужно для управления командой или бизнесом. На каждой доске есть столбцы, которые могут содержать ряд вещей, от простых, таких как текст, числа или даты, до более сложных типов, таких как человек, команда, тег или даже файлы и формулы. Мы предлагаем более 40 типов столбцов, и наши пользователи могут свободно фильтровать, сортировать или объединять практически любую комбинацию столбцов, каждая со своей собственной логикой для таких операций. Например, если вы фильтруете по человеку, вы можете сделать это по его электронной почте, имени или даже команде, частью которой он является.

Помимо данных, хранящихся на досках наших клиентов, мы предлагаем надежные функции, такие как инструменты для совместной работы, сложные информационные панели, настраиваемые формы, документы и сложные средства автоматизации. Разработчики также предлагают бесконечное количество интеграций или приложений через наш Apps Marketplace. Возможности продуктов и рабочих процессов, разработанных на нашей платформе, практически безграничны — на ней можно построить буквально бесконечное количество продуктов (даже систему лагерей беженцев).

Вы упомянули БД, о чем это было?

Давайте сломаем это. До недавнего времени, когда пользователь приземлялся на свою доску, мы загружали все данные с доски прямо в клиент (обычно веб-браузер, работающий на настольном компьютере).

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

Так что да, это было супермощно, но у этого подхода были очевидные ограничения. Прежде всего, клиент ограничен в своих ресурсах. В зависимости от клиентского устройства и структуры платы он начинал давать сбои и, наконец, вылетал после нескольких тысяч элементов («строки таблицы» в терминологии monday.com). Если бы мы действительно подтолкнули его, мы могли бы обрабатывать до 20 тысяч элементов. Кроме того, игра была окончена.

Что-то явно должно было измениться. Поэтому мы решили перенести все на сторону сервера, позволив клиенту получать только часть элементов на страницах по мере их прокрутки. Это было проще для клиента, но не так тривиально для бэкенда.

Просто найдите минутку, чтобы оценить требования, которым должна соответствовать наша БД:

  • Неограниченное количество столов
  • Бессхемные таблицы
  • Фильтровать по чему угодно (не зная этого «чего угодно» заранее)
  • Сортировать по чему угодно (опять же, ничего не зная заранее)
  • Агрегировать чем угодно (вы поняли, не зная…)
  • Динамические значения по формуле (= определяемая пользователем функция)
  • Присоединяется
  • Низкая задержка
  • Горизонтальное масштабирование
  • Пагинация
  • Актуальность данных (вставленные данные сразу доступны для запросов)
  • Гибридный режим (та же логика должна была выполняться на клиенте для небольших плат)
  • Разрешения на уровне таблицы (не позволяя никому видеть какую-либо доску)
  • Разрешения на уровне элемента (не позволять никому видеть какой-либо элемент)

И, конечно же, все это счастье должно было быть надежным, устойчивым, отказоустойчивым и прочими красивыми словами.

Вы когда-нибудь сталкивались с таким количеством требований к базе данных?

Создание собственной БД, серьезно?!

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

Это, вероятно, заслуживает отдельного сообщения в блоге, но поверьте мне, мы изучили многие из них, включая традиционные базы данных РСУБД с разделами (например, экземпляр MySQL для каждой учетной записи с выделенной таблицей для каждой платы), ElasticSearch, аналитические базы данных, такие как Apache Pinot, ClickHouse или Apache Druid, а также широкий спектр NoSQL, таких как CockroachDB, Couchbase и другие.

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

Тем не менее, мы заметили, что несколько небольших компаний столкнулись с подобной дилеммой. Угадай, что? Они построили свое дело. Porcella от Google, Husky от Datadog и Snowflake Elastic Warehouse — вот несколько примеров. Поэтому мы прочитали все их технические документы и приняли многие из их ключевых концепций с адаптациями к тому, что нам было нужно, чтобы ВЫПОЛНИТЬ.

Концепция № 1: Столбчатое хранилище

В традиционной РСУБД, такой как MySQL, строка является главной, и все данные строки хранятся вместе на диске как атомарная единица. Хотя эта настройка работает гладко при доступе ко всем данным из этой строки, она менее эффективна при выполнении таких операций, как фильтрация определенного столбца. Это связано с тем, что вам нужно будет извлечь все данные таблицы, если вы заранее не подготовили индекс столбца (что мы не можем сделать без предварительного знания схемы или запросов).

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

Чтобы понять причину этой идеи, визуализируйте следующую таблицу с точки зрения традиционного «хранилища строк» ​​и сравните ее с «хранилищем столбцов»:

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

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

Наконец, столбчатая структура открывает массу возможностей для оптимизации. Будь то сжатые данные или предварительно рассчитанные метаданные, которые могут ускорить работу. Вот пример: для столбца с низкой кардинальностью мы можем заранее подготовить идентификаторы элементов для каждого уникального значения. Это означает, что выполнение фильтров по этим значениям практически не займет времени обработки.

Концепция №2: Лямбда-архитектура

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

  1. Получить данные ячеек всего столбца.
  2. Найдите, а затем обновите данные конкретной ячейки.
  3. Перезапишите все данные ячеек обновленного столбца.

Да, и чтобы предотвратить любые условия гонки из-за нескольких одновременных обновлений, необходимо заблокировать запись во время обновления.

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

Лямбда-архитектура спешит на помощь. Не путать с лямбда-функциями AWS, они были изобретены индустрией больших данных, чтобы вы могли заранее вычислять результаты запросов к вашим данным в автономном режиме. Это означает, что запросы выполняются быстро во время выполнения. Основное преимущество заключается в том, что он по-прежнему обслуживает свежие данные, недавно полученные после того, как произошел последний предварительный расчет в автономном режиме.

Разделим нашу систему на три составляющие:

  1. Слой скорости — содержит только недавно измененные данные.
  2. Пакетный слой — содержит все прошлые исторические данные
  3. Уровень обслуживания — обслуживает запросы, объединяя данные уровней скорости и пакетной обработки во время выполнения.

данные передаются от скорости к пакетному уровню. Это обрабатывается запланированным автономным заданием, которое очищает данные, создает метаданные и выполняет другие сложные предварительные вычисления.

Немного увеличив масштаб, мы используем Redis в качестве хранилища скоростного слоя и Cassandra в качестве хранилища для пакетного слоя. Оба эти хранилища рассматриваются как простое хранилище ключей и значений, где ключ соответствует идентификатору столбца, а значение — данные ячеек столбца.

Давайте снова рассмотрим поток обновления для одной ячейки.

  1. Мы сохраняем значение ячейки в хранилище нашего уровня скорости.

Вот и все. Никаких замков. Так быстро, как это возможно.

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

  1. Получить все данные столбца из пакетного слоя.
  2. Получить все накопленные обновления со слоя скорости.
  3. Объединить.
  4. Перезапишите обновленные данные столбца на пакетный слой.
  5. ‹Представьте здесь всевозможные предварительные расчеты для оптимизации чтения›

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

Что мы получили?

  • Это решило нашу проблему с выборкой/перезаписью столбца.
  • Недавно обновленные данные сразу доступны для запросов.
  • В фоновом режиме мы можем потратить время на предварительный расчет метаданных, индексов, представлений, сжатий и многого другого.
  • Мутации данных происходят очень быстро.
  • Замки не задействованы.

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

Концепция № 3: Отделите хранилище от вычислений

Природа нашей системы такова, что ее пропускная способность динамична в течение дня. Большинство наших клиентов находятся в часовом поясе США. Это приводит к значительному увеличению пропускной способности в обычные рабочие часы в США.

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

Суть в том, что мы хотим, чтобы наша архитектура была эластичной. Когда нам нужно больше вычислительной мощности (ЦП) для более тяжелых запросов или в часы пик, мы хотим увеличить количество серверов обработки. Точно так же мы хотим иметь возможность масштабировать наш уровень хранения в соответствии с нашими требованиями к емкости данных, независимо от каких-либо соображений обработки данных.

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

Итак, вы фильтруете эффективно… что дальше?

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

Это выходит за рамки этого конкретного поста, но чтобы дать вам только суть, это основной поток:

  1. Запрос выполняется с описанной выше архитектурой, сужая идентификаторы элементов, соответствующие условиям фильтрации.
  2. Идентификаторы элементов хранятся в быстром временном хранилище.
  3. Мы создаем уникальный идентификатор ответа на запрос, назначенный этому результату запроса.
  4. На основе конкретной страницы, запрошенной клиентом, мы берем следующие N идентификаторов элементов и извлекаем все их данные из нашего специализированного хранилища элементов. Обратите внимание, что это не столбцовое хранилище — это обычное хранилище строк, которое невероятно эффективно, когда речь идет о выборке полных строк с использованием определенных идентификаторов.
  5. Мы возвращаем элементы клиенту вместе с идентификатором ответа на запрос.

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

Прелесть этого подхода в том, что нам не нужно повторно выполнять запрос на каждой странице, запрашиваемой клиентом. Кроме того, клиент может пропустить по определенному смещению, не просматривая все элементы ниже этого смещения (представьте, что пользователь нажимает клавишу «конец», и мы мгновенно получаем последнюю страницу).

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

Однако этот подход не лишен недостатков. Например, как пользователь узнает, как отобразить страницу для имитации длинной полосы прокрутки? Или выяснить, какое смещение пропускать при быстрой прокрутке? И как они могли обновить представление с живыми мутациями других с момента последнего снимка?

Это отличные вопросы (+1 себе), но они действительно заслуживают отдельного поста в блоге.

Это сработало?

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

Доска (› 5 тыс. элементов), время загрузки p99

Sпараллельное сравнение с mondayDB и без него

Что дальше?

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

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

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

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

Наше путешествие только началось.