Перейти к содержанию

ClickHouse: объекты схемы

Представления, материализованные представления, проекции и словари объявляются в модели, создаются и меняются миграциями и сравниваются hare drift — как в PostgreSQL. См. Представления, функции, последовательности и доступ. Подключение к кластеру выполняет каждую команду схемы на всех его серверах.

Представления

Meta.views с View(name, query) — представление ClickHouse: таблица движка View, которая переименовывается как таблица и не удаляется вместе с таблицей, которую читает. hare drift сравнивает запрос представления в том виде, как его разбирает сервер, поэтому тот же запрос другими словами — не расхождение.

Материализованные представления

Материализованное представление ClickHouse следует за строками, которые вставляются в таблицу его запроса: каждый вставленный блок проходит через запрос и попадает в представление. ClickhouseMaterializedView задаёт, куда идут его строки:

from hare.ddl import RawSQLTerm
from hare.dialects.clickhouse.schema_objects.clickhouse_materialized_view import ClickhouseMaterializedView


class Visit(Model):
    site = fields.CharField(max_length=50)

    class Meta:
        materialized_views = [
            # Собственное хранилище, суммируемое по site при слиянии частей.
            ClickhouseMaterializedView(
                "visits_by_site",
                RawSQLTerm("SELECT site, count() AS visits FROM visit GROUP BY site"),
                engine="SummingMergeTree",
                order_by=("site",),
            ),
            # Строки пишутся в другую таблицу.
            ClickhouseMaterializedView(
                "visits_to_archive", RawSQLTerm("SELECT * FROM visit"), to="visit_archive"
            ),
            # Весь запрос заново каждый час.
            ClickhouseMaterializedView(
                "top_sites",
                RawSQLTerm("SELECT site, count() AS visits FROM visit GROUP BY site ORDER BY visits DESC LIMIT 10"),
                refresh="EVERY 1 HOUR",
            ),
        ]
Аргумент Что задаёт
to Таблица, в которую представление пишет строки, вместо собственного хранилища.
engine, order_by, partition_by Собственное хранилище представления — по умолчанию MergeTree, отсортированная по unique_columns. Ключ — колонка представления, выражение hare над полями модели, объявляющей представление, которые представление выбирает под их же именами (Trunc("visited_at", "month")), или RawSQLTerm.
refresh Расписание, по которому сервер выполняет весь запрос, — "EVERY 1 HOUR", "AFTER 30 MINUTE", с OFFSET и RANDOMIZE FOR. Представление тогда хранит строки последнего обновления.
append С refresh: обновление добавляет строки, а не заменяет их.
depends_on С refresh вида EVERY: обновляемые представления, после которых обновляется это. С AFTER даёт ConfigurationError — ClickHouse принимает зависимости только у расписания EVERY.
with_data Заполнить представление строками его запроса при создании. По умолчанию True.

Обычное MaterializedView — представление с собственной MergeTree, отсортированной по его unique_columns. Собственное сортированное хранилище представления создаётся с allow_nullable_key = 1: его колонки — колонки запроса, и в колонке ключа может быть NULL.

  • Заполнение. Представление с with_data заполняется командой INSERT ... SELECT его запроса сразу после создания, а не через POPULATE, который теряет строки, вставленные во время его работы. На кластере заполнение выполняется на сервере, к которому подключено соединение, над его строками: представление, чей запрос читает нераспределённую таблицу — или неизвестные hare таблицы, готовый SQL без read_columns, — бросает UnSupportedError до создания чего-либо. Читайте распределённые таблицы (названные в read_columns) или объявите представление с with_data=False и заполните его на каждом сервере сами.
  • refresh_materialized_view() сразу обновляет представление с refresh и ждёт его — строки заменяются разом. Любое другое представление очищается и заполняется заново — в промежутке его строк нет, поэтому concurrently=True для него даёт UnSupportedError.
  • Изменения. Миграция меняет на месте запрос представления, которое пишет to в таблицу (MODIFY QUERY), и расписание обновляемого (MODIFY REFRESH); любое другое изменение удаляет представление и создаёт заново — собственное хранилище заполняется новым запросом, а таблица, в которую оно пишет, сохраняет свои строки. Откат удаления представления, которое пишет to в таблицу, создаёт его заново поверх строк, оставленных там удалением, без повторного заполнения; таблицу строками своего запроса заполняет только добавление представления.
  • Пересоздание таблицы миграцией захватывает её представления: они удаляются перед копированием строк и создаются заново после — иначе представление приняло бы скопированные строки второй раз.

hare drift сравнивает весь оператор материализованного представления так, как его записывает сервер, — запрос, целевую таблицу, движок, сортировку, партиционирование и расписание.

Проекции

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

from hare.dialects.clickhouse.schema_objects import ClickhouseProjection
from hare.query.functions import Count
from hare.query.queryset import ModelQuery

class Meta:
    table_options = [
        ClickhouseTableOptions(
            projections=(ClickhouseProjection("by_site", ModelQuery().values("site").annotate(visits=Count("id"))),)
        )
    ]

Запрос — набор запроса модели таблицы (ModelQuery() в Meta самой модели), который пишется как SELECT без FROM на SQL ClickHouse; он выбирает, группирует и сортирует, а фильтр, соединение или ограничение числа строк дают ConfigurationError (проекция хранит все строки: берите набор запроса без фильтра модели по умолчанию, в том числе мягкого удаления). Его сортировка — ключ сортировки: только по возрастанию, пишется без направления, а сортировка по -field даёт ConfigurationError. hare знает колонки, которые он читает: миграция, меняющая одну из них, удаляет проекцию до изменения и добавляет после, а переименование такой колонки пересоздаёт таблицу. Или RawSQLTerm с SELECT без FROM, с GROUP BY или ORDER BY, который пишется как есть, — его колонки — те, что вы назовёте в read_columns=(...). Файл миграции хранит проекцию набора запроса как её SQL с её read_columns. Миграция добавляет проекцию через ADD PROJECTION и строит её по хранимым строкам через MATERIALIZE PROJECTION, которая заканчивается до продолжения миграции; удаляет — через DROP PROJECTION.

Таблица с проекциями получает настройки, которые перестраивают их при удалении строк и при слиянии, — тогда лёгкий DELETE и слияние ReplacingMergeTree сохраняют проекции верными.

Словари

Словарь держит строки таблицы в памяти сервера; значение из них читается по ключу через DictGet вместо JOIN. ClickhouseDictionary — hare.ddl.Dictionary для ClickHouse: от этого базового класса происходит словарь любого диалекта.

from hare.dialects.clickhouse.functions import DictGet
from hare.dialects.clickhouse.schema_objects import ClickhouseDictionary


class Country(Model):
    code = fields.CharField(max_length=2, primary_key=True)
    name = fields.CharField(max_length=50)

    class Meta:
        dictionaries = [ClickhouseDictionary("country_names", key=("code",), attributes=("name",), lifetime=300)]


await Visit.objects.annotate(
    country=DictGet("country_names", "name", "country_code", output_field=fields.CharField(max_length=50))
)
await Country.objects.reload_dictionary("country_names")
Аргумент Что задаёт
key Поля, по которым ищется строка.
attributes Поля, которые даёт поиск.
layout Как сервер хранит строки, с аргументами, — "COMPLEX_KEY_HASHED()" (по умолчанию) принимает ключ из любых полей — см. layout
lifetime Через сколько секунд словарь загружается заново — число или (least, greatest), между которыми сервер выбирает момент; 0 (по умолчанию) — никогда. Раскладка DIRECT его не принимает: другое значение даёт ConfigurationError, а в её команде нет LIFETIME.
source RawSQLTerm другого источника вместо таблицы модели — в том виде, как его принимает SOURCE(...). hare drift сравнивает его в том виде, в каком его пишет сервер: запрос в двойных кавычках сервер хранит в обратных, текст внутри — как есть.

layout

Как сервер хранит строки, с аргументами, — "COMPLEX_KEY_HASHED()" (по умолчанию) принимает ключ из любых полей; "HASHED()", "FLAT()" — ключ из одного беззнакового 64-битного целого; любой другой ключ сервер хранит в соответствующем COMPLEX_KEY_-виде ("HASHED()" — как COMPLEX_KEY_HASHED()), и hare drift считает его объявленным. "DIRECT()"/"COMPLEX_KEY_DIRECT()" строк не хранят: каждое чтение идёт в источник.

Словарь загружается из таблицы модели под пользователем и паролем подключения, поэтому строка, записанная в таблицу, читается через словарь после его следующей загрузки — reload_dictionary() загружает его сразу. DictGet(..., default=...) даёт значение для ключа, которого в словаре нет (dictGetOrDefault); без него сервер даёт значение типа атрибута по умолчанию. Ключ — имя поля, выражение или литерал (текст в Value(...): голая строка называет поле); ключ из нескольких полей ищется списком или кортежем из них. Словарь, объявленный моделью, проверяется при построении запроса: атрибут — имя поля, которое он даёт (или колонка этого поля — name читает колонку title у CharField(source_field="title")), ключей столько же, сколько полей ключа, а литерал ключа проверяется как значение своего поля; иначе — QueryError/ValidationError. Словарь, объявленный не в hare, читается по имени как есть.

Пароль никогда не попадает в файл миграции: пароль подключения подставляется в команду при создании словаря ([HIDDEN] в hare sqlmigrate), а {env:NAME} в source тогда же заменяется переменной окружения NAME. Операции миграции — AddDictionary, AlterDictionary, RemoveDictionary и RenameDictionary, их пишет makemigrations. Словари модели удаляются вместе с её таблицей, а когда миграция пересоздаёт или переименовывает таблицу, они удаляются и создаются заново: сервер не удаляет и не переименовывает таблицу, которую читает словарь.

Кластер

Подключение с cluster=<name> входит в кластер серверов:

  • Каждая команда схемы — CREATE, ALTER, DROP, TRUNCATE таблиц, представлений и словарей — выполняется ON CLUSTER <name>, на каждом сервере. База движка Replicated реплицирует свою схему сама: подключение замечает это при открытии и не пишет ON CLUSTER. SQL, который пишете вы, — execute_script(), execute(), RunSQL — отправляется как написан: кластер в нём называйте сами.
  • Журнал применённых миграций — ReplicatedMergeTree, поэтому все серверы видят одни и те же миграции.
  • Таблица, которую миграция пересоздаёт на кластере, должна быть реплицируемой или распределённой: таблица, которая хранит на каждом сервере свои строки, отклоняется — скопировались бы строки только одного сервера. Так же отклоняется реплицируемая таблица на кластере из нескольких шардов, путь реплики которой называет шард ({shard}, как путь сервера по умолчанию): у каждого шарда свои строки — такую таблицу распределяют по шардам.
  • Пересоздаваемой реплицируемой таблице — на кластере или на одном сервере — нужен свой путь реплики: новая таблица создаётся рядом со старой, поэтому движок с путём без {uuid} (постоянным или по {table}, который хранит имя, данное таблице при создании) отклоняется до каких-либо изменений. Объявляйте движок без аргументов (default_replica_path сервера) или с путём, где есть {uuid}.

ClickhouseTableOptions(distributed_over=...) распределяет строки модели по серверам:

class Hit(Model):
    class Meta:
        table_options = [
            ClickhouseTableOptions(
                engine="ReplicatedMergeTree",
                distributed_over="hit_local",
                sharding_key=RawSQLTerm("cityHash64(id)"),
            )
        ]

Миграция создаёт на каждом сервере локальную таблицу hit_local с движком и опциями, а таблицу модели hit — как Distributed поверх неё; distributed_over, называющий саму таблицу модели, бросает ConfigurationError. Строки читаются и пишутся через hit, каждая — в шард, который выбирает её sharding_key (без него — в любой). Мутация, лёгкий DELETE и изменение хранения идут в hit_local на каждом сервере, изменение колонок — в обе таблицы. update() и delete() каждый сервер выполняет над своими строками и читает там свои строки каждой таблицы, которую называют его условие или значения, а GLOBAL для мутации не бывает. Поэтому мутация, читающая нераспределённую таблицу (связь, подзапрос, Exists(); реплицируемую тоже: путь реплики по {shard} хранит строки по шардам), бросает UnSupportedError, а не оставляет строки других серверов без изменений. Сделайте эту таблицу распределённой или сначала прочитайте ключи и меняйте строки по pk__in. delete() с каскадом Model.delete() (на модель ссылаются другие) сам сначала читает ключи своих строк. Пересоздаваемая таблица копируется через распределённую таблицу поверх новых локальных, каждая строка — в свой шард. mutations_sync=2 ждёт все реплики. Локальная таблица ReplicatedMergeTree держит реплики по макросам сервера {shard} и {replica} — у серверов каждого шарда должен быть свой {shard}: серверы двух шардов с общим {shard} — реплики одной таблицы, каждая хранит все строки и снова применяет изменение колонок, отправленное ON CLUSTER (DUPLICATE_COLUMN). На кластере, где у шарда несколько реплик, локальной таблице нужен движок Replicated: таблица другого движка хранит строку на той реплике, куда её записали, а чтение может спросить другую, поэтому её создание даёт UnSupportedError — и в миграции, и в generate_schemas(), до создания любой таблицы.

hare drift сравнивает с тенью обе таблицы распределённой модели — её локальную таблицу hit_local, которую не сообщает как неотслеживаемую, и таблицу Distributed hit; колонка, которую две таблицы хранят по-разному (одно только ALTER TABLE hit_local MODIFY COLUMN ...), возвращается как AlterField. Реплицируемый движок, объявленный без пути и имени реплики (engine="ReplicatedMergeTree"), — тот же, что сервер дал таблице по своим default_replica_path и default_replica_name; объявленный с путём, где есть {database} или {table}, которые сервер заменяет именами при создании таблицы, — тоже тот же.

Запрос, читающий hit, выполняется на каждом шарде, каждый — над своими строками. Поэтому каждую таблицу, которую он соединяет (связь, связь «многие ко многим», annotate(Count(...))), и каждый подзапрос его __in, exclude() и Exists() читают один раз, на сервере, куда отправлен запрос, и отправляют шардам: hare пишет там GLOBAL JOIN и GLOBAL IN. Видны и таблица со своими строками на каждом сервере, и другая распределённая модель, и строки другого шарда; без GLOBAL шард читал бы только свою часть, а две распределённые таблицы в одном запросе сервер не принимает.