Skip to main content

Обзор архитектуры ClickHouse

ClickHouse — полноценная столбцовая СУБД. Данные хранятся в столбцах, а в процессе обработки — в массивах (векторах или фрагментах столбцов — chunks). По возможности операции выполняются на массивах, а не на индивидуальных значениях. Это называется “векторизованное выполнения запросов” (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. Научная работа наших друзей показывает преимущества сочетания обоих подходов. ClickHouse использует выполнение векторизованного запроса и имеет ограниченную начальную поддержку генерации кода во время выполнения.

Столбцы

Для представления столбцов в памяти (фактически, фрагментов столбцов) используется интерфейс IColumn. Интерфейс предоставляет вспомогательные методы для реализации различных реляционных операторов. Почти все операции не изменяют данные (immutable): они не изменяют содержимое столбцов, а создают новые с изменёнными значениями. Например, метод IColumn :: filter принимает фильтр — набор байтов. Он используется для реляционных операторов WHERE и HAVING. Другой пример: метод IColumn :: permute используется для поддержки ORDER BY, метод IColumn :: cutLIMIT и т. д.

Различные реализации 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.

Дырявые абстракции (Leaky Abstractions)

IColumn предоставляет методы для общих реляционных преобразований данных, но они не отвечают всем потребностям. Например, ColumnUInt64 не имеет метода для вычисления суммы двух столбцов, а ColumnString не имеет метода для запуска поиска по подстроке. Эти бесчисленные процедуры реализованы вне IColumn.

Различные функции на столбцах могут быть реализованы обобщённым, неэффективным путем, используя IColumn-методы для извлечения значений Field, или специальным путем, используя знания о внутреннем распределение данных в памяти в конкретной реализации IColumn. Для этого функции приводятся к конкретному типу IColumn и работают напрямую с его внутренним представлением. Например, в ColumnUInt64 есть метод getData, который возвращает ссылку на внутренний массив, чтение и заполнение которого, выполняется отдельной процедурой напрямую. Фактически, мы имеем “дырявые абстракции”, обеспечивающие эффективные специализации различных процедур.

Типы данных (Data Types)

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)

Block — это контейнер, который представляет фрагмент (chunk) таблицы в памяти. Это набор троек — (IColumn, IDataType, имя столбца). В процессе выполнения запроса, данные обрабатываются блоками (Block). Если есть Block, значит у нас есть данные (в объекте IColumn), информация о типе (в IDataType), которая говорит, как работать со столбцом, и имя столбца (оригинальное имя столбца таблицы или служебное имя, присвоенное для получения промежуточных результатов вычислений).

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

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

Процессоры

Смотрите описание в файле src/Processors/IProcessor.h исходного кода.

Форматы

Форматы данных реализуются с помощью потоков блоков. Есть форматы представления (presentational), пригодные только для вывода данных клиенту, такие как Pretty-формат, который предоставляет только IBlockOutputStream. И есть форматы ввода-вывода, такие как TabSeparated или JSONEachRow.

Существуют также потоки строк: IRowInputStream и IRowOutputStream. Они позволяют вытягивать и выталкивать данные отдельными строками, а не блоками. Они нужны только для упрощения реализации ориентированных на строки форматов. Обертка BlockInputStreamFromRowInputStream и BlockOutputStreamFromRowOutputStream позволяет конвертировать потоки, ориентированные на строки, в обычные потоки, ориентированные на блоки.

I/O

Для байт-ориентированного ввода-вывода существуют абстрактные классы ReadBuffer и WriteBuffer. Они используются вместо iostream. Не волнуйтесь: каждый зрелый проект C++ использует что-то другое вместо iostream по уважительным причинам.

ReadBuffer и WriteBuffer — это просто непрерывный буфер и курсор, указывающий на позицию в этом буфере. Реализации могут как владеть, так и не владеть памятью буфера. Существует виртуальный метод заполнения буфера следующими данными (для ReadBuffer) или сброса буфера куда-нибудь (например WriteBuffer). Виртуальные методы редко вызываются.

Реализации ReadBuffer/WriteBuffer используются для работы с файлами и файловыми дескрипторами, а также сетевыми сокетами, для реализации сжатия (CompressedWriteBuffer инициализируется вместе с другим WriteBuffer и осуществляет сжатие данных перед записью в него), и для других целей – названия ConcatReadBuffer, LimitReadBuffer, и HashingWriteBuffer говорят сами за себя.

Буферы чтения-записи имеют дело только с байтами. В заголовочных файлах ReadHelpers и WriteHelpers объявлены некоторые функции, чтобы помочь с форматированием ввода-вывода. Например, есть помощники для записи числа в десятичном формате.

Давайте посмотрим, что происходит, когда вы хотите вывести результат в JSON формате в стандартный вывод (stdout). У вас есть результирующий набор данных, готовый к извлечению из QueryPipeline. Вы создаете WriteBufferFromFileDescriptor(STDOUT_FILENO) чтобы записать байты в stdout. Вы создаете JSONRowOutputFormat, инициализируете с этим WriteBuffer'ом, чтобы записать строки JSON в stdout. Чтобы соеденить выход QueryPipeline с форматом, можно использовать метод complete, который превращает QueryPipeline в завершенный QueryPipeline. Внутренний JSONRowOutputStream будет писать в формате JSON различные разделители и вызвать IDataType::serializeTextJSON метод со ссылкой на IColumn и номер строки в качестве аргументов. Следовательно, IDataType::serializeTextJSON вызовет метод из WriteHelpers.h: например, writeText для числовых типов и writeJSONString для DataTypeString.

Таблицы

Интерфейс IStorage служит для отображения таблицы. Различные движки таблиц являются реализациями этого интерфейса. Примеры StorageMergeTree, StorageMemory и так далее. Экземпляры этих классов являются просто таблицами.

Ключевые методы IStorage это read и write. Есть и другие варианты — alter, rename, drop и так далее. Метод read принимает следующие аргументы: набор столбцов для чтения из таблицы, AST запрос и желаемое количество потоков для вывода и возвращает Pipe.

В большинстве случаев метод read отвечает только за чтение указанных столбцов из таблицы, а не за дальнейшую обработку данных. Вся дальнейшая обработка данных осуществляется интерпретатором запросов и не входит в сферу ответственности IStorage.

Но есть и заметные исключения:

  • AST-запрос, передающийся в метод read, может использоваться движком таблицы для получения информации о возможности использования индекса и считывания меньшего количества данных из таблицы.
  • Иногда движок таблиц может сам обрабатывать данные до определенного этапа. Например, StorageDistributed можно отправить запрос на удаленные серверы, попросить их обработать данные до этапа, когда данные с разных удаленных серверов могут быть объединены, и вернуть эти предварительно обработанные данные. Затем интерпретатор запросов завершает обработку данных.

Метод read может возвращать Pipe, состоящий из нескольких процессоров. Каждый их этих процессоров может читать данные параллельно. Затем, вы можете соеденить эти просессоры с другими преобразованиями (такими как вычисление выражений или фильтрация), которые могут быть вычислены независимо. Далее, создан QueryPipeline поверх них, можно выполнить пайплайн с помощью PipelineExecutor.

Есть и другие варианты. Например, TableFunction возвращает временный объект IStorage, который можно подставить во FROM.

Чтобы получить быстрое представление о том, как реализовать свой движок таблиц, посмотрите на что-то простое, например StorageMemory или StorageTinyLog.

В качестве результата выполнения метода read, IStorage возвращает QueryProcessingStage – информацию о том, какие части запроса были обработаны внутри хранилища.

Разборщики (Parsers)

Написанный от руки разборщик, анализирующий запрос, работает по методу рекурсивного спуска. Например, ParserSelectQuery просто рекурсивно вызывает нижестоящие разборщики для различных частей запроса. Разборщики создают абстрактное синтаксическое дерево (AST). AST представлен узлами, которые являются экземплярами IAST.

Генераторы разборщиков не используются по историческим причинам.

Интерпретаторы

Интерпретаторы отвечают за создание конвейера выполнения запроса из AST. Есть простые интерпретаторы, такие как InterpreterExistsQuery и InterpreterDropQuery или более сложный InterpreterSelectQuery.

Конвейер выполнения запроса представляет собой комбинацию процессоров, которые могут принимать на вход и также возвращать чанки (набор колонок с их типами) Процессоры обмениваются данными через порты и могут иметь несколько входных и выходных портов. Более подробное описание можно найти в файле src/Processors/IProcessor.h.

Например, результатом интерпретации SELECT запроса является QueryPipeline, который имеет специальный выходной порт для чтения результирующего набора данных. Результатом интерпретации INSERT запроса является QueryPipeline с входным портом для записи данных для вставки. Результатом интерпретации INSERT SELECT запроса является завершенный QueryPipeline, который не имеет входов или выходов, но копирует данные из SELECT в INSERT одновременно.

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

Для решения текущих проблем, существующих в интерпретаторах, разрабатывается новый InterpreterSelectQueryAnalyzer. Это новая версия InterpreterSelectQuery, которая не использует ExpressionAnalyzer и вводит дополнительный уровень абстракции между AST и QueryPipeline, называемый QueryTree. Он еще не готов к использованию в продакшене, но его можно протестировать с помощью флага allow_experimental_analyzer.

Функции

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

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

Некоторые функции, такие как blockSize, rowNumberInBlock, и runningAccumulate, используют блочную обработку и нарушают независимость строк.

ClickHouse имеет сильную типизацию, поэтому нет никакого неявного преобразования типов. Если функция не поддерживает определенную комбинацию типов, она создает исключение. Но функции могут работать (перегружаться) для многих различных комбинаций типов. Например, функция 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 механизм таблиц для инкрементной агрегации, и люди уже используют его в эксплуатации. Именно по этой причине требуется помнить об обратная совместимости при изменении формата сериализации для любой агрегатной функции.

Сервер

Сервер предоставляет несколько различных интерфейсов.

  • HTTP-интерфейс для любых сторонних клиентов.
  • TCP-интерфейс для родного ClickHouse-клиента и межсерверной взаимодействия при выполнении распределенных запросов.
  • Интерфейс для передачи данных при репликации.

Внутри простой многопоточный сервер без сопрограмм (coroutines), фиберов (fibers) и т. д. Поскольку сервер не предназначен для обработки большого количества простых запросов, а ориентирован на обработку сложных запросов относительно низкой интенсивности, каждый из потоков может обрабатывать огромное количество аналитических запросов.

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

Мы поддерживаем полную обратную и прямую совместимость для TCP-интерфейса: старые клиенты могут общаться с новыми серверами, а новые клиенты могут общаться со старыми серверами. Но мы не хотим поддерживать его вечно и прекращаем поддержку старых версий примерно через год.

Примечание

Для всех сторонних приложений мы рекомендуем использовать HTTP-интерфейс, потому что он прост и удобен в использовании. TCP-интерфейс тесно связан с внутренними структурами данных: он использует внутренний формат для передачи блоков данных и использует специальное кадрирование для сжатых данных. Мы не выпустили библиотеку C для этого протокола, потому что потребовала бы линковки большей части кодовой базы ClickHouse, что непрактично.

Выполнение распределенных запросов (Distributed Query Execution)

Сервера в кластере в основном независимы. Вы можете создать распределённую (Distributed) таблицу на одном или всех серверах в кластере. Такая таблица сама по себе не хранит данные — она только предоставляет возможность “просмотра” всех локальных таблиц на нескольких узлах кластера. При выполнении SELECT распределенная таблица переписывает запрос, выбирает удаленные узлы в соответствии с настройками балансировки нагрузки и отправляет им запрос. Распределенная таблица просит удаленные сервера обработать запрос до той стадии, когда промежуточные результаты с разных серверов могут быть объединены. Затем он получает промежуточные результаты и объединяет их. Распределенная таблица пытается возложить как можно больше работы на удаленные серверы и сократить объем промежуточных данных, передаваемых по сети.

Ситуация усложняется при использовании подзапросов в случае IN или JOIN, когда каждый из них использует таблицу Distributed. Есть разные стратегии для выполнения таких запросов.

Глобального плана выполнения распределённых запросов не существует. Каждый узел имеет собственный локальный план для своей части работы. У нас есть простое однонаправленное выполнение распределенных запросов: мы отправляем запросы на удаленные узлы и затем объединяем результаты. Но это невозможно для сложных запросов GROUP BY высокой кардинальности или запросов с большим числом временных данных в JOIN: в таких случаях нам необходимо перераспределить (“reshuffle”) данные между узлами, что требует дополнительной координации. ClickHouse не поддерживает выполнение запросов такого рода, и нам нужно работать над этим.

Merge Tree

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 и вычисляем смещение, чтобы начать чтение этих диапазонов. Из-за разреженности могут быть прочитаны лишние данные. ClickHouse не подходит для простых точечных запросов высокой интенсивности, потому что весь диапазон строк размером index_granularity должен быть прочитан для каждого ключа, а сжатый блок должен быть полностью распакован для каждого столбца. Мы сделали индекс разреженным, потому что мы должны иметь возможность поддерживать триллионы строк на один сервер без существенных расходов памяти на индексацию. Кроме того, поскольку первичный ключ является разреженным, он не уникален: он не может проверить наличие ключа в таблице во время INSERT. Вы можете иметь множество строк с одним и тем же ключом в таблице.

При выполнении INSERT для группы данных в MergeTree, элементы группы сортируются по первичному ключу и образует новую “часть”. Фоновые потоки периодически выбирают некоторые части и объединяют их в одну отсортированную часть, чтобы сохранить относительно небольшое количество частей. Вот почему он называется MergeTree. Конечно, объединение приводит к повышению интенсивности записи. Все части иммутабельные: они только создаются и удаляются, но не изменяются. Когда выполняется SELECT, он содержит снимок таблицы (набор частей). После объединения старые части также сохраняются в течение некоторого времени, чтобы упростить восстановление после сбоя, поэтому, если мы видим, что какая-то объединенная часть, вероятно, повреждена, мы можем заменить ее исходными частями.

MergeTree не является LSM (Log-structured merge-tree — журнально-структурированным деревом со слиянием), потому что оно не содержит «memtable» и «log»: вставленные данные записываются непосредственно в файловую систему. Это делает его пригодным только для вставки данных в пакетах, а не по отдельным строкам и не очень часто — примерно раз в секунду это нормально, а тысячу раз в секунду — нет. Мы сделали это для простоты и потому, что мы уже вставляем данные в пакеты в наших приложениях.

Таблицы MergeTree могут иметь только один (первичный) индекс: вторичных индексов нет. Было бы неплохо разрешить несколько физических представлениям в одной логической таблице, например, хранить данные в более чем одном физическом порядке или даже разрешить представления с предварительно агрегированными данными вместе с исходными данными.

Существуют движки MergeTree, которые выполняют дополнительную работу во время фоновых слияний. Примерами являются CollapsingMergeTree и AggregatingMergeTree. Это можно рассматривать как специальную поддержку обновления. Помните, что это не настоящие обновления, поскольку пользователи обычно не контролируют время выполнения фоновых слияний, а данные в таблице MergeTree почти всегда хранятся в нескольких частях, а не в полностью объединенной форме.

Репликация

Репликация в ClickHouse может быть настроена для каждой таблицы отдельно. Вы можете иметь несколько реплицированных и несколько не реплицированных таблиц на одном сервере. Вы также можете реплицировать таблицы по-разному, например, одну с двухфакторной репликацией и другую с трехфакторной.

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

Репликация использует асинхронную multi-master-схему. Вы можете вставить данные в любую реплику, которая имеет открытую сессию в ZooKeeper, и данные реплицируются на все другие реплики асинхронно. Поскольку ClickHouse не поддерживает UPDATE, репликация исключает конфликты (conflict-free replication). Поскольку подтверждение вставок кворумом не реализовано, только что вставленные данные могут быть потеряны в случае сбоя одного узла.

Метаданные для репликации хранятся в ZooKeeper. Существует журнал репликации, в котором перечислены действия, которые необходимо выполнить. Среди этих действий: получить часть (get the part); объединить части (merge parts); удалить партицию (drop a partition) и так далее. Каждая реплика копирует журнал репликации в свою очередь, а затем выполняет действия из очереди. Например, при вставке в журнале создается действие “получить часть” (get the part), и каждая реплика загружает эту часть. Слияния координируются между репликами, чтобы получить идентичные до байта результаты. Все части объединяются одинаково на всех репликах. Одна из реплик-лидеров инициирует новое слияние кусков первой и записывает действия “слияния частей” в журнал. Несколько реплик (или все) могут быть лидерами одновременно. Реплике можно запретить быть лидером с помощью merge_tree настройки replicated_can_become_leader.

Репликация является физической: между узлами передаются только сжатые части, а не запросы. Слияния обрабатываются на каждой реплике независимо, в большинстве случаев, чтобы снизить затраты на сеть, во избежание усиления роли сети. Крупные объединенные части отправляются по сети только в случае значительной задержки репликации.

Кроме того, каждая реплика сохраняет свое состояние в ZooKeeper в виде набора частей и его контрольных сумм. Когда состояние в локальной файловой системе расходится с эталонным состоянием в ZooKeeper, реплика восстанавливает свою согласованность путем загрузки отсутствующих и поврежденных частей из других реплик. Когда в локальной файловой системе есть неожиданные или испорченные данные, ClickHouse не удаляет их, а перемещает в отдельный каталог и забывает об этом.

Примечание

Кластер ClickHouse состоит из независимых сегментов (shards), а каждый сегмент состоит из реплик. Кластер не является эластичным (not elastic), поэтому после добавления нового сегмента данные не будут автоматически распределены между ними. Вместо этого нужно изменить настройки, чтобы выровнять нагрузку на кластер. Эта реализация дает вам больший контроль, и вполне приемлема для относительно небольших кластеров, таких как десятки узлов. Но для кластеров с сотнями узлов, которые мы используем в эксплуатации, такой подход становится существенным недостатком. Движки таблиц, которые охватывают весь кластер с динамически реплицируемыми областями, которые могут быть автоматически разделены и сбалансированы между кластерами, еще предстоит реализовать.

{## Original article ##}