Постановка задачи
Задача была немного необычной для нашего DWH на основе Arenadata DB / Greenplum. А именно в DWH требовалось организовать приём событий пользовательского поведения из RabbitMQ и дополнительно предоставить команде e-commerce доступ к данным с задержкой не более пяти секунд. Под задержкой далее понимается интервал между публикацией сообщения в RabbitMQ и доступностью соответствующей записи для запроса в СУБД. Была выбран следующая схема обработки данных - потоковая запись из RabbitMQ в ClickHouse и минибатчинг из ClickHouse в Arenadata DB. В статье рассматривается первая часть схемы - интеграция RabbitMQ и ClickHouse. В самой статье рассматриваются несколько хинтов, которые помогли ускорить обработку данных и корректно обрабатывать сами сообщения (особенно это касается крзин).
В исходных именах таблиц и представлений используется термин customer tracking. В данной статье он обозначает события взаимодействия пользователя с платформой интернет-магазина: просмотры и переходы, поисковые запросы, операции с корзиной и действия в мобильном приложении. То есть термин clickstream events.
Сразу было принято решение записывать в отдельные таблицы:
таблица с исходными сообщения в виде JSON-логов — для диагностики, аудита преобразований и повторного разбора;
таблица с типизированными колонками — для аналитических запросов без регулярного парсинга JSON.
П.1 обеспечивает независимость от команды разработки платформы интернет-магазина и защищает (во всяком случае частично) обработчик очереди от одностороннего изменения схемы сообщений.
Ограничение в пять секунд относится к конвейеру целиком. В представленной конфигурации значение rabbitmq_flush_interval_ms = 4000 резервирует до четырёх секунд для формирования пакета на стороне RabbitMQ-движка. Однако само по себе оно не гарантирует соблюдение end-to-end SLA: в измерение также входят публикация в брокер, доставка сообщения, обработка Materialized View и вставка в целевую таблицу. Поэтому фактическое соблюдение требования следует контролировать по метрикам задержки между временем публикации и временем доступности данных в ClickHouse.
Результаты нагрузочного тестирования
На испытательном стенде конвейер сохранял работоспособность при входной нагрузке до 1 000 сообщений в секунду. Задержка измерялась от публикации сообщения до доступности соответствующей записи в ClickHouse.
Распределение задержки при этой нагрузке имело следующий вид:
72% сообщений стали доступны менее чем за 5 секунд;
100% сообщений стали доступны менее чем за 7 секунд.
Таким образом, в рамках проведённого испытания задержка не превысила семи секунд, тем не менее заказчик посчитал требования выполнеными.
Результаты относятся к конкретной конфигурации стенда и профилю нагрузки, которые я здесь не привожу. В статье разбирается подход и некоторые неочевидные решения, которые позволили достичь требуемых результатов (о них ниже).
В схеме rabbitmq_queue_base реализован набор однотипных конвейеров для событий веб-трекинга, поиска, мобильного приложения и корзины. Каждый конвейер разделяет приём сообщений, хранение исходного содержимого и формирование структурированного слоя. Такое разделение позволяет независимо управлять надёжностью приёма и правилами преобразования данных.
Состав и взаимосвязь компонентов
RabbitMQ exchange │ ▼ *_queue ENGINE = RabbitMQ │ (raw_message String) ▼ *_mv MATERIALIZED VIEW │ TO *_data ▼ *_data ReplicatedMergeTree │ (event_date, raw_message) ▼ *_mv_ext_json MATERIALIZED VIEW + JSONExtract │ TO *_data_extracted ▼ *_data_extracted ReplicatedMergeTree (типизированные поля, TTL 90 дней)
Для потока корзины предусмотрен дополнительный этап: элементы массива entries преобразуются в строки отдельной таблицы customer_cart_entry_data_extracted.
В схеме определены следующие конвейеры:
Канал событий |
Exchange / routing key |
Таблица исходных сообщений |
Таблица структурированных данных |
|---|---|---|---|
Веб-трекинг |
|
|
|
Поисковые события |
|
|
|
Мобильный трекинг |
|
|
|
События корзины |
|
|
|
Состояние корзины |
|
|
|
Ниже базовый конвейер рассмотрен на примере customer_tracking; последующие разделы фиксируют особенности остальных потоков и обработку состава корзины.
1. Приём сообщений: таблица на движке RabbitMQ
Таблица с ENGINE = RabbitMQ является точкой интеграции ClickHouse с брокером. Движок получает сообщения из заданного exchange и предоставляет их как строки с телом сообщения в колонке raw_message.
CREATE TABLE rabbitmq_queue_base.customer_tracking_queue(raw_message String) ENGINE = RabbitMQ SETTINGS rabbitmq_host_port = 'rabbitmq.example.com:5672', rabbitmq_exchange_name = 'customer-tracking-exchange', rabbitmq_exchange_type = 'direct', rabbitmq_routing_key_list = 'customer-tracking', rabbitmq_queue_base = 'customer-tracking', rabbitmq_queue_consume = true, rabbitmq_format = 'JSONAsString', rabbitmq_num_consumers = 4, rabbitmq_max_block_size = 50000, rabbitmq_flush_interval_ms = 4000, rabbitmq_vhost = '/app';
Существенные параметры конфигурации имеют следующее назначение:
rabbitmq_format = 'JSONAsString'сохраняет payload без предварительной интерпретации. Правила извлечения полей могут изменяться независимо от настроек консьюмера.rabbitmq_queue_consume = trueвключает режим потребления сообщений.rabbitmq_num_consumers = 4задаёт число параллельных потребителей.rabbitmq_max_block_sizeиrabbitmq_flush_interval_msопределяют условия формирования пакета: не более 50 000 строк либо истечение четырёх секунд.rabbitmq_exchange_name,rabbitmq_routing_key_list,rabbitmq_queue_baseиrabbitmq_vhostидентифицируют источник сообщений в RabbitMQ.
Во всех таблицах вида *_queue используется одинаковая конфигурационная модель; различаются идентификаторы exchange, routing key и очереди.
2. Слой исходных сообщений
Таблица хранения исходных сообщений
CREATE TABLE rabbitmq_queue_base.customer_tracking_data ( event_date DateTime DEFAULT now(), raw_message String ) ENGINE = ReplicatedMergeTree( '/clickhouse/tables/{shard}/customer_tracking_data', '{replica}' ) ORDER BY tuple() TTL event_date + toIntervalDay(90) SETTINGS index_granularity = 8192;
event_dateфиксирует время вставки строки в ClickHouse.raw_messageсодержит неизменённое представление payload.ORDER BY tuple()соответствует журналу последовательных вставок, для которого не предполагается выраженный ключ доступа. Вcustomer_cart_tracking_dataиспользуется сортировка поevent_date.TTL продолжительностью 90 дней ограничивает срок хранения исходных сообщений. В течение этого периода возможно повторное преобразование данных при изменении логики разбора.
Материализованное представление для записи исходных сообщений
CREATE MATERIALIZED VIEW rabbitmq_queue_base.customer_tracking_mv TO rabbitmq_queue_base.customer_tracking_data ( event_date DateTime, raw_message String ) AS SELECT raw_message FROM rabbitmq_queue_base.customer_tracking_queue;
Материализованное представление не образует самостоятельного хранилища. При вставке блоков в исходную таблицу *_queue выполняется его запрос, а результат записывается в таблицу, указанную после TO. Поскольку в выражении SELECT передаётся только raw_message, значение event_date формируется выражением DEFAULT now() в целевой таблице.
Аналогичную функцию выполняют представления customer_search_tracking_mv, customer_mobile_tracking_mv, customer_cart_tracking_mv и customer_cart_mv. Материализованное представление реагирует на новые вставки в исходную таблицу; оно не обрабатывает строки, уже находившиеся в ней к моменту создания представления.
3. Структурирование JSON-сообщений
Таблица структурированных событий
CREATE TABLE rabbitmq_queue_base.customer_tracking_data_extracted ( event_date DateTime DEFAULT now(), pk Nullable(Decimal(20, 0)), systemid Nullable(String), resultscount Nullable(Decimal(20, 0)), listingmodeparam Nullable(String), createdts DateTime, cartcode Nullable(String), uiel Nullable(String), paymenttype Nullable(String), userid Nullable(String), itemprice Nullable(Decimal(30, 8)), responsecode Nullable(String), ajax Nullable(UInt8), url Nullable(String), gaid Nullable(String), salesapplication Nullable(String), salesapplicationid Nullable(String), ip Nullable(String), referer Nullable(String), subtotal Nullable(Decimal(30, 8)), customerid Nullable(String), companyid Nullable(String), sortparam Nullable(String), useragent Nullable(String), domain Nullable(String), localip Nullable(String), deliverymode Nullable(String), sessionid Nullable(String), basestore Nullable(String), productfamilymarker Nullable(UInt8), origuserid Nullable(Decimal(20, 0)), minquantity Nullable(Decimal(20, 0)), proxyname Nullable(String), listingchanges Nullable(String) ) ENGINE = ReplicatedMergeTree( '/clickhouse/tables/{shard}/customer_tracking_data_extracted', '{replica}' ) PARTITION BY toYYYYMM(event_date) ORDER BY event_date TTL event_date + toIntervalDay(90) SETTINGS index_granularity = 8192;
Схема таблиц вида *_data_extracted основана на следующих принципах:
партиционирование
PARTITION BY toYYYYMM(event_date)группирует данные по месяцу загрузки и упрощает удаление устаревших партиций;ключ
ORDER BY event_dateориентирован на преобладающий сценарий выборок по временному интервалу;тип
Nullableиспользуется для полей, отсутствие которых допустимо в сообщении;createdtsотражает время, переданное источником, тогда какevent_date— время загрузки в ClickHouse.
Материализованное представление для извлечения полей
Преобразование реализовано одним вызовом JSONExtract(raw_message, 'Tuple(...)'). Результат извлечения имеет тип Tuple и далее распределяется по колонкам целевой таблицы посредством обращений parsed.N.
CREATE MATERIALIZED VIEW rabbitmq_queue_base.customer_tracking_mv_ext_json TO rabbitmq_queue_base.customer_tracking_data_extracted AS SELECT event_date, parsed.1 AS pk, parsed.2 AS systemid, parsed.4 AS resultscount, parsed.6 AS listingmodeparam, parseDateTimeBestEffort(parsed.7) AS createdts, parsed.8 AS cartcode, parsed.9 AS uiel, parsed.10 AS paymenttype, parsed.11 AS userid, parsed.12 AS itemprice, parsed.13 AS responsecode, CAST(parsed.14, 'Nullable(UInt8)') AS ajax, parsed.15 AS url, -- ... остальные поля ... parsed.5 AS minquantity, parsed.33 AS proxyname, parsed.3 AS listingchanges FROM ( SELECT event_date, JSONExtract( raw_message, 'Tuple( Nullable(Decimal(20, 0)), Nullable(String), Nullable(String), Nullable(Decimal(20, 0)), Nullable(Decimal(20, 0)), Nullable(String), String, Nullable(String), /* ... типы в порядке ключей JSON ... */ Nullable(String) )' ) AS parsed FROM rabbitmq_queue_base.customer_tracking_data AS c ) AS raw_data;
При поддержке такого представления необходимо учитывать три аспекта:
Извлечение выполняется позиционно. Соответствие
parsed.Nполю сообщения определяется декларацией типаTuple(...)и должно быть зафиксировано в контракте источника. Порядок колонок целевой таблицы не является основанием для такого соответствия.
Здесь нужно отметить, что именно позиционное извлечение позволило достичь целевых показателей при нагрузочном тестировании.Строковые представления дат преобразуются функциями
parseDateTimeBestEffortиparseDateTimeBestEffortOrNull. Второй вариант применяется, когда отсутствие или некорректное значение даты считается допустимым.Логические значения из JSON приводятся к типам колонок явно: например, к
Nullable(UInt8)либоNullable(Decimal(1, 0)).
4. Специфика предметных потоков
Потоки трекинга используют общий набор атрибутов контекста: идентификаторы пользователя и сессии, URL, IP-адрес, referer и другие параметры клиентского взаимодействия. Специализированные потоки дополняют этот набор атрибутами соответствующей предметной области.
Поисковые события: customer_search_tracking_*
В таблице структурированных данных дополнительно представлены finalsearchtext, searchtext, elevate, assortmentgroup, scom, scomv, cataloglimit, queryexclude и basestoreid. Эти атрибуты описывают параметры и результат выполнения поискового запроса.
Мобильный трекинг: customer_mobile_tracking_*
Специфическим атрибутом является actiontype, характеризующий действие в мобильном приложении. Для minquantity установлен тип Nullable(Int32), в отличие от Decimal-представления в ряде других потоков.
События корзины: customer_cart_tracking_*
Помимо общих полей, поток содержит qid, referercartaddedpage, type, productcode, productadditiontype, itemcost и count. Они позволяют описать контекст изменения корзины и товарную позицию, к которой относится действие.
Состояние корзины: customer_cart_*
Данный поток описывает состояние корзины, а не отдельное пользовательское действие. Наряду с реквизитами корзины в сообщении передаётся массив её позиций.
CREATE TABLE rabbitmq_queue_base.customer_cart_data_extracted ( event_date DateTime DEFAULT now(), localpk Nullable(Decimal(20, 0)), modifiedts Nullable(DateTime), createdts DateTime, totalprice Nullable(Decimal(30, 8)), code Nullable(String), paymentmodecode Nullable(String), user Nullable(String), unit Nullable(String), phonenumberextid Nullable(String), store Nullable(String), payername Nullable(String), eliteorderid Nullable(String), deliverycost Nullable(Decimal(30, 8)), deliverycostinelite Nullable(Decimal(30, 8)), topvisitedstep Nullable(Decimal(20, 0)), topvisitedstepdate Nullable(DateTime), deliveryaddressextid Nullable(String), deliverymodecode Nullable(String), entries Array(String) ) ENGINE = ReplicatedMergeTree(...) PARTITION BY toYYYYMM(event_date) ORDER BY event_date TTL event_date + toIntervalDay(90);
Колонка entries Array(String) сохраняет позиции корзины в виде массива JSON-фрагментов. Это обеспечивает сохранность исходной структуры одновременно с формированием отдельной нормализованной таблицы позиций.
5. Нормализация массива позиций корзины
Сообщение о состоянии корзины содержит массив entries. Для аналитических операций каждая его позиция представляется отдельной строкой: это позволяет связывать позиции с каталогом, агрегировать количество и стоимость по товарным кодам, а также исследовать состав корзин.
Целевая таблица:
CREATE TABLE rabbitmq_queue_base.customer_cart_entry_data_extracted ( event_date DateTime DEFAULT now(), localpk_json Int64, localpk Nullable(Decimal(20, 0)), createdts DateTime, cartlocalpk Nullable(Decimal(20, 0)), modifiedts Nullable(DateTime), productcode Nullable(String), baseprice Nullable(Decimal(30, 8)), quantity Nullable(Decimal(30, 8)) ) ENGINE = ReplicatedMergeTree(...) PARTITION BY toYYYYMM(event_date) ORDER BY event_date TTL event_date + toIntervalDay(90);
Материализованное представление последовательно извлекает массив, разворачивает его и интерпретирует каждый элемент.
CREATE MATERIALIZED VIEW rabbitmq_queue_base.customer_cart_entry_mv_ext_json TO rabbitmq_queue_base.customer_cart_entry_data_extracted AS WITH customer_cart_data_array AS ( SELECT event_date, JSONExtractArrayRaw(raw_message, 'entries') AS entries_str, JSONExtractInt(raw_message, 'localPk') AS localpk FROM rabbitmq_queue_base.customer_cart_data ), customer_cart_data_flatten AS ( SELECT event_date, localpk, entries_str FROM customer_cart_data_array LEFT ARRAY JOIN entries_str ) SELECT event_date, localpk AS localpk_json, parsed.1 AS localpk, parseDateTimeBestEffort(parsed.2) AS createdts, parsed.4 AS cartlocalpk, parseDateTimeBestEffortOrNull(parsed.3) AS modifiedts, parsed.5 AS productcode, parsed.6 AS baseprice, parsed.7 AS quantity FROM ( SELECT event_date, localpk, JSONExtract( entries_str, 'Tuple( localPk Nullable(Decimal(20, 0)), createdTs String, modifiedTs Nullable(String), cartLocal Nullable(Decimal(20, 0)), productCode Nullable(String), basePrice Nullable(Decimal(30, 8)), quantity Nullable(Decimal(30, 8)) )' ) AS parsed FROM customer_cart_data_flatten WHERE entries_str != '' ) AS raw_entry;
Последовательность преобразования следующая:
JSONExtractArrayRawизвлекаетentriesкак массив исходных JSON-строк, аJSONExtractIntизвлекает идентификатор корзиныlocalPk.LEFT ARRAY JOINпреобразует одну строку с массивом в набор строк — по одной на каждый элементentries.JSONExtract(..., 'Tuple(...)')применяется к отдельному элементу массива и извлекает его атрибуты.Идентификатор родительской корзины передаётся в
localpk_json. Он используется для связи строки позиции с заголовком корзины без повторного разбора исходного сообщения.
Источником представления является customer_cart_data, то есть структурирование заголовка корзины и нормализация её позиций выполняются на одном слое исходных сообщений.
Взаимосвязь объектов потока корзины:
customer_cart_queue │ ▼ customer_cart_mv ──────────────────────────► customer_cart_data │ ┌──────────────────────────┼──────────────────────────┐ ▼ ▼ │ customer_cart_mv_ext_json customer_cart_entry_mv_ext_json │ │ │ │ ▼ ▼ │ customer_cart_data_extracted customer_cart_entry_data_extracted │
6. Последовательность развёртывания
Объекты должны создаваться в порядке, соответствующем зависимостям. Для одного потока последовательность имеет вид:
*_data*_data_extracted(и*_entry_data_extracted, если есть)*_queue*_mv*_mv_ext_json(и*_entry_mv_ext_json)
На момент создания Materialized View целевая таблица, указанная в TO, уже должна существовать. В противном случае команда CREATE MATERIALIZED VIEW завершится ошибкой.
После развёртывания публикация сообщения в RabbitMQ инициирует его последовательное прохождение через таблицу-консьюмер, слой исходных данных и представления, формирующие структурированные таблицы.
7. Примеры аналитических запросов
Количество событий веб-трекинга по дням:
SELECT toDate(event_date) AS day, count() AS events FROM rabbitmq_queue_base.customer_tracking_data_extracted WHERE event_date >= today() - 7 GROUP BY day ORDER BY day;
Поисковые запросы, для которых не зафиксирован результат:
SELECT searchtext, count() AS cnt FROM rabbitmq_queue_base.customer_search_tracking_data_extracted WHERE event_date >= now() - INTERVAL 1 DAY AND (resultscount = 0 OR resultscount IS NULL) AND searchtext IS NOT NULL GROUP BY searchtext ORDER BY cnt DESC LIMIT 50;
Товарные коды с наибольшим числом позиций в корзинах:
SELECT productcode, count() AS lines, sum(quantity) AS qty FROM rabbitmq_queue_base.customer_cart_entry_data_extracted WHERE event_date >= today() - 7 AND productcode IS NOT NULL GROUP BY productcode ORDER BY lines DESC LIMIT 100;
Связь реквизитов корзины с её позициями:
SELECT c.code, c.user, c.totalprice, e.productcode, e.quantity, e.baseprice FROM rabbitmq_queue_base.customer_cart_data_extracted AS c INNER JOIN rabbitmq_queue_base.customer_cart_entry_data_extracted AS e ON c.localpk = e.localpk_json AND c.event_date = e.event_date WHERE c.event_date >= today() - 1 LIMIT 100;
Состав объектов
Объект |
Тип |
Назначение |
|---|---|---|
|
Table, RabbitMQ |
Получение сообщения брокера в raw_message |
|
Materialized View |
Запись исходного сообщения в таблицу *_data |
|
Table, ReplicatedMergeTree |
Хранение исходного JSON и времени загрузки |
|
Materialized View |
Преобразование JSON в типизированный набор атрибутов |
|
Table, ReplicatedMergeTree |
Хранение структурированных событий |
|
Materialized View |
Разворачивание и разбор массива позиций корзины |
|
Table, ReplicatedMergeTree |
Хранение нормализованных позиций корзины |
Выводы
Рассмотренный конвейер разделяет приём сообщений, хранение исходного JSON и формирование структурированного слоя. В результате аналитики получают как исходные логи для диагностики и повторной обработки, так и типизированные таблицы для регулярных запросов.
Существенным элементом реализации является извлечение набора атрибутов одним вызовом JSONExtract с типом Tuple(...). После получения кортежа значения передаются в целевые колонки через обращения parsed.N, без повторного разбора исходной JSON-строки для каждого поля. Такой подход сокращает число операций извлечения JSON и явных преобразований типов в Materialized View.
В совокупности с пакетной обработкой сообщений, Materialized View и таблицами ReplicatedMergeTree использование Tuple позволило обеспечить на стенде обработку нагрузки до 1 000 сообщений в секунду; все сообщения становились доступны в ClickHouse менее чем за семь секунд. В сопоставимом нагрузочном тесте с альтернативной реализацией разбора JSON максимальная производительность составила 700 сообщений в секунду. Следовательно, применение Tuple повысило измеренный предел обработки на 300 сообщений в секунду, или приблизительно на 43%, и стало значимым фактором достижения требуемой пропускной способности.
Комментарии (4)

jorgvonfrundsberg
12.10.2026 20:51А в чем был смысл наличия ClickHouse в этой схеме, если вся работа велась с данными в Arenadata DB / Greenplum, если я правильно вас понял?
Coocooroo
"смешались кони, люди".
то, какие-то сообщения в секунду, то, "доступность сообщений менее чем за семь секунд "
с метриками определитесь
pony77 Автор
Читаем внимательно, там все написано на русском языке.
После прочтения можем обсудить.