Зачем: ревью в три линии нашло настоящий дефект. Девятая проверка кластера
звала dictGet('dds.products', …) — объекта после переноса не существует, и
make check-clickhouse падал. Промах вырос из недосчёта: упоминания dds.products
я искал с фильтром по расширениям, .sh туда не попал, и в ADR уехало «четыре
упоминания» вместо семи.
Что: проверка переведена на dic.products и сверяет цену, умножив её обратно на
сто, — так утверждается ещё и точность приведения к Decimal. Раздел 3
мастер-спеки и список пользователей README догнали перенос: оба описывали
отменённое устройство. В ADR 0012 добавлено условие пересмотра для дома — для
него его не было, хотя ради дома тикет и заводился; недосчёт записан в
«Что проверено» как урок. Разнобой обозначений сведён: карта таблиц в доке
хранилища перешла на имена баз строчными, таблица мастер-спеки — на заголовок
«Где», зону всюду зовут зоной, а не слоем. Термин «Справочник» переписан без
метафоры и уложен в формат глоссария. Вычтено лишнее: комментарии DDL
сократились вдвое, из ADR 0006 и 0007 убраны самооправдание и дублирующие
абзацы, отраслевой образец получил честную оговорку о непроверяемости.
Проверка: make config-test, make lint, make smoke и make check-clickhouse —
все зелёные, десять проверок кластера из десяти.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
21 KiB
Markdown
220 lines
21 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 и впишет старый адрес в витрины.
|
||
Повод пересмотреть не наступает, а цена только растёт.
|
||
|
||
## Условия пересмотра
|
||
|
||
- **Дом.** Появляется справочные данные, которые хранилище производит само —
|
||
скажем, таблица соответствий, собранная из DDS. Посылка решения «в хранилище
|
||
их никто не производит» на них не распространяется, и зону придётся либо
|
||
сузить до пришедших извне, либо переопределить. Дешевле всего это до этапа 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` стоял в семи строках трёх файлов: дока хранилища,
|
||
DDL словаря и проверка `scripts/check-clickhouse.sh`. Первый замер насчитал
|
||
четыре и проверку не увидел — искали с фильтром по расширениям, куда `.sh`
|
||
не попал. Из недосчёта вырос настоящий дефект: девятая проверка кластера
|
||
осталась звать несуществующий объект и упала. Поймало ревью, не автор.
|
||
Урок тот же, что в [ADR 0006](0006-object-naming.md): «прогнал по
|
||
репозиторию» — такое же утверждение, как утверждение о поведении системы, и
|
||
проверять его надо так же.
|
||
- Движок `File` требует у `etl` права на источник. Отказ называет
|
||
`TABLE ENGINE ON File`, но права с таким именем не хватает: замер тремя
|
||
пользователями показал, что работает `GRANT FILE ON *.*`, а `TABLE ENGINE ON
|
||
File` не нужен вовсе.
|
||
- Стенд, собранный с нуля этим решением, поднимается зелёным: оба объекта зоны
|
||
на месте, словарь `LOADED` со 180 строками, база `dds` пуста, `bi` читает
|
||
словарь со второй ноды, `analyst` — подложку соединением. Словарь спрашивает
|
||
`make check-clickhouse` — 10 проверок из 10; девятая берёт известный `sku` на
|
||
обеих нодах и сверяет цену с колонкой файла, умножив её обратно на сто, так
|
||
что приведение к `Decimal` проверено вместе с ответом. `make smoke` словарь
|
||
не трогает вовсе.
|
||
|
||
Сверено по документации ClickHouse через MCP Context7 20 августа 2026 года.
|
||
|
||
- Движок таблиц `Dictionary` существует затем, чтобы выставить словарь явной
|
||
таблицей — когда нужен доступ к сырым данным или соединение.
|
||
- Для небольших измерений документация рекомендует словарь вместо `JOIN`:
|
||
соединение выполняется до фильтрации `WHERE` и между запросами не кэшируется.
|
||
- Обратный случай назван там же: если фильтровать по подставленному значению на
|
||
многих строках, лучше обычная колонка с индексом — `dictGet` считается
|
||
построчно и индексом не поддержан. Отсюда обе формы чтения в решении.
|
||
- Диапазон `LIFETIME(MIN … MAX …)` перезагружает словарь в случайный момент
|
||
внутри окна, чтобы разнести обращения разных серверов к источнику.
|
||
- Если хост источника локальный, запрос идёт без сети.
|