feat(clickhouse): справочники вынесены в зону dic, словарь читает подложку
Зачем: место словаря в dds было унаследовано от первого объекта, а не решено. Справочник читают несколько слоёв, а производит его в хранилище ни один — прописка внутри одного из потребителей приписывала DDS владение, которого у него нет. Что: заведена база dic вне цепочки STG → ODS → DDS → DM, dds.products переехал в dic.products. Под словарём появилась подложка dic.products_file на движке File — намеренное усложнение ради урока: в бою источник словаря приезжает процессом, а не лежит файлом у сервера. Источник объявлен формой query и приводит цену из целых копеек каталога в Decimal(18, 2), как у денег бэкенда. Обновление — окном LIFETIME(MIN 60 MAX 90) вместо ручной перезагрузки, ценой заявленной неатомарности между нодами. У словаря свой беспарольный пользователь dict с единственным правом на чтение dic. Суффикс _file добавлен в конвенцию имён ADR 0006. Решение, отвергнутые варианты и условия пересмотра — ADR 0012. Проверка: make clean && make up на собранном заново стенде — зелено, make smoke 20 проверок и 0 ошибок, make lint чисто. Словарь LOADED со 180 строками, цена Decimal(18, 2) и точна на копейках: 188990 → 1889.90. Пользователь bi читает словарь dictGet-ом со второй ноды, analyst — подложку соединением, база dds пуста. Минимальное право на движок замерено тремя пользователями: достаточно GRANT FILE ON *.*, хотя отказ называет TABLE ENGINE ON File. Closes #105 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,13 @@
|
||||
|
||||
Имя объекта в ClickHouse заканчивается тем, что это за объект: `_rep` —
|
||||
локальная таблица шарда, `_dist` — `Distributed` поверх неё, `_kafka` — чтец
|
||||
топика, `_mv` — материализованное представление, `_v` — обычное представление.
|
||||
топика, `_file` — чтец файла, `_mv` — материализованное представление, `_v` —
|
||||
обычное представление.
|
||||
|
||||
Суффикс `_file` добавлен 20 августа 2026 года вместе с подложкой под словарём
|
||||
товаров ([ADR 0012](0012-dictionary-home.md)). Он ровно параллелен `_kafka`:
|
||||
чтец внешнего источника, по копии на каждой ноде, без репликации и без
|
||||
распределённой пары.
|
||||
|
||||
Суффикс носит каждый физический объект, поэтому голого имени у таблицы не
|
||||
существует: запрос к `stg.hits_raw` даёт ошибку «нет такой таблицы».
|
||||
|
||||
@@ -4,19 +4,23 @@
|
||||
|
||||
## Решение
|
||||
|
||||
У кластера четыре пользователя и три роли.
|
||||
У кластера пять пользователей и четыре роли.
|
||||
|
||||
- `default` — с паролем, только служебный: проверки здоровья нод и работа
|
||||
изнутри контейнеров. Приложения им не ходят.
|
||||
- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходит
|
||||
Airflow и применяется DDL при подъёме стенда.
|
||||
- `bi` с ролью `bi_reader` — чтение витрин DM и слоя DDS. Им ходит Superset.
|
||||
- `analyst` с ролью `analyst_reader` — чтение всех слоёв. Им человек
|
||||
подключается снаружи, из своего клиента.
|
||||
- `bi` с ролью `bi_reader` — чтение витрин DM, слоя DDS и справочников `dic`.
|
||||
Им ходит Superset.
|
||||
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
|
||||
человек подключается снаружи, из своего клиента.
|
||||
- `dict` с ролью `dict_reader` — чтение одной базы `dic`, пароля нет. Им
|
||||
словарь читает свою подложку, и больше он никем не используется
|
||||
([ADR 0012](0012-dictionary-home.md)).
|
||||
|
||||
Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения
|
||||
на запросы `ON CLUSTER` и на чтение системных таблиц, а без второго клиент
|
||||
не открывает соединение вовсе. Состав прав каждой роли — работа тикета
|
||||
на запросы `ON CLUSTER`, на чтение системных таблиц и на каждый движок
|
||||
внешнего источника, а без второго клиент не открывает соединение вовсе. Состав прав каждой роли — работа тикета
|
||||
реализации.
|
||||
|
||||
Пользователи, роли и права объявлены файлами настройки сервера, а не
|
||||
@@ -46,7 +50,16 @@
|
||||
описания. Поэтому пользователей мало, имена у них говорящие, а границы
|
||||
проходят там, где их обычно проводят в компаниях: `bi` видит витрины и слой
|
||||
DDS под ними, потому что так чаще всего и бывает; заодно расхождение витрины
|
||||
со слоем ниже остаётся проверяемым одним и тем же пользователем.
|
||||
со слоем ниже остаётся проверяемым одним и тем же пользователем. Справочники
|
||||
видны всем читателям и никому на запись: зона входит в хранилище сбоку, и её
|
||||
содержимым владеет файл репозитория, а не слой.
|
||||
|
||||
**Беспарольный `dict` — намеренное исключение.** Пароля у него нет не по
|
||||
недосмотру: ходить этой учёткой некуда, кроме локального чтения одной базы, а
|
||||
пароль пришлось бы вписать в текст DDL словаря — то есть положить секрет в git.
|
||||
Право у него одно, `SELECT` на `dic`. Отдельная учётка под словарь взята из
|
||||
промышленной практики, где источник словаря читают минимальной служебной
|
||||
учётной записью, а не общим админом.
|
||||
|
||||
**Секрет, а не учётные данные по репликам.** У каждой реплики в описании
|
||||
кластера своя учётка — вписанная явно или подразумеваемая, и тогда это
|
||||
@@ -92,10 +105,11 @@ Postgres и Grafana. Цена выбора известна: объявленн
|
||||
|
||||
## Следствия
|
||||
|
||||
До появления слоёв DDS и DM читать `bi` нечего, и это намеренно. Дашборды
|
||||
рисуются поверх модели данных, а не поверх типизированных событий, поэтому
|
||||
доступ Superset к ODS не планировался и правами не выдаётся. Пока витрин нет,
|
||||
подключение Superset проверяется связью, а не запросом к таблице.
|
||||
До появления слоёв DDS и DM читать `bi` почти нечего, и это намеренно.
|
||||
Дашборды рисуются поверх модели данных, а не поверх типизированных событий,
|
||||
поэтому доступ Superset к ODS не планировался и правами не выдаётся.
|
||||
Единственное, что ему доступно сегодня, — справочники: с ними зона `dic`
|
||||
пришла раньше своих потребителей.
|
||||
|
||||
Владелец матвью приёма определяется тем, кто применил DDL. Весь DDL стенда
|
||||
идёт через `IF NOT EXISTS`, поэтому на существующих томах три матвью
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# ADR 0012. Справочники: зона вне цепочки слоёв и подложка под словарём
|
||||
|
||||
Дата: 20 августа 2026 года. Статус: принято.
|
||||
|
||||
## Решение
|
||||
|
||||
**Дом.** Справочные данные живут в базе `dic`, вне цепочки STG → ODS → DDS → DM.
|
||||
Каталог товаров переезжает из `dds.products` в `dic.products`. Правило одно и
|
||||
оно не про слой: справочник — самостоятельная зона; читать её вправе любой
|
||||
слой, писать — никто, кроме владельца источника. Будущий словарь регионов
|
||||
попадает туда же, независимо от того, какой слой его читает.
|
||||
|
||||
**Устройство.** Словарь читает не файл, а таблицу хранилища. Под `dic.products`
|
||||
лежит подложка `dic.products_file` на движке `File` — она ничего не хранит и
|
||||
перечитывает CSV на каждом запросе. Словарь берёт её источником `CLICKHOUSE` от
|
||||
собственного пользователя с одним правом на чтение и обновляется сам, окном
|
||||
`LIFETIME(MIN 60 MAX 90)`.
|
||||
|
||||
Источник объявлен формой `query`, а не `table`: словарь приводит цену каталога
|
||||
из целых копеек к `Decimal(18, 2)` — то есть нормализует на входе, а не
|
||||
зеркалит подложку.
|
||||
|
||||
Форма чтения принадлежит потребителю, а не месту хранения: подстановка атрибута
|
||||
по ключу идёт через `dictGet`, а соединение и фильтрация по атрибуту — по
|
||||
подложке напрямую.
|
||||
|
||||
## Почему зона, а не прописка в слое
|
||||
|
||||
**Справочник читают несколько слоёв, а производит его ни один.** Каталог нужен
|
||||
модели заказов в DDS и витрине выручки в DM; будущие регионы — представлению
|
||||
событий в DDS; `analyst` читает его напрямую. Писателя нет вовсе: содержимое
|
||||
приходит из файла репозитория. Прописка внутри `dds` приписывала слою модели
|
||||
владение, которого у него нет: DDS этот объект не производит, не обновляет и не
|
||||
проверяет.
|
||||
|
||||
**На направление данных ссылаться нельзя, и это важно не спутать.** Чтение из
|
||||
DM объекта, лежащего в DDS, — течение вниз, ровно то, которое разрешено.
|
||||
Промах был во владении, а не в направлении. Но зона вне цепочки закрывает и
|
||||
будущий случай: как только справочник понадобится выше по течению — например,
|
||||
проверка `sku` позиции при разборе заказа в ODS, — чтение `dds.*` из `ods.*`
|
||||
стало бы настоящей инверсией. У зоны вне цепочки направления относительно слоёв
|
||||
нет, и такой запрос законен по построению.
|
||||
|
||||
**Отраслевой образец говорит то же самое другим средством.** В хранилищах, где
|
||||
база одна, а слои выражены префиксами имён, справочники несут собственный
|
||||
префикс наравне с префиксами стадий — то есть образуют свой слой, а не
|
||||
приписаны к слою модели. Причём префикс накрывает всю цепочку справочника: и
|
||||
таблицу, и представление над ней, и сам объект словаря. Средство разное,
|
||||
утверждение одно. У нас слои выражены базами — значит справочникам полагается
|
||||
база.
|
||||
|
||||
**Имя `dic`, а не `ref`.** `ref` точнее называет содержимое, `dic` —
|
||||
узнаваемее: это то слово, которое менти встретит в промышленном хранилище. Три
|
||||
буквы становятся в ряд к `stg`, `ods`, `dds`, `dm`. Опасение, что `dic` называет
|
||||
механизм и содержимое его перерастёт, снято проверкой: словарь и так читается
|
||||
как таблица, а зона держит и обычные таблицы тоже.
|
||||
|
||||
## Почему подложка, а не файл прямо в словаре
|
||||
|
||||
Это намеренное усложнение, и платит за него учебная ценность. Урок называется
|
||||
одной фразой: **словарь читает таблицу хранилища, а не файл рядом с сервером, —
|
||||
потому что в бою справочник приезжает процессом.** Файловый источник у словаря
|
||||
работает и на стенде работал, но в бою он редкость: там источник приезжает ETL
|
||||
или словарь смотрит прямо в чужую базу. Стенд учит форме, которую менти
|
||||
встретит.
|
||||
|
||||
Подложка добавляет к уроку три вещи, которых у файлового источника нет:
|
||||
предложение `SOURCE` с запросом и учётной записью; обновление окном вместо
|
||||
ручной команды; и справочник, доступный соединению — а не только `dictGet`.
|
||||
|
||||
**Цена приводится к `Decimal(18, 2)` в самом источнике.** В файле лежат целые
|
||||
копейки, и `129000` о своей единице не говорит ничего — а каталог единственное
|
||||
место на стенде, где число правит рукой человек. Довод не в единообразии:
|
||||
стенд намеренно держит три представления денег, и расхождение между ними
|
||||
заявлено уроком. Довод в том, что урок про округление живёт ровно на стыке
|
||||
каталога и события, где `productPrice` — уже целые рубли. С `Decimal` менти
|
||||
сравнивает `1290.00` против `1290` и видит потерю; с `Int64` он сначала делит
|
||||
на сто в уме, в тот самый момент, когда должен смотреть на округление.
|
||||
Приведение вынуждает форму `query` у источника — подложка сырая и
|
||||
конвертировать не умеет, а `Decimal` прямо на ней прочитал бы `129000.00`.
|
||||
|
||||
**Историзацию и представление «последняя загрузка» из образца не берём.** В
|
||||
промышленном примере таблица копит загрузки с меткой времени, а представление
|
||||
берёт последнюю: источник приезжал извне, и версий у него не было нигде. У нас
|
||||
версии есть — **история каталога это git**. Дублировать её в ClickHouse значит
|
||||
поставить конструкцию, у которой на стенде нет причины, а расшифровывать её
|
||||
менти всё равно придётся.
|
||||
|
||||
**Материализованной таблицы с шагом наполнения тоже не берём.** Шаг наполнения
|
||||
— вторая половина промышленного устройства, и она стоит движущейся части:
|
||||
кто-то обязан его запускать. Расписание для файла в git врало бы: каталог не
|
||||
меняется сам. Подложка на файловом движке даёт `SOURCE` без этой цены.
|
||||
|
||||
## Отвергнутые варианты размещения
|
||||
|
||||
- **Оставить в `dds`** — разделяемый ресурс внутри одного из потребителей; учит
|
||||
тому, что в бою устроено иначе. Совпадение с будущими регионами держится, но
|
||||
по другой причине, чем у товаров, — значит это не правило.
|
||||
- **Рядом с главным потребителем** — потребителей несколько и они в разных
|
||||
слоях: товары читает витрина, регионы — представление DDS. Правило требует
|
||||
выбрать главного там, где главного нет.
|
||||
- **Рядом с владельцем содержимого или предметной областью** — владелец у обоих
|
||||
справочников один и тот же, репозиторий стенда; правило ничего не различает.
|
||||
Предметные базы вдобавок разошлись бы со слоёвыми.
|
||||
- **Без единого правила, по происхождению каждого справочника** — оставляет
|
||||
менти список случаев вместо урока.
|
||||
- **Выразить принадлежность именем объекта, а не базой** (снять исключение
|
||||
[ADR 0006](0006-object-naming.md), дать словарю суффикс вида) — чинит
|
||||
читаемость имени, но не трогает ни владение, ни зону. При базе `dic`
|
||||
избыточно: адрес говорит это раньше имени.
|
||||
- **Не выражать принадлежность в хранилище вовсе** — отказ от предмета решения.
|
||||
Вдобавок `SHOW DICTIONARIES` без указания базы возвращает пусто, так что поиск
|
||||
объекта переложился бы на `system.dictionaries`.
|
||||
- **Отказаться от объекта-словаря, читать CSV табличной функцией** — убирает
|
||||
разговор о политике обновления, ради которого раздел 3 мастер-спеки словарь и
|
||||
держит.
|
||||
- **Отложить решение до второго справочника** — выглядит осторожным, но им не
|
||||
является. Регионы стоят в спеке опорной точкой для будущих лекций, и CSV под
|
||||
них не существует. DM же придёт этапом 4 и впишет старый адрес в витрины.
|
||||
Повод пересмотреть не наступает, а цена только растёт.
|
||||
|
||||
## Условия пересмотра
|
||||
|
||||
- Справочник перестаёт быть файлом репозитория и начинает приезжать извне —
|
||||
возвращается вопрос устройства: историзованная таблица, «последняя загрузка»,
|
||||
словарь поверх. Адрес это переживает.
|
||||
- Усложнение перестаёт окупаться: если менти проходит мимо подложки не заметив
|
||||
её, режется обратно до файлового источника у словаря.
|
||||
|
||||
## Следствия
|
||||
|
||||
- [ADR 0007](0007-clickhouse-access.md) правится тем же коммитом: у зоны свой
|
||||
блок прав и свой пользователь.
|
||||
- [ADR 0006](0006-object-naming.md) получает шестой суффикс вида — `_file`,
|
||||
чтец файла, ровно параллельный `_kafka`. Исключение для словарей остаётся и
|
||||
читается яснее прежнего: голое имя означает словарь.
|
||||
- **Обновление словаря на кластере не атомарно, и это заявленное свойство.**
|
||||
Случайный момент внутри окна разводит опросы разных серверов, чтобы они не
|
||||
ходили к источнику разом. Побочный эффект — ноды перезагружают словарь в
|
||||
разное время, и на нашей паре расхождение доходит до 30 секунд: правка
|
||||
каталога полминуты видна одной ноде и не видна другой. Прежнее решение
|
||||
`LIFETIME(0)` эту неатомарность предотвращало; теперь она предъявлена
|
||||
намеренно. Урок записан опорной точкой в мастер-спеке, тащить его в курс —
|
||||
отдельное решение.
|
||||
- **Номенклатура товаров — медленно изменяющийся справочник**, и в предметной
|
||||
области это так. Стенд упрощает: файл в репозитории под версией git.
|
||||
Историзации в хранилище нет, и обещать её этот документ не должен.
|
||||
- Витрина выручки — представление, поэтому категория подставляется в момент
|
||||
запроса, и правка каталога меняет отчёт задним числом. Материализуй мы
|
||||
категорию при приёме — не изменила бы.
|
||||
|
||||
## Что проверено
|
||||
|
||||
Замерено на живом стенде 20 августа 2026 года, ClickHouse 26.3.17.56.
|
||||
|
||||
- Движок `File` с явным путём внутрь `user_files` работает и раскатывается
|
||||
`ON CLUSTER`: обе ноды читают свой смонтированный файл, по 180 строк.
|
||||
- Относительный путь у этого движка считается **от `user_files`**, а не от
|
||||
корня данных: форма `./user_files/catalog/products.csv`, рабочая для
|
||||
файлового источника словаря, даёт `Code: 107 ... FILE_DOESNT_EXIST`.
|
||||
- Подложка ничего не хранит: строка, дописанная в файл снаружи, меняет
|
||||
`count()` без единой команды перезагрузки.
|
||||
- Словарь подхватывает правку сам. `element_count` изменился без
|
||||
`SYSTEM RELOAD DICTIONARY` через 116 секунд после предыдущей загрузки —
|
||||
внутри окна `MIN 60 MAX 120`, на котором шёл замер.
|
||||
- Без указания пользователя источник `CLICKHOUSE` ходит как `default` с пустым
|
||||
паролем и падает: `Code: 516 ... AUTHENTICATION_FAILED`. Пользователя надо
|
||||
называть явно.
|
||||
- Приведение цены: `toDecimal64(price, 2) / 100` даёт ровно `Decimal(18, 2)`,
|
||||
`129000 → 1290.00`, `128990 → 1289.90`. Вариант через `divideDecimal`
|
||||
возвращает `Decimal(76, 2)` — шире, чем нужно, и не взят.
|
||||
- Беспарольный пользователь, объявленный файлом настройки, принимается молча:
|
||||
словарь грузится, `dictGet` отвечает, в журнале после перечитывания конфига
|
||||
нет ни одного предупреждения про `no_password`.
|
||||
- `SHOW DICTIONARIES` без указания базы возвращает пусто; словарь в базе стоит
|
||||
среди таблиц слоя и отличается от них только колонкой движка в
|
||||
`system.tables`.
|
||||
- До переноса `dds.products` упоминался в репозитории четырежды, в дагах — ноль.
|
||||
- Движок `File` требует у `etl` права на источник. Отказ называет
|
||||
`TABLE ENGINE ON File`, но права с таким именем не хватает: замер тремя
|
||||
пользователями показал, что работает `GRANT FILE ON *.*`, а `TABLE ENGINE ON
|
||||
File` не нужен вовсе.
|
||||
- Стенд, собранный с нуля этим решением, поднимается зелёным: оба объекта зоны
|
||||
на месте, словарь `LOADED` со 180 строками, база `dds` пуста, `bi` читает
|
||||
словарь со второй ноды, `analyst` — подложку соединением. `make smoke` —
|
||||
20 проверок, 0 ошибок.
|
||||
|
||||
Сверено по документации ClickHouse через MCP Context7 20 августа 2026 года.
|
||||
|
||||
- Движок таблиц `Dictionary` существует затем, чтобы выставить словарь явной
|
||||
таблицей — когда нужен доступ к сырым данным или соединение.
|
||||
- Для небольших измерений документация рекомендует словарь вместо `JOIN`:
|
||||
соединение выполняется до фильтрации `WHERE` и между запросами не кэшируется.
|
||||
- Обратный случай назван там же: если фильтровать по подставленному значению на
|
||||
многих строках, лучше обычная колонка с индексом — `dictGet` считается
|
||||
построчно и индексом не поддержан. Отсюда обе формы чтения в решении.
|
||||
- Диапазон `LIFETIME(MIN … MAX …)` перезагружает словарь в случайный момент
|
||||
внутри окна, чтобы разнести обращения разных серверов к источнику.
|
||||
- Если хост источника локальный, запрос идёт без сети.
|
||||
Reference in New Issue
Block a user