воскресенье, 1 ноября 2020 г.

Доработка веб-интерфейса Zabbix 3.4 для работы с ClickHouse

Теперь, когда у нас имеется настроенный сервер Clickhouse с заготовленными в нём таблицами истории и тенденций Zabbix, можно попробовать доработать веб-интерфейс Zabbix для работы с Clickhouse. Реализацию поддержки Clickhouse будем делать на базе уже имеющейся поддержки хранилищ Elasticsearch и SQL. Поскольку ClickHouse использует SQL-образный синтаксис запросов, но может возвращать ответ в виде JSON по протоколу HTTP, то нам пригодятся фрагменты кода реализации поддержки как того, так и другого типа хранилища. Разработка и отладка заплатки выполнялась на данных, скопированных в Clickhouse из хранилища SQL при помощи описанного ранее скрипта copy_data.py.

Получившуюся заплатку с неописанными здесь мелкими изменениями в комментариях к другим функциям можно взять по ссылке zabbix3_4_12_frontend_clickhouse.patch.
Описанная здесь реализация поддержки ClickHouse отличается от реализации из Glaber, следующим:
  • вместо модуля curl для php для обращения к API ClickHouse используется штатная функция file_get_contents,
  • вместо типа DateTime для колонок clock используется тип UInt32, что приближает поддержку ClickHouse к родной структуре таблиц Zabbix,
  • реализована поддержка работы с таблицами истории журнального типа - history_log,
  • реализовано раздельное хранение исторических данных в таблицах history, history_uint, history_str, history_text, history_log, что приближает поддержку ClickHouse к родной структуре таблиц Zabbix,
  • веб-интерфейс поддерживает индивидуальный выбор типа хранилища для каждой из таблиц истории: SQL, Elasticsearch или ClickHouse,
  • при отображении графиков используются данные из таблиц trends и trends_uint, которые должны быть доступны по URL таблиц history и history_uint соответственно,
  • реализованы оптимизации запросов на страницах последних данных и графиков нескольких элементов данных: вместо отдельных запросов по каждому элементу данных выполняется от одного до 5 запросов по каждому из типов элементов данных. Внутри каждого запроса данные подзапросов объединяются при помощи выражения UNION ALL.
Я старался скрупулёзно воспроизводить стиль исходного кода, однако не всегда этот код мне кажется идеальным. В частности, в коде присутствуют микрооптимизации, кэширующие соответствие типов значений элементов данных URL'ам хранилища. Эти микрооптимизации на фоне обращений к самим хранилищам экономят настолько мизерное количество ресурсов, что лучше было бы обойтись вообще без них - код бы от этого стал только нагляднее. Не совсем понятно, почему в некоторых не самых тяжёлых функциях реализовано объединение запросов при помощи UNION ALL, а в наиболее тяжёлых - не реализовано. Наконец, сама поддержка различных хранилищ не выполнена в стиле ООП: нет базового класса хранилища и нет отдельных реализаций хранилищ в виде классов, отнаследованных от базового класса. Вместо этого поддержка разных типов хранилищ реализована прямо в коде классов CHistoryManager, CHistory, CTrend, из которых два последних используют часть методов из первого.

У веб-интерфейса есть интересная особенность. На странице просмотра графика могут использоваться данные из таблицы тенденций, даже если данные есть в таблице истории. На выбор таблицы-источника данных влияет длительность хранения исторических данных, указанная в свойствах самого элемента данных. Также если в настройках на странице «Администрирование» - «Общие», в разделе «Очистка истории» отмечена галочка «Переопределить период хранения истории элементов данных», то используется значение, указанное в поле «Период хранения данных». В моём случае в этом поле было указано значение 60d, а в таблице истории имелись данные за 365 дней, включая те данные, которые были сгенерированы по таблицам тенденций. Когда я поменял значение в этом поле на 365d, на графиках стали отображаться только данные из таблиц истории.

Ниже описаны внесённые заплаткой доработки веб-интерфейса и их обоснование.

Файл конфигурации

Первым делом поправим пример файла конфигурации frontends/php/conf/zabbix.conf.php.example. В нём можно увидеть, что в переменной конфигурации $HISTORY['types'] можно указать список таблиц, для которых будет использоваться Elasticsearch. Делается это следующим обрзом:
$HISTORY['types'] = ['uint', 'text'];
Поскольку нам нужно достичь возможности использовать одно из трёх разных хранилищ, я решил изменить формат этой переменной так, чтобы для каждой из таблиц можно было указывать тип её хранилища:
$HISTORY['type'] = [
                'uint' => 'clickhouse',
                'text' => 'elastic'
];
При этом в переменной $HISTORY['url'], как и раньше, будет указывается URL хранилища. Только теперь это может быть как URL хранилища Elasticsearch, так и URL хранилища Clickhouse.

Отобразим логику изменений файла конфигурации в примере этого файла следующим образом:
Index: zabbix-3.4.12-1+buster/frontends/php/conf/zabbix.conf.php.example
===================================================================
--- zabbix-3.4.12-1+buster.orig/frontends/php/conf/zabbix.conf.php.example
+++ zabbix-3.4.12-1+buster/frontends/php/conf/zabbix.conf.php.example
@@ -17,10 +17,13 @@ $ZBX_SERVER_NAME            = '';
 
 $IMAGE_FORMAT_DEFAULT  = IMAGE_FORMAT_PNG;
 
-// Elasticsearch url (can be string if same url is used for all types).
+// Elasticsearch or ClickHouse url.
 $HISTORY['url']   = [
-               'uint' => 'http://localhost:9200',
+               'uint' => 'http://login:password@localhost:8123/?database=zabbix',
                'text' => 'http://localhost:9200'
 ];
-// Value types stored in Elasticsearch.
-$HISTORY['types'] = ['uint', 'text'];
+// Value types stored in Elasticsearch or ClickHouse.
+$HISTORY['types'] = [
+               'uint' => 'clickhouse',
+               'text' => 'elastic'
+];

Вспомогательный класс для работы с Clickhouse

В отличие от Glaber, в моей доработке веб-интерфейса Zabbix для обращения к Clickhouse не используется модуль php-curl, а используется встроенная в PHP функция file_get_contents, что позволяет обойтись прежними зависимостями при установке веб-интерфейса.

Для выполнения запросов к Clickhouse добавим файл frontends/php/include/classes/helpers/CClickHouseHelper.php со вспомогательным классом CClickHouseHelper:
<?php

/**
 * A helper class for working with ClickHouse.
 */
class CClickHouseHelper {

       /**
        * Perform request to ClickHouse.
        *
        * @param string $method      HTTP method to be used to perform request
        * @param string $endpoint    requested url
        * @param mixed  $request     data to be sent
        *
        * @return string    result
        */
       private static function request($method, $endpoint, $query) {
               $options = [
                       'http' => [
                               'header'  => "Content-Type: application/json; charset=UTF-8",
                               'method'  => $method,
                               'ignore_errors' => true // To get error messages from ClickHouse.
                       ]
               ];

               $query .= ' FORMAT JSONCompact';
               $options['http']['content'] = $query;

               try {
                       $response = file_get_contents($endpoint, false, stream_context_create($options));
               }
               catch (Exception $e) {
                       error($e->getMessage());
               }

               return json_decode($response, true);
       }

       public static function values($method, $endpoint, $query = null, $columns = null, $map = null) {
               #file_put_contents('/var/log/nginx/chartlog.log', "$query\n\n", FILE_APPEND);
               $response = self::request($method, $endpoint, $query);

               $values = [];
               foreach ($response['data'] as $row) {
                       $value = [];
                       for($i = 0; $i < count($row); $i++)
                       {
                               if ($columns) {
                                       $column = $columns[$i];
                               } else {
                                       $column = $response['meta'][$i]['name'];
                               }

                               if ($map && array_key_exists($column, $map))
                               {
                                       $column = $map[$column];
                               }

                               $value[$column] = $row[$i];
                       }
                       $values[] = $value;
               }
               #$json = json_encode($values, true);
               #file_put_contents('/var/log/nginx/chartlog.log', "$json\n", FILE_APPEND);
               return $values;
       }

       public static function value($method, $endpoint, $query = null, $column = 'value') {
               $values = self::values($method, $endpoint, $query, [$column]);

               if ((count($values) > 0) && array_key_exists($column, $values[0])) {
                       return $values[0][$column];
               }
               return null;
       }
}
В отличие от Glaber, этот класс выполняет запросы в формате JSON, а не TSV. В классе есть функция request, которая выполняет переданные ей запросы и возвращает в ответ данные, извлечённые из JSON. Эта функция вызывается только из функций values и value.

Функция values позволяет выполнить SQL-запрос на получение множества строк данных. Clickhouse вместе с данными ответа также возвращает имена колонок. Если не указывать аргументы columns и map, то при формировании результата будут использоваться имена колонок, которые вернул Clickhouse. Результат будет представлять собой список словарей: каждая строчка списка будет соответствовать одной строке из результата выполнения запроса, а каждый словарь в строке будет в ключе содержать имя колонки, а в значении ключа - значение этой колонки.

Если указать функции values аргумент columns, то вместо возвращённых сервером Clickhouse имён колонок будут использоваться указанные, в порядке их указания в списке columns.

Если указать функции values аргумент maps, являющийся словарём, то вместо возвращённых сервером Clickhouse имён колонок будут возвращаться значения из словаря. maps должен быть словарём, в котором ключами являются имена колонок, возвращённых сервером Clickhouse, а их значениями - желаемые имена колонок.

Функция value возвращает одно значение, возвращённое запросом, или значение null, если запрос ничего не вернул. Если запрос вернёт несколько строк, то будет возвращено значение из первой строки. Если аргумент column не указан, то возвращено будет значение из колонки value.

В тексте функции values имеются закомментированные строчки, которые могут помочь при отладке запросов к Clickhouse. Раскомментировав их, можно, по желанию, вести журнал запросов и результатов выполнения этих запросов.

Новый тип хранилища

Теперь добавим новое определение источника данных в файл frontends/php/include/defines.inc.php:
Index: zabbix-3.4.12-1+buster/frontends/php/include/defines.inc.php
===================================================================
--- zabbix-3.4.12-1+buster.orig/frontends/php/include/defines.inc.php
+++ zabbix-3.4.12-1+buster/frontends/php/include/defines.inc.php
@@ -41,6 +41,7 @@ define('ZBX_PERIOD_DEFAULT',  3600); // 1
 // by default set to 86400 seconds (24 hours)
 define('ZBX_HISTORY_PERIOD', 86400);
 
+define('ZBX_HISTORY_SOURCE_CLICKHOUSE',        'clickhouse');
 define('ZBX_HISTORY_SOURCE_ELASTIC',   'elastic');
 define('ZBX_HISTORY_SOURCE_SQL',               'sql');

Доработка класса CHistoryManager

В файле frontends/php/include/classes/api/managers/CHistoryManager.php определён класс CHistoryManager, который отвечает за работу с таблицами истории непосредственно самого веб-интерфейса. Потребуется доработать функции getLastValues, getValueAt, getGraphAggregation, getAggregatedValue и getMinClock. Начнём, однако, не с этого, а с введения новых вспомогательных фукнций.

Новая функция getClickHouseEndpoints

Вместо функции getElasticsearchUrl введём аналогичную по смыслу фукнцию getClickHouseEndpoints, которая будет использовать вспомогательную функцию getClickhouseUrl:
Index: zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
===================================================================
--- zabbix-3.4.12-1+buster.orig/frontends/php/include/classes/api/managers/CHistoryManager.php
+++ zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
@@ -968,6 +1321,51 @@ class CHistoryManager {
                return $cache[$value_type];
        }
 
+       private static function getClickHouseUrl($value_name) {
+               static $urls = [];
+               static $invalid = [];
+
+               // Additional check to limit error count produced by invalid configuration.
+               if (array_key_exists($value_name, $invalid)) {
+                       return null;
+               }
+
+               if (!array_key_exists($value_name, $urls)) {
+                       global $HISTORY;
+
+                       $urls[$value_name] = $HISTORY['url'][$value_name];
+               }
+
+               return $urls[$value_name];
+       }
+
+       /**
+        * Get endpoints for ClickHouse requests.
+        *
+        * @param mixed $value_types    value type(s)
+        *
+        * @return array    ClickHouse query endpoints
+        */
+       public static function getClickHouseEndpoints($value_types) {
+               if (!is_array($value_types)) {
+                       $value_types = [$value_types];
+               }
+
+               $endpoints = [];
+
+               foreach (array_unique($value_types) as $type) {
+                       if (self::getDataSourceType($type) === ZBX_HISTORY_SOURCE_CLICKHOUSE) {
+                               $index = self::getTypeNameByTypeId($type);
+
+                               if (($url = self::getClickHouseUrl($index)) !== null) {
+                                       $endpoints[$type] = $url;
+                               }
+                       }
+               }
+
+               return $endpoints;
+       }
+
        private static function getElasticsearchUrl($value_name) {
                static $urls = [];
                static $invalid = [];
Функция getClickhouseEndpoints возвращает URL для доступа к таблицам истории указанных типов значений.

Доработка функции getLastValues

Функция getLastValues последовательно обращается к хранилищам каждого типа и запрашивает у него последние значения тех элементов данных, которые хранятся в соответствующем хранилище. Результаты запросов складываются в общую копилку и возвращаются в качестве результата. Добавим в функцию поддержку хранилища ClickHouse:
Index: zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
===================================================================
--- zabbix-3.4.12-1+buster.orig/frontends/php/include/classes/api/managers/CHistoryManager.php
+++ zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
@@ -37,6 +37,12 @@ class CHistoryManager {
                $results = [];
                $grouped_items = self::getItemsGroupedByStorage($items);
 
+               if (array_key_exists(ZBX_HISTORY_SOURCE_CLICKHOUSE, $grouped_items)) {
+                       $results += $this->getLastValuesFromClickHouse($grouped_items[ZBX_HISTORY_SOURCE_CLICKHOUSE], $limit,
+                                       $period
+                       );
+               }
+
                if (array_key_exists(ZBX_HISTORY_SOURCE_ELASTIC, $grouped_items)) {
                        $results += $this->getLastValuesFromElasticsearch($grouped_items[ZBX_HISTORY_SOURCE_ELASTIC], $limit,
                                        $period
Теперь нужно реализовать функцию getLastValuesFromClickhouse. Я реализовал два варианта функции. Первый просто последовательно запрашивает последнее значение каждого из указанных в запросе элементов данных и объединяет результаты запросов, как это сделано в функции getLastValuesFromElasticsearch. Второй вариант из запрашиваемых значений формирует группы по их типам. Для каждого типа значений формируется единый запрос, объединяющий результаты отдельных запросов при помощи выражения UNION ALL. Таким образом можно увеличить отзывчивость веб-интерфейса, сократив количество HTTP-запросов к серверу Clickhouse. Первый вариант фигурирует в коде под именем _getLastValuesFromClickHouse, а второй - более эффективный - под именем getLastValuesFromClickHouse:
Index: zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
===================================================================
--- zabbix-3.4.12-1+buster.orig/frontends/php/include/classes/api/managers/CHistoryManager.php
+++ zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
@@ -51,6 +57,77 @@ class CHistoryManager {
        }
 
        /**
+        * ClickHouse specific implementation of getLastValues.
+        *
+        * @see CHistoryManager::getLastValues
+        */
+       private function _getLastValuesFromClickHouse($items, $limit, $period) {
+               $results = [];
+
+               foreach ($items as $item) {
+                       $endpoints = self::getClickHouseEndpoints($item['value_type']);
+                       if ($endpoints) {
+                               $query =
+                                       'SELECT *'.
+                                       ' FROM '.self::getTableName($item['value_type']).
+                                       ' WHERE itemid='.($item['itemid'] + 0).
+                                               ($period ? ' AND clock>'.(time() - $period) : '').
+                                       ' ORDER BY clock DESC';
+
+                               if ($limit > 0) $query .= ' LIMIT '.$limit;
+
+                               $values = CClickHouseHelper::values('POST', reset($endpoints), $query);
+                               if ($values) {
+                                       $results[$item['itemid']] = $values;
+                               }
+                       }
+               }
+
+               return $results;
+       }
+
+       /**
+        * ClickHouse specific implementation of getLastValues.
+        *
+        * @see CHistoryManager::getLastValues
+        */
+       private function getLastValuesFromClickHouse($items, $limit, $period) {
+               $results = [];
+               $type_queries = [];
+
+               foreach ($items as $item) {
+                       $query =
+                               'SELECT *'.
+                               ' FROM '.self::getTableName($item['value_type']).
+                               ' WHERE itemid='.($item['itemid'] + 0).
+                                       ($period ? ' AND clock>'.(time() - $period) : '').
+                               ' ORDER BY clock DESC';
+
+                       if ($limit > 0) $query .= ' LIMIT '.$limit;
+
+                       $type_queries[$item['value_type']][] = $query;
+               }
+
+               foreach ($type_queries as $value_type => $queries) {
+                       $endpoints = self::getClickHouseEndpoints($value_type);
+                       if ($endpoints) {
+                               $query =
+                                       'SELECT *'.
+                                       ' FROM ('.implode(' UNION ALL ', $queries).')';
+
+                               $values = CClickHouseHelper::values('POST', reset($endpoints), $query);
+
+                               foreach($values as $row) {
+                                       $itemid = $row['itemid'];
+                                       $results[$itemid][] = $row;
+                               }
+                       }
+               }
+
+               return $results;
+       }
+
+       /**
         * Elasticsearch specific implementation of getLastValues.
         *
         * @see CHistoryManager::getLastValues
Можно пойти дальше и написать вариант функции, который использует специфический тип запросов, поддерживаемый ClickHouse: LIMIT 1 BY itemid. В таком случае можно будет упростить запрос и обойтись без выражений UNION ALL.

Доработка функции getValueAt

Аналогичным образом доработаем функцию getValueAt, которая ищет значение элемента данных, соответствующее указанной отметки времени:
Index: zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
===================================================================
--- zabbix-3.4.12-1+buster.orig/frontends/php/include/classes/api/managers/CHistoryManager.php
+++ zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
@@ -172,6 +249,9 @@ class CHistoryManager {
         */
        public function getValueAt($item, $clock, $ns) {
                switch (self::getDataSourceType($item['value_type'])) {
+                       case ZBX_HISTORY_SOURCE_CLICKHOUSE:
+                               return $this->getValueAtFromClickHouse($item, $clock, $ns);
+
                        case ZBX_HISTORY_SOURCE_ELASTIC:
                                return $this->getValueAtFromElasticsearch($item, $clock, $ns);
 
@@ -181,6 +261,74 @@ class CHistoryManager {
        }
 
        /**
+        * ClickHouse specific implementation of getValueAt.
+        *
+        * @see CHistoryManager::getValueAt
+        */
+       private function getValueAtFromClickHouse($item, $clock, $ns) {
+               $value = null;
+               $table = self::getTableName($item['value_type']);
+
+               $endpoints = self::getClickHouseEndpoints($item['value_type']);
+               if ($endpoints) {
+                       $url = reset($endpoints);
+
+                       $query = 'SELECT value'.
+                                       ' FROM '.$table.
+                                       ' WHERE itemid='.($item['itemid']+0).
+                                               ' AND clock='.($clock+0).
+                                               ' AND ns='.($ns+0).
+                                       ' LIMIT 1';
+                       $value = CClickHouseHelper::value('POST', $url, $query);
+                       if ($value !== null) {
+                               return $value;
+                       }
+
+                       $query = 'SELECT DISTINCT clock'.
+                                       ' FROM '.$table.
+                                       ' WHERE itemid='.($item['itemid']+0).
+                                               ' AND clock='.($clock+0).
+                                               ' AND ns<'.($ns+0);
+                       $max_clock = CClickHouseHelper::value('POST', $url, $query, 'clock');
+
+                       if ($max_clock === null) {
+                               $query = 'SELECT MAX(clock) AS clock'.
+                                               ' FROM '.$table.
+                                               ' WHERE itemid='.($item['itemid']+0).
+                                                       ' AND clock<'.($clock+0).
+                                                       (ZBX_HISTORY_PERIOD ? ' AND clock>='.($clock - ZBX_HISTORY_PERIOD) : '');
+
+                               $max_clock = CClickHouseHelper::value('POST', $url, $query, 'clock');
+                       }
+
+                       if ($max_clock === null) {
+                               return $value;
+                       }
+
+                       if ($clock == $max_clock) {
+                               $query = 'SELECT value'.
+                                               ' FROM '.$table.
+                                               ' WHERE itemid='.($item['itemid']+0).
+                                                       ' AND clock='.($clock+0).
+                                                       ' AND ns<'.($ns+0).
+                                               ' LIMIT 1';
+                       }
+                       else {
+                               $query = 'SELECT value'.
+                                               ' FROM '.$table.
+                                               ' WHERE itemid='.($item['itemid']+0).
+                                                       ' AND clock='.($max_clock+0).
+                                               ' ORDER BY itemid, clock, ns DESC'.
+                                               ' LIMIT 1';
+                       }
+
+                       $value = CClickHouseHelper::value('POST', $url, $query);
+
+               }
+               return $value;
+       }
+
+       /**
         * Elasticsearch specific implementation of getValueAt.
         *
         * @see CHistoryManager::getValueAt
Оптимизированной версии фукнции тут нет, потому что функция выполняет только один запрос, извлекающий единственное значение.

Доработка функции getGraphAggregation

Функция getGraphAggregation возвращает агрегированные данные одного или нескольких элементов данных для отрисовки графика, доступного по ссылкам на странице просмотра последних данных. Поскольку на одном графике могут отображаться кривые нескольких элементов данных, то данные для каждой из кривых можно получать либо отдельными запросами, либо сгруппированными запросами с выражениями UNION ALL. Первый вариант функции фигурирует ниже под именем _getGraphAggregationFromClickHouse, а второй, оптимизированный вариант функции можно найти по имени getGraphAggregationFromClickHouse:
Index: zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
===================================================================
--- zabbix-3.4.12-1+buster.orig/frontends/php/include/classes/api/managers/CHistoryManager.php
+++ zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
@@ -345,6 +493,12 @@ class CHistoryManager {
                $grouped_items = self::getItemsGroupedByStorage($items);
 
                $results = [];
+               if (array_key_exists(ZBX_HISTORY_SOURCE_CLICKHOUSE, $grouped_items)) {
+                       $results += $this->getGraphAggregationFromClickHouse($grouped_items[ZBX_HISTORY_SOURCE_CLICKHOUSE],
+                                       $time_from, $time_to, $width, $size, $delta
+                       );
+               }
+
                if (array_key_exists(ZBX_HISTORY_SOURCE_ELASTIC, $grouped_items)) {
                        $results += $this->getGraphAggregationFromElasticsearch($grouped_items[ZBX_HISTORY_SOURCE_ELASTIC],
                                        $time_from, $time_to, $width, $size, $delta
@@ -361,6 +515,114 @@ class CHistoryManager {
        }
 
        /**
+        * ClickHouse specific implementation of getGraphAggregation.
+        *
+        * @see CHistoryManager::getGraphAggregation
+        */
+       private function _getGraphAggregationFromClickHouse(array $items, $time_from, $time_to, $width, $size, $delta) {
+               $group_by = 'itemid';
+               $sql_select_extra = '';
+
+               if ($width !== null && $size !== null && $delta !== null) {
+                       $calc_field = 'round('.$width.'*modulo(clock+'.$delta.','.$size.')/('.$size.'),0)';
+
+                       $sql_select_extra = ','.$calc_field.' AS i';
+                       $group_by .= ','.$calc_field;
+               }
+
+               $results = [];
+
+               foreach ($items as $item) {
+                       $endpoints = self::getClickHouseEndpoints($item['value_type']);
+                       if ($endpoints) {
+                               if ($item['source'] === 'history') {
+                                       $sql_select = 'COUNT(*) AS count,AVG(value) AS avg,MIN(value) AS min,MAX(value) AS max';
+                                       $sql_from = ($item['value_type'] == ITEM_VALUE_TYPE_UINT64) ? 'history_uint' : 'history';
+                               }
+                               else {
+                                       $sql_select = 'SUM(num) AS count,AVG(value_avg) AS avg,MIN(value_min) AS min,MAX(value_max) AS max';
+                                       $sql_from = ($item['value_type'] == ITEM_VALUE_TYPE_UINT64) ? 'trends_uint' : 'trends';
+                               }
+
+                               $query =
+                                       'SELECT itemid,'.$sql_select.$sql_select_extra.',MAX(clock) AS max_clock'.
+                                       ' FROM '.$sql_from.
+                                       ' WHERE itemid='.($item['itemid']+0).
+                                               ' AND clock>='.($time_from+0).
+                                               ' AND clock<='.($time_to+0).
+                                       ' GROUP BY '.$group_by;
+
+                               $values = CClickHouseHelper::values('POST', reset($endpoints), $query, null, ['max_clock' => 'clock']);
+
+                               $results[$item['itemid']]['source'] = $item['source'];
+                               $results[$item['itemid']]['data'] = $values;
+                       }
+               }
+
+               return $results;
+       }
+
+       /**
+        * ClickHouse specific implementation of getGraphAggregation.
+        *
+        * @see CHistoryManager::getGraphAggregation
+        */
+       private function getGraphAggregationFromClickHouse(array $items, $time_from, $time_to, $width, $size, $delta) {
+               $group_by = 'itemid';
+               $sql_select_extra = '';
+               $query_extra = '';
+
+               if ($width !== null && $size !== null && $delta !== null) {
+                       $calc_field = 'round('.$width.'*modulo(clock+'.$delta.','.$size.')/('.$size.'),0)';
+
+                       $sql_select_extra = ','.$calc_field.' AS i';
+                       $group_by .= ','.$calc_field;
+                       $query_extra = ',i';
+               }
+
+               $results = [];
+               $url_queries = [];
+               foreach ($items as $item) {
+                       $endpoints = self::getClickHouseEndpoints($item['value_type']);
+                       if ($endpoints) {
+                               if ($item['source'] === 'history') {
+                                       $sql_select = 'COUNT(*) AS count,AVG(value) AS avg,MIN(value) AS min,MAX(value) AS max';
+                                       $sql_from = ($item['value_type'] == ITEM_VALUE_TYPE_UINT64) ? 'history_uint' : 'history';
+                               }
+                               else {
+                                       $sql_select = 'SUM(num) AS count,AVG(value_avg) AS avg,MIN(value_min) AS min,MAX(value_max) AS max';
+                                       $sql_from = ($item['value_type'] == ITEM_VALUE_TYPE_UINT64) ? 'trends_uint' : 'trends';
+                               }
+
+                               $query =
+                                       'SELECT itemid,'.$sql_select.$sql_select_extra.',MAX(clock) AS max_clock'.
+                                       ' FROM '.$sql_from.
+                                       ' WHERE itemid='.($item['itemid']+0).
+                                               ' AND clock>='.($time_from+0).
+                                               ' AND clock<='.($time_to+0).
+                                       ' GROUP BY '.$group_by;
+
+                               $results[$item['itemid']]['source'] = $item['source'];
+                               $url_queries[reset($endpoints)][] = $query;
+                       }
+               }
+
+               foreach ($url_queries as $url => $queries) {
+                       $query =
+                               'SELECT itemid,count,avg,min,max'.$query_extra.',max_clock'.
+                               ' FROM ('.implode(' UNION ALL ', $queries).')';
+
+                       $values = CClickHouseHelper::values('POST', $url, $query, null, ['max_clock' => 'clock']);
+
+                       foreach($values as $row) {
+                               $results[$row['itemid']]['data'][] = $row;
+                       }
+               }
+
+               return $results;
+       }
+
+       /**
         * Elasticsearch specific implementation of getGraphAggregation.
         *
         * @see CHistoryManager::getGraphAggregation

Доработка функции getAggregatedValue

Функция getAggregatedValue, как следует из её названия, возвращает агрегированное значение элемента данных. Функция агрегации указывается в аргументе aggregation (значением может быть строка «MAX», «MIN», «AVG», «COUNT», «SUM»), интересующий элемент данных - в аргументе item, а начальная отметка времени, начиная с которого нужно вернуть агрегированное значение, указывается в аргументе time_from. Из аргумента item на самом деле используется только идентификатор элемента данных, доступный по ключу itemid. По понятным причинам, оптимизированной версии функции getAggregatedValueFromClickHouse нет - здесь происходит запрос только по одному элементу данных:
Index: zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
===================================================================
--- zabbix-3.4.12-1+buster.orig/frontends/php/include/classes/api/managers/CHistoryManager.php
+++ zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
@@ -585,6 +847,9 @@ class CHistoryManager {
         */
        public function getAggregatedValue(array $item, $aggregation, $time_from) {
                switch (self::getDataSourceType($item['value_type'])) {
+                       case ZBX_HISTORY_SOURCE_CLICKHOUSE:
+                               return $this->getAggregatedValueFromClickHouse($item, $aggregation, $time_from);
+
                        case ZBX_HISTORY_SOURCE_ELASTIC:
                                return $this->getAggregatedValueFromElasticsearch($item, $aggregation, $time_from);
 
@@ -594,6 +859,27 @@ class CHistoryManager {
        }
 
        /**
+        * ClickHouse specific implementation of getAggregatedValue.
+        *
+        * @see CHistoryManager::getAggregatedValue
+        */
+       private function getAggregatedValueFromClickHouse(array $item, $aggregation, $time_from) {
+               $value = null;
+               $endpoints = self::getClickHouseEndpoints($item['value_type']);
+               if ($endpoints) {
+                       $query =
+                               'SELECT '.$aggregation.'(value) AS value'.
+                               ' FROM '.self::getTableName($item['value_type']).
+                               ' WHERE clock>'.$time_from.
+                               ' AND itemid='.($item['itemid']+0).
+                               ' HAVING COUNT(*)>0';
+
+                       $value = CClickHouseHelper::value('POST', reset($endpoints), $query);
+               }
+               return $value;
+       }
+
+       /**
         * Elasticsearch specific implementation of getAggregatedValue.
         *
         * @see CHistoryManager::getAggregatedValue

Доработка функции getMinClock

Функция getMinClock принимает список элементов данных в аргументе items, для которых нужно найти наименьшую отметку времени. Насколько я понимаю, эта функция используется при попытке открыть график в последних данных за всё время. Здесь выполняется один запрос с выражениями UNION ALL для объединения результатов запросов ко всем таблицам в ClickHouse. Интересно, что выражение UNION ALL используется и в функции getMinClockFromSql. Собственно, после того, как я наткнулся на эту функцию, мне и пришла в голову идея оптимизировать остальные функции, уменьшив количество запросов к ClickHouse при помощи выражения UNION ALL.
Index: zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
===================================================================
--- zabbix-3.4.12-1+buster.orig/frontends/php/include/classes/api/managers/CHistoryManager.php
+++ zabbix-3.4.12-1+buster/frontends/php/include/classes/api/managers/CHistoryManager.php
@@ -718,6 +1004,10 @@ class CHistoryManager {
 
                $min_clock = [];
 
+               if (array_key_exists(ZBX_HISTORY_SOURCE_CLICKHOUSE, $storage_items)) {
+                       $min_clock[] = $this->getMinClockFromClickHouse($storage_items[ZBX_HISTORY_SOURCE_CLICKHOUSE], $source);
+               }
+
                if (array_key_exists(ZBX_HISTORY_SOURCE_ELASTIC, $storage_items)) {
                        $min_clock[] = $this->getMinClockFromElasticsearch($storage_items[ZBX_HISTORY_SOURCE_ELASTIC]);
                }
@@ -750,6 +1040,66 @@ class CHistoryManager {
        }
 
        /**
+        * ClickHouse specific implementation of getMinClock.
+        *
+        * @see CHistoryManager::getMinClock
+        */
+       private function getMinClockFromClickHouse(array $items, $source) {
+               $url_queries = [];
+               $endpoints = self::getClickHouseEndpoints(array_keys($items));
+               foreach ($items as $type => $itemids) {
+                       if (!$itemids) {
+                               continue;
+                       }
+
+                       if (!array_key_exists($type, $endpoints)) {
+                               continue;
+                       }
+
+                       $url = $endpoints[$type];
+
+                       switch ($type) {
+                               case ITEM_VALUE_TYPE_FLOAT:
+                                       $sql_from = $source;
+                                       break;
+                               case ITEM_VALUE_TYPE_STR:
+                                       $sql_from = 'history_str';
+                                       break;
+                               case ITEM_VALUE_TYPE_LOG:
+                                       $sql_from = 'history_log';
+                                       break;
+                               case ITEM_VALUE_TYPE_UINT64:
+                                       $sql_from = $source.'_uint';
+                                       break;
+                               case ITEM_VALUE_TYPE_TEXT:
+                                       $sql_from = 'history_text';
+                                       break;
+                               default:
+                                       $sql_from = 'history';
+                       }
+
+                       $url_queries[$url][] =
+                               'SELECT MIN(clock) AS min_clock'.
+                               ' FROM '.$sql_from.
+                               ' WHERE itemid IN ('.implode(',', $itemids).')';
+               }
+
+               $min_clock = [];
+               foreach ($url_queries as $url => $queries) {
+                       $query =
+                               'SELECT MIN(min_clock) AS min'.
+                               ' FROM ('.implode(' UNION ALL ', $queries).')';
+
+                       $clock = CClickHouseHelper::value('POST', $url, $query, 'min');
+                       if ($clock !== null) {
+                               $min_clock[] = $clock;
+                       }
+               }
+
+               return min($min_clock);
+       }
+
+       /**
         * Elasticsearch specific implementation of getMinClock.
         *
         * @see CHistoryManager::getMinClock

Доработка класса CHistory

В файле frontends/php/include/classes/api/services/CHistory.php определён класс CHistory, который отвечает за работу метода API history.get. Метод позволяет получать значения указанных элементов данных за указанный период. По сути в этом классе есть только одна публичная функция get и по одной приватной функции с реализацией каждого из типов хранилищ. Доработаем саму функцию get и добавим функцию getFromClickHouse с реализацией доступа к хранилищу ClickHouse:
Index: zabbix-3.4.12-1+buster/frontends/php/include/classes/api/services/CHistory.php
===================================================================
--- zabbix-3.4.12-1+buster.orig/frontends/php/include/classes/api/services/CHistory.php
+++ zabbix-3.4.12-1+buster/frontends/php/include/classes/api/services/CHistory.php
@@ -118,6 +118,9 @@ class CHistory extends CApiService {
                ]);
 
                switch (CHistoryManager::getDataSourceType($options['history'])) {
+                       case ZBX_HISTORY_SOURCE_CLICKHOUSE:
+                               return $this->getFromClickHouse($options);
+
                        case ZBX_HISTORY_SOURCE_ELASTIC:
                                return $this->getFromElasticsearch($options);
 
@@ -127,6 +130,139 @@ class CHistory extends CApiService {
        }
 
        /**
+        * ClickHouse specific implementation of get.
+        *
+        * @see CHistory::get
+        */
+       private function getFromClickHouse($options) {
+               $result = [];
+               $sql_parts = [
+                       'select'        => ['history' => 'h.itemid'],
+                       'from'          => [],
+                       'where'         => [],
+                       'group'         => [],
+                       'order'         => [],
+                       'limit'         => null
+               ];
+
+               if (!$table_name = CHistoryManager::getTableName($options['history'])) {
+                       $table_name = 'history';
+               }
+
+               $endpoints = CHistoryManager::getClickHouseEndpoints($options['history']);
+               if (!$endpoints) {
+                       return $result;
+               }
+               $url = reset($endpoints);
+
+               $sql_parts['from']['history'] = $table_name.' h';
+
+               // itemids
+               if ($options['itemids'] !== null) {
+                       $sql_parts['where']['itemid'] = dbConditionInt('h.itemid', $options['itemids'], false, true, false);
+               }
+
+               // time_from
+               if ($options['time_from'] !== null) {
+                       $sql_parts['where']['clock_from'] = 'h.clock>='.($options['time_from']+0);
+               }
+
+               // time_till
+               if ($options['time_till'] !== null) {
+                       $sql_parts['where']['clock_till'] = 'h.clock<='.($options['time_till']+0);
+               }
+
+               // filter
+               if (is_array($options['filter'])) {
+                       $this->dbFilter($sql_parts['from']['history'], $options, $sql_parts);
+               }
+
+               // search
+               if (is_array($options['search'])) {
+                       zbx_db_search($sql_parts['from']['history'], $options, $sql_parts);
+               }
+
+               // output
+               if ($options['output'] == API_OUTPUT_EXTEND) {
+                       unset($sql_parts['select']['clock']);
+                       $sql_parts['select']['history'] = 'h.*';
+               }
+               elseif ($options['output'] != API_OUTPUT_COUNT) {
+                       unset($sql_parts['select']['clock']);
+                       $sql_parts['select']['history'] = implode(',', $options['output']);
+               }
+
+               // countOutput
+               if ($options['countOutput']) {
+                       $options['sortfield'] = '';
+                       $sql_parts['select'] = ['count(*) as rowscount'];
+
+                       // groupCount
+                       if ($options['groupCount']) {
+                               foreach ($sql_parts['group'] as $key => $fields) {
+                                       $sql_parts['select'][$key] = $fields;
+                               }
+                       }
+               }
+
+               // sorting
+               $sql_parts = $this->applyQuerySortOptions($table_name, $this->tableAlias(), $options, $sql_parts);
+
+               // limit
+               if (zbx_ctype_digit($options['limit']) && $options['limit']) {
+                       $sql_parts['limit'] = $options['limit'];
+               }
+
+               $sql_parts['select'] = array_unique($sql_parts['select']);
+               $sql_parts['from'] = array_unique($sql_parts['from']);
+               $sql_parts['where'] = array_unique($sql_parts['where']);
+               $sql_parts['order'] = array_unique($sql_parts['order']);
+
+               $sql_select = '';
+               $sql_from = '';
+               $sql_order = '';
+
+               if ($sql_parts['select']) {
+                       $sql_select .= implode(',', $sql_parts['select']);
+               }
+
+               if ($sql_parts['from']) {
+                       $sql_from .= implode(',', $sql_parts['from']);
+               }
+
+               $sql_where = $sql_parts['where'] ? ' WHERE '.implode(' AND ', $sql_parts['where']) : '';
+
+               if ($sql_parts['order']) {
+                       $sql_order .= ' ORDER BY '.implode(',', $sql_parts['order']);
+               }
+
+               if ($sql_parts['limit'] > 0) {
+                       $sql_limit = ' LIMIT '.$sql_parts['limit'];
+               }
+               $query = 'SELECT '.$sql_select.
+                               ' FROM '.$sql_from.
+                               $sql_where.
+                               $sql_order.
+                               $sql_limit;
+
+               $values = CClickHouseHelper::values('POST', $url, $query);
+               foreach ($values as $row) {
+                       if ($options['countOutput']) {
+                               $result = $row;
+                       }
+                       else {
+                               $result[] = $row;
+                       }
+               }
+
+               if (!$options['preservekeys']) {
+                       $result = zbx_cleanHashes($result);
+               }
+
+               return $result;
+       }
+
+       /**
         * SQL specific implementation of get.
         *
         * @see CHistory::get

Доработка класса CTrend

Почти всё, сказанное про класс CHistory, справедливо и для класса CTrend. В файле frontends/php/include/classes/api/services/CTrend.php определён класс CTrend, который отвечает за работу метода API trend.get. Метод позволяет получать из таблиц тенденций агрегированные почасовые значения указанных элементов данных за указанный период. В этом классе есть только одна публичная функция get и по одной приватной функции с реализацией каждого из типов хранилищ.

Поскольку в реализации поддержки Elasticsearch нет поддержки таблиц тенденций, а поддержка ClickHouse была сделана на базе поддержки Elasticsearch, то поддержки таблиц тенденций не должно быть и в реализации ClickHouse. Собственно, поэтому в файле конфигурации не предусмотрена возможность указать тип используемого хранилища для таблиц тенденций. Я же реализовал поддержку таблиц тенденций в неявном предположении, что таблица тенденций находится в том же хранилище ClickHouse, что и основная таблица с историческими данными. То есть, если для таблицы history используется хранилище ClickHouse, то неявно предполагается, что по той же ссылке должна быть доступна и таблица trends. И если в ClickHouse хранится таблица history_uint, то по той же ссылке должна быть доступна таблица trends_uint.

Интересно, что раньше в Zabbix не было методов API для доступа к таблицам тенденций, но в сети гуляли заплатки с реализацией метода trend.get, выполненные по аналогии с методом history.get. Когда же разработчики Zabbix решили добавить метод API для доступа к таблицам тенденций, то реализовали метод trend.get несколько иначе. В частности, методу trend.get не нужно указывать тип запрашиваемых значений элементов данных, метод ищет данные во всех таблицах и возвращает результат поиска в обеих таблицах тенденций.

Итак, доработаем саму функцию get и добавим функцию getFromClickHouse с реализацией доступа к хранилищу ClickHouse:
Index: zabbix-3.4.12-1+buster/frontends/php/include/classes/api/services/CTrend.php
===================================================================
--- zabbix-3.4.12-1+buster.orig/frontends/php/include/classes/api/services/CTrend.php
+++ zabbix-3.4.12-1+buster/frontends/php/include/classes/api/services/CTrend.php
@@ -71,11 +71,15 @@ class CTrend extends CApiService {
                        }
                }
 
-               foreach ([ZBX_HISTORY_SOURCE_ELASTIC, ZBX_HISTORY_SOURCE_SQL] as $source) {
+               foreach ([ZBX_HISTORY_SOURCE_CLICKHOUSE, ZBX_HISTORY_SOURCE_ELASTIC, ZBX_HISTORY_SOURCE_SQL] as $source) {
                        if (array_key_exists($source, $storage_items)) {
                                $options['itemids'] = $storage_items[$source];
 
                                switch ($source) {
+                                       case ZBX_HISTORY_SOURCE_CLICKHOUSE:
+                                               $data = $this->getFromClickHouse($options);
+                                               break;
+
                                        case ZBX_HISTORY_SOURCE_ELASTIC:
                                                $data = $this->getFromElasticsearch($options);
                                                break;
@@ -92,6 +96,103 @@ class CTrend extends CApiService {
                                }
                        }
                }
+
+               return $result;
+       }
+
+       /**
+        * ClickHouse specific implementation of get.
+        *
+        * @see CTrend::get
+        */
+       private function getFromClickHouse($options) {
+               $sql_where = [];
+
+               if ($options['time_from'] !== null) {
+                       $sql_where['clock_from'] = 't.clock>='.($options['time_from']+0);
+               }
+
+               if ($options['time_till'] !== null) {
+                       $sql_where['clock_till'] = 't.clock<='.($options['time_till']+0);
+               }
+
+               if (!$options['countOutput']) {
+                       $sql_limit = ($options['limit'] && zbx_ctype_digit($options['limit'])) ? $options['limit'] : null;
+
+                       $sql_fields = [];
+
+                       if (is_array($options['output'])) {
+                               foreach ($options['output'] as $field) {
+                                       if ($this->hasField($field, 'trends') && $this->hasField($field, 'trends_uint')) {
+                                               $sql_fields[] = 't.'.$field;
+                                       }
+                               }
+                       }
+                       elseif ($options['output'] == API_OUTPUT_EXTEND) {
+                               $sql_fields[] = 't.*';
+                       }
+
+                       // An empty field set or invalid output method (string). Select only "itemid" instead of everything.
+                       if (!$sql_fields) {
+                               $sql_fields[] = 't.itemid';
+                       }
+
+                       $result = [];
+
+                       foreach ([ITEM_VALUE_TYPE_FLOAT, ITEM_VALUE_TYPE_UINT64] as $value_type) {
+                               $endpoints = CHistoryManager::getClickHouseEndpoints($value_type);
+                               if (!$endpoints) {
+                                       continue;
+                               }
+
+                               if ($sql_limit !== null && $sql_limit <= 0) {
+                                       break;
+                               }
+
+                               $sql_from = ($value_type == ITEM_VALUE_TYPE_FLOAT) ? 'trends' : 'trends_uint';
+
+                               if ($options['itemids'][$value_type]) {
+                                       $sql_where['itemid'] = dbConditionInt('t.itemid', array_keys($options['itemids'][$value_type]), false, true, false);
+
+                                       $query = 'SELECT '.implode(',', $sql_fields).
+                                                       ' FROM '.$sql_from.' t'.
+                                                       ' WHERE '.implode(' AND ', $sql_where);
+
+                                       if ($sql_limit > 0) $query .= ' LIMIT '.$sql_limit;
+
+                                       $values = CClickHouseHelper::values('POST', reset($endpoints), $query);
+
+                                       if ($sql_limit !== null) {
+                                               $sql_limit -= count($values);
+                                       }
+
+                                       $result = array_merge($result, $values);
+                               }
+                       }
+
+                       $result = $this->unsetExtraFields($result, ['itemid'], $options['output']);
+               }
+               else {
+                       $result = 0;
+
+                       foreach ([ITEM_VALUE_TYPE_FLOAT, ITEM_VALUE_TYPE_UINT64] as $value_type) {
+                               if ($options['itemids'][$value_type]) {
+                                       $endpoints = CHistoryManager::getClickHouseEndpoints($value_type);
+                                       if (!$endpoints) {
+                                               continue;
+                                       }
+
+                                       $sql_from = ($value_type == ITEM_VALUE_TYPE_FLOAT) ? 'trends' : 'trends_uint';
+                                       $sql_where['itemid'] = dbConditionInt('t.itemid', array_keys($options['itemids'][$value_type]), false, true, false);
+
+                                       $query = 'SELECT COUNT(*) AS rowcount'.
+                                                       ' FROM '.$sql_from.' t'.
+                                                       ' WHERE '.implode(' AND ', $sql_where);
+
+                                       $result += CClickHouseHelper::value('POST', reset($endpoints), $query, 'rowcount');
+                               }
+                       }
+               }
 
                return $result;
        }

воскресенье, 25 октября 2020 г.

Подготовка ClickHouse для хранения истории и тенденций Zabbix

Создание таблиц истории

Подключиться к серверу Clickhouse можно при помощи команды следующего вида:
$ clickhouse-client -u zabbix --ask-password zabbix
В Glaber'е используется одна таблица вместо таблиц history, history_uint, history_str и history_text. Таблица history_log не поддерживается. В отличие от Glaber, я решил скрупулёзно воспроизвести схему данных, принятую в Zabbix.

Однако, если писать данные в таблицы небольшими порциями, менее 8192 строк за раз, Clickhouse не выполняет слияние таких маленьких фрагментов в фоновом режиме. В процессе всего нескольких часов работы Zabbix в Clickhouse могут накопиться сотни тысяч фрагментов. При необходимости перезапуска Clickhouse в таком случае можно столкнуться с интересной проблемой: Clickhouse запущен, но не открывает порты на прослушивание и не принимает подключения. В это время Clickhouse перебирает все фрагменты таблиц, чтобы составить их каталог. Запуск может затянуться на несколько часов.

Чтобы Clickhouse своевременно сливал фрагменты таблиц в фоновом режиме, нужно чтобы в каждом фрагменте было не мнее 8192 строк. Но даже если ваш сервер Zabbix генерирует более 8192 новых значений в секунду, они будут во-первых распределяться между процессами DBSyncers (количество которых настраивается через опцию конфигурации StartDBSyncers), а во-вторых - они будут распределяться между разными таблицами истории. В моей практике наибольшая доля данных приходилась на таблицу history_uint, а history_log во многих случаях не использовалась вовсе.

По умолчанию процессы DBSyncers запускаются раз в секунду. Уменьшать их количество не всегда возможно, т.к. эти же процессы используются и для чтения данных из таблиц истории, когда необходимых данных нет в кэше значений. Особенно высока потребность в большом количестве процессов DBSyncers при старте Zabbix, когда кэш значений ещё пуст. Я пробовал делать заплатку для Zabbix, которая добавляет поддержку опции конфигурации DBSyncersPeriod и позволяет настраивать периодичность записи процессами DBSyncers. Такое решение не прошло проверку практикой, при малом количестве новых значений в секунду и большом количестве DBSyncers для накопления достаточного объёма данных приходится выполнять запись раз в 2-5 минут. И это без учёта неравномерности распределения данных по разным таблицам!

Таким образом, решение использовать буферную таблицу в Glaber было вполне оправданным. Поэтому настоящие таблицы истории в моём варианте можно создать при помощи следующих запросов:
CREATE TABLE real_history_uint
(
    itemid UInt64,
    clock UInt32,
    ns UInt32,
    value UInt64
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(toDateTime(clock))
ORDER BY (itemid, clock);

CREATE TABLE real_history
(
    itemid UInt64,
    clock UInt32,
    ns UInt32,
    value Float64
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(toDateTime(clock))
ORDER BY (itemid, clock);

CREATE TABLE real_history_str
(
    itemid UInt64,
    clock UInt32,
    ns UInt32,
    value String
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(toDateTime(clock))
ORDER BY (itemid, clock);

CREATE TABLE real_history_text
(
    itemid UInt64,
    clock UInt32,
    ns UInt32,
    value String
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(toDateTime(clock))
ORDER BY (itemid, clock);

CREATE TABLE real_history_log
(
    itemid UInt64,
    clock UInt32,
    timestamp DateTime,
    source FixedString(64),
    severity UInt32,
    value String,
    logeventid UInt32,
    ns UInt32
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(toDateTime(clock))
ORDER BY (itemid, clock);
Как можно заметить, в отличие от Glaber, здесь таблицы разделены не на помесячные, а на посуточные секции. Для удаления ненужных секций в дальнейшем можно будет воспользоваться запросами следующего вида:
ALTER TABLE real_history_uint DROP PARTITION 20190125;

Создание буферных таблиц истории

Теперь нужно создать буферные таблицы, с которыми непосредственно будет работать сам Zabbix. При вставке данных в буферную таблицу данные сохраняются в оперативной памяти и не записываются в нижележащую таблицу, пока не будет достигнуто одно из условий. При чтении данных из буферной таблицы данные ищутся как в самой буферной таблице, так и в нижележащей таблице на диске. Я создал буферные таблицы следующим образом:
CREATE TABLE history_uint
(
    itemid UInt64,
    clock UInt32,
    ns UInt32,
    value UInt64
) ENGINE = Buffer(zabbix, real_history_uint, 8, 30, 60, 8192, 65536, 262144, 67108864);

CREATE TABLE history
(
    itemid UInt64,
    clock UInt32,
    ns UInt32,
    value Float64
) ENGINE = Buffer(zabbix, real_history, 8, 30, 60, 8192, 65536, 262144, 67108864);

CREATE TABLE history_str
(
    itemid UInt64,
    clock UInt32,
    ns UInt32,
    value String
) ENGINE = Buffer(zabbix, real_history_str, 8, 30, 60, 8192, 65536, 262144, 67108864);

CREATE TABLE history_text
(
    itemid UInt64,
    clock UInt32,
    ns UInt32,
    value String
) ENGINE = Buffer(zabbix, real_history_text, 8, 30, 60, 8192, 65536, 262144, 67108864);

CREATE TABLE history_log
(
    itemid UInt64,
    clock UInt32,
    timestamp DateTime,
    source FixedString(64),
    severity UInt32,
    value String,
    logeventid UInt32,
    ns UInt32
) ENGINE = Buffer(zabbix, real_history_log, 8, 30, 60, 8192, 65536, 262144, 67108864);
Для всех созданных буферных таблиц действуют следующие условия записи данных в реальную таблицу:
  • 30 секунд - минимальное время, которое должно пройти со момента предыдущей записи в реальную таблицу, прежде чем буферная таблица запишет данные в реальную таблицу,
  • 60 секунд - максимальное время с момента предыдущей записи в реальную таблицу, по прошествии которого операция записи будет выполнена вне зависимости от всех остальных условий,
  • 8192 строк - минимальное количество записей, которое должно быть в буферной таблице, прежде чем буферная таблица запишет данные в реальную таблицу,
  • 65536 строк - максимальное количество записей, которое должно быть в буферной таблице, по достижении которого операция записи будет выполнена вне зависимости от всех остальных условий,
  • 256 килобайт - минимальный объём данных, который должен накопиться в буферной таблице, прежде чем буферная таблица запишет данные в реальную таблицу,
  • 64 мегабайта - максимальный объём данных, который должен накопиться в буферной таблице, по достижении которого операция записи будет выполнена вне зависимости от всех остальных условий.
Итак, буферная таблица ждёт либо выполнения одного из условий максимума, либо выполнения всех условий минимума, после чего данные будут записаны в реальную таблицу.

Виртуальные таблицы тенденций в ClickHouse

Т.к. в моём случае внедрение Zabbix состоялось давно и вокруг него уже было написано значительное количество различных скриптов, использующих данные из таблиц тенденций, то мне нужно было сделать переход на ClickHouse максимально мягким. Для этого я воспользовался готовым решением Михаила Макурова, которое он продемонстрировал на одном из слайдов своей презентации. Воспользуемся агрегирующими материализованными представлениями ClickHouse, создав их при помощи следующих запросов:
CREATE MATERIALIZED VIEW trends
ENGINE = AggregatingMergeTree() PARTITION BY toYYYYMMDD(toDateTime(clock))
ORDER BY (itemid, clock)
AS SELECT
  itemid,
  toUInt32(toStartOfHour(toDateTime(clock))) AS clock,
  count(value) AS num,
  min(value) AS value_min,
  avg(value) AS value_avg,
  max(value) AS value_max
FROM real_history
GROUP BY itemid, clock;

CREATE MATERIALIZED VIEW trends_uint
ENGINE = AggregatingMergeTree() PARTITION BY toYYYYMMDD(toDateTime(clock))
ORDER BY (itemid, clock)
AS SELECT
  itemid,
  toUInt32(toStartOfHour(toDateTime(clock))) AS clock,
  count(value) AS num,
  min(value) AS value_min,
  toUInt64(avg(value)) AS value_avg,
  max(value) AS value_max
FROM real_history_uint
GROUP BY itemid, clock;
Эти представления материализованные, а это значит, что они будут вычисляться не при поступлении запроса, а будут храниться на диске. Представления будут автоматически обновляться сервером Clickhouse по мере вставки новых данных в таблицы истории. Серверу Zabbix не нужно будет вычислять эти данные самостоятельно, т.к. всю необходимую работу за него будет делать Clickhouse.

Скрипты

Итак, структура таблиц готова, теперь нужно разобраться с обслуживанием таблиц, в том числе удалением устаревших секций таблиц истории и материализованных видов таблиц тенденций, а также с переносом данных. Для обеих задач я решил написать скрипты на Python, воспользовавшись модулем Python для работы с Clickhouse, который называется clickhouse-driver. Из всех рассмотренных мной модулей для языка Python этот модуль приглянулся по следующим причинам:
  • единственный, который использует двоичный протокол ClickHouse, а не использует доступ по HTTP,
  • снабжён файлом README и каталогом с документацией. Документация также доступна онлайн: Welcome to clickhouse-driver.
Я собрал deb-пакеты с модулем clickhouse-driver, воспользовавшись своей статьёй Создание deb-пакетов для модулей Python. Вместе с этим модулем понадобилось также собрать deb-пакеты с требуемыми ей модулями clickhouse-cityhash и zstd.

Обслуживание таблиц

Для удаления устаревших секций таблиц и для принудительного слияния фрагментов секций я написал скрипт на Python, который назвал maintein_tables.py:
#!/usr/bin/python
# -*- coding: UTF-8 -*-

from clickhouse_driver import Client
from datetime import datetime, timedelta

try:
    c = Client(host='localhost',
               port=9000,
               connect_timeout=3,
               database='zabbix',
               user='zabbix',
               password='zabbix')
except clickhouse_driver.errors.ServerException:
    print >>sys.stderr, 'Cannot connect to database'
    sys.exit(1)

def maintein_table(c, database, table, keep_interval):
    """
    Удаление устаревших разделов указанной таблицы и оптимизация оставшихся разделов
    
    c - подключение к базе данных 
    database - имя базы данных, в которой нужно произвести усечение таблицы
    table - имя таблицы, которую нужно усечь
    keep_interval - период, данные за который нужно сохранить, тип - timedelta
    """
    now = datetime.now()

    rows = c.execute('''SELECT partition,
                               COUNT(*)
                        FROM system.parts
                        WHERE database = '%s'
                          AND table = '%s'
                        GROUP BY partition
                        ORDER BY partition
                     ''' % (database, table))
    for partition, num in rows:
        if now - datetime.strptime(partition, '%Y%m%d') > keep_interval:
            print 'drop partition %s %s %s' % (database, table, partition)
            c.execute('ALTER TABLE %s.%s DROP PARTITION %s' % (database, table, partition))
        elif num > 1:
            print 'optimize partition %s %s %s' % (database, table, partition)
            c.execute('OPTIMIZE TABLE %s.%s PARTITION %s FINAL DEDUPLICATE' % (database, table, partition))

maintein_table(c, 'zabbix', 'trends', timedelta(days=3650))
maintein_table(c, 'zabbix', 'trends_uint', timedelta(days=3650))
maintein_table(c, 'zabbix', 'real_history', timedelta(days=365))
maintein_table(c, 'zabbix', 'real_history_uint', timedelta(days=365))
maintein_table(c, 'zabbix', 'real_history_str', timedelta(days=7))
maintein_table(c, 'zabbix', 'real_history_text', timedelta(days=7))
maintein_table(c, 'zabbix', 'real_history_log', timedelta(days=7))
c.disconnect()
Запрос для удаления устаревших секций таблиц уже был приведён, а для слияния фрагментов секций таблиц в скрипте используется запрос следующего вида:
OPTIMIZE TABLE history PARTITION 20200521 FINAL DEDUPLICATE;
В примере скрипт настроен на хранение числовых исторических данных в течение года, текстовых и журнальных данных - в течение семи дней и тенденций в течение 10 лет. При необходимости можно поменять настройки подключения к серверу Clickhouse и настройки длительности хранения данных в таблицах.

Скрипт можно также скачать по ссылке maintein_tables.py

Копирование данных

Михаил Макуров реализовал поддержку хранения исторических данных Zabbix в ClickHouse на основе поддержки хранения исторических данных в ElasticSearch. В документации Zabbix упоминается, что при хранении исторических данных в ElasticSearch таблицы тенденций не используются. Стало быть, таблицы тенденций не используются и при хранении исторических данных в ClickHouse. Для решения этой проблемы были созданы материализованные представления trends и trends_uint, которые описаны выше.

При переключении существующей инсталляции Zabbix на использование ClickHouse или ElasticSearch не составляет особого труда перенести содержимое таблиц истории из старого хранилища в новое. А вот таблицы тенденций копировать просто некуда. Скопировать их можно было бы в таблицы истории, но в таблицах тенденций нет точных значений, а есть лишь минимальные, средние и максимальные значения за час, а также количество значений в исходной выборке. Чтобы сохранить возможность видеть данные из таблиц тенденций на графиках, можно попытаться сгенерировать выборку, удовлетворяющую этим условиям, и поместить получившиеся значения в таблицы истории.

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

Скрипт copy_data.py выполняет полное копирование таблиц истории, а также дополняет таблицы истории правдоподобными данными, сгенерированными на основе таблиц тенденций.

В начале скрипта можно найти настройки, которые будут использоваться для подключения к базе данных с исходными данными и к целевой базе данных. В конце скрипта можно найти вызовы функций копирования данных:
copy_history('history_str', ('itemid', 'clock', 'ns', 'value'))
copy_history('history_text', ('itemid', 'clock', 'ns', 'value'))
copy_history('history_log', ('itemid', 'clock', 'timestamp', 'source', 'severity', 'value', 'logeventid', 'ns'))

clock_min, clock_max = copy_history('history', ('itemid', 'clock', 'ns', 'value'), interval=10800)
copy_trends('trends', 'history', clock_max=clock_min)

clock_min, clock_max = copy_history('history_uint', ('itemid', 'clock', 'ns', 'value'), interval=1800)
copy_trends('trends_uint', 'history_uint', clock_max=clock_min, int_mode=True)
Сначала копируется содержимое таблиц history_str, history_text и history_log, потом копируется таблица history порциями по 3 часа, потом в таблицу history вносятся данные, сгенерированные из данных таблицы trends, и, наконец, таблица history_uint копируется порциями по полчаса и в неё вносятся данные, сгенерированные из данных таблицы trends_uint.

Функция copy_history перед началом работы выполняет запрос, который находит минимальное и максимальное значение отметок времени в таблице. Этот запрос может выполняться очень долго, поэтому в функциях предусмотрена возможность указания минимального и максимального значения отметок времени в аргументах clock_min и clock_max. Кроме того, указывая эти значения вручную, можно точно настраивать период времени, данные за который нужно обработать. Это может быть полезно, например, для того, чтобы скопировать данные до конца суток, предшествующих переключению Zabbix на Clickhouse. После переключения можно указать период времени, за который накопились новые данные с момента прошлого копирования.

Если вы не собираетесь копировать данные из таблиц тенденций, то вызовы функций copy_trends можно закомментировать. Можно скопировать тенденции после переключения Zabbix на Clickhouse - скрипт не имеет жёстко предписанной последовательности действий и может быть адаптирован под необходимую вам последовательность действий.

Скрипт осуществляет вставку данных в ClickHouse порциями по 1048576 строк. Размер порции можно настраивать при помощи аргумента portion функций copy_history и copy_trends. При настройке portion стоит учитывать, что объём вставляемых за один раз данных не должно превышать значения настройки max_memory_usage из файла конфигурации /etc/clickhouse-server/users.xml сервера Clickhouse.

В процессе тестирования скрипта при переносе данных из базы данных PostgreSQL выяснилась интересная особенность: PostgreSQL не поддерживает колонки с числами с плавающей запятой, а вместо этого используется тип Decimal, имеющий более ограниченную точность в представлении данных. Из-за меньшей точности данные в таблице тенденций чисел с плавающей запятой оказывается невозможно сгенерировать такую правдоподобную выборку данных, которая точно соответствовала бы указанным свойствам. Если сгенерированная выборка не удовлетворяет указанным свойствам, то сгенерированные данные всё-таки вставляются в таблицу истории, но скрипт выдаёт предупреждение о подозрительных данных тенденций.

Скрипт не приведён в статье из-за его относительно большого объёма. Скрипт можно взять по ссылке copy_data.py

Пасхальное яйцо

При выходе из клиента ClickHouse 1 января 2020 года заметил поздравление с новым годом:
db.server.tld :) exit
Happy new year.

воскресенье, 18 октября 2020 г.

Установка и настройка сервера ClickHouse

Пакеты с клиентом и сервером Clickhouse имеются в официальных репозиториях Debian Buster. Для их установки можно воспользоваться следующей командой:
# apt-get install clickhouse-server clickhouse-client
Для работы серверу Clickhouse требуется поддержка дополнительных процессорных инструкций SSE 4.2. Чтобы проверить наличие поддержки этих инструкций и пересобрать Clickhouse, если они не поддерживаются, обратитесь к статье Пересборка Clickhouse для процессоров без поддержки SSE 4.2.

В каталоге /etc/clickhouse-server находится файл config.xml с настройками сервера и файл users.xml с настройками пользователей. Оба файла хорошо прокомментированы, но из-за обилия настроек ориентироваться в них довольно тяжело. Я переименовал эти файлы, чтобы создать более компактные файлы конфигурации:
# cd /etc/clickhouse-server/
# cp users.xml users.xml.sample
# cp config.xml config.xml.sample
В файл конфигурации config.xml я вписал следующие настройки:
<?xml version="1.0"?>
<yandex>
    <logger>
        <level>warning</level>
        <log>/var/log/clickhouse-server/clickhouse-server.log</log>
        <errorlog>/var/log/clickhouse-server/clickhouse-server.err.log</errorlog>
        <size>10M</size>
        <count>10</count>
    </logger>
    <display_name>ufa</display_name>
    <http_port>8123</http_port>
    <tcp_port>9000</tcp_port>
    <listen_host>0.0.0.0</listen_host>
    <max_connections>4096</max_connections>
    <keep_alive_timeout>3</keep_alive_timeout>
    <max_concurrent_queries>16</max_concurrent_queries>
    <uncompressed_cache_size>1073741824</uncompressed_cache_size>
    <mark_cache_size>5368709120</mark_cache_size>
    <path>/var/lib/clickhouse/</path>
    <tmp_path>/var/lib/clickhouse/tmp/</tmp_path>
    <user_files_path>/var/lib/clickhouse/user_files/</user_files_path>
    <users_config>users.xml</users_config>
    <default_profile>default</default_profile>
    <default_database>zabbix</default_database>
    <timezone>Asia/Yekaterinburg</timezone>
    <mlock_executable>true</mlock_executable>
    <builtin_dictionaries_reload_interval>3600</builtin_dictionaries_reload_interval>
    <max_session_timeout>3600</max_session_timeout>
    <default_session_timeout>60</default_session_timeout>
    <max_table_size_to_drop>0</max_table_size_to_drop>
    <max_partition_size_to_drop>0</max_partition_size_to_drop>
    <format_schema_path>/var/lib/clickhouse/format_schemas/</format_schema_path>
</yandex>
Смысл большинства настроек можно понять из их названия. Кратко опишу некоторые из них:
  • display_name - отображаемое в клиенте имя сервера,
  • max_connections - максимальное количество подключений от клиентов,
  • max_concurrent_queries - максимальное количество одновременно обрабатываемых запросов. Т.к. каждый запрос обслуживается конвейером из нескольких потоков, то каждый запрос порождает нагрузку как минимум на одно процессорное ядро. Лучше всего будет выполнять одновременно количество запросов, не превышающее количество процессорных ядер сервера или виртуальной машины.
  • uncompressed_cache_size задаёт размер кэша несжатых данных в байтах. Если предполагается, что на сервере часто будут выполняться короткие запросы, этот кэш поможет снизить нагрузку на дисковую подсистему. Обратите внимание, что в настройках пользователя должно быть разрешено использование кэша несжатых данных в опции use_uncompressed_cache.
  • mark_cache_size - кэш меток. Метки являются своего рода индексами данных. Сервер Clickhouse не хочет запускаться, если значение этой настройки меньше 5 гигабайт. Хорошая новость в том, что память под этот кэш будет выделяться по мере необходимости.
  • path - путь к файлам базы данных,
  • default_database - имя базы данных, с которой будут работать клиенты, не указавшие какую-то определённую базу данных,
  • timezone - часовой пояс сервера.
Файл users.xml я привёл к следующему виду:
<?xml version="1.0"?>
<yandex>
    <users>
        <zabbix>
            <password>zabbix</password>
            <networks>
                <ip>127.0.0.1</ip>
            </networks>
            <profile>default</profile>
            <quota>default</quota>
        </zabbix>
    </users>
    <profiles>
        <default>
            <max_memory_usage>2147483648</max_memory_usage>
            <max_query_size>1048576</max_query_size>
            <max_ast_elements>1000000</max_ast_elements>
            <use_uncompressed_cache>1</use_uncompressed_cache>
            <load_balancing>random</load_balancing>
            <readonly>0</readonly>
        </default>
        <readonly>
            <readonly>1</readonly>
        </readonly>
    </profiles>
    <quotas>
        <default>
            <interval>
                <duration>3600</duration>
                <queries>0</queries>
                <errors>0</errors>
                <result_rows>0</result_rows>
                <read_rows>0</read_rows>
                <execution_time>0</execution_time>
            </interval>
        </default>
    </quotas>
</yandex>
Файл состоит из трёх секций:
  • users - пользователи базы данных. Каждый пользователь содержит ссылку на профиль и квоту,
  • profiles - профили содержат настройки пользователей,
  • quotas - квоты содержат ограничения на выполнение запросов от пользователей.
В примере конфигурации выше описан пользователь zabbix с паролем zabbix, который может устанавливать подключения к серверу только с IP-адреса 127.0.0.1, использует профиль default и квоту default.

В профиле default выставлены следующие настройки:
  • max_memory_usage - максимальный объём памяти, который сервер может выделить пользователю для обработки его запросов, в примере настроено ограничение в 2 гигабайта,
  • max_query_size - максимальный размер одного запроса, по умолчанию - 256 килобайт, в примере - 1 мегабайт,
  • max_ast_elements - максимальное количество элементов в дереве синтаксического разбора, по умолчанию - 50 тысяч элементов, в примере - 1 миллион элементов,
  • use_uncompressed_cache - значение этой опции разрешает или запрещает использование кэша несжатых данных, в примере значение 1 разрешает его использование,
  • readonly - значение этой опции разрешает или запрещает запросы на изменение данных, в примере значение 0 разрешает изменение данных.
В квоте default выставлено единственное ограничение - длительность обработки запроса ограничена одним часом.

Включим автозапуск сервера:
# systemctl enable clickhouse-server.service
Запустим сервер:
# systemctl start clickhouse-server.service

Решение проблем

Если спустя некоторое время в журнале /var/log/clickhouse-server/clickhouse-server.err.log появляются ошибки следующего вида:
2020.04.17 10:44:51.741280 [ 6317714 ] {} <Error> HTTPHandler: std::exception. Code: 1001, type: std::system_error, e.what() = Resource temporarily unavailable
То может помочь увеличение переменной ядра vm.max_map_count следующей командой:
# sysctl -w vm.max_map_count = 524288
Если изменение этой настройки помогло справиться с проблемой, можно прописать её в файл /etc/sysctl.conf, чтобы оно автоматически применялось при загрузке системы:
vm.max_map_count=524288
В документации ядра Linux эта переменная ядра объясняется следующим образом:
This file contains the maximum number of memory map areas a process may have. Memory map areas are used as a side-effect of calling malloc, directly by mmap and mprotect, and also when loading shared libraries.

While most applications need less than a thousand maps, certain programs, particularly malloc debuggers, may consume lots of them, e.g., up to one or two maps per allocation.

The default value is 65536.
Перевод:
Этот файл содержит максимальное количество участков памяти, которое может иметь процесс. Участки памяти косвенно создаются при вызове malloc, а напрямую - при вызове mmap и mprotect, а также при загрузке разделяемых библиотек.

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

Значение по умолчанию - 65536.