Представьте, что вы спокойно работаете, и тут к вам в чат прилетает сообщение от руководителя:

Вам задали вопрос, нужно постараться побыстрее ответить, к тому же и звучит несложно. Вы вспоминаете, что уже совсем недавно делали похожие запросы в базу, пытаетесь вспомнить, где они. Проходит пара минут, и собирается еще пара вопросов:

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

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

Но иногда могут случаться неприятности по типу таких:

А может быть ещё хуже:

Цель этой статьи — помочь вам, чтобы таких «сори» было как можно меньше. В статье мы рассмотрим проблему производительности и стабильности системы дашбордов на основе связки Grafana + Clickhouse. Обозначим, каким требованиям система должна отвечать и как их достичь. 

При чём здесь я?

Меня зовут Валя Борисов, и я — аналитик в команде Ozon. Задача нашей команды — создавать инструменты для мониторинга и анализа скорости. 

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

Если хотите больше подробностей о работе нашей команды, вы можете посмотреть выступление на тему «Скорость как показатель качества» по этой ссылке.

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

Какие инструменты обсудим в статье

  • Clickhouse — колоночная БД, храним данные распределённо в нескольких дата-центрах

  • Grafana — система для визуализации данных в реальном времени

  • Altiity Plugin — плагин для подключения Clickhouse к Grafana

Мы тут

Суммарный объём аналитических данных больше 200Тб. Данные хранятся преимущественно в Clickhouse. На основе этих данных построено более 2.5к графиков. 35 дашбордов, которыми ежедневно пользуются более 50 человек. Днём в пике нагрузка доходит до 300 распределённых запросов в минуту. Иногда трафик доходит до >1к распределённых запросов в минуту. В 95 квантили скорость выполнения запросов не более 500 миллисекунд — иначе говоря, 5% запросов отработали больше чем за полсекунды. Как правило, это запросы для временных рядов в промежутках от часа до нескольких недель в реальном времени. В среднем наши дашборды грузятся за 2 секунды целиком.

Много дашбордов — много графиков — много данных

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

Каким должен быть дашборд?

Я бы формулировал требования к дашборду следующими:

  1. Графики читаемы и интуитивно понятны — у пользователя не возникает вопросов о том, на какие данные он смотрит.

  2. Дашборд и графики грузятся стабильно и быстро.

  3. Одни и те же данные согласуются между разными дашбордами. Значения одних и тех же показателей не различаются между дашбордами.

Первый пункт больше в сторону UX-дизайна. О методологиях и примерах «как надо» хорошо рассказано тут ПДД: Паттерны Дизайна Дашбордов. Остальное же больше про техническую сторону, именно об этом и поговорим.

Наша команда использует Grafana в качестве источника визуализации. Clickhouse как источник данных. Altinity Plugin как Data Source в Grafana.

Grafana, как правило, используют для timeseries-данных: Prometheus, Influx. С привычной аналитикой это не вяжется, потому Grafana остается, скорее, инструментом инженеров, чем аналитиков. Но моё мнение, что Grafana даёт возможность соответствовать всем требованиям к дашборду, которые я обозначил выше. При этом разработка дашборда проходит быстрее в сравнении с BI-системами.

Решаем проблему производительности

Графики должны грузиться быстро

Можно бесконечно проводить работы по оптимизации, не сравнивая с тем, как было. Но однажды к вам придут с вопросами, например, почему всё работает так медленно. Такие вопросы могут загонять в депрессию, тем более, если на них нет ответа. Вы должны знать, где и когда стало лучше, — это мотивирует продолжать работы. А если стало хуже, то важно понимать почему.

Решение проблемы медленных графиков — многосоставная задача. Поделю работу на пункты:

  1. Мониторинг за мониторингом — научитесь видеть картину стабильности работы вашей системы.

  2. Правильно проектируйте дашборд — не совершайте ошибки, которые тормозят первое открытие дашборда.

  3. Разделение ответственности — разделите дашборды на общие и внутренние. Введите квоты на запросы.

Отдельно выделю пункты, которые требуют плотной работы с хранилищем данных Clickhouse.

  1. Делайте оптимальные запросы — определите, какие запросы тормозят графики и как это исправить.

  2. Оптимально храните данные — научитесь хранить данные так, чтобы запросы были быстрыми.

  3. Кэшируйте результаты запросов — если вы грузите одни и те же данные, то можно не выполнять лишней работы.

Мониторинг за мониторингом

Научитесь работать с системными таблицами

А именно — с таблицей логов запросов system.query_log. Здесь есть вся нужная информация по всем запросам в Clickhouse:

  • query_duration_ms — время выполнения запроса в миллисекундах;

  • read_bytes — объём прочитанных данных в байтах;

  • initial_user — инициатор запроса — пользователь, от которого пошли распределённые;

  • query — полный SQL-текст запроса;

  • normalized_query_hash — нормализованный запрос (в запросе остались только поля и операторы) в хешированном виде.

Это полезно при поиске проблемных или зависших запросов. Они могут аффектить общую производительность кластера.

Динамика по времени выполнения запросов:

select toStartOfInterval(event_time, interval 1 day) as t,

       quantileTDigest(0.95)(query_duration_ms) as "q0.95",
       quantileTDigest(0.9)(query_duration_ms) as "q0.9",
       quantileTDigest(0.75)(query_duration_ms) as "q0.75",
       quantileTDigest(0.5)(query_duration_ms) as "q0.5",
       quantileTDigest(0.25)(query_duration_ms) as "q0.25"

from clusterAllReplicas('cluster_name', system.query_log)
where read_bytes > 0
  and type == 'QueryFinish'
  and query_kind == 'Select'
  and initial_user in ('grafana')

group by t
order by t

Количество запросов, которые отправляли не меньше 10 раз:

select uniq(h)
    from (
          select normalized_query_hash as h
          from clusterAllReplicas('cluster_name', system.query_log)
          where user in ('grafana')
            and type in ('QueryFinish')
            and is_initial_query = 1
          group by h
          having count() > 10
             );

Настройте алерты на скорость выполнения запросов

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

Размечайте запросы в Grafana и ловите их в логах

Необходимо уметь разделять запросы из разных дашбордов. Если есть проблема, то важно понимать, где исправлять. Есть несколько глобальных стандартных переменных, которые вы можете использовать:

  • $__dashboard — название дашборда

  • ${__user.login} — логин пользователя, который открыл дашборд

Будет полезно разделить разные открытия дашборда от одного пользователя. Придумать своего рода view_id. Для решения этой задачки создайте переменную в Grafana, допустим, dashboard_view_id, которая подключается к Clickhouse, обновляется только при открытии борды и выполняет:

select generateUUIDv4()

На каждое открытие будет генерироваться уникальный UUID

Наш типичный запрос из Grafana выглядит примерно так:

WITH '$__dashboard' as dashboard,
     '${__user.login}' as user,
     '$dashboard_view_id' as view_id
SELECT $timeSeries as t,
    quantileTDigestIf($q)(metric, metric>0) as value
FROM table
WHERE $timeFilter
GROUP BY t
ORDER BY t

-- Generated SQL ----------------------------------------------------

WITH 'Base Alerts Dashboard' as dashboard,
     'napitok' as user,
     'd3b4779e-8184-4e4f-ba7f-aba7060426e6' as view_id
SELECT $timeSeries as t,
    quantileTDigestIf($q)(metric, metric>0) as value
FROM table
WHERE $timeFilter
GROUP BY t
ORDER BY t

В логах его можно будет поймать регулярками. Например, вот так:

select extract(query, '([^\'|\)]*)\' as dashboard') as name_dashboard,
       extract(query, '([^\'|\)]*)\' as user')      as user_login
from clusterAllReplicas('cluster_name', system.query_log)

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

Используя такой подход, можно вывести метрику Error Rate по загрузке дашбордов:

select name_dashboard,
       uniq(view_id) as uniq_loads,
       100 * avg(error) as error_rate,
       100 * sum(bad_requests) / sum(all_requests) as bad_requests_rate
from (
      with extract(query, '([^\'|\)]*)\' as dashboard') as name_dashboard,
          extract(query, '([^\'|\)]*)\' as user') as user_login,
          extract(query, '([^\'|\)]*)\' as view_id') as view_id

      select name_dashboard,
             view_id,
             max(if(exception_code != 0 or query_duration_ms > 25000, 1, 0)) as error,
             countIf(exception_code != 0 or query_duration_ms > 25000) as bad_requests,
             count() as all_requests
      from clusterAllReplicas('cluster_name', system.query_log)
      where query like '% as dashboard%'
        and user in ('grafana')
      group by name_dashboard, view_id
         )
group by name_dashboard
order by sum(all_requests) desc

Используйте инструменты внешнего мониторинга

Графики должны грузиться не только быстро, но и стабильно. Нужно, чтобы кто-то или что-то открывало ваши дашборды регулярно и фиксировало скорость открытия. Для решения этой задачи в нашем случае отлично подошёл sitespeed. Для нас это был наиболее простой вариант, так как sitespeed — один из инструментов, который поддерживает наша команда.

Выберите эталонный дашборд и настройте регулярный запуск сценария по его открытию.

Пример того, как это может выглядеть.
Пример того, как это может выглядеть.

Логируйте метрики с самой Grafana, используйте их в анализе

Допустим, мы стали замечать, что дашборды стали открываться медленнее. Лишь этого факта недостаточно, чтобы делать выводы о причинах. Как и в любом анализе, важно отделить причину, почему это произошло. Вполне возможно, что проблема тормозов на уровне самой Grafana, а не источника БД или сети. Возьмите за ориентир время получения данных дашборда, метод GET dashboards/uid. Несколько раз нас это спасало от долгих исследований в сторону проблем с БД.

Соберите весь мониторинг в одном месте

Используйте ту же Grafana для мониторинга, соберите удобный дашборд. Это ваша текущая картина стабильности всей системы.

Правильно проектируйте дашборд

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

Внимательно относитесь к переменным в дашборде

Часто используется тип Query, где переменная — это запрос в базу. Пока не загрузятся все переменные, дашборд не откроется. Если что-то можно вынести в запросы графиков без потери скорости, то лучше так и сделать.

Не храните все графики в одной строке (Row)

Раскрывая строку, вы отправите много запросов сразу. Grafana ограничивает количество параллельно отправляемых запросов, по умолчанию их 5. То есть в один момент времени может выполняться не более 5 запросов. Даже со скоростью менее секунды на запрос это может привести к задержкам, если их больше 20 штук.

Один из наших основных дашбордов для deep-dive выглядит примерно так.
Один из наших основных дашбордов для deep-dive выглядит примерно так.

По умолчанию все строки (Row) стоит сворачивать

Открытие дашборда должно быть лёгким. Если строки раскрыты, то при каждом открытии создаётся бесполезная нагрузка на БД, получаем ощущение, что вся система работает медленно. Это не критично, но за этим нужно следить.

Несколько быстрых запросов лучше, чем один медленный

Панели в Grafana позволяют использовать сразу несколько источников для отрисовки графиков. Иногда стоит разделить один большой запрос на несколько маленьких, которые будут вызываться параллельно. Это может быть актуально, например, когда вы используете UNION в запросе.

Разделение ответственности

Дашборды можно поделить на условные 2 категории

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

  • Внутренние. Эти дашборды нужны для deep-dive-исследований. Их используют, когда нужны дополнительные разрезы. Графики здесь также должны грузиться быстро, но нет строгого требования к стабильности.

Чтобы подключить Clickhouse к Grafana так или иначе необходимо завести сервисного пользователя, от имени которого Grafana будет отправлять запросы.

Чтобы контролировать потребление данных, учётку нужно ограничить настройками профиля и квотами на чтение. Именно квоты и профиль могут оказаться узким горлышком, если в системе с более 2к графиков. Если кто-то будет исследовать проблему, используя внутренние дашборды, то это может аффектить открытие общих. Мы выйдем из квот, пользователи получат ошибку вместо данных.

Отсутствие квот и ограничений = хаос

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

  • critical — учётка для критически важных (общих) дашбордов, для неё можно дать увеличенную квоту.

  • base — учётка для всех остальных (внутренних) дашбордов с базовыми ограничениями.

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

При таком разделении ответственности, одна группа (base) не влияет на другую (critical) в плане квот и ограничений. Только если не положить весь кластер одним запросом, и такое может быть????

Я выделил наиболее важные ограничения, список можно (стоит) расширить. Значения показателей зависят от ваших мощностей.

create quota grafana_reader_quota on cluster cluster_name
    FOR INTERVAL 1 minute MAX read_bytes = 644245094400
    to grafana
;
create settings profile grafana_reader_profile on cluster cluster_name
    settings log_queries = true
        , max_memory_usage = 42949672960
        , max_rows_to_read = 4000000000
        , max_bytes_to_read = 644245094400
        , max_result_rows = 200000000
        , max_result_bytes = 6000000000
        , max_execution_time = 120.
        , min_execution_speed = 50000
        , max_temporary_non_const_columns = 400
        , cancel_http_readonly_queries_on_client_close = true
    to grafana
;

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

Наш основной паттерн — это построение и исследование временных рядов в реальном времени и у этого есть своя специфика.

Тем не менее, для Clickhouse есть несколько универсальных советов:

1. Чем меньше колонок в запросе, тем лучше. Причина — колоночное хранение данных в Clickhouse.

2. Старайтесь использовать колонки из ключа сортировки таблицы (секция ORDER BY при создании таблицы). Идеальная ситуация — когда в запросе используются все колонки из сортировки, и только они.

3. Используйте динамическое семплирование. Чем больший промежуток смотрите, тем меньше данных читаете.

Можно сделать следующим образом:

  1. создаём колонку sample типа UInt16 и используем её в ключе сортировки;

  2. заполняем случайными значениями в диапазоне от 0 до 99.

В каждом запросе ограничиваем sample пропорционально количеству прочитанных дней

 WITH ceil(($to - $from) / 3600 / 24)  as days
SELECT $timeSeries as t,
    quantileTDigest($q)(metric) as value
FROM table
WHERE sample <= 100/days
   AND ts <= toDateTime($to)
   AND ts >= toDateTime($from)
   //
GROUP BY t
ORDER BY t

Таким образом, при чтении данных за 2 дня мы прочитаем случайную половину от всего объёма, при чтении 10 дней прочитаем 1/10 и так далее.

4. Используйте макрос $unescape()

Макрос позволяет скрывать часть запроса, если переменная в состоянии All, то есть не использовать колонки, которые не нужны в запросе.

Например, есть фильтр по регионам. Если выбрать все регионы, то условие на регион становится бессмысленным. При этом данные из колонки мы читаем. Тут макрос и пригодится.

5. Выполняйте приближенные вычисления на больших данных

В основном мы используем квантили, потому для нас это особенно критично. Например, мы используем quantileTDigest вместо наиболее точного варианта quantileExact

6. Агрегируйте данные за долгий период заранее, используйте в графиках агрегаты и сырые данные вместе

Используйте Materialized View или внешние системы по типу Airflow для вычисления агрегатов в фоне.

При чтении данных более чем за пару дней подключайте агрегаты, иначе используйте сырые данные.
Пример запроса:

SELECT t, value
from(
  #Запрашиваем сырые данные из таблицы raw_table 
  WITH ceil(($to - $from) / 3600 / 24)  as day_interval
  SELECT $timeSeries as t,
    quantileTDigestIf($q)(metric, metric > 0) as value
  FROM raw_table
  WHERE sample <= 1000/day_interval
    #Добавим условие на количество дней из сырой таблицы
    AND ts > if(day_interval > 8,
	                toDateTime($to) - interval 1 day, toDateTime($from))
    AND ts <= toDateTime($to)

  GROUP BY t
  ORDER BY t

UNION ALL

  #Запрашиваем агругеированные данные из талицы aggregate_table
  WITH ceil(($to - $from) / 3600 / 24) as day_interval
  SELECT toUInt32(ts) * 1000 as t,
    any(aggregated_metric) as value
  FROM aggregated_table
  WHERE day_interval > 8 #Если читаем более 8 дней, то подключаем агрегаты
    AND ts <= toDateTime($to)
    AND ts >= toDateTime($from)

  GROUP BY t
  ORDER BY t
  )
ORDER by t

Как понять, что мой запрос оптимальный?

Есть три основных показателя оптимальности работы запроса:

  1. elapsed — время выполнения

  2. read_rows — количество прочитанных строк

  3. read_bytes — количество прочитанных байт

Есть несколько способов получить эти данные быстро:

  1. Выполните запрос и найдите его в логах по query_id. Вы можете получить id своего запроса прямо во время его выполнения, используя функцию query_id()

SELECT 123, query_id();

123

queryID()

123

50550ffe-0654-409c-bc81-44e3185b0cca

select read_rows, read_bytes, query_duration_ms, query
from clusterAllReplicas('cluster_name', system.query_log)
where query_id = '50550ffe-0654-409c-bc81-44e3185b0cca'
 and type in ('QueryFinish')

read_rows

read_bytes

query_duration_ms

query

1

1

1

SELECT 123, query_id()

2. В Grafana каждый запрос отправляется с клиента по отдельности. В devtools в Chrome можно найти нужную информацию по запросам, не переключаясь от дашборда.

Фиксируйте эти показатели, экспериментируйте с запросами и сравнивайте результаты с прошлыми замерами. По моим наблюдениям, если запрос читает более 10-20 Гб, то начинаются визуальные тормоза графиков. Мы ставим ограничение в 600 Гб на один запрос, иначе он будет отбиваться с ошибкой.

Оптимально храните данные

Как-то раз мы переделали структуру таблицы и получили профит производительности запросов больше чем в 100 раз на тех же данных (ни капли преувеличения).

Как же хранить данные в Clickhouse оптимально? Обозначу пункты, по которым стоит пройтись.

Для наглядности примера будем отталкиваться от следующих данных:

  • timestamp — время измерения

  • platform — название платформы

  • url — урл страницы

  • metric_name — название метрики

  • flag — какой-то флаг разметки

  • metric_value — значение метрики в миллисекундах

timestamp

platform

url

metric_name

flag

metric_value

2023-09-29 00:00

WEB

https://{domain}/{path}/?{params}

browsertime.statistics.timings.navigationTiming.domInteractive

0

6902

2023-09-29 00:01

MOBILE_WEB

https://{domain}/{path}/?{params}

browsertime.statistics.timings.navigationTiming.domainLookupEnd

0

2936

2023-09-29 00:02

WEB

https://{domain}/{path}/?{params}

browsertime.statistics.timings.navigationTiming.domainLookupStart

1

1231

Используйте правильные типы

Если это флаг, то используйте UInt8. Если это число и точность значения не важна, то используйте Int вместо Float. Если это строка и она низкокардинальная, то используйте LowCardinality(String). Если строка длинная, а вам нужна только ее часть, то разделите строку на несколько частей и храните отдельно — например урл поделите на домен, путь и get-параметры.


  • platform — низкокардинальный признак. Мы заранее знаем список платформ.

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

  • metric_name — низкокардинальный признак. Мы заранее знаем список метрик.

  • flag — число со значениями 0/1. Для хранения достаточно UInt8.

  • metric_value — для миллисекунд вполне достаточно UInt32.

create table table_name on cluster cluster_name
(
    timestamp     DateTime,
    platform       LowCardinality(String),
    domain         LowCardinality(String),
    url            String,
    metric_name    LowCardinality(String),
    flag           UInt8,
    metric_value   UIn32
)
engine = ReplicatedMergeTree()Подберите ключ сортировки

Подберите ключ сортировки

Выберите только те поля, которые регулярно используются в запросах. Отсортируйте поля от низко- до высококардинальных. Например, начните с платформы устройства, закончите временем. По умолчанию по ключу сортировки строится первичный ключ.


В нашем примере основной паттерн использования — построение временного ряда, потому timestamp обязательно должен быть частью сортировки.

ORDER BY (platform, domain, metric_name, timestamp)

Подберите ключ партицирования

Интерпретируйте партицию как отдельный файл. На открытие и чтение каждого файла система тратит время. Чем меньше партиций используется в запросе, тем лучше. При этом размер одной партиции должен быть не больше 100-150 Гб — найдите баланс. В моем случае партицирование, как правило, по дню, в редком случае — по неделям и месяцам.


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

PARTITION BY toMonth(timestamp)

Экспериментируйте с гранулярностью

Если данных много, то дефолтного значения в 8192 достаточно. Если используете готовую витрину, то порежьте индекс до 1024 и меньше. Гранула — количество строк между двумя метками из индекса. Мы не можем прочитать строк меньше, чем в одной грануле.


С нашей вариативностью в данных экспериментально подобрали гранулярность в 1024:

SETTINGS index_granularity = 1024

Используйте смешанную политику хранения

Храните свежие данные на SSD, старые — на HDD. По нашим замерам, разница между дисками доходила до 20 раз. Настройка storage_policy.


Наши данные используются редко, будем хранить их на HDD, чтобы оставить больше места для SSD:

storage_policy = 'only_hdd'

Что делать, если уже есть огромная медленная таблица?

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

Так или иначе, спроектируйте таблицу со структурой, которую считаете правильной.

Далее есть два пути:

  1. Перекопировать данные из старой таблицы в новую. Примерно так:

INSERT INTO new_table
SELECT *
FROM table;

RENAME table TO old_table;
RENAME new_table TO table;

DROP old_table;

Если данных много, то стоит автоматизировать процесс или использовать clickhouse-copier https://clickhouse.com/docs/en/operations/utilities/clickhouse-copier

  1.  Сделать Materialized View со вставкой в новую таблицу. В таком случае будет дубликация данных, но как быстрый вариант вполне ок.

А вообще, о том, что делать, если необходимо оптимизировать старую таблицу, описано здесь: https://kb.altinity.com/altinity-kb-schema-design/change-order-by/

Кэшируйте результаты запросов

Используйте настройку use_query_cache = True. Возможность кэшировать результаты запроса появилась в версии 23.1. https://clickhouse.com/docs/en/operations/query-cache

Подробнее про применение тут: https://clickhouse.com/blog/introduction-to-the-clickhouse-query-cache-and-design

В нашем случае был реализован собственный сервис, который хранит результаты в Memcache. Заодно реализовали прокси, который направляет запросы в нужный кластер. Получилась единая точка входа сразу на несколько кластеров и в целом интересный инструмент.

Решаем проблему согласованности данных

Данные должны быть одинаковыми

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

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

  1. Большое количество условий тормозит запросы.

  2. Когда графиков много, то легко что-то забыть.

Один источник данных для всего

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

Мы создали еще одну таблицу, в которую приходят анонимные события (без урлов, идентификаторов пользователя). Только метрики скорости и основные атрибуты. Таблицу наполняют Materialized View, которые триггерятся на вставку в основные таблицы. В Materialized View уже используются все наши собранные условия. Тем самым в итоговую таблицу попадают события, которые не нужно фильтровать. Эту таблицу мы назвали «горячей», основные таблицы — «холодными».

CREATE MATERIALIZED VIEW hot_matview TO hot_table
SELECT metric1,
       ...
       metricN
FROM table
WHERE condition1
 AND condition2
 ...
 AND conditionM

Этим решением мы покрыли большую часть дашбордов. Но остались графики для deep-dive-исследований, графики по новым метрикам и атрибутам, которых еще нет в горячей таблице и так далее.

Единая точка для фильтров во всех дашбордах

Создайте View, в котором строкой хранили набор условий:

CREATE VIEW filters
SELECT 'condition1 AND condition2 ... AND conditionM'

Создайте переменную в Grafana, в которой обращаетесь к View. Сделайте мультивыбор, включая All. Это нужно чтобы при копировании ссылки на дашборд пользователь не видел SQL-код в гет-параметрах урла.

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

SELECT toStartOfInterval(ts, interval 1 minute) as t_rpm,
       quantileTDigest(0.9)(metric) as value
FROM table
WHERE $timeFilter
 AND ${filter:csv}       
GROUP BY t_rpm 
ORDER BY t_rpm

Теперь изменение во View изменит все дашборды, на которых она завязана.

Итоги

Я собрал краткую выжимку по тем решениям, которые помогли решить проблему медленных и нестабильно работающих графиков. Использовал как очевидные и обобщенные советы, например, «храните данные оптимально, а неоптимально не храните», так и рекомендации по внедрению динамического семплирования, которые могут подойти не для всех ситуаций. А история с внешним мониторингом за дашбордами с использованием sitespeed может показаться вовсе перебором.

Нет единственного решения всех проблем для каждого случая, но собрать универсальные советы попробовать можно:

  1. Следите за стабильностью и скоростью загрузки графиков. Смотрите за системными таблицами.

  2. Делайте быстрые запросы, а при проектировании хранилища учитывайте специфичную нагрузку.

  3. При возможности кэшируйте результаты запросов.

  4. Ограничивайте запросы квотами и другими лимитами.

  5. Чем меньше различных источников данных, тем лучше.

Делайте дашборды не только красивыми, но и быстрыми

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


  1. ivankudryavtsev
    20.11.2023 12:10
    +6

    Чрезвычайно годный контент. Все-таки когда люди на чем-то фокусируются, сразу видно компетенции.


    1. napit_ok Автор
      20.11.2023 12:10
      +1

      Спасибо!


  1. titan_pc
    20.11.2023 12:10
    -3

    Добрый день. Интересная статья. На сколько Вам был бы полезен такой инструмент?

    https://www.youtube.com/playlist?list=PLCxvGZsc-aLnk79wFLUzdaO6_4eitiMym


  1. HaMLeTT
    20.11.2023 12:10
    +1

    Очень много полезной информации, спасибо!

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


    1. napit_ok Автор
      20.11.2023 12:10

      Да, насколько я в курсе, ограничить выбор датасорса не получится и все равно кто-то будет строить графики с неправильным. Но можно намекнуть в названии датасорса, что он нужен только для супер важных графиков, по типу [Clickhouse] CRITICAL


  1. G0tem
    20.11.2023 12:10
    +1

    Полезная статья, сейчас в команде как раз внедряем grafana, правда источник данных influxdb. Но годные советы во время проектирования всегда хорошо!)


  1. SiplatovKirill
    20.11.2023 12:10

    Спасибо за статью! Правильно ли я понял, что динамическое семплирование используется для контроля объема данных передаваемых между Clickhouse и Grafana?


  1. Mauzzz0
    20.11.2023 12:10

    3. Используйте динамическое семплирование. Чем больший промежуток смотрите, тем меньше данных читаете.

    1. создаём колонку sample типа UInt16 и используем её в ключе сортировки;

    2. заполняем случайными значениями в диапазоне от 0 до 99.

    В каждом запросе ограничиваем sample пропорционально количеству прочитанных дней

    Таким образом, при чтении данных за 2 дня мы прочитаем случайную половину от всего объёма, при чтении 10 дней прочитаем 1/10 и так далее.

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

    Думали над таким же решением, но у нас оно практически постоянно давало неточность измерений для частных случаев:

    1. Если за последний день основные всплески трафика записались с sample от 50 до 100. График "за день" их покажет, а вот "за последние 3 дня" их проигнорирует, т.к. where sample <= 100/days возьмёт лишь треть трафика и в итоге выведет число, которое может отличаться от реального (или от того числа, которое выводится на графике "за день") более чем на 20%, что может быть критично.

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

    Понятное дело что эту ошибку можно уменьшить если поиграться с диапазоном для sample и условием where sample <= , но у каждого клиента может быть разное поведение трафика и подобрать "ключ ко всем" не получится, ровно как и определить количество записей до начала запроса и скорректировать запрос.

    А вы не сталкивались с подобными проблемами? Или в вашем случае различия в значениях на дневном и недельном графиках не критичны?


  1. syngrou-fix
    20.11.2023 12:10

    Масштабный материал, спасибо. Хотя я бы тут добавил, что Grafana для OLAP (для которого Clickhouse большей частью и используется) — это из раздела "сомнительно, но окей", так как когда задачи не time-series, а slice-and-dice, все эти time-series корни графаны начинают заметно мешаться под ногами. Жить можно, конечно, но можно рассмотреть и другие продукты.

    Несколько вопросов по статье:

    Старайтесь использовать колонки из ключа сортировки таблицы (секция ORDER BY при создании таблицы).

    Можете разьяснить, это о чём? В чем разница чтения колонки из индекса и не из индекса?

    К тому же, на основании данной фразы кто-то может подумать, что надо затянуть как можно больше колонок в sorting key (а лучше вообще все), что приведет к проблемам.

    Используйте Materialized View или внешние системы по типу Airflow для вычисления агрегатов в фоне.

    Там теперь еще есть production-ready проекции, которые и считают агрегаты в фоне без необходимости создания и поддержки дополнительных таблиц (но есть подвох для Replacing/Aggregating движков)

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

    пишете вы, а потом используете партиционирование по toMonth(timestamp), складывая в одну партицию месяцы разных годов и лишая себя возможности использовать minmax индекс партиции.

    Пример с партициямипо toMonth: https://fiddle.clickhouse.com/cd9fe46b-fedc-41e8-85e1-9893577d3729

    Пример с партициями по toYYYYMM: https://fiddle.clickhouse.com/b6e5848d-a86f-4c8f-98f1-6772c13b4e59

    Экспериментируйте с гранулярностью индекса

    и не забывайте о последствиях, я бы добавил, дабы не возникло желание сделать гранулярность 1, "как в b-tree".