Files
clickstream-data-platform/docs/adr/0012-dictionary-home.md
T
ddadminandClaude Opus 5 abc94ba406 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>
2026-08-20 11:37:26 +03:00

19 KiB
Raw Blame History

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 …) перезагружает словарь в случайный момент внутри окна, чтобы разнести обращения разных серверов к источнику.
  • Если хост источника локальный, запрос идёт без сети.