Привет, Хабр! Я — Максим Шитилов, продуктовый аналитик в каршеринг-сервисе Ситидрайв. Каждый день мы обрабатываем большие объёмы данных, и ClickHouse — один из наших ключевых инструментов. Если вы когда-либо пытались связать события с временными интервалами или рассчитать метрику за определённое окно после события, то наверняка сталкивались с типичной конструкцией на self-join. Вроде бы работает, но запрос становится громоздким, ресурсоёмким и плохо масштабируется.

В этой статье я расскажу, как решать такие задачи проще и эффективнее — с помощью массивов, arrayFilter и arrayMap. Покажу, как отказаться от self-join’ов без потери точности, ускорить обработку и упростить код. Примеры — из реальных бизнес-кейсов: телеметрия, аренды, GMV и события, которые нужно связать между собой по времени. Так как схожих решений на просторах интернета я не нашёл, предлагаю назвать этот подход «Array Join Pattern». Если метод окажется полезным для сообщества, то такой паттерн легко будет найти другим аналитикам и девам.

Как связать события и интервалы без лишней нагрузки?

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

«Посчитай сумму только за следующие 24 часа.»

Обычный подход — это self-join:

  • Соединяем каждое событие со всеми возможными интервалами аренды;

  • Проверяем условия через WHERE или ON с использованием BETWEEN.

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

Решение: массивы + arrayFilter

Вместо того чтобы пытаться скрестить всё со всем — складываем нужные интервалы или события в массивы и локально фильтруем их:

  1. Группируем данные в массивы, например,  собираем все заказы машины в массив кортежей (дата заказа, id, GMV).

  2. При анализе конкретного события фильтруем массив: оставляем только те элементы, которые попадают в интересующий временной интервал (или иное условие фильтрации).

  3. Уже из отфильтрованного массива извлекаем нужную метрику — сумму, минимум, первый элемент и т.д.

Пример 1: привязка превышения скорости к аренде

Бизнес-задача:

  • Машины регулярно высылают пакеты телеметрии: координаты, скорость, запас топлива и прочие важные метрики;

  • Из этих пакетов необходимо выделять случаи превышения скорости, например, все, кто разогнался больше 140 км/ч за последние 3 недели.

Шаг 1: собираем все заказы в массив к каждой машине

Для начала соберём всю историю аренды каждой машины в один массив. Каждый элемент массива — это кортеж из времени начала аренды, времени окончания аренды и id аренды.

WITH orders_by_car_cte AS (
    SELECT
        car_id,
        groupArray((order_created_at, order_finished_at, order_id)) AS orders_array
    FROM orders
    WHERE order_created_at >= now() - INTERVAL 21 DAY
    GROUP BY car_id
),

Шаг 2: Находим превышения скорости в телеметрии

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

  • максимальную скорость в интервале (argMax)

  • количество записей в интервале (чтобы понимать относительную продолжительность превышения скорости)

max_speed_cte AS (
    SELECT
        car_id,
        toStartOfInterval(telemetry_created_at_dttm, INTERVAL 5 minute) AS dttm_5min_interval,
        max(speed) AS max_speed, -- максимальная скорость за 5-минутный интервал
        count() AS speed_packages_cnt
    FROM telemetry tm
    WHERE telemetry_created_at_dttm >= now() - INTERVAL 21 DAY
      AND speed >= 140 -- отсекаем ненужные пакеты телеметрии
    GROUP BY 1, 2
)

Шаг 3: Соединяем, но без join по времени!

Теперь главное — сопоставим превышения скорости с заказами, но не через self-join, а через работу с массивом.

SELECT
    ms.car_id,
    dttm_5min_interval,
    max_speed,
    speed_packages_cnt,
    arrayMap(x -> x.3,  -- вытаскиваем order_id
        arrayFilter(
            x -> (toDateTime(dt_interval) BETWEEN x.1 AND x.2),
            orders_array
        )
    )[1] AS order_id -- берём первый попавшийся заказ
FROM max_speed_cte ms
GLOBAL LEFT JOIN orders_by_car_cte obc ON USING (car_id)

В этом выражении мы:

  • Берём массив заказов orders_array;

  • Фильтруем его так, чтобы остались только те order_id, у которых dt_interval (начало пятиминутки) попадает в интервалы аренды;

  • Достаём order_id первого подходящего заказа.

То есть: для каждой строки (интервала) внутри строки перебрали все аренды и выбрали ту, в которую попадает dt_interval.

Итог: сервер живёт, DBA не пишут в личку ночью, а аналитик — молодец.

Однако, на этом этапе может возникнуть вполне резонный вопрос: 

А как вообще работает arrayFilter? Нет ли такого, что он пробегает по всем заказам и смотрит на их время? В таком случае у нас для каждого интервала dt_interval смотрятся все элементы из orders_array и всё равно получатся квадратичная сложность (то есть self-join)?

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

Да, это означает, что если в массиве тысячи заказов, arrayFilter действительно прогонит их все. Но важно понимать:

  • Это не то же самое, что JOIN, где у нас на выходе создаётся декартово произведение (миллионы строк).

  • Здесь всё остаётся в одной строке. Мы просто “фильтруем внутри”.

  • При очень длинных массивах (например, groupArray 10k+ элементов) это всё же может быть долго. Лучше лимитировать или предфильтровать источники.

Если бы это был Python, то выглядело бы примерно так:

import numpy as np

arr_np = np.arange(10_000_000)
result_np = arr_np[arr_np % 2 == 0]

Но ClickHouse делает это массово и эффективно, используя SIMD-инструкции и предвыделенную память (где возможно). Так что: логика — как у цикла, производительность — как у векторной обработки.

Пример 2: gmv за 24 часа после события

Бизнес-задача:

Хотим понять уровень дохода авто после какого-то события (например, мойки, ТО и т.д.). Нужно посчитать сумму GMV заказов, начавшихся в течение следующих 24 часов после события.

Решение:

  • arrayFilter оставляет заказы в нужном окне времени

  • arrayMap выдёргивает GMV

  • arraySum его суммирует

WITH orders_by_car_cte AS (
    SELECT
        car_id,
        groupArray((order_created_at, order_id, toFloat64(coalesce(gmv, 0)))) AS orders_array
    FROM orders
    WHERE order_created_at >= now() - INTERVAL 1 MONTH
    GROUP BY car_id
)

SELECT
    ev.car_id,
    ev.event_time,
    ev.event_type,
    round(
        arraySum(
            arrayMap(
                x -> x.3,
                arrayFilter(
                    x -> x.1 BETWEEN ev.event_time AND ev.event_time + INTERVAL 24 HOUR,
                    orders_array
                )
            )
        ), 2
    ) AS gmv24h
FROM events ev
GLOBAL LEFT JOIN orders_by_car_cte USING (car_id)

Давайте сравним производительности

Прогоним оба метода за одинаковый временной интервал (месяц) и посмотрим логи запросов:

Метрика

arrayFilter

self-join

Время выполнения

7.07 сек

44.5 сек

Прочитано строк

20.2 млн

8.4 млн 

Прочитано байт

1.25 ГБ

482 МБ

Использование RAM

1.52 ГБ

12.6 ГБ

Выводы:

  • Время: arrayFilter в 6.3 раза быстрее, несмотря на то, что читает в 2.4 раза больше строк.

  • Память: self-join прожорливее в 8.2 раза по RAM — почти 12.6 ГБ, и это уже опасно на проде: риск переполнения и ООМ.

  • Объём прочитанных данных: массивный подход читает больше, потому что агрегирует все заказы в массив заранее. Но это читается одним сканом, без повторных пересечений.

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

Где это реально помогает

  • Привязка событий (телеметрия → аренды, заказы → пользователи, инциденты → машины).

  • Динамическое суммирование в подвижных окнах (GMV за сутки, количество событий за Х времени, и т.п.).

  • Быстрая агрегация локальных окон без построения тяжёлых витрин.

Подводные камни

  • Размер массивов: если у объекта тысячи событий, массивы могут разрастись. Следите за ограничениями истории (например, месяц максимум. Но при этом всегда можно разбивать на партиции). 

  • Пересечения интервалов: в некоторых редких случаях событие может попасть в несколько интервалов — стоит явно указывать приоритет (например, брать самый первый).

  • Погрешности времени: будьте внимательны при сравнениях дат (Date vs DateTime).

Когда вам в следующий раз захочется написать self-join через 10 таблиц и 500 условий, подумайте: а нельзя ли просто сложить данные в массивы и обработать их локально? ClickHouse это позволяет. И делает это очень быстро.