Каждый месяц приносит новую волну изобретательных способов использования объектного хранилища — пожалуй, самого вулканически активного уголка не-AI инфраструктуры на планете. Последним таким извержением стал разбор Винсента Марти о подходе Cursor Origin к управлению Git-репозиториями в масштабе — через связку S3 и WAL. Это образец технического текста, в отличие от нынешнего материала. Ещё до прочтения возникла мысль о совершенно другой задаче, где объектное хранилище, вероятно, просто работает — на этот раз для клиентских аналитических дашбордов. У знакомого есть данные об использовании продукта в формате Iceberg на R2, и он хочет показывать пользователям простые графики с фильтрами. При этом добавлять новых вендоров он не хотел, что сразу исключило MotherDuck — облачную базу данных на основе DuckDB, где работает автор.

В аналитике, когда под рукой только объектное хранилище, всё начинает выглядеть как range-запрос. Можно свернуть такие данные в куб на базе Parquet и с помощью запросов по диапазону байт вытаскивать агрегированные строки, нужные для дашборда. Обычно для BI-нагрузки такого рода подошёл бы DuckDB-Wasm, но выяснилось, что Hyparquet — JavaScript-читалка Parquet, работающая прямо в браузере, — способна выполнять те же выборочные сканирования, но весит 18 килобайт вместо нескольких мегабайт и отдельного воркера. С таким подходом можно отдавать полноценный drilldown-дашборд без базы данных и без движка запросов. Куб может весить десятки или даже сотни мегабайт: если файл правильно уложен, за раз читаются лишь несколько небольших срезов. Нужен только конвейер данных, который будет собирать эти кубы — и именно туда, как оказывается, уходят реальные деньги, даже когда под рукой есть настоящая аналитическая база.

Соблазн проверить эту ересь оказался слишком велик. Для теста был взят хорошо известный датасет обращений NYC 311 — около 34 миллионов строк на уровне отдельных заявок за 15 с лишним лет — и свёрнут в 40-мегабайтный Parquet-куб с фильтрами по городскому агентству, типу жалобы, способу подачи и району, плюс единственная колонка времени создания для временного ряда. Затем файл был загружен на R2. 40 МБ — достаточно много, чтобы ощутить боль от скачивания файла целиком.

Демо-дашборд ниже читает данные напрямую из этого файла с помощью Hyparquet. Байты по пути проходят через небольшой Cloudflare Worker, потому что бесплатный URL r2.dev ограничен по частоте запросов. Честно говоря, скорость загрузки новых данных удивила — учитывая, что здесь нет ни настоящей базы данных, ни мощного движка запросов. Весь реальный труд по чтению делает интерфейс, и он настолько лёгкий, что его можно встроить прямо в этот пост без ущерба для загрузки страницы. Вся сложность практически полностью перенесена на уровень раскладки данных в кубе. Стоит попробовать поводить курсором по графику или кликнуть по строкам лидербордов.

Как устроен этот дашборд?

Куб данных и группирующий набор. Такой дашборд рассчитан на ограниченный набор аналитических вопросов: сколько заявок в день, сколько заявок в день по одному агентству, суммарные показатели по районам за всё время. Каждый вопрос можно закрыть запросом GROUP BY, поэтому все такие запросы можно посчитать заранее и сохранить каждый результат в виде отдельной небольшой таблицы — группирующего набора (grouping set). Если сложить все группирующие наборы в один Parquet-файл, по одной секции на набор, получится куб данных. Группирующий набор полезен, если он либо позволяет ответить на вопрос, либо снижает задержку при выборке данных. В этом файле есть оба типа наборов. Итоговые суммы за всё время питают лидерборды, а ежедневный группирующий набор для каждой комбинации фильтров даёт данные для линейного графика. Недельные и годовые наборы уменьшают число строк, которые приходится сканировать при выделении диапазона на графике. Те же суммы теоретически можно получить, складывая ежедневные строки, но если заранее посчитать суммы по неделям и годам, строк для выборки требуется меньше.

Группа строк и футер. Файл теперь содержит группирующие наборы, из которых строится дашборд, но браузеру всё ещё нужно вытащить только нужные строки. Это становится возможным благодаря двум особенностям формата Parquet. Parquet-файл делится на группы строк (row groups) по несколько десятков тысяч строк каждая, а в конце файла — футер с метаданными о диапазонах байт для групп строк и минимальных/максимальных значениях по каждой колонке внутри группы. Клиент читает футер один раз. Затем каждый запрос использует min/max-значения, чтобы отобрать подходящие группы строк, выгружает нужные диапазоны байт и агрегирует строки прямо в браузере.

Низкая задержка запросов дашборда объясняется тем, как строки в Parquet-файле отсортированы и сканируются. Если бы строки файла были расположены в случайном порядке, min/max-значения каждой группы строк охватывали бы почти весь диапазон каждой колонки, и запросу пришлось бы прочитать большую часть файла ради небольшого процента строк. Вместо этого строки каждого группирующего набора отсортированы по колонкам, которые фильтруют его запросы. Подходящие строки благодаря этому обычно образуют непрерывный участок файла, а статистика min/max позволяет читалке игнорировать остальные группы строк. Именно поэтому клик по NYPD в лидерборде агентств читает около 260 КБ из 40-мегабайтного файла, а не весь файл целиком. Ниже — фактическая раскладка файла по байтам и группирующим наборам:

группирующий наборстрокиразмер
totals — питает суммарный счётчик «requests in view» и четыре лидерборда831.1k1.7mb
all time (1 группа строк) — читается, когда диапазон дат не выбран4.8k103kb
by week (16 групп строк) — читается при выделении диапазона: недели на краях диапазона796.6k1.3mb
by ISO year (2 группы строк) — читается при выделении диапазона: целые годы в середине диапазона29.7k272kb
daily · без измерений (1 группа строк) — рисует линейный график без активных фильтров5.0k171kb
daily · одно измерение — рисует линейный график с одним активным фильтром770.3k2.4mb
channel (1 группа строк)22.7k171kb
borough (2 группы строк)30.0k330kb
complaint (13 групп строк)635.6k1.5mb
agency (3 группы строк)82.0k383kb
daily · два измерения — рисует линейный график с двумя активными фильтрами4.5m10.6mb
borough + channel (3 группы строк)127.7k401kb
complaint + channel (23 группы строк)1.1m2.6mb
complaint + borough (42 группы строк)2.1m4.5mb
agency + channel (5 групп строк)198.0k640kb
agency + borough (8 групп строк)358.5k997kb
agency + complaint (13 групп строк)643.0k1.5mb
daily · три измерения — рисует линейный график с тремя активными фильтрами7.3m15.8mb
complaint + borough + channel (65 групп строк)3.3m6.8mb
agency + borough + channel (17 групп строк)804.0k1.9mb
agency + complaint + channel (22 группы строк)1.1m2.4mb
agency + complaint + borough (43 группы строк)2.1m4.6mb
daily · все четыре измерения (64 группы строк) — рисует линейный график при всех четырёх активных фильтрах3.3m6.6mb
footer · индекс — диапазоны байт и min/max статистика для каждой секции; читается первым, один раз195kb

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

В этом примере явно доминирует дневная гранулярность: ежедневные секции занимают большую часть байт файла. Второй множитель — кардинальность: тип жалобы имеет 485 различных значений, и он присутствует в каждой крупной секции на диаграмме. Собственно, выбор дневной гранулярности для линейного графика увеличил размер файла примерно в 7 раз по сравнению с эквивалентом на неделях (5.6 МБ). Тем не менее дневная гранулярность заметно не повлияла на задержку range-запросов, поскольку любое взаимодействие читает лишь несколько групп строк. И в данном случае приятно видеть резкий всплеск в отдельный день — рост числа заявок может произойти за один день из-за крупных событий вроде ураганов или снежных бурь.

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

Range-запросы к аккуратно уложенному файлу имеют богатую предысторию. PMTiles упаковывает набор тайлов в один файл, который клиенты читают через range-запросы по HTTP. Это работает, потому что тайлы уложены в файле вдоль кривой Гильберта, поэтому тайлы для конкретного вида карты оказываются рядом друг с другом в файле и выгружаются за несколько объединённых range-запросов. Известный разбор про размещение SQLite-баз через HTTP доказал, что этот приём работает даже для B-деревьев. Единственное реальное требование — хост, отдающий диапазоны байт по HTTP: R2, S3, GitHub Pages или обычная nginx-машина.

Самое приятное в этом подходе — что сложность сдвигается «влево», вплоть до самого конвейера данных. Раскладка определяется заранее, поэтому к моменту, когда пользователь кликает по лидерборду или проводит по графику временного ряда, клиенту остаётся лишь выгрузить нужные строки и просуммировать их. Что касается конвейера, для большинства клиентских дашбордов 10-мегабайтный куб на клиента получается прямо из одного оператора DuckDB GROUP BY GROUPING SETS. Естественно, здесь есть предвзятость, но для сборки такого конвейера был бы выбран MotherDuck (облачная база данных и движок запросов на основе DuckDB) и Flights (функция MotherDuck для планирования python-задач такого рода). С подходящим агентом настройка получается довольно простой. Как бы ни был реализован конвейер, для упомянутого знакомого, который работает дата-инженером, перенос сложности в конвейер — это безупречная déformation professionnelle.

Один файл на клиента также делает авторизацию приятно скучной. Контроль доступа сводится к подписанному URL для файла конкретного клиента или крошечному Worker, проверяющему сессию.

Поскольку у R2 бесплатный исходящий трафик, реальные деньги уходят именно на конвейер. Запись стоит 4,50 доллара за миллион операций (в 12,5 раза дороже чтения), и на каждую пересборку приходится одна запись на клиента, независимо от размера файла (загрузка 1-мегабайтного и 40-мегабайтного куба стоит одинаково). Возьмём 10 000 клиентов. Каждая пересборка заменяет файлы, поэтому хранилище остаётся плоским по стоимости: кубы по 10 МБ дают 100 ГБ, около 1,50 доллара в месяц; кубы по 40 МБ дают 400 ГБ, около 6 долларов в месяц. Пересборка каждого файла раз в день — это 300 тысяч записей в месяц, около 1,35 доллара; пересборка каждый час — 7,2 миллиона записей, около 32 долларов; пересборка каждые пять минут в течение месяца — 86 миллионов записей, около 389 долларов. К счастью, диффы снапшотов Iceberg точно показывают, у каких клиентов появились новые данные, так что легко пересобирать только те кубы, где была активность.

Даже если предположить обновление каждые 5 минут и активность у каждого клиента в этом окне (что маловероятно), для такого сценария 389 долларов в месяц за 10 тысяч клиентов, скорее всего, дешевле, чем разворачивать новую инфраструктуру, и почти наверняка проще. При этом и экономика, и пользовательский опыт изменились совсем недавно: платный исходящий трафик не убил бы эту идею полностью, но наверняка отпугивал от подобных экспериментов. Та же схема на S3 обходится в итоге лишь примерно на 20% дороже — около 20 долларов в месяц за исходящий трафик при миллионе запросов, а миллион запросов — это больше трафика, чем увидит большинство клиентских дашбордов за всё время.

Ещё один приятный момент — предельно тонкая реализация: 18-килобайтная JavaScript-читалка и раскладка байт, которая выполняет всю работу базы данных за вас. Вот такой мир.