Важно: Данный раздел актуален для Платформы данных в Публичном облаке и On-Premise.
Настоящий документ включает вводную часть, описывающую систему «RT.WideStore», ее возможности и отличительные особенности, а также подробную информацию об установке, запуске и обновлению «RT.WideStore».
Наименование системы: RT.WideStore.
RT.WideStore – столбцовая система управления базами данных (СУБД), предназначенная для онлайн обработки аналитических запросов (OLAP).
В настоящем документе использованы и определены следующие термины и сокращения:
Термин/Сокращение | Определение |
---|---|
Apache ZooKeeper |
Программный проект Apache Software Foundation. По сути, это сервис для распределенных систем, предлагающий иерархическое хранилище ключей и значений, которое используется для предоставления распределенных:
для больших распределенных систем. |
CPU | Процессор |
GDPR | (англ. General Data Protection Regulation) общий регламент по защите данных жителей ЕС, вступил в действие с 25 мая 2018. |
ODBC | (англ. Open Database Connectivity) – широко распространенный интерфейс прикладного программирования (API) для доступа к базе данных. Он основан на спецификациях интерфейса уровня вызовов (CLI) от Open Group и ISO / IEC для API баз данных и использует язык структурированных запросов (SQL) в качестве языка доступа к базе данных. |
OLAP | (англ. online analytical processing, интерактивная аналитическая обработка) – технология обработки данных, заключающаяся в подготовке суммарной (агрегированной) информации на основе больших массивов данных, структурированных по многомерному принципу. |
SQL | (англ. structured query language, язык структурированных запросов) –декларативный язык программирования, применяемый для создания, модификации и управления данными в реляционной базе данных, управляемой соответствующей системой управления базами данных. |
SSD | (англ. Solid-State Drive,твердотельный накопитель) – компьютерное энергонезависимое немеханическое запоминающее устройство на основе микросхем памяти, альтернатива HDD. |
SSE 4.2 | Один из наборов инструкций процессора. |
URL |
(англ. Uniform Resource Locator, унифицированный указатель ресурса) – система унифицированных адресов электронных ресурсов, или единообразный определитель местонахождения ресурса (файла). Используется как стандарт записи ссылок на объекты в Интернете. |
Система | Система «RT.WideStore». |
СУБД | Система управления базами данных. |
СУБД RT.WideStore из-за своей архитектуры оптимальна для выполнения онлайн запросов, используемых для построения отчетов на основе больших массивов данных, структурированных по многомерному принципу.
RT.WideStore представляет собой столбцовую система управления базами данных.
В обычной, «строковой» СУБД, данные относящиеся к одной строке физически хранятся рядом.
Примеры строковых СУБД: MySQL, Postgres, MS SQL Server.
В «столбцовых» СУБД, значения из разных столбцов хранятся отдельно, а данные одного столбца - вместе.
Примеры столбцовых СУБД: Vertica, Paraccel (Actian Matrix, Amazon Redshift), Sybase IQ, Exasol, Infobright, InfiniDB, MonetDB (VectorWise, Actian Vector), LucidDB, SAP HANA, Google Dremel, Google PowerDrill, Druid, kdb+.
Разный порядок хранения данных лучше подходит для разных сценариев работы.
Сценарий работы с данными – это то, какие производятся запросы, как часто и в каком соотношении; сколько читается данных на запросы каждого вида – строк, столбцов, байт; как соотносятся чтения и обновления данных; какой рабочий размер данных и насколько локально он используется; используются ли транзакции и с какой изолированностью; какие требования к дублированию данных и логической целостности; требования к задержкам на выполнение и пропускной способности запросов каждого вида и т. п.
Чем больше нагрузка на систему, тем более важной становится специализация под сценарий работы, и тем более конкретной становится эта специализация. Не существует системы, одинаково хорошо подходящей под существенно различные сценарии работы. Если система подходит под широкое множество сценариев работы, то при достаточно большой нагрузке, система будет справляться со всеми сценариями работы плохо, или справляться хорошо только с одним из сценариев работы.
Ключевые особенности OLAP сценария работы:
Легко видеть, что OLAP сценарий работы существенно отличается от других распространённых сценариев работы (например, OLTP или Key-Value сценариев работы). Таким образом, не имеет никакого смысла пытаться использовать OLTP или Key-Value БД для обработки аналитических запросов, если вы хотите получить высокую производительность. Например, если вы попытаетесь использовать для аналитики MongoDB или Redis - вы получите низкую производительность по сравнению с OLAP-СУБД.
Столбцовые СУБД лучше (от 100 раз по скорости обработки большинства запросов) подходят для OLAP сценария работы.
Причины, по которым столбцовые СУБД лучше подходят для OLAP сценария:
1) По вводу-выводу:
Например, для запроса «посчитать количество записей для каждой рекламной системы», требуется прочитать один столбец «идентификатор рекламной системы», который занимает 1 байт в несжатом виде. Если большинство переходов было не с рекламных систем, то можно рассчитывать хотя бы на десятикратное сжатие этого столбца. При использовании быстрого алгоритма сжатия, возможно разжатие данных со скоростью более нескольких гигабайт несжатых данных в секунду. То есть, такой запрос может выполняться со скоростью около нескольких миллиардов строк в секунду на одном сервере. На практике, такая скорость действительно достигается.
2) По вычислениям:
Так как для выполнения запроса надо обработать достаточно большое количество строк, становится актуальным диспетчеризовывать все операции не для отдельных строк, а для целых векторов, или реализовать движок выполнения запроса так, чтобы издержки на диспетчеризацию были примерно нулевыми. Если этого не делать, то при любой не слишком плохой дисковой подсистеме, интерпретатор запроса неизбежно упрётся в CPU.
Имеет смысл не только хранить данные по столбцам, но и обрабатывать их, по возможности, тоже по столбцам.
Есть два способа это сделать:
В «обычных» БД этого не делается, так как не имеет смысла при выполнении простых запросов. Хотя есть исключения. Например, в MemSQL кодогенерация используется для уменьшения latency при выполнении SQL запросов. Для сравнения, в аналитических СУБД требуется оптимизация throughput, а не latency.
Стоит заметить, что для эффективности по CPU требуется, чтобы язык запросов был декларативным (SQL, MDX) или хотя бы векторным (J, K). То есть, чтобы запрос содержал циклы только в неявном виде, открывая возможности для оптимизации.
RT.WideStore работает на следующих платформах операционных систем:
В по-настоящему столбцовой СУБД рядом со значениями не хранится никаких лишних данных. Например, должны поддерживаться значения постоянной длины, чтобы не хранить рядом со значениями типа «число» их длины. Для примера, миллиард значений типа UInt8 (целое число, занимающее 8 байт) должен действительно занимать в несжатом виде около 1GB, иначе это сильно ударит по эффективности использования CPU. Очень важно хранить данные компактно (без «мусора») в том числе в несжатом виде, так как скорость разжатия (использование CPU) зависит, в основном, от объёма несжатых данных.
Этот пункт пришлось выделить, так как существуют системы, которые могут хранить значения отдельных столбцов по отдельности, но не могут эффективно выполнять аналитические запросы в силу оптимизации под другой сценарий работы. Примеры: HBase, BigTable, Cassandra, HyperTable. В этих системах вы получите пропускную способность в районе сотен тысяч строк в секунду, но не сотен миллионов строк в секунду.
Также стоит заметить, что RT.WideStore является системой управления базами данных, а не одной базой данных. То есть, RT.WideStore позволяет создавать таблицы и базы данных в runtime, загружать данные и выполнять запросы без переконфигурирования и перезапуска сервера.
Некоторые столбцовые СУБД (InfiniDB CE, MonetDB) не используют сжатие данных. Однако сжатие данных действительно играет одну из ключевых ролей в демонстрации отличной производительности. RT.WideStore может использовать сжатие данных.
Многие столбцовые СУБД (SAP HANA, Google PowerDrill) могут работать только в оперативной памяти. Такой подход стимулирует выделять больший бюджет на оборудование, чем фактически требуется для анализа в реальном времени. RT.WideStore спроектирован для работы на обычных жестких дисках, что обеспечивает низкую стоимость хранения на гигабайт данных, но SSD и дополнительная оперативная память тоже полноценно используются, если доступны.
Большие запросы естественным образом распараллеливаются, используя все необходимые ресурсы из доступных на сервере.
Почти все перечисленные ранее столбцовые СУБД не поддерживают распределённую обработку запроса.
В RT.WideStore данные могут быть расположены на разных шардах. Каждый шард может представлять собой группу реплик, которые используются для отказоустойчивости. Запрос будет выполнен на всех шардах параллельно. Это делается прозрачно для пользователя.
RT.WideStore поддерживает декларативный язык запросов на основе SQL и во многих случаях совпадающий с SQL стандартом.
Поддерживаются GROUP BY, ORDER BY, подзапросы в секциях FROM, IN, JOIN, а также скалярные подзапросы.
Зависимые подзапросы и оконные функции не поддерживаются.
Данные не только хранятся по столбцам, но и обрабатываются по векторам – кусочкам столбцов. За счёт этого достигается высокая эффективность по CPU.
RT.WideStore поддерживает таблицы с первичным ключом. Для того, чтобы можно было быстро выполнять запросы по диапазону первичного ключа, данные инкрементально сортируются с помощью merge дерева. За счёт этого, поддерживается постоянное добавление данных в таблицу. Блокировки при добавлении данных отсутствуют.
Физическая сортировка данных по первичному ключу позволяет получать данные для конкретных его значений или их диапазонов с низкими задержками - менее десятков миллисекунд.
Низкие задержки позволяют не откладывать выполнение запроса и не подготавливать ответ заранее, а выполнять его именно в момент загрузки страницы пользовательского интерфейса. То есть, в режиме онлайн.
RT.WideStore предоставляет различные способы разменять точность вычислений на производительность:
Используется асинхронная multimaster репликация. После записи на любую доступную реплику, данные распространяются на все остальные реплики в фоне. Система поддерживает полную идентичность данных на разных репликах. Восстановление после большинства сбоев осуществляется автоматически, а в сложных случаях — полуавтоматически. При необходимости, можно включить кворумную запись данных.
RT.WideStore - полноценная колоночная СУБД. Данные хранятся в колонках, а в процессе обработки - в массивах (векторах или фрагментах (chunk’ах) колонок). По возможности операции выполняются на массивах, а не на индивидуальных значениях. Это называется “векторизованное выполнения запросов” (vectorized query execution), и помогает снизить стоимость фактической обработки данных.
Эта идея не нова. Такой подход использовался в APL (A programming language, 1957) и его потомках: A + (диалект APL), J (1990), K (1993) и Q (язык программирования Kx Systems, 2003). Программирование на массивах (Array programming) используется в научных вычислительных системах. Эта идея не является чем-то новым и для реляционных баз данных: например, она используется в системе VectorWise (так же известной как Actian Vector Analytic Database от Actian Corporation).
Существует два различных подхода для увеличения скорости обработки запросов: выполнение векторизованного запроса и генерация кода во время выполнения (runtime code generation). В последнем случае код генерируется на лету для каждого типа запроса, удаляя все косвенные и динамические обращения. Ни один из этих подходов не имеет явного преимущества. Генерация кода во время выполнения выигрывает, если объединяет большое число операций, таким образом полностью используя вычислительные блоки и конвейер CPU. Выполнение векторизованного запроса может быть менее практично потому, что задействует временные векторы данных, которые должны быть записаны и прочитаны из кэша. Если временные данные не помещаются в L2 кэш, будут проблемы. С другой стороны, выполнение векторизованного запроса упрощает использование SIMD инструкций CPU. Научная работа наших друзей показывает преимущества сочетания обоих подходов. RT.WideStore использует выполнение векторизованного запроса и имеет ограниченную начальную поддержку генерации кода во время выполнения.
Для представления столбцов в памяти (фактически, фрагментов столбцов) используется интерфейс IColumn. Интерфейс предоставляет вспомогательные методы для реализации различных реляционных операторов. Почти все операции иммутабельные: они не изменяют оригинальных колонок, а создают новую с измененными значениями. Например, метод "IColumn :: filter" принимает фильтр – набор байт. Он используется для реляционных операторов WHERE и HAVING. Другой пример: метод "IColumn :: permute" используется для поддержки ORDER BY, метод "IColumn :: cut" - LIMIT и т. д.
Различные реализации IColumn (ColumnUInt8, ColumnString и т. д.) отвечают за распределение данных колонки в памяти. Для колонок целочисленного типа это один смежный массив, такой как std :: vector. Для колонок типа String и Array это два вектора: один для всех элементов массивов, располагающихся смежно, второй для хранения смещения до начала каждого массива. Также существует реализация ColumnConst, в которой хранится только одно значение в памяти, но выглядит как колонка.
Тем не менее, можно работать и с индивидуальными значениями. Для представления индивидуальных значений используется Поле (Field). Field – размеченное объединение UInt64, Int64, Float64, String и Array. IColumn имеет метод оператор [] для получения значения по индексу n как Field, а также метод insert для добавления Field в конец колонки. Эти методы не очень эффективны, так как требуют временных объектов Field, представляющих индивидуальное значение. Есть более эффективные методы, такие как insertFrom, insertRangeFrom и т.д.
Field не несет в себе достаточно информации о конкретном типе данных в таблице. Например, UInt8, UInt16, UInt32 и UInt64 в Field представлены как UInt64.
IColumn предоставляет методы для общих реляционных преобразований данных, но они не отвечают всем потребностям. Например, ColumnUInt64 не имеет метода для вычисления суммы двух столбцов, а ColumnString не имеет метода для запуска поиска по подстроке. Эти бесчисленные процедуры реализованы вне IColumn.
Различные функции на колонках могут быть реализованы обобщенным, неэффективным путем, используя IColumn методы для извлечения значений Field, или специальным путем, используя знания о внутреннем распределение данных в памяти в конкретной реализации IColumn. Для этого функции приводятся к конкретному типу IColumn и работают напрямую с его внутренним представлением. Например, в ColumnUInt64 есть метод getData, который возвращает ссылку на внутренний массив, чтение и заполнение которого, выполняется отдельной процедурой напрямую. Фактически, мы имеем "дырявую абстракции", обеспечивающие эффективные специализации различных процедур.
IDataType отвечает за сериализацию и десериализацию - чтение и запись фрагментов колонок или индивидуальных значений в бинарном или текстовом формате.
IDataType точно соответствует типам данных в таблицах - DataTypeUInt32, DataTypeDateTime, DataTypeString и т. д.
IDataType и IColumn слабо связаны друг с другом. Различные типы данных могут быть представлены в памяти с помощью одной реализации IColumn. Например, и DataTypeUInt32, и DataTypeDateTime в памяти представлены как ColumnUInt32 или ColumnConstUInt32. В добавок к этому, один тип данных может быть представлен различными реализациями IColumn. Например, DataTypeUInt8 может быть представлен как ColumnUInt8 и ColumnConstUInt8.
IDataType хранит только метаданные. Например, DataTypeUInt8 не хранить ничего (кроме скрытого указателя vptr), а DataTypeFixedString хранит только N (фиксированный размер строки).
В IDataType есть вспомогательные методы для данных различного формата. Среди них методы сериализации значений, допускающих использование кавычек, сериализации значения в JSON или XML. Среди них нет прямого соответствия форматам данных. Например, различные форматы Pretty и TabSeparated могут использовать один вспомогательный метод serializeTextEscaped интерфейса IDataType.
Block это контейнер, который представляет фрагмент (chunk) таблицы в памяти. Это набор троек – (IColumn, IDataType, имя колонки). В процессе выполнения запроса, данные обрабатываются Blockами. Если у нас есть Block, значит у нас есть данные (в объекте IColumn), информация о типе (в IDataType), которая говорит нам, как работать с колонкой, и имя колонки (оригинальное имя колонки таблицы или служебное имя, присвоенное для получения промежуточных результатов вычислений).
При вычислении некоторой функции на колонках в блоке мы добавляем еще одну колонку с результатами в блок, не трогая колонки аргументов функции, потому что операции иммутабельные. Позже ненужные колонки могут быть удалены из блока, но не модифицированы. Это удобно для устранения общих подвыражений.
Блоки создаются для всех обработанных фрагментов данных. Напоминаем, что одни и те же типы вычислений, имена колонок и типы переиспользуются в разных блоках и только данные колонок изменяются. Лучше разделить данные и заголовок блока потому, что в блоках маленького размера мы имеем большой оверхэд по временным строкам при копировании умных указателей (shared_ptrs) и имен колонок.
Потоки блоков обрабатывают данные. Мы используем потоки блоков для чтения данных, трансформации или записи данных куда-либо. IBlockInputStream предоставляет метод read для получения следующего блока, пока это возможно, и метод write, чтобы продвигать (push) блок куда-либо.
Потоки отвечают за:
Имеются и более сложные трансформации. Например, когда вы тянете блоки из AggregatingBlockInputStream, он считывает все данные из своего источника, агрегирует их, и возвращает поток агрегированных данных вам. Другой пример: конструктор UnionBlockInputStream принимает множество источников входных данных и число потоков. Такой Stream работает в несколько потоков и читает данные источников параллельно.
Потоки блоков используют «втягивающий» (pull) подход к управлению потоком выполнения: когда вы вытягиваете блок из первого потока, он, следовательно, вытягивает необходимые блоки из вложенных потоков, так и работает весь конвейер выполнения. Ни «pull» ни «push» не имеют явного преимущества, потому что поток управления неявный, и это ограничивает в реализации различных функций, таких как одновременное выполнение нескольких запросов (слияние нескольких конвейеров вместе). Это ограничение можно преодолеть с помощью сопрограмм (coroutines) или просто запуском дополнительных потоков, которые ждут друг друга. У нас может быть больше возможностей, если мы сделаем поток управления явным: если мы локализуем логику для передачи данных из одной расчетной единицы в другую вне этих расчетных единиц.
Следует отметить, что конвейер выполнения запроса создает временные данные на каждом шаге. Мы стараемся сохранить размер блока достаточно маленьким, чтобы временные данные помещались в кэш процессора. При таком допущении запись и чтение временных данных практически бесплатны по сравнению с другими расчетами. Мы могли бы рассмотреть альтернативу, которая заключается в том, чтобы объединить многие операции в конвейере вместе. Это может сделать конвейер как можно короче и удалить большую часть временных данных, что может быть преимуществом, но у такого подхода также есть недостатки. Например, разделенный конвейер позволяет легко реализовать кэширование промежуточных данных, использование промежуточных данных из аналогичных запросов, выполняемых одновременно, и объединение конвейеров для аналогичных запросов.
Форматы данных реализуются с помощью потоков блоков. Есть форматы представления (presentational), пригодные только для вывода данных клиенту, такие как Pretty формат, который предоставляет только IBlockOutputStream. И есть форматы ввода/вывода, такие как TabSeparated или JSONEachRow.
Существуют также потоки строк: IRowInputStream и IRowOutputStream. Они позволяют вытягивать/выталкивать данные отдельными строками, а не блоками. Они нужны только для упрощения реализации ориентированных на строки форматов. Обертка BlockInputStreamFromRowInputStream и BlockOutputStreamFromRowOutputStream позволяет конвертировать потоки, ориентированные на строки, в обычные потоки, ориентированные на блоки.
Для байт-ориентированных ввода/вывода существуют абстрактные классы ReadBuffer и WriteBuffer. Они используются вместо C++ iostream. Не волнуйтесь: каждый зрелый проект C++ использует что-то другое вместо iostream по уважительным причинам.
ReadBuffer и WriteBuffer это просто непрерывный буфер и курсор, указывающий на позицию в этом буфере. Реализации могут как владеть, так и не владеть памятью буфера. Существует виртуальный метод заполнения буфера следующими данными (для ReadBuffer) или сброса буфера куда-нибудь (например, WriteBuffer). Виртуальные методы редко вызываются.
Реализации ReadBuffer/WriteBuffer используются для работы с файлами и файловыми дескрипторами, а также сетевыми сокетами, для реализации сжатия (CompressedWriteBuffer инициализируется вместе с другим WriteBuffer и осуществляет сжатие данных перед записью в него), и для других целей – названия ConcatReadBuffer, LimitReadBuffer, и HashingWriteBuffer говорят сами за себя.
Буферы чтения/записи имеют дело только с байтами. В заголовочных файлах ReadHelpers и WriteHelpers объявлены некоторые функции, чтобы помочь с форматированием ввода/вывода. Например, есть помощники для записи числа в десятичном формате.
Давайте посмотрим, что происходит, когда вы хотите вывести результат в JSON формате в стандартный вывод (stdout). У вас есть результирующий набор данных, готовый к извлечению из IBlockInputStream. Вы создаете WriteBufferFromFileDescriptor(STDOUT_FILENO) чтобы записать байты в stdout. Вы создаете JSONRowOutputStream, инициализируете с этим WriteBuffer'ом, чтобы записать строки JSON в stdout. Кроме того, вы создаете BlockOutputStreamFromRowOutputStream, реализуя IBlockOutputStream. Затем вызывается copyData для передачи данных из IBlockInputStream в IBlockOutputStream и все работает. Внутренний JSONRowOutputStream будет писать в формате JSON различные разделители и вызвать IDataType::serializeTextJSON метод со ссылкой на IColumn и номер строки в качестве аргументов. Следовательно, IDataType::serializeTextJSON вызовет метод из WriteHelpers.h: например, writeText для числовых типов и writeJSONString для DataTypeString.
Интерфейс IStorage служит для отображения таблицы. Различные движки таблиц являются реализациями этого интерфейса. Примеры StorageMergeTree, StorageMemory и так далее. Экземпляры этих классов являются просто таблицами.
Ключевые методы IStorage это read и write. Есть и другие варианты – alter, rename, drop и так далее. Метод read принимает следующие аргументы: набор столбцов для чтения из таблицы, AST запрос и желаемое количество потоков для вывода. Он возвращает один или несколько объектов IBlockInputStream и информацию о стадии обработки данных, которая была завершена внутри табличного движка во время выполнения запроса.
В большинстве случаев метод read отвечает только за чтение указанных столбцов из таблицы, а не за дальнейшую обработку данных. Вся дальнейшая обработка данных осуществляется интерпретатором запросов и не входит в сферу ответственности IStorage.
Но есть и заметные исключения:
Метод read может возвращать несколько объектов IBlockInputStream, позволяя осуществлять параллельную обработку данных. Эти несколько блочных входных потоков могут считываться из таблицы параллельно. Затем вы можете обернуть эти потоки различными преобразованиями (такими как вычисление выражений или фильтрация), которые могут быть вычислены независимо, и создать UnionBlockInputStream поверх них, чтобы читать из нескольких потоков параллельно.
Есть и другие варианты. Например, TableFunction возвращает временный объект IStorage, который можно подставить во FROM.
Чтобы получить быстрое представление о том, как реализовать свой движок таблиц, посмотрите на что-то простое, например, StorageMemory или StorageTinyLog.
В качестве результата выполнения метода read, IStorage возвращает QueryProcessingStage – информацию о том, какие части запроса были обработаны внутри хранилища.
Написанный от руки парсер, анализирующий запрос, работает по методу рекурсивного спуска. Например, ParserSelectQuery просто рекурсивно вызывает нижестоящие парсеры для различных частей запроса. Парсеры создают абстрактное синтаксическое дерево (AST). AST представлен узлами, которые являются экземплярами IAST.
Генераторы парсеров не используются по историческим причинам.
Интерпретаторы отвечают за создание конвейера выполнения запроса из AST. Есть простые интерпретаторы, такие как InterpreterExistsQuery и InterpreterDropQuery или более сложный InterpreterSelectQuery. Конвейер выполнения запроса представляет собой комбинацию входных и выходных потоков блоков. Например, результатом интерпретации SELECT запроса является IBlockInputStream для чтения результирующего набора данных; результат интерпретации INSERT запроса – это IBlockOutputStream, для записи данных, предназначенных для вставки; результат интерпретации INSERT SELECT запроса – это IBlockInputStream, который возвращает пустой результирующий набор при первом чтении, но копирует данные из SELECT к INSERT.
InterpreterSelectQuery использует ExpressionAnalyzer и ExpressionActions механизмы для анализа запросов и преобразований. Именно здесь выполняется большинство оптимизаций запросов на основе правил. ExpressionAnalyzer написан довольно грязно и должен быть переписан: различные преобразования запросов и оптимизации должны быть извлечены в отдельные классы, чтобы позволить модульные преобразования или запросы.
Существуют обычные функции и агрегатные функции. Агрегатные функции смотрите в следующем разделе.
Обычный функции не изменяют число строк и работают так, как если бы обрабатывали каждую строку независимо. В действительности же, функции вызываются не к отдельным строкам, а блокам данных для реализации векторизованного выполнения запросов.
Некоторые функции, такие как blockSize, rowNumberInBlock, и runningAccumulate, эксплуатируют блочную обработку и нарушают независимость строк.
RT.WideStore имеет сильную типизацию, поэтому нет никакого неявного преобразования типов. Если функция не поддерживает определенную комбинацию типов, она создает исключение. Но функции могут работать (перегружаться) для многих различных комбинаций типов. Например, функция plus (для реализации + оператор) работает для любой комбинации числовых типов: UInt8 + Float32, UInt16 + Int8 и так далее. Кроме того, некоторые вариадические функции, такие как concat, могут принимать любое количество аргументов.
Реализация функции может быть немного неудобной, поскольку функция явно определяет поддерживаемые типы данных и поддерживается IColumns. Например, в plus функция имеет код, генерируемый экземпляром шаблона C++ для каждой комбинации числовых типов, а также постоянные или непостоянные левые и правые аргументы.
Это отличное место для реализации генерации кода во время выполнения, чтобы избежать раздувания кода шаблона. Кроме того, он позволяет добавлять слитые функции, такие как fused multiply-add или выполнять несколько сравнений в одной итерации цикла.
Из-за векторизованного выполнения запроса функции не закорачиваются. Например, если вы пишете WHERE f(x) AND g(y), обе части вычисляются, даже для строк, когда f(x) равно нулю (за исключением тех случаев, когда f(x) является нулевым постоянным выражением). Но если избирательность условия f(x) высока, и расчет f(x) обходится гораздо дешевле, чем g(y), лучше всего разделить вычисление на этапы. На первом этапе вычислить f(x), отфильтровать результирующие столбцы, а затем вычислять g(y) только для меньших, отфильтрованных фрагментов данных.
Агрегатные функции – это функции с состоянием (stateful). Они накапливают переданные значения в некотором состоянии и позволяют получать результаты из этого состояния. Работа с ними осуществляется с помощью интерфейса IAggregateFunction. Состояния могут быть как простыми (состояние для AggregateFunctionCount это всего лишь одна переменная типа UInt64) так и довольно сложными (состояние AggregateFunctionUniqCombined представляет собой комбинацию линейного массива, хэш-таблицы и вероятностной структуры данных HyperLogLog).
Состояния распределяются в Arena (пул памяти) для работы с несколькими состояниями при выполнении запроса GROUP BY высокой кардинальности (большим числом уникальных данных). Состояния могут иметь нетривиальный конструктор и деструктор: например, сложные агрегатные состояния могут сами аллоцировать дополнительную память. Потому к созданию и уничтожению состояний, правильной передаче владения и порядку уничтожения следует уделять больше внимание.
Агрегатные состояния могут быть сериализованы и десериализованы для передачи их по сети во время выполнения распределенного запроса или для записи их на диск при дефиците оперативной памяти. Они даже могут храниться в таблице с DataTypeAggregateFunction, чтобы позволяет выполнять инкрементное агрегирование данных.
Формат сериализации данных для состояний агрегатных функций в настоящее время не версионируется. Это нормально, если агрегатные состояния хранятся только временно. Но у нас есть такая возможность AggregatingMergeTree механизм таблиц для инкрементной агрегации, и люди уже используют его в эксплуатации. Именно по этой причине требуется помнить об обратная совместимости при изменении формата сериализации для любой агрегатной функции.
Сервер предоставляет несколько различных интерфейсов.
Внутри простой многопоточный сервер без корутин (coroutines), файберов (fibers) и т.д. Поскольку сервер не предназначен для обработки большого количества простых запросов, а ориентирован на обработку сложных запросов относительно низкой интенсивности, каждый из потоков может обрабатывать огромное количество аналитических запросов.
Сервер инициализирует класс Context, где хранит необходимое для выполнения запроса окружение: список доступных баз данных, пользователей и прав доступа, настройки, кластеры, список процессов, журнал запросов и т.д. Это окружение используется интерпретаторами.
Мы поддерживаем полную обратную и прямую совместимость для TCP интерфейса: старые клиенты могут общаться с новыми серверами, а новые клиенты могут общаться со старыми серверами. Но мы не хотим поддерживать его вечно и прекращаем поддержку старых версий примерно через год.
Примечание: Для всех сторонних приложений мы рекомендуем использовать HTTP интерфейс, потому что он прост и удобен в использовании. TCP интерфейс тесно связан с внутренними структурами данных: он использует внутренний формат для передачи блоков данных и использует специальное кадрирование для сжатых данных. Мы не выпустили библиотеку C для этого протокола, потому что потребовала бы линковки большей части кодовой базы RT.WideStore, что непрактично.
Сервера в кластере в основном независимы. Вы можете создать Распределенную (Distributed) таблицу на одном или всех серверах в кластере. Такая таблица сама по себе не хранит данные – она только предоставляет возможность "просмотра" всех локальных таблиц на нескольких узлах кластера. При выполнении SELECT распределенная таблица переписывает запрос, выбирает удаленные узлы в соответствии с настройками балансировки нагрузки и отправляет им запрос. Распределенная таблица просит удаленные сервера обработать запрос до той стадии, когда промежуточные результаты с разных серверов могут быть объединены. Затем он получает промежуточные результаты и объединяет их. Распределенная таблица пытается возложить как можно больше работы на удаленные серверы и сократить объем промежуточных данных, передаваемых по сети.
Ситуация усложняется, при использовании подзапросы в случае IN или JOIN, когда каждый из них использует таблицу Distributed. Есть разные стратегии для выполнения таких запросов.
Глобального плана выполнения распределенных запросов не существует. Каждый узел имеет собственный локальный план для своей части работы. У нас есть простое однонаправленное выполнение распределенных запросов: мы отправляем запросы на удаленные узлы и затем объединяем результаты. Но это невозможно для сложных запросов GROUP BY высокой кардинальности или запросов с большим числом временных данных в JOIN: в таких случаях нам необходимо перераспределить («reshuffle») данные между серверами, что требует дополнительной координации. RT.WideStore не поддерживает выполнение запросов такого рода, и нам нужно работать над этим.
MergeTree – это семейство движков хранения, поддерживающих индексацию по первичному ключу. Первичный ключ может быть произвольным набором (кортежем) столбцов или выражений. Данные в таблице MergeTree хранятся "частями" (“parts”). Каждая часть хранит данные отсортированные по первичному ключу (данные упорядочены лексикографически). Все столбцы таблицы хранятся в отдельных файлах column.bin в этих частях. Файлы состоят из сжатых блоков. Каждый блок обычно содержит от 64 КБ до 1 МБ несжатых данных, в зависимости от среднего значения размера данных. Блоки состоят из значений столбцов, расположенных последовательно один за другим. Значения столбцов находятся в одинаковом порядке для каждого столбца (порядок определяется первичным ключом), поэтому, когда вы выполняете итерацию по многим столбцам, вы получаете значения для соответствующих строк.
Сам первичный ключ является "разреженным" ("sparse"). Он не относится к каждой отдельной строке, а только к некоторым диапазонам данных. Отдельный файл «primary.idx» имеет значение первичного ключа для каждой N-й строки, где N называется гранулярностью индекса ("index_granularity", обычно N = 8192). Также для каждого столбца у нас есть файлы column.mrk с "метками" ("marks"), которые обозначают смещение для каждой N-й строки в файле данных. Каждая метка представляет собой пару: смещение начала сжатого блока от начала файла и смещение к началу данных в распакованном блоке. Обычно сжатые блоки выравниваются по меткам, а смещение в распакованном блоке равно нулю. Данные для primary.idx всегда находятся в памяти, а данные для файлов column.mrk кэшируются.
Когда мы собираемся читать что-то из части данных MergeTree, мы смотрим содержимое primary.idx и определяем диапазоны, которые могут содержать запрошенные данные, затем просматриваем содержимое column.mrk и вычисляем смещение, чтобы начать чтение этих диапазонов. Из-за разреженности могут быть прочитаны лишние данные. RT.WideStore не подходит для простых точечных запросов высокой интенсивности, потому что весь диапазон строк размером index_granularity должен быть прочитан для каждого ключа, а сжатый блок должен быть полностью распакован для каждого столбца. Мы сделали индекс разреженным, потому что мы должны иметь возможность поддерживать триллионы строк на один сервер без существенных расходов памяти на индексацию. Кроме того, поскольку первичный ключ является разреженным, он не уникален: он не может проверить наличие ключа в таблице во время INSERT. Вы можете иметь множество строк с одним и тем же ключом в таблице.
При выполнении INSERT для группы данных в MergeTree, элементы группы сортируются по первичному ключу и образует новую “часть”. Фоновые потоки периодически выбирают некоторые части и объединяют их в одну отсортированную часть, чтобы сохранить относительно небольшое количество частей. Вот почему он называется MergeTree. Конечно, объединение приводит к повышению интенсивности записи. Все части иммутабельные: они только создаются и удаляются, но не изменяются. Когда выполняется SELECT, он содержит снимок таблицы (набор частей). После объединения старые части также сохраняются в течение некоторого времени, чтобы упростить восстановление после сбоя, поэтому, если мы видим, что какая-то объединенная часть, вероятно, повреждена, мы можем заменить ее исходными частями.
MergeTree не является деревом LSM (Log-structured merge-tree — журнально-структурированное дерево со слиянием), потому что оно не содержит «memtable» и «log»: вставленные данные записываются непосредственно в файловую систему. Это делает его пригодным только для вставки данных в пакетах, а не по отдельным строкам и не очень часто - примерно раз в секунду это нормально, а тысячу раз в секунду - нет. Мы сделали это для простоты и потому, что мы уже вставляем данные в пакеты в наших приложениях.
Таблицы MergeTree могут иметь только один (первичный) индекс: вторичных индексов нет. Было бы неплохо разрешить несколько физических представлениям в одной логической таблице, например, хранить данные в более чем одном физическом порядке или даже разрешить представления с предварительно агрегированными данными вместе с исходными данными.
Существуют движки MergeTree, которые выполняют дополнительную работу во время фоновых слияний. Примерами являются CollapsingMergeTree и AggregatingMergeTree. Это можно рассматривать как специальную поддержку обновления. Помните, что это не настоящие обновления, поскольку пользователи обычно не контролируют время выполнения фоновых слияний, а данные в таблице MergeTree почти всегда хранятся в нескольких частях, а не в полностью объединенной форме.
Репликация в RT.WideStore может быть настроена для каждой таблицы отдельно. Вы можете иметь несколько реплицированных и несколько не реплицированных таблиц на одном сервере. Вы также можете реплицировать таблицы по-разному, например, одну с двухфакторной репликацией и другую с трехфакторной.
Репликация реализована в движке таблицы ReplicatedMergeTree. Путь в ZooKeeper указывается в качестве параметра движка. Все таблицы с одинаковым путем в ZooKeeper становятся репликами друг друга: они синхронизируют свои данные и поддерживают согласованность. Реплики можно добавлять и удалять динамически, просто создавая или удаляя таблицу.
Репликация использует асинхронную multi-master схему. Вы можете вставить данные в любую реплику, которая имеет открытую сессию в ZooKeeper, и данные реплицируются на все другие реплики асинхронно. Поскольку RT.WideStore не поддерживает UPDATE, репликация исключает конфликты (conflict-free replication). Поскольку подтверждение вставок кворумом не реализовано, только что вставленные данные могут быть потеряны в случае сбоя одного узла.
Метаданные для репликации хранятся в ZooKeeper. Существует журнал репликации, в котором перечислены действия, которые необходимо выполнить. Среди этих действий: получить часть (get the part); объединить части (merge parts); удалить партицию (drop a partition) и так далее. Каждая реплика копирует журнал репликации в свою очередь, а затем выполняет действия из очереди. Например, при вставке в журнале создается действие «получить часть» (get the part), и каждая реплика загружает эту часть. Слияния координируются между репликами, чтобы получить идентичные до байта результаты. Все части объединяются одинаково на всех репликах. Одна из реплик-лидеров инициирует новое слияние кусков первой и записывает действия «слияния частей» в журнал. Несколько реплик (или все) могут быть лидерами одновременно. Реплике можно запретить быть лидером с помощью merge_tree настройки replicated_can_become_leader.
Репликация является физической: между узлами передаются только сжатые части, а не запросы. Слияния обрабатываются на каждой реплике независимо, в большинстве случаев, чтобы снизить затраты на сеть, во избежание усиления роли сети. Крупные объединенные части отправляются по сети только в случае значительной задержки репликации.
Кроме того, каждая реплика сохраняет свое состояние в ZooKeeper в виде набора частей и его контрольных сумм. Когда состояние в локальной файловой системе расходится с эталонным состоянием в ZooKeeper, реплика восстанавливает свою согласованность путем загрузки отсутствующих и поврежденных частей из других реплик. Когда в локальной файловой системе есть неожиданные или испорченные данные, RT.WideStore не удаляет их, а перемещает в отдельный каталог и забывает об этом.
Примечание: Кластер RT.WideStore состоит из независимых шардов, а каждый шард состоит из реплик. Кластер не является эластичным (not elastic), поэтому после добавления нового шарда данные не будут автоматически распределены между ними. Вместо этого нужно изменить настройки, чтобы выровнять нагрузку на кластер. Эта реализация дает вам больший контроль, и вполне приемлема для относительно небольших кластеров, таких как десятки узлов. Но для кластеров с сотнями узлов, которые мы используем в эксплуатации, такой подход становится существенным недостатком. Движки таблиц, которые охватывают весь кластер с динамически реплицируемыми областями, которые могут быть автоматически разделены и сбалансированы между кластерами, еще предстоит реализовать.
RT.WideStore предоставляет два сетевых интерфейса (оба могут быть дополнительно обернуты в TLS для дополнительной безопасности):
В большинстве случаев рекомендуется использовать подходящий инструмент или библиотеку, а не напрямую взаимодействовать с RT.WideStore по сути.
Поддерживаются:
Существует также широкий спектр сторонних библиотек для работы с RT.WideStore:
HTTP интерфейс позволяет использовать RT.WideStore на любой платформе, из любого языка программирования. HTTP интерфейс удобно использовать для работы из Java и Perl, а также из shell-скриптов. HTTP интерфейс можно использовать из Perl, Python и Go. HTTP интерфейс более ограничен по сравнению с родным интерфейсом, но является более совместимым.
Для уменьшения трафика по сети можно использовать сжатие при передаче большого количества данных, а также для создания сразу сжатых дампов.
Вы можете использовать внутренний формат сжатия RT.WideStore при передаче данных. Формат сжатых данных нестандартный, и вам придётся использовать для работы с ним специальную программу clickhouse-compressor.
Также можно использовать сжатие HTTP. RT.WideStore поддерживает следующие методы сжатия:
Нативный протокол используется в клиенте командной строки, для взаимодействия между серверами во время обработки распределенных запросов, а также в других программах на C++. К сожалению, у родного протокола RT.WideStore пока нет формальной спецификации, но в нем можно разобраться с использованием исходного кода RT.WideStore и/или путем перехвата и анализа TCP трафика.
RT.WideStore предоставляет собственный клиент командной строки: clickhouse-client. Клиент поддерживает запуск с аргументами командной строки и с конфигурационными файлами.
Клиенты и серверы различных версий совместимы, однако если клиент старее сервера, то некоторые новые функции могут быть недоступны. Мы рекомендуем использовать одинаковые версии клиента и сервера.
Клиент может быть использован в интерактивном и не интерактивном (batch) режиме.
Существует следующие JDBC-драйверы:
Это официальная реализация драйвера ODBC для доступа к RT.WideStore как источнику данных.
Библиотека к C++ которая поддерживает следующие типы данных:
Существуют следующие клиентские библиотеки для доступа к RT.WideStore как источнику данных:
Существуют следующие библиотеки для интеграции к RT.WideStore от сторонних разработчиков:
1. Инфраструктурные продукты
2. Экосистемы вокруг языков программирования
Визуальные интерфейсы от сторонних разработчиков:
1. С открытым исходным кодом
Tabix
Веб-интерфейс для RT.WideStore в проекте Tabix.
Основные возможности:
HouseOps
UI/IDE для OSX, Linux и Windows.
Основные возможности:
Планируется разработка следующих возможностей:
LightHouse
Легковесный веб-интерфейс для RT.WideStore.
Основные возможности:
Redash
Платформа для отображения данных.
Поддерживает множество источников данных, включая RT.WideStore. Redash может объединять результаты запросов из разных источников в финальный набор данных.
Основные возможности:
DBeaver
Универсальный desktop клиент баз данных с поддержкой RT.WideStore.
Основные возможности:
clickhouse-cli
Альтернативный клиент командной строки для RT.WideStore, написанный на Python 3.
Основные возможности:
clickhouse-flamegraph
Специализированный инструмент для визуализации system.trace_log в виде flamegraph.
clickhouse-plantuml
Скрипт, генерирующий PlantUML диаграммы схем таблиц.
xeus-clickhouse
Ядро Jupyter для RT.WideStore, которое поддерживает запрос RT.WideStore-данных с использованием SQL в Jupyter.
2. Коммерческие:
DataGrip
IDE для баз данных о JetBrains с выделенной поддержкой RT.WideStore. Он также встроен в другие инструменты на основе IntelliJ: PyCharm, IntelliJ IDEA, GoLand, PhpStorm и другие.
Основные возможности:
Holistics Software
full-stack платформа для обработки данных и бизнес-аналитики.
Основные возможности:
Looker
Платформа для обработки данных и бизнес-аналитики. Поддерживает более 50 диалектов баз данных, включая RT.WideStore. Looker можно установить самостоятельно или воспользоваться готовой платформой SaaS.
Просмотр данных, построение отображений и дашбордов, планирование отчётов и обмен данными с коллегами доступны с помощью браузера. Также, Looker предоставляет ряд инструментов, позволяющих встраивать сервис в другие приложения и API для обмена данными.
Основные возможности:
RT.WideStore может работать на любой операционной системе Linux, FreeBSD или Mac OS X с архитектурой процессора x86_64, AArch64 или PowerPC64LE.
Предварительно собранные пакеты компилируются для x86_64 и используют набор инструкций SSE 4.2, поэтому, если не указано иное, его поддержка в используемом процессоре, становится дополнительным требованием к системе. Вот команда, чтобы проверить, поддерживает ли текущий процессор SSE 4.2:
grep -q sse4_2 /proc/cpuinfo && echo "SSE 4.2 supported" || echo "SSE 4.2 not supported"
Чтобы запустить RT.WideStore на процессорах, которые не поддерживают SSE 4.2, либо имеют архитектуру AArch64 или PowerPC64LE, необходимо самостоятельно собрать RT.WideStore из исходного кода с соответствующими настройками конфигурации.
Для установки выполните команду:
yum install rt.ws-***.rpm
Интересующую версию пакета можно получить по запросу.
Для запуска сервера в качестве демона, выполните:
sudo service clickhouse-server start
Смотрите логи в директории:
/var/log/clickhouse-server/
Если сервер не стартует, проверьте корректность конфигурации в файле:
/etc/clickhouse-server/config.xml
Также можно запустить сервер вручную из консоли:
clickhouse-server --config-file=/etc/clickhouse-server/config.xml
При этом, лог будет выводиться в консоль, что удобно для разработки.
Если конфигурационный файл лежит в текущей директории, то указывать параметр "--config-file" не требуется, по умолчанию будет использован файл "./config.xml".
После запуска сервера, соединиться с ним можно с помощью клиента командной строки:
clickhouse-client
По умолчанию он соединяется с localhost:9000, от имени пользователя default без пароля. Также клиент может быть использован для соединения с удалённым сервером с помощью аргумента "--host".
Терминал должен использовать кодировку UTF-8.
Более подробная информация о клиенте располагается в разделе «Клиент командной строки».
Пример проверки работоспособности системы:
$ ./clickhouse-client
ClickHouse client version 0.0.18749.
Connecting to localhost:9000.
Connected to ClickHouse server version 0.0.18749.
:) SELECT 1
SELECT 1
┌─1─┐
│ 1 │
└───┘
1 rows in set. Elapsed: 0.003 sec.