UNPKG

vrack-db

Version:

This is an In Memory database designed for storing time series (graphs).

1,072 lines (753 loc) 79.3 kB
Содержание =========== - [Введение](#введение) - [Какие задачи решает эта база?](#какие-задачи-решает-эта-база) - [Миграция с V2](миграция-с-v2) - [Гайд](#гайд) - [Установка](#установка) - [Начало работы](#начало-работы) - [SingleDB](#singledb) - Каждая метрика может иметь индивидуальные настройки - [Инициализация](#инициализация) - [Запись данных](#запись-данных) - [Модификаторы](#модификаторы) - [Получения данных](#получения-данных) - [Агрегация](#агрегация) - [SchemeDB](#schemedb) - Метрики принимают настройки от группы, к которой принадлежат - [Продвинутые функции](#продвинутые-функции) - [Оптимизация хранения](#оптимизация-хранения) - [Почему не Bigint](#почему-не-bigint) - [Работа со временем слоев](#работа-со-временем-слоев) - [Отслеживание данных](#отслеживание-данных) - [Как это работает](#как-это-работает) - [Layer](#layer) - [Layer vs Array](#layer-vs-array) - [Модификаторы](#модификаторы-записи) - [Collector](#collector) - [Запись данных](#запись-данных-1) - [Продвинутое получение данных](#продвинутое-получение-данных) - [SingleDB](#singledb-1) - [Заключение](#заключение) Введение ======== VRackDB - Это **In Memory** база данных, предназначенная для хранения временных рядов (графиков). Особенно хорошо себя база показывает в построении графиков на основе постоянно приходящих данных. Особенности: - Очень простая/экономичная/быстрая - Хранит данные в памяти. При закрытии программы данные будут потеряны - Всегда возвращает данные в виде графика - Имеет простой формат запросов - Агрегация и модификаторы данных - Резервирует память метрики, последующее добавление данных метрики не увеличивает потребление памяти - Поддерживает секунды/миллисекунды/микросекунды Какие задачи решает эта база? ----------------------------- Не всегда очевидно, насколько, сложно работать с графиками, пока сам не попробуешь. Давайте представим, что вам нужно формировать график о потреблении памяти вашего приложения. Вам хотелось бы видеть динамику на малой дистанции и общие тренды на большой. Если вы будете писать в массив текущее состояние памяти: - Потребление памяти будет расти в зависимости от количества итераций сбора; - Данные будут не линейны во времени; - Для получения графика за определенный период, необходимо полностью перебирать массив. Увеличение массива приводит к потере производительности; - Нормальный информативный график обычно вмещает в себя от 120 до 1200 точек. Увеличение количества точек на графике сделает его слишком плотным и неинформативным. Поэтому необходимо каким-либо образом упрощать график перед его отображением. Все вышеперечисленные проблемы легко решает VRackDB: - Память для метрики резервируется один раз, последующее добавление данных этой метрики не приводит к увеличению потребления памяти; - Данные приводятся всегда к определенной точности, что значительно уменьшает проблему нелинейности при запросе высокой точности. При более низкой точности запроса - нелинейности не будет; - Для получения графика за определенный период нет необходимости перебирать все данные; - Вы можете выбирать нужный вам интервал, на который будет поделен график. Можно указать сколько точек на график вы хотите получить, независимо от размера периода. Даже на такой простой задаче возникает много сложных проблем. К этим проблемам могут еще добавиться проблема агрегации, систематизация хранения, поиск и тп. Если вам нужно строить графики, которые не имеют смысла после закрытия приложения - VRackDB может стать хорошим инструментом для вас. Вот примеры применения VRackDB: - Построение графика скачивания файла - Анализ потребления памяти приложения - Диагностика задержек HTTP/API/WebScoket и других запросов - Количественный анализ успешных/неуспешных операций в интервал времени - Приложения для SOC компьютеров и устройств на их основе (мультиметры, лабораторные блоки питания и т.п.) - Кеширование данных для быстрого отображения/анализа графиков - Хранение временной диагностической информации Достаточно попробовать VRackDB, чтобы понять насколько он практичен и прост. Миграция с V2 ============= Важные изменения для быстрой миграции с версии 2.* ## Database - Класс Database заменен на `SchemeDB` - Из класса `SchemeDB` убрана работа с деревом метрик - Теперь можно использовать `MetricTree` отдельно от класса `SchemeDB` - Инициализация схемы теперь происходит с спользованием именованных параметров: ```ts // раньше было DB.scheme('connect', 'connect.*', '100ms:2m, 5s:30m', ...) // теперь DB.scheme({ name: 'connect', pattern: 'connect.*', retentions: '100ms:2m, 5s:30m' }) ``` ## Collector - Метод `init` теперь используют именованные параметры: ```ts col.init({ name: 'metric.id', retentions: '10s:1h' }) ``` - Внутренние свойства были переименованы (убрано `#` в начале) - Теперь все свойства класса определены как `protected` ## Layer - Создание класса теперь требует именованные параметры: ```ts new Layer({ interval: 123, period: 22}) ``` - Теперь все свойства класса определены как `protected` - Были переименованы все свойства класса, теперь у класса добавлены геттеры для каждого свойства Гайд ==== Установка --------- Установка через npm: ``` # npm install -s vrack-db ``` Начало работы -------------- Для получения лучшего опыта работы с метриками можно использовать 2 класса **SingleDB** и **SchemeDB**. Разница этих классов в системе хранения метрик. **SingleDB** используется когда необходимо определить индивидуальные настройки для каждой метрик. В **SchemeDB** можно создавать схемы/группы метрик, которые будут определять настройки всех входящих в группу метрик. SingleDB ---------- ### Инициализация ```ts import { SingleDB } from "vrack-db"; // OR const { SingleDB } = require('vrack-db') const SDB = new SingleDB() ``` Можно создать несколько экземпляров независимых баз. Добавление новой метрики: ```ts SDB.metric({ name: 'test.metric.1', retentions: '5s:10m, 1m:2h' }) ``` - **name** _test.metric.1_ - Название метрики (ее идентификатор) - **retentions** _5s:10m, 1m:2h_ - Указания точности и размера периода хранения метрик. **name** должно быть уникальным. Строка может содердать следующие символы `[a-zA-Z0-9._*]`. Не делайте длинных названий. Пример `boilers.{bid}.{metricid}`,`houses.{houseid}.{metricid}`, `memory.{memoryParam}`,`downloads.{fileid}`. **retentions** (`5s:10m, 1m:2h`) указывает настройку слоев. Каждое значение через запятую добавляет в метрику слой с определенной точностью и общим периодом. Слой - это хранилище данных, которое распределяет данные внутри себя по ячейкам памяти. Каждый слой имеет период (длину) и разделен на интервалы. Метрики обычно хранятся на нескольких слоях. Например, чтобы хранить данные за последний день с точностью 5 секунд, нужно добавить слой `5s:1d`. Для хранения данных с точностью 1 минута одну неделю - нужно добавить слой `1m:1w`. Настройка "retentions" для такой метрики будет выглядеть так - `5s:1d, 1m:1w`. Вы сами должны оценивать, на какой дистанции и с какой точностью вы хотите хранить данные для решения ваших задач. Учитывайте, что слои идут не друг за другом, а располагаются друг над другом. Если ваш самый длинный слой вмещает в себя год, то все, что дольше года, вы будете терять. Значение типа `5s`, `10m`, `2h`, являются интервалами, которые можно расшифровывать как `5 секунд`, `10 минут`, `2 часа`. Вот список поддерживаемых интервалов: - us - микросекунды - ms - миллисекунды - s - секунды - m - минуты - h - часы - d - дни - w - недели - mon - месяцы - y - годы **Очень важная информация** - для поддержки миллисекунд или микросекунд необходимо указывать соответствующий класс интервала при инициализации метрики. `IntervalMs` - для миллисекунд, `IntervalUs` - для микросекунд. Пример: ```ts const { SingleDB, IntervalMs } = require('vrack-db') ///.... SDB.metric({ name: 'test.metric.1', retentions: '5s:10m, 1m:2h', CInterval: IntervalMs }) ``` Указанный класс `CInterval` определяет MTU метрики - минимальный юнит времени. Для класса `Interval` - MTU = секунда. Это значит, что этот класс всегда работает только с секундами (по умолчанию). Классы `IntervalMs` и `IntervalUs` работают с миллисекундами и микросекундами в качестве MTU соответственно. **Далее все указания времени будут в MTU** Для удобства вы можете считать, что MTU = секунда. Но для продвинутого пользования нужно понимать, что в качестве MTU могут использоваться другие размеры времени. Если вы попытаетесь записать метрику без инициализации, она будет создана с параметрами по умолчанию - эквивалентно выражению ниже: ```ts SDB.metric({ name: '{metricName}', retentions: '5s:10m, 1m:2h, 15m:1d, 1h:1w, 6h:1mon, 1d:1y', CInterval: Interval }) ``` Подробнее о параметрах инициализации можно будет узнать далее в продвинутом руководстве. Удаления метрики - метод `destroy`: ```ts SDB.destroy('test.metric.1') ``` Проверка существования - метод `has`: ```ts SDB.has('test.metric.1') // true or false ``` Можно получить размер метрики, учитывая все слои в байтах: ```ts SDB.size('test.metric.1') // size in bytes ``` ### Запись данных Запись данных метрики осуществляется по ее идентификатору: ```ts SDB.write('test.metric.1', 1234.5678) ``` Где: - `test.metric.1` - Идентификатор метрики - `1234.5678` - По умолчанию **значение для метрики имеет тип float(32bit)**. Но это поведение можно изменить, подробнее в продвинутом руководстве. По умолчанию данные в базу всегда записываются от "сейчас". С указанием времени: ```ts SDB.write('test.metric.1',Math.rand(), 123456789) ``` По умолчанию время равно `0`. Если значение `0`, то текущее время будет установлено в `now`. Если вы используете абстрактные значения для указания времени, они должны начинаться со значения > 0. Учитывайте, что все вычисления времени ведутся в целых числах. Нельзя использовать в качестве времени число с плавающей точкой. ### Модификаторы Запись в базу производится по индексу времени в рамках каждого слоя. Такое поведение вызвано особенностью работы слоя, который не может хранить данные чаще, чем точность слоя. Каждый раз, когда данные записываются в ячейку, в которой уже есть релевантные данные для этого интервала - они перезаписываются новыми данными. Для того, чтобы компенсировать часть проблем, связанных с этой особенностью, можно применять разные модификаторы значения при записи. Указание модификатора значения: ```ts SDB.write("test.metric.1", 1234.5678, 0, 'sum') ``` Вот их список: - **last** - Постоянно перезаписывает значение индекса метрики - **first** - Оставляет первое записываемое значение метрики - **max** - Перезаписывает значение, если новое значение больше старого - **min** - Перезаписывает значение, если новое значение меньше старого - **avg** - Вычисляет среднее значение между текущим и предыдущим - **sum** - Прибавляет текущее значение к прошлому **По умолчанию**, данные всегда записываются, **используя модификатор `last`**. ### Получения данных Получение данных с помощью упрощенной записи периода. ```ts SDB.read('test.metric.1', 'now-6h:now', '15m') ``` Где: - `test.metric.1` - Название метрики - `now-6h:now` - Относительный период - `15m` - Интервал Такой запрос вернет все записи за последние 6 часов с точностью 15 минут. Указание `now` с вычислением (прим. `now-15m:now`) можно использовать только в методе `SDB.read`. Пример ответа: ```json { "relevant": true, "start": 1697826000, "end": 1697829600, "rows": [ { "time": 1697826000, "value": 4.7855000495910645 }, { "time": 1697826300, "value": 4.797100067138672 }, ... ] } ``` С версии 3.0.1 Добавлена возможность указывать в запросе относительного периода `start` и `end` - переменные которые будут заменены на начало и конец метрики соответсвенно. ```ts SDB.read('test.metric.1', 'start:end', '15m') ``` Теперь нет необходимости использовать метод `readAll` но он был оставлен для обратной совместимости. Если метрики не существует, запрос вернет все значения равные `null` и флаг `relevant: false`. Можно использовать метод запроса с указанием произвольного времени периода. ```ts const { SingleDB, Interval } = require('vrack-db') // ... // Interval.now() Возвращает время в секундах const start = Interval.now() - 1000 // начало = сейчас - 1000 секунд const end = Interval.now() SDB.readCustomRange("test.metric.1", start, end, '15m', 'avg') ``` Рекомендуется использовать упрощенный способ указания периода. Подходит для реализации пресетов с указанием определенной точности и дальности просмотра метрики. Любой интервал всегда приводится к MTU, поэтому, если есть необходимость указать интервал в MTU, проще всего сформировать строку с интервалом и нужным количеством MTU. Пример, когда мы имеем интервал (точность) в произвольном количестве секунд: ```ts SDB.readCustomRange("test.metric.1", start, end, intervalInSec + 's', 'avg') ``` Иногда удобнее получать всегда одно и то же количество точек, независимо от периода и указанной точности. Такое поведение для графика более ожидаемо для пользователя. Пользователь всегда видит одинаковое количество точек на график, независимо от пресетов типа `now-1d:now`, `now-1w:now`, `now-1mon:now`. Для этого вы можете указать вместо интервала количество точек. ```ts SDB.read('test.metric.1', 'now-6h:now', 300) ``` Учитывайте, что минимальное значения для интервала может быть 1 MTU, это значит, что если в переданный период не будет вмещаться 300 точек, количество точек будет сокращаться пропорционально размеру периода. В любом случае, такое поведение считается условно безопасным и не приведет к запросу с очень большим количеством точек на график. ### Агрегация Функции чтения из базы данных поддерживают несколько базовых функций агрегации. Функции будут применяться к набору данных внутри интервалов периода. По умолчанию используется функция `last`. Список доступных функций: - **last** - Вернет последнее не `null` значение, если все значения `null` - вернет null - **first** - Вернет первое не `null` значение, если все значения `null` - вернет null - **max** - Возвращает максимальное значение, если все значения `null` - вернет null - **min** - Возвращает минимальное значение, если все значения `null` - вернет null - **avg** - Возвращает среднее значение, если все значения `null` - вернет null - **sum** - Возвращает сумму значений, если все значения `null` - вернет null Пример использования: ```ts SDB.read('test.metric.1', 'now-6h:now', '15m','avg') ``` Можно воспользоваться классом `MetricResult` для применения функции агрегации к результатам запроса метрик. Это может быть полезно, например, для получения максимального и минимального значения в выборке. ```ts const data = SDB.read('test.metric.1', 'now-6h:now', '15m','avg') const dataMax = MetricResult.aggregate(data, 'max') // Вернет число или null ``` SchemeDB ---------- Класс для работы с метриками `SchemeDB` является непосредственным наследником класса `SingleDB`. Концептуальное отличие между этими классами в том, что `SchemeDB` определяет настройки метрик по заранее определенным схемам метрик. Инициализация проходит так же: ```ts import { SchemeDB } from "vrack-db"; // OR const { SchemeDB } = require('vrack-db') const SDB = new SchemeDB() ``` Но в инициализации каждой метрики нет необходимости. Мы можем создать схему, в которой все метрики будут иметь одинаковые параметры: ```ts SDB.scheme({ name:'test', pattern:'test.name', retentions: '5s:10m, 1m:2h' }) ``` - **name** _test_ - Название схемы - **pattern** _test.name_ - Все метрики с именем `test.name.*` будут попадать в группу `test`. - **retentions** _5s:10m, 1m:2h_ - Указания точности и размера периода хранения метрик. **Name** должно быть уникальным. Старайтесь не делать его длинным. Пример `boilers`,`houses`, `memory`,`downloads`. **Pattern** пишется маленькими буквами и использует в качестве разделителя актетов символ точки '.'. Когда вы пишите в базу данных метрику с именем `test.name.1`, база ищет подходящую схему и применяет к ней правила схемы. **Retentions** (`5s:10m, 1m:2h`) указывает настройку слоев. Каждое значение через запятую добавляет слой с определенной точностью и общим периодом. Каждая метрика этой схемы будет иметь именно такую настройку слоев. Если название метрики не подходит ни под один паттерн, будет использована схема по умолчанию: ```ts { name: 'default', pattern: '*', retentions: '5s:10m, 1m:2h, 15m:1d, 1h:1w, 6h:1m, 1d:1y', } ``` Все основные методы касаемо получения и записи данных выглядят так же, как и в `SingleDB`. Класс `SchemeDB` имеет дополнительные методы для работы со схемами: - **schemeMetrics** - Возвращает название метрик в схеме - **schemeHas** - Проверяет существование схемы - **schemeList** - Возвращает список схем - **schemeSize** - Возвращает размер схемы в байтах - **schemeDestroy** - Удаляет схему (на самом деле он просто разрывает связь со схемой, если кто-то еще имеет связь с ней - то схема не удалится из памяти) Продвинутые функции ------------------- ### Оптимизация хранения Слои поддерживают разные хранилища для оптимизации хранения данных. По умолчанию, для времени используются `Uint64` и `Float` для хранения значения. Такие хранилища могут быть не особо оптимальны, если вы хотите хранить специфические данные. Например, для хранения булевых значений. Для оптимизации хранения можно указать конкретные хранилища значений и времени. Для SingleDB: ```ts SDB.metric({ name: 'test.metric.1', retentions: '5s:10m, 1m:2h', vStorage: StorageTypes.Bit, // (valueStorage) Может хранить только битовые значения - 1/0 // tStorage: StorageTypes.Uint32 // (timeStorage) не рекомендуется, лучше Uint64 }) ``` Теперь в качестве значений для метрики с именем `test.name.bit.1` могут использоваться только битовые значения (0,1). В памяти на каждые 8 метрик в этой группе будет выделено всего 1 байт памяти, что в 32 раз меньше, чем если бы использовался стандартный тип памяти. Чаще всего, такие оптимизации не имеют значения, но могут быть полезны на старых устройствах типа **orange pi zero** или во встраеваемых устройствах, где мало оперативной памяти. Нужно учитывать, что хранилище времени должно вмещать нужный индекс на 100%. Например, если мы выберем тип для хранилища времени `Uint8` и попытаемся записать значение 256, мы фактически запишем значение `0` в метку времени, что приведет к смещению `startTime` и `endTime` слоя. Такое поведение может привести к потере данных. Поэтому, при выборе хранилища для времени нужно, чтобы записываемые значения всегда вмещались в этот тип. Естественно для хранилища индекса времени необходимо использовать только целочисленные типы данных. Иначе это может привести к непредсказуемым последствиям при округлении чисел к точности слоя. Поддерживаются следующие типы: - Bit - Битовое значение 1 или 0 - Double - Число с плавающей точкой (размер 64 bit) - Float - Число с плавающей точкой (размер 32 bit) - Int8 - Целое число до 127 (может быть отрицательным) - Int16 - Целое число до 32 767 (может быть отрицательным) - Int32 - Целое число до 2 147 483 647 (может быть отрицательным) - Int64 - Целое число до 9 223 372 036 854 775 807 (может быть отрицательным) - Uint8 - Целое число от 0 до 255 - Uint16 - Целое число от 0 до 65 535 - Uint32 - Целое число от 0 до 294 967 295 - Uint64 - Целое число от 0 до 18 446 744 073 709 551 615 ### Почему не Bigint? Если вы используете для хранения времени тип `StorageTypes.Uint64`, вы ожидаете получить число, которое будет превышать размерность стандартного типа `number` - `bigint`. Но в итоге всегда получаете тип `number`. Такое решение было сделано сознательно. Проблема в том, что в JS не удобно работать с числами `bigint`. Это не примитив по типу `number` или `string`, которые JS умело преобразует "на лету". Это отдельный тип, который постоянно требует проверки и, самое главное, он не допускает математические операции с `number`. Фактически `bigint` нельзя использовать в `Math` и нельзя смешивать в операциях с любыми экземплярами `number`. Что сводит на нет любое удобство использования этого типа. Надо либо чтобы все операции производились в `bigint`, что приведет к снижению производительности, либо необходимо везде проверять, что же вы получили - `bigint` или `number`, что совсем неудобно. В угоду удобства было решено оставить только тип `number`, который может вмещать себя **9,007,199,254,740,991**. Такая размерность подходит даже для хранения времени в микросекундах. Возможно в будущем, если JS лучше интегрирует `Bigint`, можно будет поменять поведение базы, но пока нужно учитывать такой факт при работе с большими числами. ### Работа со временем слоев Если вы используете базу данных для кеширования, вы можете столкнуться с проблемой получения данных относительным периодом. Проще говоря, вы взяли старые данные и записали их в базу. Как вам узнать начало и конец графика? Для этого можно воспользоваться методами: ```ts if ( SDB.has(test.metric)){ const startedAt = SDB.start('test.metric') const endedAt = SDB.end('test.metric') } ``` Но лучше использовать метод для получения всего графика: ```ts // metric.id & points count const result = SDB.readAll('test.metric', 300) ``` С помощью этого метода можно легко строить "бесконечный" график. В некоторых ситуация бывает необходимо строить "бесконечный" график. Такой график набирает, например, 600 точек, после чего происходит только модификация данных внутри графика. Набор данных в такой график как бы постоянно сжимает его, при этом график не смещается во времени. Это может быть использовано в мультиметрах и системах, где нужно следить за динамикой на протяжении всего времени работы. Высокая точность тут важна только на первый набор значений, потом можно использовать слой с меньшей точностью. Например, мы получаем данные каждую секунду, добавим слой `1s:10m`, он будет занимать примерно 8KB памяти и вмещать наши 600 точек. Далее нам необходимо добавить слой, который бы взял на себя основную нагрузку. Пока мы добавили слой для хранения только 10 минут, добавим слой на 3 часа с таким же количеством точек `18s:3h`. Далее можно уже добавлять все менее и менее точные слои, например `2m:1d`, `1h:1mon`. Конечно, график не будет бесконечным и все-таки через месяц он уже начнет смещаться, но обычно такое количество времени не требуется для сбора временных данных бесконечного графика. При необходимости можно добавить еще слоев, чтобы помещать больше данных. Отслеживание данных ------------------- С версии 2.1.0 появился инструмент `Alerting` для отслеживания и создания тревожных сообщений. Для использования этого инструмента необходим уже инициализированный класс `SingleDB`, `SchemeDB` или его потомок . Инициализация: ```ts const AT = new Alerting(SDB) ``` Класс `Alerting` сам запрашивает данные из базы данных, используя настройки, определенные в классе `AlertQuery`. Пример создания класса настройки запроса, который будет запрашивать среднее значение каждые 5 секунд за последние 15 секунд: ```ts const aQuery = new AlertQuery('5s', '1s', 'now-15s:now', 'avg') ``` - **evaluateInterval** _5s_ - Интервал, с которым будут запрашиваться данные - **interval** _1s_ - Интервал запроса - **period** _now-15s:now_ - Относительный периода запроса - **func** _avg_ - Функция агрегации запроса Для определения условий используется класс `BasicCondition`: ```ts // level, condition type, params const aConfig = new BasicCondition('danger',"outRange",[-5,5]) ``` - **level** _danger_ - Текстовое представление уровня опасности (определяется пользователем, будет отражено в сообщении). Можно оставить поле пустое для экономии памяти - **type** _outRange_ - Тип проверки состояния - **params** _[-5,5]_ - Параметры для проверки состояния Доступные типы проверки для `BasicCondition`: - **isAbove** - Значение выше параметра - **isBelow** - Значение ниже параметра - **isEqual** - Значение равно параметру - **outRange** - Выход за пределы параметров (требует 2 параметра) - **inRange** - Вход в определенные параметрами пределы (требует 2 параметра) - **noValue** - Если в результате запроса было получено значение `null` (не требует параметров) Теперь, когда параметры определены, можно назначать их нужной метрике: ```ts // path, query, config, id, additional const watchId = AT.watch('test.name.2',aQuery, aConfig, '', {}) ``` - **path** _test.name.2_ - Путь до метрики - **query** _aQuery_ - Экземпляр класса настройки запроса `AlertQuery` - **config** _aConfig_ - Экземпляр класса настройки проверки состояния `BasicCondition` - **id** _''_ - Уникальный идентификатор. Если указать пустую строку, он будет сформирован автоматически. - **additional** _{}_ - Дополнительные данные, которые будут переданы при нарушении правила -------- Можно назначить разным метрикам одни и те же правила и настройки запроса. **Если вы будете создавать для каждой точки отслеживания новые параметры, вы можете потерять гораздо больше памяти** -------- Вы можете назначать несколько обработчиков для одной метрики. Чтобы их отключать, необходимо использовать идентификатор отслеживания, который был либо передан, либо получен после генерации: ```ts AT.unwatch(watchId) ``` Чтобы получать сообщения срабатывания условия, необходимо подписаться: ```ts AT.addListener((alert) => { console.log(alert) }) ``` В случае нарушения правила, вы будете получать сообщение типа: ```ts { id: '24cf92b9066b5', value: 7.50190642674764, status: 'updated', count: 36, timestamp: 1702377145, created: 1702377145, condition: { level: 'danger', id: '6633a39c10328', type: 'outRange', params: [ -5, 5 ] }, areas: [ [ null, -5 ], [ 5, null ] ], threshholds: [ -5, 5 ], additional: {} } ``` * **id** - Идентификатор точки отслеживания (указывается в функции `watch`) * **value** - Результат выполнения запроса для метрики * **status** - Статус сообщения * **created** - Когда сообщение было получено первый раз * **updated** - Когда сообщение повторяется больше одного раза * **ok** - Сообщение, когда данные перестали нарушать условия * **count** - Количество нарушений * **timestamp** - Время обновления * **created** - Время создания * **condition** - Нарушенное условие * **areas** - Зоны условия * **threshholds** - Пороговое значение * **additional** - Дополнительная информация точки отслеживания Areas - зоны, всегда указываются от меньшего к большему. Например, зона от 3 до 5 `[3,5]`. Зоны могут начинаться с минус бесконечности и заканчиваться бесконечностью. В таком случае, вместо значения будет указано `null`. Такой инструмент для отслеживания может помочь в построении тех же мультиметров, блоков питания и т.п. приборов, требующих простых настроек для отслеживания их внутренних параметров. Как это работает ================ Предлагаю вначале разобраться с тем, что мы хотим получить. Нам нужна база, которая будет хранить, например, метрики производительности CPU/SSD/GPU/Network. У нас должна быть возможность просмотра последних актуальных данных и менее точной истории. Необходимо смотреть тренды графика за большие периоды, например, 6 часов, 1 день, неделя, месяц. Получать всегда одинаковое количество точек на график независимо от длины выбранного периода. Все это должно работать максимально быстро. С чего обычно начинают - берут массив и складывают туда новые значения. В целом если данные будут приходить стабильно с определенным интевалом, то мы можем выводить последние актуальные данные. Но вот с менее точной историей возникают серъезные проблемы - как агрегировать данные? как упрощать данные для их более компактного хранения? Как формировать графики за большие периоды? Нужно чтобы это работало еще и очень быстро. Производительность - важная часть построения графика. Большое количество данных будет формироваться большое количество времени. Большое количество данных на графике будет приводить к потере производительности самого интерфейса. Комфортное количество точек на график обычно не превышает 700. Логично было бы предположить, что нам не совсем удобно обращаться к массиву, в котором могут быть 20 000 точек, или, например, 200 000 точек. Очевидно, нам нужна другая система хранения данных. Один из примеров баз, которая может справиться с нашей задачей - файловая база данных, которая обслуживает **Graphite** - **Whisper**. Whisper — это база данных, похожая по конструкции и назначению на RRD (round-robin-database). Она обеспечивает быстрое и надежное хранение числовых данных с течением времени. Whisper позволяет более высокому разрешению (секунды на точку) последних данных деградировать до более низкого разрешения для долгосрочного хранения исторических данных. Вот основные проблемы Whisper: - Сильно нагружает SSD, что приводит к его быстрой деградации - Очень плохая мобильность, его сложно встраивать в другие приложения Вот основные плюсы Whisper: - Скорость записи данных - Скорость получения данных - Фиксированное потребление памяти, добавление данных в метрику не увеличивают потребление памяти - Очень хорошо подходит для трендовых данных типа метрики производительности CPU/SSD/GPU/Network В основе хранилища Whisper лежит алгоритм `round robin`, на основе него и было создано хранилище данных - **Layer**. Layer ----- В VRackDB как хранилище данных используется класс `Layer`. При создании нового слоя мы можем указать его общий размер (период) и то, насколько наш период будет разделен (интервал). В коде это может выглядеть так: ```ts const lay = new Layer({ interval: 2, period: 60 }) ``` **Слой (Layer) считает интервал и период в MTU, но для удобства будут указаны секунды** Мы создали слой с интервалом 2 секунды и периодом 60 секунд. Этот слой не может хранить данные чаще интервала (2 секунды) и не дольше периода (1 минуты). Для подсчета ячеек в этом слое - делим период на интервал (60/2=30), такой слой не может хранить больше 30 значений. Если посмотреть внимательно, можно заметить, что после создания слоя нам известны все его параметры и даже его размер. Так и есть - после создания слоя, он автоматически резервирует память под все значения, которые может содержать. Размер занимаемого места будет зависеть от настроек типов хранения, периода и интервала. По умолчанию для хранения времени используется тип `Uint64`, а для данных - `Float`. Данные хранятся в бинарном виде, используя `Buffer`. Почему бы просто не использовать массив чисел для хранения времени и данных? Дело в том, что javaScript не имеет специфичных типов данных, имеющих конкретный размер для хранения чисел. По сути, любое числов в JavaScript имеет тип `number`, который, на самом деле, имеет размер `Double`, то есть 64 битное число с плавающей точкой. Это значит, что нет возможности оптимизировать хранение данных в памяти, и даже на одно битовое значение 0/1 будет тратиться 8 байт данных памяти. Использование буферов же позволяет хранить даже битовые значения, которые будут занимать всего 1 байт, вместо 512 байт. ### Layer vs Array Чем же отличается кольцевой массив от слоя? Давайте попробуем с этим разобраться. Чтобы привести наглядный пример, создадим другой слой с периодом 100 секунд и интервалом 10 секунд. Он будет содержать всего 10 ячеек. Теперь давайте попробуем записать в него значение с индексом времени 155, что же произойдет? ```ts lay.write(155, 2.25) ``` Вначале наш слой приведет индекс времени (155) к точности (интервалу) слоя `( time - ( time % interval ) )`, результатом вычислений будет 150. **Слой всегда** приводит индекс времени к интервалу слоя. Именно этим и обоснован минимальный интервал, который может хранить слой. При попытке записи в слой чаще чем интервал слоя, индекс времени всегда будет приводиться к интервалу, что, в свою очередь, будет приводить к одному и тому же числу. Например, если мы попробуем записать несколько значений в слой с индексом времени меньше интервала: ```ts lay.write(151, 1.75) lay.write(152, 6.53) lay.write(153, 3.21) lay.write(154, 2.25) ``` Слой каждый раз будет приводить индекс времени к точности интервала (кратно интервалу) в данном случае `150` и каждый раз перезаписывать ячейку, которая относится к этому индексу времени. В итоге в ячейке под индексом времени `150` будет лежать значение `2.25`. Может показаться это поведение очень странным, но все нормально, так и должно быть. А в какую ячейку слой запишет наше значение? В первую? По порядку? Как слой распределяет значение по ячейкам? Индекс ячейки для записи вычисляется на основе переданного индекса времени. Ведь по сути не важно какого размера мы передадим индекс времени, он должен записаться в ячейку. Индекс времени может выходить далеко за пределы периода. Вот как вычисляется индекс ячейки в слое: ```ts const coilIndex = Math.floor(this._cells + time / this._interval) % this._cells; ``` Где: - **coilIndex** - Индекс ячейки - **time** - Индекс времени - **_cells** - Количество ячеек в слое - **_interval** - Интервал слоя Подобную формулу можно достаточно часто увидеть в программировании. Каждый раз, когда `time` будет увеличиваться больше чем на `_interval` - формула будет возвращать следующий номер ячейки по кругу - `1,2,3,4,5,6,7,8,9,10,1,2,3,4,5...`. Индекс ячейки для значения `150` этого слоя по этой формуле будет равен `5`. Давайте посмотрим теперь на наш слой с ячейками: ``` 1 2 3 4 5 6 7 8 9 10 | null | null | null | null | 2.25 | null | null | null | null | null | | null | null | null | null | 155 | null | null | null | null | null | ``` Давайте теперь попробуем записать значение '2.45' для индекса 174: ``` 1 2 3 4 5 6 7 8 9 10 | null | null | null | null | 2.25 | null | 2.45 | null | null | null | | null | null | null | null | 150 | null | 170 | null | null | null | ``` Вот так слой распределяет значения в памяти. Это значит, что в отличие от кольцевого массива, который хранит каждое значение, слой может по факту вмещать в себя гораздо меньше данных. Но в отличие от кольцевого массива, слой хранит данные линейно во времени. Теперь давайте разберем еще один момент. Добавим значение 3.31 с индексом 267, вот как будет выглядеть память слоя: ``` 1 2 3 4 5 6 7 8 9 10 | null | null | null | null | 2.25 | 3.31 | 2.45 | null | null | null | | null | null | null | null | 150 | 260 | 170 | null | null | null | ``` Теперь совсем непонятно, как нам получить данные, чтобы они были релевантными. Теперь у нас между индексами 150 и 170 расположился индекс 260. Если просто взять данные подряд по ячейкам, вместо данных получим ерунду. Нам же нужно, получая данные подряд, получать релевантные данные, которые соответствуют нашим ожиданиям. Чтобы решить вопрос релевантности возвращаемых данных, в слой были введены два значения, отвечающие за начало и конец индексов времени. Когда мы записывали значение с индексом 260, у нас конец слоя имел значение 170, поскольку 260 больше чем 170, он был перезаписан. Когда перезаписывается конец слоя, перезаписывается и начало слоя: ```js this.start = end - interval * (points - 1) ``` Теперь начало слоя = 170, а конец = 260. Давайте же разберемся как слой отдает данные за определенный период. Когда мы хотим получить данные за определенный период, мы должны передать начало и конец. ```ts lay.read(150, 280) // from, to ``` Переданный период будет обрезан до границ слоя, что будет эквивалентно: ```ts lay.read(170, 260) ``` После чего слой вычисляет индекс первой ячейки (from) и начинает подряд читать их. Вспомним, как выглядит на данный момент память слоя: ``` 1 2 3 4 5 6 7 8 9 10 | null | null | null | null | 2.25 | 3.31 | 2.45 | null | null | null | | null | null | null | null | 150 | 260 | 170 | null | null | null | ``` Рассмотрим поэтапный процесс получения данных запроса: ``` Вычисляем ячейку для индекса времени 170 - 7 Берем индекс времени из самой ячейки - 170 Значение индекса находится между началом и концом слоя? - ДА Значение ячейки считается релевантным и мы записываем в массив результатов Прибавляем к 170 один интервал слоя - 180 - ячейка 8 Берем индекс времени самой ячейки - null Записываем в массив результатов для индекса времени 180 значение null ... Вычисляем ячейку для индекса времени 250 - 5 Берем индекс времени из самой ячейки - 150 Значение индекса выходит за пределы начала или конца слоя? - НЕТ Записываем в массив результатов для индекса времени 250 значение null (данные в этой ячейке более нерелевантны) Вычисляем ячейку для индекса времени 260 - 6 Берем индекс времени из самой ячейки - 260 Значение индекса находится между началом и концом слоя? - ДА Значение ячейки считается релевантным и мы записываем в массив результатов ``` Результат такого запроса будет: ```json { "relevant":true, "start": 170, "end": 260, "rows": [ { "time": 170, "value": 2.45 }, { "time": 180, "value": null }, { "time": 190, "value": null }, ..., { "time": 250, "value": null }, { "time": 260, "value": 3.31 } ] } ``` Обратите внимание - слой обрезал значения `start` и `end` в возвращенном результате. Это сделано потому что он указывает фактически затрагиваемый период времени. Для нас **всегда** будет удобнее воспринимать память слоя **последним значением вперед**, потому что именно так слой и работает с данными по времени: ``` 7 8 9 10 1 2 3 4 5 6 | 2.45 | null | null | null | null | null | null | null | null | 3.31 | | 170 | null | null | null | null | null | null | null | null | 260 | ``` Как видно из примера, нам не важны индексы ячеек слоя, мы работаем непосредственно с индексами времени. Слой сам делает всю работу за нас. На практике хорошо видно, что слой работает совсем не так как массив. Слой всегда знает, где лежат его данные по индексу времени, а значит, ему ничего не нужно перебирать и анализировать. Это же относится и к записи данных. Слой, в отличие от кольцевого массива, не может хранить значение чаще указанного интервала слоя. При округлении индекса времени, данные всегда будут попадать в одну и ту же ячейку, перезаписывая предыдущее значение. Для компенсации этой проблемы можно использовать более точные слои или модификаторы. ### Модификаторы записи При записи данных мы можем применять модификатор к значению ячейки. По умолчанию модификатором является функция `last`, которая по сути просто переписывает значение ячейки на новую. Но можно использовать разные функции для достижения нужного результата. Модификатор применяется тогда, когда при записи в ячейку оказывается, что там уже есть релевантные данные. В таком случае, берется значение ячейки и новое значение, применяется одна из функций: - **last** - Записывает последнее значение - **first** - Оставляет первое записываемое значение - **avg** - Вычисляет среднее между новым значением и значением ячейки, перезаписывает результат - **max** - Оставляет максимальное - **min** - Оставляет минимальное - **sum** - Суммирует значения Пример записи значения с функцией: ```ts lay.write(177,3.23, 'avg') ``` ## Collector **Collector** занимается инициализацией метрик, все его методы оперируют только метриками. Это следующий уровень VRackDB после слоя. Его работа начинается с инициализацией метрики, которая может состоять из нескольких слоев разной точности и длины. **Главная задача Collector** - это работа с несколькими слоями на одну метрику. Пример создания коллектора: ```ts const col = new Collector() ``` Пример инициализации метрики: ```ts col.init( {name: 'test', retentions: '10s:1m' } ) ``` - **name** _test_ - Название метрики (идентификатор). Далее все операции с метрикой производятся через ее идентификатор - **retentions** _10s:1m_ - Описывает слои, в данном случае один слой размером 10s:1m где 10s - 10 секунд (интервал), а 1m - 1 минута (общий период слоя) Все операции в базе данных происходят в MTU. Но указывать конкретное количество MTU не всегда удобно, сложно, например, сходу сказать сколько секунд в неделе. Для упрощения указания времени используется формат интервалов, указывающий количество и тип интервала. Все типы интервалов: - us - микросекунды - ms - миллисекунды - s - секунды - m - минуты - h - часы - d - дни - w - недели - mon - месяцы - y - годы Можно указать сразу несколько слоев с разной точностью через запятую, например '10s:1m, 1m:6h, 1h:1w' Есть **2 правила** добавления слоев: 1. Нельзя добавлять слой, в котором интервал будет больше, чем период, например '1h:1m', поскольку в таком случае, даже 1 интервал не может поместиться в общий период. 2. Нельзя добавлять новый слой с меньшим интервалом, но с большим периодом, чем уже есть. Например, не имеет смысла добавлять слой с параметрами `10s:1y`, а потом добавлять `1m:1y`, поскольку уже есть слой на тот же общий период, но с точностью выше. При добавлении слои сортируются по длине затрагиваемого времени (периоду) - см. `Layer.period`. Их можно представить как слоеный перевернутый пирог, выравненный по правой стороне: ``` Direction of time [last data] >--------------------------------------> Layers index 0 |-------|-------|-------|-------|-------| index 1 |--|--|--|--|--|--|--|--|--|--| index 2 |-|-|-|-|-|-|-|-|-|-| <---------------------------------------< Lost data direction ``` Выше уже говорилось, что лучше представлять слои последними данными по индексу времени вперед. На схеме выше слои представлены именно так. Также можно обратить внимание, что менее точные слои имеют ячейки, затрагивающие ячейки более точных слоев, из чего можно сделать выводы, что у такого метода хранения есть некоторая избыточность данных. Это действительно так. Но такой подход позволяет оптимизировать получения менее точных данных, не пытаясь вести подсчет с более точных слоев. Проще говоря, если для запроса уже есть слой с достаточной точностью - нет необходимости спускаться на слой ниже для анализа более точного слоя. Когда новые данные поступают для данной метрики, данные постоянно смещаются влево. Те данные, которые достигают левого края - теряются (см. `Lost data direction`). По такой схеме наглядно видно, что данная метрика не сможет хранить данные чаще самого маленького интервала слоя и не сможет хранить данные дольше самого длинного периода. ### Запись данных Особенность записи значения в метрику заключается в том, что `Collector` производит запись во все слои одновременно. Это приводит к тому, что данные в слоях с малым интервалом быстро смещаются, накапливая новые точные данные, а слои с большим интервалом и большим периодом постоянно перезаписывают значение ячейки, ожидая, пока округленный индекс времени перейдет на другую ячейку. Таким образом данные распределяются между всеми слоями. Такой подход позволяет получить высокую точность на маленькой дистанции и низкую точность на большой. Конечно мы можем сделать один слой высокой точности на большую дистанцию, но на практике это не имеет особого значения. Такой подход будет занимать большое количество памяти, а полученная точность будет избыточна. Поэтому нужно соблюдать баланс между точностью и полезностью. При записи, `Collector` также может использовать модификаторы для слоев. Он использует их для каждого слоя. Такое поведение может вызвать проблему, связанную с записью накопительных данных - при. модификатора `sum`. См. ниже **Получение данных**. Пример записи: ```ts col.write('test', 23.131) ``` Для записи значения в метрику нам нужно указать 2 параметра - название и значение. Используя `Collector` у нас нет необходимости указывать индекс времени, но мы можем это делать. По умолчанию используется время от "сейчас". ### Продвинутое получение данных `Collector` предоставляет более умную функцию получения данных, позволяя затрагивать все слои метрики, а также позволяет использовать произвольные интервалы и периоды для запроса. Например, можно запросить данные, которые вообще не входят ни в один из слоев, и тогда будет сформирован ответ, состоящий из указанных интервалов и значений `null`. Это полезное поведение, которое позволяет не думать о том, будут ли данные в запросе или нет, и всегда одинаково реагировать на результат запроса. Еще одна особенность чтения данных Collector это проход по всем слоям данных. Вы можете запрашивать данные с точностью выше, чем самый длинный слой, и меньше, чем слой ниже, и для этого ответа Collector попытается сформировать ответ с максимальной точностью. Подобная работа может быть проделана через несколько слоев, постепенно получая все более точные данные. Пример такой работы: ```ts // Запрос col.read('test', 1, 9, '1s') /* Слои 2s:10s |-----|-----|-----|-----|-----| 1s:4s |--|--|--|--| */ // Ответ { start: 1, end: 9, rows: [ {time: 1, value: null}, {time: 2, value: 2.5}, {time: 3, value: null}, {time: 4, value: 4.5}, {time: 5, value: null}, {time: 6, value: 6.5}, {time: 7, value: 7.5}, {time: 8, value: 8.5}, {time: 9, value: 9.5}, ] } ``` Из примера выше видно, что если `Collector` не может сформировать данные с достаточной точность, он возьмет данные со слоя с меньшей точностью. Если запрашиваемый период выходит за пределы слоев - эти данные будут заполнены `null`. Выше было указано, что модификатор `sum` может вызывать проблему. Это проблема будет проявляться только тогда, когда запрос будет затрагивать 2 слоя разной точности, как из примера выше. Если применять функцию `sum`, то на слоях с большей точностью мы будем видеть маленькие числа, но чаще. На слоях с меньшей точностью мы будем видеть большие числа, но реже. По сумме за определенный период времени эти числа будут равны, но на графике это может выглядеть совсем не так, как вы ожидаете: ``` Layer0 Layer1 14 | 12 * | 10 * | * | | 5 5 | * 4 * 4 | * * 3 | * ``` **Пожалуйста, обратите внимание, что на стыках слоев данные могут быть не всегда предсказуемыми**. Это связанно с тем, что округление интервалов происходит не с точностью слоя. Из-за подобного, именно на стыках слоев можно получить `null` вместо значения, которое вы ожидаете. Применять модификатор `sum` нужно с умом. Вы можете использовать интервалы и периоды по размеру слоев, чтобы не допускать таких проблем. ## SingleDB `SingleDB` - следующий уровень абстракции, который упрощает работу с `Collector`. Например, при попытке записать данные в несуществующую метрику (чего не допускает `Collector`), будет создана новая метрика с параметрами по умолчанию. Или при попытке чтения несуществующей метрики - будет возвращен результат полностью заполненый значениями `null`. Работа с `SingleDB` менее требовательна и более удобна для использования. Чтение из базы позволяет использовать строковое представление периода и интервала, например: ```ts SDB.read('metric.id','now-6h:now', '15m', 'last') ``` Относительный период типа `now-6h:now` и интервал `15m` упрощает создание простых пресетов просмотра графика. При желании можно указать количество точек, которое вы хотите видеть на графике вместо строкового интервала: ```ts SDB.read('metric.id','now-6h:now', 300, 'last') ``` Метод, который отвечает за формирование интервала: ```ts static getIntervalOfFixedCount(start: number, end: number, count: number): number { let cnt = Math.floor(Math.abs(start - end) / count) if (isNaN(cnt) || cnt < 1) cnt = 1 return cnt } ``` Очень полезный метод - получение всего графика: ```ts readAll('metric.id', 300) ``` Используя `Collector.start(name)` и `Collector.end(name)` вычисляется начало и конец всего графика, учитывая все слои. После чего происходит чтение с точностью 300 точек на график. ## Заключение Тут перечислены только основные методы и основной функционал. Подробнее по реализации можно посмотреть непосредственно в коде. Чтобы эффективно использовать инструмент, необходимо хорошо знать его. Надеюсь, данная документация поможет вам приблизиться к понимаю этой базы данных и, возможно, реализовать что-то подобное на вашем любимом языке для вашего приложения. Данная база данных разрабатывалась одним человеком вместе со всей сопутствующей документацией. Из-за отсутствия какой-либо обратной связи - очень сложно формировать хорошую документацию. Я всегда готов сделать ее лучше, если у вас есть какие-либо идеи или предложения.