Nullable(String) и первый настоящий сюрприз от ClickHouse
Когда в проекте МТО данные наконец начали приходить из экстрактора 1C в ClickHouse, я сначала был просто рад, что они вообще появились. Открываю таблицы и вижу: каждое поле имеет тип Nullable(String).
Тогда это не показалось мне чем-то важным. NULL я привык воспринимать просто как отсутствие значения: нет значения, значит нет. Что тут вообще может быть сложного, подумал я, и пошёл дальше писать запросы.
Сложность появилась не сразу, а когда таблицы разрослись до миллионов строк. ClickHouse устроен иначе, чем базы, с которыми я работал раньше: это колоночная аналитическая СУБД, и то, как ты определил столбец, напрямую влияет на то, сколько данных приходится хранить и читать при каждом запросе.
Оказалось, что за возможность хранить NULL приходится платить: помимо самих значений, база отдельно держит информацию о том, есть ли значение в конкретной строке или нет. На небольшой таблице я бы этого, скорее всего, вообще не заметил. Но когда Nullable стоит в каждой колонке просто потому, что так пришло из экстрактора, а строк уже миллионы, это перестаёт быть мелочью, запросы начинают выполняться неприлично долго.
Именно тогда у меня впервые появился вопрос, который раньше я почти не задавал: а что вообще происходит с данными внутри базы, когда я запускаю обычный SELECT. До этого я думал на уровне SQL: какие таблицы соединить, какой фильтр поставить. Здесь пришлось спуститься на уровень ниже и разобраться, как база хранит то, что я в неё положил.
Разбираясь дальше, я обнаружил вторую вещь. Если колонка содержит ограниченный набор строковых значений, вроде статуса “новый”, “в работе”, “закрыт”, то хранить каждый раз полноценную строку не обязательно. Для таких случаев в ClickHouse есть отдельный тип, LowCardinality(String): значения хранятся один раз в виде словаря, а в самих строках лежат только ссылки на них.
Заодно выяснилась еще одна особенность. Колонки без Nullable ClickHouse умеет сжимать намного эффективнее: когда база точно знает, что пустых значений не будет, ей не нужно тратить место и вычисления на маску NULL, и обычные типы данных сжимаются заметно плотнее. То есть отказ от Nullable там, где он реально не нужен бизнес-логике, это не только вопрос чистоты схемы, а ещё и прямая экономия места на диске и скорости чтения.
И вот здесь для меня впервые соединились две вещи, которые до этого существовали отдельно. Тип данных это не только про то, правильно ли он описывает значение. Это ещё и про то, сколько лишней работы придётся делать запросу каждый раз, когда он к этой колонке обращается.
После этого я стал внимательнее смотреть не только на структуру запроса, но и на то, из чего вообще состоит таблица. И довольно быстро понял, что дело не только в отдельных колонках. Данные, которые приходят из источника, вообще нельзя использовать один в один: их сначала нужно привести в порядок, выбрать правильные типы, разобраться, что где хранить. То есть между “как есть у источника” и “с чем удобно работать” должен появиться отдельный шаг.
Это было мое знакомство с тем, что данные нужно хранить слоями, и подобный подход давно описан в теории DWH. Я просто увидел, что иначе не получается: если каждый раз разгребать одни и те же проблемы с типами прямо в запросе, рано или поздно это выйдет дороже, чем один раз навести порядок заранее.