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 шард читал бы только
свою часть, а две распределённые таблицы в одном запросе сервер не принимает.