Зачем: горячее ревью поймало ошибку в трёх местах сразу. Было записано, что после правки каталога ноды расходятся до тридцати секунд — число взято из ширины окна LIFETIME(MIN 60 MAX 90). Ширина тут ни при чём: каждая нода ждёт правки от нуля до верхней границы окна, фазы у них независимы, поэтому расходиться они могут почти на все полторы минуты. Что: исправлены ADR 0012, опорная точка мастер-спеки и комментарий DDL. Заодно уточнено, что LIFETIME(0) неатомарность не предотвращал, а сжимал до разброса исполнения SYSTEM RELOAD DICTIONARY ON CLUSTER, и что витрина выручки пока спроектирована, а не построена. Проверка: рассуждением, замером не подтверждалось — прежнее число тоже было выведено, а не измерено. Замер в ADR (116 секунд на окне MIN 60 MAX 120) относится к другому утверждению: что словарь обновляется сам. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
19 KiB
Markdown
203 lines
19 KiB
Markdown
# 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`. Исключение для словарей остаётся и
|
||
читается яснее прежнего: голое имя означает словарь.
|
||
- **Обновление словаря на кластере не атомарно, и это заявленное свойство.**
|
||
Случайный момент внутри окна разводит опросы разных серверов, чтобы они не
|
||
ходили к источнику разом. Побочный эффект — ноды перезагружают словарь в
|
||
разное время. Ждать правки каталога каждой из них приходится от нуля до
|
||
верхней границы окна, а фазы у них независимы, поэтому расходиться они могут
|
||
почти на все полторы минуты: одна перезагрузилась сразу после правки, вторая
|
||
ещё нет. Прежнее решение `LIFETIME(0)` окно не закрывало, а сжимало до
|
||
разброса исполнения `SYSTEM RELOAD DICTIONARY ON CLUSTER`; теперь оно
|
||
раскрыто намеренно. Урок записан опорной точкой в мастер-спеке, тащить его в курс —
|
||
отдельное решение.
|
||
- **Номенклатура товаров — медленно изменяющийся справочник**, и в предметной
|
||
области это так. Стенд упрощает: файл в репозитории под версией git.
|
||
Историзации в хранилище нет, и обещать её этот документ не должен.
|
||
- Витрина выручки спроектирована представлением, поэтому категория будет
|
||
подставляться в момент запроса, и правка каталога изменит отчёт задним
|
||
числом. Материализуй мы категорию при приёме — не изменила бы. Самой витрины
|
||
ещё нет, она приходит этапом 4.
|
||
|
||
## Что проверено
|
||
|
||
Замерено на живом стенде 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 …)` перезагружает словарь в случайный момент
|
||
внутри окна, чтобы разнести обращения разных серверов к источнику.
|
||
- Если хост источника локальный, запрос идёт без сети.
|