Оптимизируем загрузку графиков
https://dskr.dev/blog/chart-data-compression/Последний год я переписываю аналитический продукт с большим количеством графиков. Он состоит из фронтенда, BFF на Node.js и бэкенда на Java с ClickHouse. Я отвечаю за фронтенд и BFF.
На одном из больших запросов размер сжатого ответа уменьшился с 600 до 300 КБ. При скорости соединения 4 Мбит/с время до отрисовки графика сократилось с 3 до 2 секунд. На быстрой сети экономия на передаче данных менее заметна, но время обработки ответа на сервере и в браузере тоже зависит от его структуры.
Эти цифры получены в продукте. Чтобы отдельно разобраться во влиянии структуры ответа, сериализации и сжатия, я собрал демку с синтетическими данными. Ниже покажу, какие варианты сравнивал и что получилось.
[картинка продукта]
Меняем структуру ответа
Старый формат ответа выглядел так:
{
"data": [
{"time": 1791021600, "key": {"operator": 1}, "value": {"s": 850, "b": 12.37}},
{"time": 1791021600, "key": {"operator": 2}, "value": {"s": 700, "b": 10.93}},
{"time": 1790971200, "key": {"operator": 1}, "value": {"s": 900, "b": 12.58}},
{"time": 1790971200, "key": {"operator": 2}, "value": {"s": 600, "b": 11.2}}
]
}
Массив никак не отсортирован, линия определяется ключом и метрикой. Для метрик используются короткие идентификаторы. В примере они состоят из одного символа, в продукте могут достигать десяти. Сначала я попробовал сгруппировать точки по ключу:
{
"data": [
{
"key": {"operator": 1},
"serie": [
{"time": 1791021600, "s": 850, "b": 12.37},
{"time": 1790971200, "s": 900, "b": 12.58}
]
},
{
"key": {"operator": 2},
"serie": [
{"time": 1791021600, "s": 700, "b": 10.93},
{"time": 1790971200, "s": 600, "b": 11.2}
]
}
]
}
Таким образом удаётся избавиться от повторяющегося ключа. Подготавливать такой массив для отрисовки проще, но остаются повторяющиеся имена метрик и поле time.
Побрейнштормив с LLM, я пришел к такому варианту:
{
"key": {
"operator":[1,2]
},
"time":[1790971200, 1791021600],
"series": {
"s":[[900, 850], [600, 700]],
"b": [[12.58,12.37], [11.2, 10.93]]
}
}
Значение series.s[i][j] соответствует метрике s для key.operator[i] в момент time[j]. В таком случае ключи и имена метрик больше не повторяются в каждой точке. Данные для отрисовки подготавливаются одним проходом массива. В колоночном формате отсутствующие значения приходится заполнять null. Для разреженных данных это создаёт дополнительные накладные расходы.
Так как шаг и период фиксированны, можно было бы пойти дальше и не отправлять массив со временем. Но выигрыш там не очень большой, и я решил не переусложнять.
Проверяем на демке
На примере с двумя операторами сложно понять, сколько это даст на настоящем ответе. Поэтому я собрал демку, которая генерирует набор данных и отдаёт его в нескольких форматах. На клиенте собираются одинаковые серии для графиков, так как были подозрения, что из некоторых форматов подготовка серий может занимать больше времени. Но оказалось, что из колоночного формата собирать серии даже быстрее.
Я сравнил исходный строчный формат, строчный с группировкой по ключу и два колоночных варианта с явным и вычисляемым временем.
Для каждой структуры в локальных тестах проверил JSON, MessagePack и BSON. Каждый вариант протестировал без сжатия и с gzip, Brotli, deflate и Zstandard. Для небольшого и среднего набора сделал по 100 повторов каждого сочетания. Для больших наборов и тестов на ограниченной сети использовал по 10 повторов.
Для сравнения взял три синтетических набора разного размера. Во всех таблицах ниже используются данные без пропусков. У каждой серии есть значение на каждом шаге общей временной сетки. Часть значений постоянные, часть плавно меняется, часть содержит шум. Дробные значения в генераторе ограничены четырьмя знаками после запятой.
| Набор | Метрик | Сегментов | Точек на серию | Всего значений |
|---|---|---|---|---|
| Небольшой | 2 | 3 | 60 | 360 |
| Средний | 8 | 8 | 360 | 23 040 |
| Большой | 16 | 32 | 1440 | 737 280 |
Тесты проводил на Mac с Apple M3 Pro. Сервер и Chromium находились на одном Mac, использовал production сборку, все тесты проводил последовательно. Перед серией измерений один раз прогрел каждое сочетание структуры, сериализатора и сжатия. Прогревочные запросы в результаты не включал. Node.js 24.13.0, Chromium 151.0.7922.34. В демке измеряю время от запуска запроса до подготовки серий для ECharts. Саму отрисовку здесь не учитываю. В таблицах приведена медиана времени (p50). Во всех тестах 1 КБ = 1000 байт.
Исходники и инструкция запуска, полные результаты замеров.
Помогают ли колонки, если уже есть gzip?
Возьму JSON с одним и тем же gzip level 6. Так можно сравнить формат ответа на каждом наборе, не меняя заодно сериализатор и компрессор.
Размер тела ответа, КБ
| Набор, JSON + gzip | Строки | Строки оптимизированные | Колонки | Колонки с вычисляемым временем |
|---|---|---|---|---|
| Небольшой | 2,17 | 2,08 | 1,50 | 1,36 |
| Средний | 77 | 62 | 31 | 30 |
| Большой | 2635 | 1806 | 811 | 807 |
Время до готовых серий (p50), мс
| Набор, JSON + gzip | Строки | Строки оптимизированные | Колонки | Колонки с вычисляемым временем |
|---|---|---|---|---|
| Небольшой | 0,9 | 0,8 | 0,6 | 0,6 |
| Средний | 17 | 12 | 4,8 | 4,7 |
| Большой | 623 | 357 | 126 | 133 |
Я предполагал, что сжатие может свести экономию к нулю, так как много ключей повторяются, но это оказалось не так. На небольшом наборе разница между колонками и строками 31%, на среднем 60% и почти 70% на большом.
Отказ от массива времени дополнительно уменьшил сжатый ответ на 9,2% на небольшом наборе и всего на 0,5% на большом. На большом наборе медиана выросла со 126 до 133 мс. При десяти повторах нельзя уверенно отделить этот эффект от разброса измерений.
Что добавляют другие алгоритмы сжатия?
Теперь проверим, как влияют разные алгоритмы сжатия. Возьмём большой набор данных в строчном и колоночном форматах. Для сравнения возьмём gzip и deflate level 6, Brotli quality 4 и Zstandard level 3.
Размер тела ответа, КБ
| Сжатие JSON | Строки | Колонки |
|---|---|---|
| Без сжатия | 18414 | 4927 |
| gzip | 2635 | 811 |
| Brotli | 2726 | 791 |
| deflate | 2635 | 811 |
| Zstandard | 2783 | 888 |
Время до готовых серий (p50), мс
| Сжатие JSON | Строки | Колонки |
|---|---|---|
| Без сжатия | 415 | 68 |
| gzip | 623 | 126 |
| Brotli | 552 | 92 |
| deflate | 612 | 129 |
| Zstandard | 455 | 79 |
При выбранных настройках самый маленький ответ для строк дали gzip и deflate. Для колонок лучший результат по размеру показал Brotli. При этом локальное время с gzip и deflate оказалось выше, чем с Brotli и Zstandard. Без ограничения пропускной способности затраты на сжатие не окупились уменьшением времени передачи.
А если вместо JSON взять бинарный формат?
Я не планировал переходить на бинарный формат, но раз уж взялся гонять бенчмарки, почему бы и в эту сторону не посмотреть.
Размер тела ответа, КБ
| Сериализатор колонок | Без сжатия | gzip | Brotli | deflate | Zstandard |
|---|---|---|---|---|---|
| JSON | 4927 | 811 | 791 | 811 | 888 |
| MessagePack | 4987 | 984 | 803 | 984 | 845 |
| BSON | 8729 | 3185 | 2235 | 3185 | 2156 |
Время до готовых серий (p50), мс
| Сериализатор колонок | Без сжатия | gzip | Brotli | deflate | Zstandard |
|---|---|---|---|---|---|
| JSON | 68 | 126 | 92 | 129 | 79 |
| MessagePack | 54 | 91 | 81 | 89 | 66 |
| BSON | 105 | 310 | 189 | 296 | 137 |
С gzip и deflate MessagePack оказался примерно на 21% объёмнее JSON. С Zstandard он занял примерно на 5% меньше места. При этом во всех локальных сравнениях в этой таблице медиана времени у MessagePack ниже. Но придётся добавить дополнительную зависимость.
Для этой структуры данных BSON оказался заметно объёмнее. BSON сохраняет индексы элементов массива как имена полей. Это создаёт дополнительные накладные расходы. В дальнейшее сетевое сравнение я его не включал.
Что меняется на медленной сети?
Прогнал все те же тесты с JSON и MessagePack с ограничением пропускной способности до 4 Мбит/с, без дополнительной задержки.
Размер тела ответа, КБ
| Сжатие | JSON: строки | JSON: колонки | MessagePack: строки | MessagePack: колонки |
|---|---|---|---|---|
| Без сжатия | 18414 | 4927 | 15898 | 4987 |
| gzip | 2635 | 811 | 2904 | 984 |
| Brotli | 2726 | 791 | 2488 | 803 |
| deflate | 2635 | 811 | 2904 | 984 |
| Zstandard | 2783 | 888 | 2023 | 845 |
Время до готовых серий (p50), мс
| Сжатие | JSON: строки | JSON: колонки | MessagePack: строки | MessagePack: колонки |
|---|---|---|---|---|
| Без сжатия | 37435 | 9978 | 32483 | 10074 |
| gzip | 6034 | 1765 | 6570 | 2085 |
| Brotli | 6089 | 1685 | 5690 | 1721 |
| deflate | 6013 | 1770 | 6597 | 2090 |
| Zstandard | 6192 | 1884 | 4668 | 1784 |
В этих замерах колоночный формат заметно уменьшил ответ даже поверх gzip. Переход на MessagePack после этого уже не дал однозначного преимущества. Локально обработка была быстрее, но на ограниченной сети многое зависело от размера сжатого тела.
После получения данных остаётся отрисовать график.
Когда линий слишком много
Пользователь может выбирать, по каким фильтрам группировать данные, и добавлять метрики. Каждое сочетание метрики и группы даёт отдельную линию. В результате на одном графике могут оказаться сотни линий. Такой график долго отрисовывается, а разобраться в нём становится сложно.
Поэтому одной оптимизации формата ответа недостаточно. В продукте я добавил проверки до запроса данных и упрощение отрисовки после загрузки.
Оцениваем объём до запроса
Перед загрузкой приложение оценивает максимально возможное количество линий для выбранных метрик и группировок.
Если их больше 100, приложение показывает предупреждение. Пользователь может дождаться загрузки графика или получить только таблицу с суммарными значениями. [Скриншот предупреждения с выбором]
Если ожидается больше 500 линий, остаётся только вариант с суммарными данными. [Скриншот предупреждения для большого количества линий]
Суммарные значения рассчитывает ClickHouse. Если пользователь выбирает только таблицу, приложение запрашивает агрегаты без точек временных рядов. Это уменьшает объём данных, передаваемых из базы в приложение и далее в браузер, а также избавляет браузер от подготовки и отрисовки графика.
Но предварительная оценка может быть завышена. Не все возможные сочетания фильтров обязательно присутствуют в ответе, и фактических линий иногда оказывается гораздо меньше. Если были загружены только суммарные значения, данных для построения графика уже нет. В бэклоге лежит задача добавить кнопку, по которой пользователь сможет отдельно загрузить график.
Упрощаем отрисовку после загрузки
Когда данные получены, приложение уже знает фактическое количество линий. Если их больше 32, отключаются анимации и подсветка линий при наведении, а толщина уменьшается с 2 до 1,5 пикселя. Так для небольших графиков сохраняется привычное поведение, а для более сложных используется упрощённый вариант отображения.
Пока загружаются данные
Даже после оптимизаций данные иногда приходится ждать. Смотреть на обычный крутящийся лоадер было скучно, поэтому во время загрузки я добавил короткие сообщения:
- «Опрашиваем сервера…»
- «Перекладываем нули…»
- «Считаем до десяти. Медленно…»
Часть сообщений общая для всего приложения, часть зависит от раздела. Например, при загрузке графиков появляются «Выравниваем оси…», «Считаем точки на графике…» и «Подбираем цвета для линий…».
Заготовки помогла сделать LLM. Я сгенерировал по сто общих сообщений и сообщений для каждого раздела, затем примерно половину удалил, так как они мне не понравились. [Видео с примером загрузки]
Пользователи писали, что им эта деталь понравилась. Получилось небольшое дополнение к техническим оптимизациям, которое сделало ожидание приятнее.
Что получилось
Для своей задачи я остановился на колоночном JSON с явным массивом времени. Основной выигрыш дала смена структуры ответа, а переход на бинарный формат пока не показал убедительного преимущества в моих условиях. При этом для тяжёлых сценариев полезно смотреть шире сериализации. Возможность запросить только суммарные значения позволяет вовсе избежать передачи и отрисовки сотен линий.
Реакции
Лайки и комментарии принимаются через Webmention с вашего сайта.
Пока нет реакций.