Files
clickstream-data-platform/docs/adr/0012-dictionary-home.md
T
ddadminandClaude Opus 5 5cc0ede75a fix(clickhouse): починена проверка словаря, выметены хвосты переноса
Зачем: ревью в три линии нашло настоящий дефект. Девятая проверка кластера
звала 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>
2026-08-20 12:10:03 +03:00

21 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 и впишет старый адрес в витрины. Повод пересмотреть не наступает, а цена только растёт.

Условия пересмотра

  • Дом. Появляется справочные данные, которые хранилище производит само — скажем, таблица соответствий, собранная из DDS. Посылка решения «в хранилище их никто не производит» на них не распространяется, и зону придётся либо сузить до пришедших извне, либо переопределить. Дешевле всего это до этапа 4: как только витрины начнут звать словарь по имени, каждый разворот станет правкой по всем ссылкам.
  • Справочник перестаёт быть файлом репозитория и начинает приезжать извне — возвращается вопрос устройства: историзованная таблица, «последняя загрузка», словарь поверх. Адрес это переживает.
  • Усложнение перестаёт окупаться: если менти проходит мимо подложки не заметив её, режется обратно до файлового источника у словаря.

Следствия

  • ADR 0007 правится тем же коммитом: у зоны свой блок прав и свой пользователь.
  • ADR 0006 получает шестой суффикс вида — _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: «прогнал по репозиторию» — такое же утверждение, как утверждение о поведении системы, и проверять его надо так же.
  • Движок 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 …) перезагружает словарь в случайный момент внутри окна, чтобы разнести обращения разных серверов к источнику.
  • Если хост источника локальный, запрос идёт без сети.