В мире разработки программного обеспечения непрерывно растут требования к производительности и масштабируемости систем. Особенно остро это чувствуется в проектах, где требуется эффективно работать с крупными объёмами данных, а их обработка должна происходить быстро и надёжно. Один из таких проектов - Matrix Rust SDK, представляющий собой набор библиотек на Rust для создания клиентов Matrix - открытой системы обмена сообщениями. Важную роль в работе SDK играет эффективное сохранение и извлечение данных из баз данных, среди которых широко используется SQLite. История оптимизации одного из запросов к SQLite внутри Matrix Rust SDK стала настоящим примером того, как грамотный подход к анализу и переработке SQL-запросов способен кардинально улучшить производительность до невероятных показателей.
В этой истории скрыто множество ценных уроков и практических советов, которые будут полезны разработчикам всех уровней. Matrix Rust SDK и структура данных LinkedChunk Matrix Rust SDK поддерживает работу с различными базами данных, включая in-memory хранилище, IndexedDB и SQLite. Следует отметить, что именно SQLite занимает значительное место благодаря своей простоте и универсальности. Важным элементом SDK является пользовательский тип данных - LinkedChunk, который представляет собой структуру, напоминающую связанный список, но с некоторыми уникальными особенностями. Каждый узел LinkedChunk содержит либо набор событий (Items), либо так называемый разрыв (Gap).
Такая архитектура обусловлена спецификой протокола Matrix и необходимостью оптимально обрабатывать последовательности событий в комнатах. В базе данных LinkedChunk представлен несколькими таблицами. Первая - linked_chunks - содержит информацию о каждом чанкe, включая связи с предыдущими и следующими, а также тип содержимого - события (обозначены "E") или разрыв ("G"). Данные об отдельных событиях связаны с чанками в таблице event_chunks, где перечислены идентификаторы событий и их позиции внутри чанка. Таблица gap_chunks хранит данные о разрывах.
Кроме того, полный набор событий, включая как встроенные в чанки, так и вне их ("out-of-band"), находится в таблице events, где для каждого события хранится его идентификатор и зашифрованный JSON-контент. Причины возникновения проблемы с производительностью Существенную проблему выявил один из продвинутых пользователей, который стал жаловаться на значительные задержки при синхронизации. Из-за того, что SDK используется в разнообразных реальных условиях на устройствах пользователей, прямой доступ к процессу профилирования отсутствовал, однако разработчики смогли выстроить систему детального логирования временных затрат на выполнение ключевых участков кода. Специальный тип таймера - TracingTimer - позволял фиксировать время выполнения функций и генерировать логи, которые затем анализировались с помощью утилит поиска и фильтрации. Одним из медленных мест стало выполнение метода load_all_chunks_metadata, затрачивавшего до 100 секунд на обработку одного запроса.
Этот метод запускал SQL-запрос с LEFT JOIN между linked_chunks и event_chunks для подсчёта количества событий в каждом чанке. При близком рассмотрении стало ясно, что запрос оказался невероятно неэффективным. Поясним детали. Наличие в таблице linked_chunks и чанков с типом "Gap" - без содержащихся событий - приводило к тому, что SQLite для каждого такого чанка с типом G пытался выполнять JOIN с таблицей event_chunks. Поскольку соответствующих событий у разрывов не было, фактически происходило сканирование всех событий, что вызывало поистине колоссальные накладные расходы.
При наличии сотен разрывов и тысяч событий это приводило к миллионам бесполезных сравнений. Важность индексов как решения? На первый взгляд, естественным решением выглядело создание индексов по колонкам, используемым в JOIN, чтобы ускорить поиск. От индексов в базах данных ожидают преобразования затрат с линейных в логарифмические. Однако в данном случае использование индекса оказалось далеко не оптимальным вариантом. Помимо того, что индексы требуют дополнительного пространства и времени на поддержание, они по-прежнему заставляли SQLite обходить данные для чанков с типом "Gap", выполнения которых можно было избежать вовсе.
Рассмотрение столбца type, ограниченного значениями 'E' или 'G', открывало возможности для логического обхода проблемы. Можно было избежать запроса к большому числу строк таблицы event_chunks, задав условие, что для "Gap"-чанков количество событий равно нулю без дополнительного подсчёта. Здесь на помощь пришёл SQL-оператор CASE, способный возвращать разные результаты в зависимости от значения поля в выборке. Первое серьёзное улучшение: перевод запроса на использование CASE В новой версии запрос перестраивался так, чтобы для чанков с type = 'E' считать количество связанных событий с помощью подзапроса COUNT, а для type = 'G' сразу возвращать ноль. Это позволило избежать LEFT JOIN и устранить избыточные сканирования таблицы event_chunks.
Бенчмарки показали резкий рост скорости выполнения запроса - примерно в 12,6 раза быстрее, чем первая реализация, достигнув обработки почти 250 тысяч элементов в секунду со значительно меньшим временем отклика около 40 миллисекунд. Такое улучшение позволило уже говорить об отлично оптимизированной работе, но команда не остановилась на этом. Революция с разделением логики на два отдельных запроса Проанализировав сценарий подробнее, разработчики обнаружили, что нынешняя реализация всё равно заставляет СУБД выполнять подзапросы один за другим для каждого чанка типа "E". При значительном количестве чанков (например, 100 и более) это вызывало массовые повторения обращений, что отпускало простор для дополнительной оптимизации. Читалка "холодной воды" пришла вместе с идеей разделить задачу на два запроса.
Первый - один запрос к таблице event_chunks, который сгруппирует и посчитает количество событий для каждого чанка по chunk_id и вернёт это как хэш-таблицу (HashMap) внутри Rust-кода. Второй - отдельный запрос в linked_chunks, извлекающий список чанков для linked_chunk_id и их метаданные. Далее сама Rust-логика объединит результаты, сопоставляя количество событий с каждым чанком по идентификатору, подставляя нули для чанков типа "G", у которых нет связанных событий. Такой подход позволил на порядок сократить число SQL-запросов, они стали выполняться пакетно и параллельно. Результат впечатляет - теперь система может обрабатывать более четырёх миллионов элементов в секунду, что в сравнении с исходными 19 тысячами является улучшением в 211 раз.
Время выполнения упало с 500 миллисекунд до 2 миллисекунд, что говорит об исключительной эффективности реализованного решения. Почему это важно Matrix - это экосистема, в которой отдельные пользователи могут быть в тысячах комнат одновременно. Плюс существуют боты и сервисы, участвующие в десятках тысяч комнат, которые должны работать максимально быстро и экономно расходовать ресурсы. Производительность на таком уровне критически важна как для UX, так и для ресурсного бюджета. Оптимизация запросов - часто незаметный, но ключевой аспект разработки.
В данном случае изменения коснулись только SQL-запросов, а схема базы данных и структура SDK остались прежними. Это подчёркивает силу грамотного анализа и оптимизации кода без необходимости переписывать архитектуру или вводить дополнительные внешние инструменты. Уроки из опыта Matrix Rust SDK Эффективная работа с базами данных требует понимания особенностей данных и логики доступа к ним. При проектировании стоит учитывать, где можно избежать избыточных соединений и обращений к таблицам. Нередко кажется, что добавление индексов - универсальный способ, но иногда более элегантные логические преобразования, такие как использование CASE, приводят к лучшим результатам.
Меры профилирования и логирования обеспечивают ценные сведения о проблемах производительности, особенно в условиях работы на конечных пользовательских устройствах, где классические инструменты профилирования недоступны. Важна помощь пользователей и тесная обратная связь. В случае Matrix Rust SDK это история коллективных усилий, объединяющих знания об архитектуре, SQL и Rust, которые вместе позволили добиться выдающихся результатов. Итог Оптимизация запроса, направленная на подсчёт количества событий в чанках через умное использование условий CASE, позволила увеличить производительность почти в 13 раз. Разбиение процесса подсчёта на два отдельных запроса и последующая агрегация в коде увеличили скорость ещё в 17 раз, доведя общую производительность до четырёх миллионов событий в секунду.
Пример Matrix Rust SDK - это образец того, как системное мышление, знание деталей реализации баз данных и готовность экспериментировать с архитектурой запросов способны привести к впечатляющим результатам. Для разработчиков, работающих с SQLite и другими СУБД, это напоминает о необходимости внимательно относиться к структуре запросов и природе данных, чтобы получить максимальную отдачу от используемых технологий. Подобные истории производительности актуальны не только для сложных систем обмена сообщениями, но и для любых приложений, где важна скорость обработки больших объёмов данных без потери надёжности и стабильности. .