Скорость имеет значение. Это всегда важно и всегда интересно. В контексте информационных систем скорость и производительность – это то, что может отличать хорошую систему от совершенно непригодной для использования. Для программиста скорость – это ещё и спортивный азарт. Тот, кто испытывал удовольствие от увеличения производительности системы в 2, 3, 10 раз, понимает, о каком азарте идёт речь. Для пользователя вопрос производительности – это не только вопрос комфорта, но и экономический вопрос. Выше производительность – ниже потребности в серверных мощностях, а значит, ниже затраты на инфраструктуру. Кроме того, выше производительность – значит, быстрее получение значимого для бизнеса результата.

Мы в компании «Магнит» много лет строим и эксплуатируем корпоративное хранилище данных и занимаемся различными задачами, связанными с этим. В частности, разрабатываем инструмент для конечного пользователя – систему отчётности и BI, о которой в статье речь и пойдёт. 

Про саму систему и предпосылки её создания мы уже рассказывали в статье «Импортозамещение BI своими руками». Сейчас хотим рассмотреть вопросы оптимизации производительности системы. Вопрос производительности в контексте такой системы, как разрабатываемая нами, имеет несколько аспектов – их мы ниже последовательно и рассмотрим. 

Статья вышла довольно длинной, поэтому вначале кратко приведём тезисы её разделов. 

В разделе «Взаимодействие с СУБД» рассматриваются вопросы влияния BI-системы на скорость получения данных на стороне СУБД, а именно вопросы оптимизации формируемых со стороны BI-системы SQL-запросов. Оптимизация SQL-запросов позволяет в несколько раз сократить время выполнения запросов на СУБД. Предложены две очень востребованные оптимизации. 

В разделе «Система очередей» рассматриваются вопросы управления очередью задач на формирование отчётов с учётом комбинации использования различных ресурсных пулов: пула потоков выполнения запросов самой системы отчётности и пулов соединений с СУБД. Система очередей стоит на страже ресурсов сервера, не позволяя системе деградировать. 

В разделе «Синхронные и асинхронные запросы» рассматривается дилемма организации взаимодействия между клиентской частью приложения и серверной: какие запросы имеет смысл выполнять в синхронном режиме, а какие – в асинхронном. Выбор между методами влияет, во-первых, на пользовательский опыт работы с приложением, во-вторых, требует разных подходов к управлению ресурсами сервера. 

В разделе «Высокоинтенсивные вычисления» рассматривается задача оптимизации алгоритма вычисления агрегирующей функции count distinct (количество различных значений). 

В разделе «Эффективность алгоритмов фронтенда» рассмотрены вопросы оптимизации отрисовки большого объёма данных в браузерном клиенте и оптимизации взаимодействия с сервером. В частности, разобран пример оптимизации рендеринга большой таблицы, содержащей встроенные в ячейки визуальные компоненты. 

В разделе «Другие аспекты производительности» кратко рассмотрены вопросы оптимизации взаимодействия с репозиторием метаданных и файлового экспорта большого объёма данных. 

Автор выражает благодарность за плодотворное сотрудничество своим замечательным коллегам по разработке системы «Магрепорт», вместе с которыми уже несколько лет увлечённо решает описанные и многие другие задачи. 

Взаимодействие с СУБД

Вообще говоря, под BI-системой часто понимаются совершенно разные по своей концепции, архитектуре и базовым сценариям использования системы. Для нас BI-система – это такая система, которая должна из некоторого источника получать некоторый объём данных и предоставлять пользователю удобные и богатые возможности анализа этих данных. И причём чаще всего речь идёт о получении данных по запросу пользователя в интерактивном режиме. А в роли источника очень часто выступает реляционная СУБД. Для нас, как, наверное, и для очень многих, этот тип источников имеет первостепенную важность. 

По имеющейся у нас статистике, без учёта ожидания в очереди на выполнение (об очередях будет рассказано ниже) до 90% времени выполнения отчёта – это время выполнения на стороне БД. Остальное время – время сохранения и какой-то дополнительной обработки данных на стороне BI-системы (экспорт в файловый формат, взаимодействие с сервером шифрования). И это с учётом того, что у нас достигнут достаточно высокий уровень оптимизации запросов к БД. Это означает, что борьба за скорость получения данных должна вестись в плоскости оптимизации выполнения запросов на стороне БД. Довольно очевидный вывод, но, работая с другими BI-системами, мы неоднократно встречали свидетельства его игнорирования. 

Мы с самого начала разработки нашей системы фокусировались на качественном взаимодействии с СУБД и, в частности, на оптимизации SQL-запросов, порождаемых BI-системой. Нам были известны некоторые традиционные проблемные ситуации, которые мы попытались разрешить. Они связаны, как правило, с игнорированием некоторых особенностей работы СУБД и архитектуры данных: партиционирования, индексов, скорости обработки данных различных типов. Ниже они рассмотрены и описаны их решения.  

Все рассматриваемые проблемы формирования SQL-запросов связаны с формированием предиката запроса (секцией WHERE запроса SELECT) – это именно то, что делает наша система: добавляет предикат при обращении к витрине данных. Если бы речь шла о более сложной структуре запроса, например, при обращении к таблице фактов с соединением со справочниками по схеме «звезда», то тогда бы могли возникать ещё некоторые другие особенности формирования оптимального запроса. Запросы по такой схеме «Магрепорт» пока не делает, но, возможно, в будущем будет делать, и мы придём к необходимости использования ещё и других, не рассматриваемых здесь оптимизаций. 

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

Довольно часто пользователь имеет какой-то список сущностей, по которым хочет получить данные. Он идентифицирует эти сущности какими-то ключами, которые ему понятны и хорошо знакомы и которыми он оперирует в своей работе. В нашей, например, бизнес-области пользователь часто оперирует кодами магазинов и товаров. Это строковый идентификатор, который пользователь вставляет в фильтр отчёта. Если этот идентификатор вставить непосредственно в запрос, его предикат схематично будет выглядеть условно так: 

WHERE  

ТОВАР_CODE IN (‘123746’, ‘647382’, ‘436473’, 
‘726482’, ‘373929’, ‘102945’) 

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

WHERE  

ТОВАР_ID IN (14, 17, 3, 29, 54, 41) 

Прирост производительности запроса в результате такой оптимизации весьма впечатляет. По проведённым экспериментам на нашей промышленной СУБД, являющейся центральным элементом нашего корпоративного хранилища данных, получены следующие результаты (довольно бессмысленно ориентироваться на эти показатели, потому что это показатели для некоторого модельного примера, они лишь иллюстрируют факт существенного увеличения производительности): 

  • сокращение потребления CPU: в 3–3,5 раза 

  • сокращение потребления IO: в 1,5–2,5 раза 

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

Отметим, что шаг разыменования ключей сам по себе очень быстрый и практически не заметен для пользователя. Более того, он может даже не создавать нагрузку на основную аналитическую СУБД, на которой выполняются отчёты: фильтры могут работать на выделенной быстрой СУБД, в которую реплицируются справочники и которая не несёт аналитическую нагрузку. 

Проброс фильтрации на уровень партиционирования 

Классической известной проблемой, ставшей кошмаром нашей СУБД при использовании некоторых сторонних BI-систем, стала проблема выбора пользователем значения параметра фильтрации по иерархии выше уровня партиционирования.

Например, у вас есть таблица, партиционированная по месяцу. Пользователю позволено выбирать месяцы в некотором двухуровневом иерархическом календаре Год – Месяц. Фильтр допускает возможность указания любого уровня иерархии, чем часто пользователь и пользуется: выбирает в календаре весь год вместо указания конкретных месяцев. Причём он не обязательно при этом хочет недопустимо много данных – он может другими параметрами сильно ограничивать выборку. Такой выбор пользователя иллюстрирует следующая картинка: 

 

Наша сторонняя BI-система в этом случае порождала запрос с таким предикатом: 

WHERE ГОД IN (2021) 

Для СУБД это ужасный запрос. Самые «умные» СУБД на лету постараются вычислить партиции, к которым происходит обращение, и будут делать то, что обычно называется динамическим исключением партиций. СУБД попроще будут осуществлять полное сканирование таблицы фактов. 

Решение этой проблемы снова может взять на себя BI-система при помощи аналогичного рассмотренному выше промежуточного шага получения списка нужных партиций – мы это называем пробросом фильтрации на уровень партиционирования. И наши фильтры с пробросом выбора по иерархии вниз делают как раз это. В результате предикат запроса выглядит следующим образом: 

WHERE  

МЕСЯЦ IN (202101, 202102, 202103, 202104, 202105, 202106, 202107, 202108, 202109, 202110, 202111, 202112) 

Упомянутая выше промышленная СУБД в ядре нашего корпоративного хранилища данных достаточно «умная», чтобы попытаться осуществить динамическое исключение партиций, но даже для неё прирост производительности при использовании явного указания партиций на модельном примере оказался следующим: 

  • сокращение потребления CPU: в 5 раз; 

  • сокращение потребления IO: в 5 раз. 

Система очередей

Выше было упомянуто, что доля времени выполнения запроса на СУБД составляет в среднем около 90% общего времени выполнения отчёта без учёта ожидания в очереди. Речь идёт об очереди на стороне «Магрепорта», в которой запрос ожидает выполнения на СУБД. Система очередей выполнения стала нашим ответом на возросшую конкурентную нагрузку. Технологии параллелизма выполнения, которые мы применяем, используют потоки выполнения для обслуживания параллельных задач. Бесконтрольный рост количества выполняемых потоков, как известно, ведёт к общей деградации системы и коллапсу, который нам пару раз доводилось наблюдать воочию. Поэтому нам пришлось разработать систему очередей выполнения запросов пользователей, учитывающую множественность источников данных и ограниченность пулов коннектов к ним. 

Среднее время ожидания выполнения запроса в очереди растёт с ростом общей пользовательской нагрузки на систему. Его доля может быть сколь угодно большой в общем времени выполнения запроса. Например, у нас она доходила в среднем до 60%, что, конечно, является очень высоким показателем. Никакого интенсивного ответа на её рост (то есть использующего какую-то оптимизацию) быть, конечно, не может – потому что сам по себе механизм очередей служит защитой системы от коллапса и обусловлен ограниченностью вычислительных мощностей системы. Поэтому ответ на увеличение доли времени ожидания запроса в очереди может быть только экстенсивный – увеличение серверных мощностей BI-системы.  

Правда, если взглянуть на проблему чуть шире и учесть то обстоятельство, что одним из факторов возникновения очередей на BI-системе является низкая производительность выполнения запросов на стороне СУБД, то становится ясно, что определённый простор для интенсивного ответа на эту ситуацию всё же существует: он лежит в плоскости оптимизаций на стороне СУБД – оптимизаций запросов и оптимизаций архитектуры хранения данных. Но этот фактор находится вне зоны влияния разработчиков BI-системы и является внешним по отношению к самой BI-системе. 

Рассмотрим чуть подробнее нашу систему управления конкурентными запросами. Она состоит из следующих элементов: 

  • пул потоков-исполнителей отчётов; 

  • пул соединений с СУБД; 

  • пул потоков-обработчиков OLAP-запросов. 

Потоки-исполнители отчётов выполняют все задачи по формированию запросов, обращению к СУБД, сохранению получаемых данных и экспорту этих данных в Excel. Фактически там даже несколько разных пулов и разные стадии этой работы выполняют потоки разных типов, но это детали, несущественные для рассмотрения общих принципов работы системы. Ограничение размера пула потоков исполнителей отчётов служит единственной цели – защитить операционную систему сервера «Магрепорта» от коллапса, вызванного слишком большим количеством одновременно выполняемых потоков, и увеличить общую производительность системы за счёт снижения доли накладных расходов операционной системы на переключение контекста выполняемых потоков. 

Для каждого реляционного источника данных задаётся размер пула соединений. Пул соединений призван сократить время выполнения запроса за счёт переиспользования открытых сессий (это особенно важно для обращения к справочникам). Ограничение пула коннектов для этого источника призвано контролировать нагрузку на СУБД со стороны «Магрепорта». Отметим, что в некоторых СУБД есть собственные внутренние способы контроля нагрузки: например, ограничение на количество одновременно выполняющихся запросов от имени данного пользователя или группы пользователей. В этом случае имеет смысл задавать ограничение на размер пула коннектов, согласованное с этим ограничением. 

Пул потоков-обработчиков OLAP-запросов так же, как пул потоков-исполнителей отчётов, призван защитить ресурсы сервера от истощения и систему от коллапса. OLAP-запросы в «Магрепорт» – это запросы, порождаемые встроенными в «Магрепорт» сводными таблицами. По своей сути это высокоинтенсивные вычисления, использующие данные в оперативной памяти. Точнее, при первом обращении к данным происходит считывание данных сформированного отчёта с диска и формирование виртуального куба, при последующих обращениях – работа ведётся с кубом, загруженным в оперативную память.

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

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

Например, на картинке выше представлено, как потоки-исполнители освободились и взяли в работу запросы с номерами 1, 2 и 3, использующие красный и синий источники данных. Пусть размер пула соединений к фиолетовому источнику данных равен 1 и единственное соединение уже задействовано. Тогда запросы с номерами 4 и 5 не смогут быть взяты в работу и будут отложены до тех пор, пока не отработает запрос к фиолетовому источнику. Освободившийся поток-исполнитель, обращавшийся к синему или красному источнику, будет простаивать и ждать появления новых запросов в очереди. 

В такой схеме очень важно, чтобы размеры пулов соединений к источникам данных и пула потоков-исполнителей были сбалансированы. Например, чтобы не было слишком большого пула у медленного источника – запросы к нему могут заполнить весь пул потоков-исполнителей и не дать выполнять отчёты на других источниках. Или если СУБД имеет собственные ограничения на количество одновременно выполняющихся отчётов для пользователя, от имени которого работает «Магрепорт», то суммарный размер пулов соединений к этой СУБД, использующих данного пользователя, не должен превышать это ограничение: превышение будет просто приводить к простою потоков-исполнителей. 

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

Синхронные и асинхронные запросы

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

У этой дилеммы есть следующие аспекты:

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

  • Для синхронных запросов трудно контролировать потребление ресурсов, особенно в случае, если под каждый отдельный запрос выделяется свой поток выполнения, как сделано в некоторых web-серверах (например, в tomcat). Поэтому если вы хотите ограничивать нагрузку на сервер, то лучше всего просто отказывать в обработке новых запросов, если происходит превышение лимита выполняемых запросов. 

  • Синхронные запросы довольно трудно отменять – если закрыть соединение со стороны клиента, сервер не узнает, что соединение закрыто, пока не попытается отправить что-нибудь. Поэтому если вы хотите прерывать долгую операцию, инициированную клиентом в рамках синхронного запроса при отмене этого запроса со стороны клиента, приходится использовать такой трюк: пытаться писать в сокет с некоторой периодичностью в ответ клиенту пробелы. Если у вашего ответа Content-Type=application/json, то лишние пробелы в начале json-объекта не помешают его обработке. 

  • Техника работы с асинхронными запросами существенно сложнее как на стороне сервера, так и на стороне клиента. 

В «Магрепорте» синхронные и асинхронные запросы мы используем следующим образом: 

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

  • Запросы от справочников мы делаем синхронными. Это соответствует логике работы пользователя с фильтрами – объектами, взаимодействующими со справочниками: пользователь произвёл какое-то действие, и он, как правило, ждёт каких-то данных, прежде чем совершать следующее, например, ждёт раскрытия иерархического справочника или вариантов подсказок. Никакой нужды выполнять такие операции асинхронными запросами нет. Если фильтру не удалось уложиться в отведённое время для выполнения запроса, вполне нормально выдать пользователю соответствующее сообщение об ошибке и предложить снова осуществить взаимодействие со справочником, потому что такая ситуация должна возникать крайне редко и она означает, что происходит что-то совсем ненормальное. 

  • OLAP-запросы от встроенных сводных таблиц «Магрепорт» мы делаем синхронными. В отличие от первых двух типов запросов здесь выбор подхода не так очевиден. Потому что OLAP-запросы создают высокую вычислительную нагрузку на сервер и, соответственно, требуется механизм ограничения количества одновременно выполняющихся запросов, а это аргумент в пользу очередей. Кроме того, пользователь может генерировать запросы быстрее, чем они выполняются, что тоже является аргументом за систему очередей. Но в то же время OLAP-запросы предполагаются достаточно быстрыми для выполнения – характерное время выполнения не должно превышать десятка секунд, а в большинстве случаев должно укладываться в секунду. И пользователь ждёт ответа на тот запрос, результат которого его интересует, и ничего другого не делает. Это всё аргументы в пользу синхронных запросов. В совокупности с более простой реализацией аргументы за синхронное выполнение OLAP-запросов перевешивают. В результате мы делаем так: перед отправкой нового OLAP-запроса клиент отменяет предыдущий запрос. Если происходит превышение лимита одновременно выполняемых запросов, пользователю просто идёт отказ и предлагается повторить попытку. 

Высокоинтенсивные вычисления

В контексте системы «Магрепорт» к высокоинтенсивным вычислениям относятся OLAP-запросы от встроенных сводных таблиц «Магрепорта». И их реализация предоставляет довольно широкое пространство для оптимизации. Нам приходилось сталкиваться с разными оптимизационными задачами в этом направлении. Например, критически важной была задача оптимизации алгоритма расчёта функции подсчёта количества различных значений (count distinct). Суть проблемы в следующем: когда мы говорим о сводной таблице, мы имеем в виду, что у нас есть некоторые измерения по строкам (в примере ниже A и B), некоторые измерения по столбцам (в примере ниже C и D) и метрики (в примере ниже – M), вычисляемые путём агрегации значений, соответствующих данному кортежу значений измерений по строкам и данному картежу измерений по столбцам (например, M2342 соответствует кортежам {A2, B3} и {C4, D2}): 

 

C1 

C2 

C3 

C4 

D1 

D2 

D3 

D1 

D4 

D4 

D2 

D5 

A1 

B1 

M1111 

M1112 

M1113 

M1121 

M1124 

M1134 

M1142 

M1145 

B2 

M1211 

M1212 

M1213 

M1221 

M1224 

M1234 

M1242 

M1245 

A2 

B2 

M2211 

M2212 

M2213 

M2221 

M2224 

M2234 

M2242 

M2245 

B3 

M2311 

M2312 

M2313 

M2321 

M2324 

M2334 

M2342 

M2345 

B4 

M2411 

M2412 

M2413 

M2421 

M2424 

M2434 

M2442 

M2445 

A3 

B1 

M3111 

M3112 

M3113 

M3121 

M3124 

M3134 

M3142 

M3145 

Ясно, что мы можем ввести сквозную нумерацию строк и столбцов и считать M просто двумерным массивом M[i][j], где i соответствует некоторому кортежу значений измерений по строкам, j – некоторому кортежу значений измерений по столбцам. Для того, чтобы вычислить количество различных значений, необходимо использовать какую-то реализацию множества, для оптимизации скорости работы лучше всего использовать реализацию на основе хэш-таблиц (в этом случае количество действий пропорционально количеству строк исходного набора данных).  

Первая версия алгоритма у нас строила свою хэш-таблицу для каждого M[i][j] – получилась структура M[i][j][k], где k – ключ, соответствующий агрегируемому значению. Хоть суммарное количество элементов во всех множествах при такой реализации и не превосходит размера исходного набора данных, но реализация хэш-таблиц имеет накладные расходы по памяти, имеющие особенно большой вес для множеств маленького размера. Когда мы пытались вычислить такую метрику для сводной таблицы из нескольких десятков миллионов ячеек, у нас просто заканчивалась память на сервере, и «Магрепорт» аварийно завершал свою работу. 

Мы придумали такую оптимизацию: стали использовать одну хэш-таблицу сразу для всех ячеек. Для этого в ключ хэш-таблицы включили индексы i и j. Получили структуру M[{i,j,k}], которая также содержала элементы в количестве не более размера исходного набора данных, но имела существенно меньшие накладные расходы. Заодно и на времени выделения памяти сэкономили. 

Другой пример оптимизации в этом направлении – оптимизация форматов структур исходных наборов данных и результирующих сводных таблиц: когда удаётся в 10 раз сократить объём, занимаемых одним, как мы его называем «кубом» (это не совсем куб в классическом смысле, но мы привыкли эти структуры так называть по аналогии с аналогичными объектами в других системах), ты понимаешь, что ты сможешь в 10 раз большему количеству пользователей дать возможность одновременно работать с OLAP-запросами. 

Эффективность алгоритмов фронтенда

Не только на СУБД и бэкенд-серверах, обрабатывающих большие объёмы данных, высока цена ошибок и небрежности в вопросах производительности вычислений. Фронтенд (в нашем случае браузерный http-клиент) очень здорово умеет зависать и жутко тормозить, если приходится работать с чем-то большим, чем список личных покупок, которые необходимо совершить при следующем посещении продуктового магазина (отсылка к классическому примеру, на котором построены многие тьюториалы). 

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

И первой техникой, которая используется на основании этого соображения, является пейджинация. Мы используем пейджинацию повсеместно: при показе плоской таблицы отчёта, при показе списков пользователей или ролей. Причём возникает альтернатива, как лучше реализовать: загрузить с сервера сразу всё, а потом уже на клиенте выбирать данные, соответствующие текущему отображаемому объёму данных, или запрашивать с сервера данные частями — непосредственно то, что отображается, или чуть шире. Если объём данных в байтах не слишком велик и их полная выгрузка происходит на сервере достаточно быстро, то проще и эффективнее получить сразу всё, а потом уже на клиенте рисовать визуальные компоненты по тому или иному фрагменту данных. Так, например, разумно поступить при выводе списка пользователей, если их, с одной стороны, очень много (десятки тысяч), с другой — суммарный объём данных не очень велик (около 1МБ) и получение их всех не составляет большого труда (выгрузка из одной таблицы репозитория). 

Другая причина ощущения медленной работы с приложением заключается в частых ожиданиях пользователем получения данных с сервера. Чтобы решить эту проблему, приходит на помощь техника кеширования данных: мы запрашиваем данных больше, чем нам нужно, стараясь угадать, какие данные понадобятся пользователю в следующий момент времени. Так у нас реализован показ сводной таблицы, которая может иметь достаточно большие размеры по строкам и по столбцам: если мы планируем пользователю показывать фрагмент таблицы размером n x m (n строк, m столбцов), то запрашиваем с сервера фрагмент размером N x M, где N = k x n, M = k x m для некоторого k (у нас k = 3). Если пользователь выполняет плавную прокрутку по строкам или по столбцам в любую сторону, отрисовка таблицы происходит очень быстро без обращения к серверу. Когда граница просматриваемого фрагмента приближается к границе кэшированного фрагмента, происходит фоновое обращение к серверу и кэшированный фрагмент смещается. Это совершается незаметно для пользователя, и он вообще не ощущает, что производится загрузка данных с сервера. Если, конечно, он одномоментно далеко переместит окно просмотра таблицы, ему придётся немного подождать, пока будут загружены новые данные – но это для человека выглядит совершенно естественно. 

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

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

Мы используем фреймворк React для построения интерфейса пользователя. Сначала мы полностью в соответствии с концепцией React создавали всю иерархию компонентов, и каждый компонент «ячейка» включал в себя четыре компонента «кнопка-стрелочка», которые имели исходное невидимое состояние и становились видимыми при наведении на ячейку указателя мыши. Вскоре мы выяснили, что для больших таблиц время рендера таблицы оказалось неприемлемо большим (вплоть до 2–3 секунд). Ситуация усугублялась ещё тем, что у нас был реализован подгон размера таблицы под размеры экрана, требовавший нескольких перерисовок таблицы. Мы вышли из положения при помощи следующего трюка: отрисовали таблицу так, что каждая ячейка стала обычным html-элементом td вместо составного React-компонента. Никаких кнопок в ячейке не содержалось. Зато ячейке был добавлен хендлер на событие входа указателя мыши: 

onMouseEnter={cell.type === "metricValues" ? () => handleShowArrows(id, cell) : () => {}} 

И вот уже этот хендлер создавал панель кнопок-стрелочек и размещал его в текущей ячейке: 

ReactDOM.render(layout, document.getElementById(`${id}`)) 

Влияние на производительность оказалось впечатляющим: скорость отрисовки таблицы увеличилась в 10–15 раз и стала составлять даже для очень больших таблиц около 150 мс. Отрисовка кнопок-стрелочек при наведении курсора мыши на ячейку работает очень быстро и совершенно незаметно для пользователя. 

Другие аспекты производительности

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

  • структуры и индексы таблиц; 

  • оптимальность запросов к репозиторию; 

  • использование методов пакетной вставки при вставке большого объёма данных в таблицы; 

  • отсутствие серий последовательных запросов с непредсказуемым количеством итераций (так называемая проблема «N+1»); 

  • файловое логирование вместо логирования в БД репозитория; 

  • выделение статистики в отдельные БД. 

Нет нужды подробно рассматривать вопросы оптимизации структур хранения данных репозитория и оптимизации самих запросов – это классические оптимизационные задачи СУБД. Проблема «N+1»-го запроса тоже достаточно хорошо известна и описана. Единственно отметим, что она действительно периодически возникает, особенно в тех местах, в которых изначально не предполагается получение большого объёма данных, а практика использования системы и её эволюция приводит к тому, что соответствующие запросы должны возвращать много данных. 

Вопросы логирования и сохранения статистики представляют определённые трудности, причём эти трудности оказываются СУБД-специфичными. Например, мы используем embedded H2 для репозитория – это сильно упрощает архитектуру системы по сравнению с использованием внешней СУБД. Ещё это упрощает процессы отладки и резервного копирования – достаточно просто скопировать файл БД, и мы получаем копию системы на другом сервере. Однако H2 начинает очень медленно работать, когда объём БД приближается к 1ГБ (точный критический порог, возможно, ещё от производительности подсистемы ввода-вывода зависит, но порядок величины будет примерно такой в любом случае). К сожалению, мы заранее этого не предвидели и стали вести статистику (какие отчёты были запущены, кем, в какое время, какой объём данных был получен и т.п.) в базе репозитория, из-за чего он стал сильно разрастаться.  

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

По аналогичным причинам хочется иметь логи использования системы (кто, что, когда и с чем сделал) в БД вместе с объектами репозитория, и по аналогичным же причинам этого делать нельзя. Причем с логами ситуация ещё несколько сложнее: если речь идёт о каком-то детальном логировании пользовательских действий, то их имеет смысл сначала накапливать в файле, а потом периодически отдельным процессом выгружать в ту же БД статистики для анализа, чтобы не перегружать СУБД большим потоком запросов. 

Последняя оптимизационная задача, которую упомянем в этом обзоре, – задача оптимизации файлового экспорта. С ней нам тоже пришлось сталкиваться в нашей практике, потому что пользователю приходится формировать файлы нетривиального формата (Excel) и большого размера (иногда – сотни мегабайт). Если это делать долго, пользователь, во-первых, нервничает, во-вторых, начинает думать, что что-то не сработало, и заново запускает экспорт, лишний раз нагружая систему (такие действия, конечно, стоит ограничивать – не принимать заявки на экспорт от пользователя, для которого уже формируется файл, не давать многократно нажимать кнопку экспорта или показывать прогресс).

Медленный экспорт в достаточно сложные форматы бывает обусловлен техникой формирования файла: если итоговый файл формируется как цельный объект в оперативной памяти, потом с ним выполняются ещё какие-то действия (например, задание форматирования ячеек), а потом сохраняется на диск – такой механизм, как правило, работает медленно. Альтернативой ему служит так называемый потоковый механизм, при котором данные формируются и записываются на диск фрагментами. 

Заключение

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

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

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

Комментарии (2)


  1. economist75
    31.05.2024 09:20
    +1

    Познавательно. Добавлю что в мелком "крупном бизнесе" (2-50 млрд. выр. в год) - все данные бухучета за 5 лет обычно легко помещаются в RAM на ПК аналитика (32 GB, конечно же), но уже после оптимизации типов хранимых значений. Но брать сырые (свежие, "грязные") данные - все равно приходится SQL-запросом из БД и партицировать уже в аналитической БД для BI-дэшбордов. Тут все чаще вместо быстрой SQLite можно встретить еще более быстрый DuckDB (и даже Feather-файлы для Pandas, которой хватает, если BI ведется в одно лицо).

    Очень хорошо что инженеры Магнита думают о пользователях. Быстрое получение данных провоцирует на озарения и наоборот: запросы с откликом больше 10 секунд обычно сводят полет мысли "на нет". А "из под-палки" хорошие исследования обычно не начинаются. Языку SQL нужно учить уже в школах, но не уходить слишком глубоко (например, в оконные ф-ии).


    1. V_Sukhov Автор
      31.05.2024 09:20

      Вы правильно отметили, что иногда можно целиком все данные загрузить в оперативную память на более-менее мощном ПК и работать с ними - и это будет идеально. Когда так можно сделать - так и надо делать и надо использовать соответствующие инструменты, которые предоставляют достаточно широкий функционал для работы по такому сценарию. Наш случай, над которым мы работаем все эти годы - это случай существенно большего объема данных. И тогда приходится использовать и мощные СУБД с терабайтами или петабайтами данных, и соответствующие средства доступа к ним. И здесь возникает своя специфика.