Зачем: место словаря в 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>
19 KiB
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, дать словарю суффикс вида) — чинит
читаемость имени, но не трогает ни владение, ни зону. При базе
dicизбыточно: адрес говорит это раньше имени. - Не выражать принадлежность в хранилище вовсе — отказ от предмета решения.
Вдобавок
SHOW DICTIONARIESбез указания базы возвращает пусто, так что поиск объекта переложился бы наsystem.dictionaries. - Отказаться от объекта-словаря, читать CSV табличной функцией — убирает разговор о политике обновления, ради которого раздел 3 мастер-спеки словарь и держит.
- Отложить решение до второго справочника — выглядит осторожным, но им не является. Регионы стоят в спеке опорной точкой для будущих лекций, и CSV под них не существует. DM же придёт этапом 4 и впишет старый адрес в витрины. Повод пересмотреть не наступает, а цена только растёт.
Условия пересмотра
- Справочник перестаёт быть файлом репозитория и начинает приезжать извне — возвращается вопрос устройства: историзованная таблица, «последняя загрузка», словарь поверх. Адрес это переживает.
- Усложнение перестаёт окупаться: если менти проходит мимо подложки не заметив её, режется обратно до файлового источника у словаря.
Следствия
- ADR 0007 правится тем же коммитом: у зоны свой блок прав и свой пользователь.
- ADR 0006 получает шестой суффикс вида —
_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 …)перезагружает словарь в случайный момент внутри окна, чтобы разнести обращения разных серверов к источнику. - Если хост источника локальный, запрос идёт без сети.