Learning Platform
Глоссарий Troubleshooting
Урок 03.03 · 15 мин
Средний
NullableПроизводительностьMask FileDefault Values

Nullable: цена удобства

В PostgreSQL или MySQL NULL — естественная часть данных, почти без дополнительных затрат. В ClickHouse ситуация другая. Официальная документация прямо предупреждает:

WARNING

“Using Nullable almost always negatively affects performance, keep this in mind when designing your databases.” — ClickHouse Documentation

Это не абстрактная рекомендация. Nullable добавляет конкретные затраты на уровне файлов, памяти и ограничений.


Как работает Nullable: отдельный mask file

Для каждого Nullable-столбца ClickHouse создаёт дополнительный файл в part — null mask. Это массив бит (1 бит на строку), где 1 означает NULL, 0 — не NULL. На практике выравнивание и формат хранения увеличивают это до 1 байта на строку.

Nullable(UInt32): данные + null mask
age.bin4 байта на строкуage.bin: файл данных столбца. Хранит значения UInt32 (4 байта каждое). Для строк с NULL хранит значение 0 (default) -- байты всё равно расходуются. Без Nullable этот файл -- единственный для столбца.
age.null.bin1 байт на строкуage.null.bin: null mask file. Дополнительный файл, создаваемый ТОЛЬКО для Nullable-столбцов. 1 байт на строку (не 1 бит -- выравнивание). Это чистый overhead: дополнительный I/O при чтении, дополнительная память при обработке. При 1 миллиарде строк -- 1 ГБ дополнительно.

Конкретные затраты для Nullable(UInt32):

  • UInt32 без Nullable: 4 байта на строку
  • Nullable(UInt32): 4 байта (данные) + 1 байт (mask) = 5 байт на строку
  • Overhead: 25% дополнительного объёма

При 1 миллиарде строк null mask занимает ~1 ГБ дополнительного дискового пространства — независимо от того, сколько строк реально содержат NULL. Даже если NULL встречается в 0.1% строк, mask file создаётся для каждой строки.


Производственные последствия

Дополнительный I/O

Каждый запрос к Nullable-столбцу читает два файла вместо одного: данные и mask. Это удваивает количество seek-операций на HDD и увеличивает нагрузку на page cache SSD.

Дополнительная память

При обработке запроса оба файла загружаются в RAM. Nullable(String) с длинными строками особенно расточителен: помимо самих строк, для каждой строки хранится байт маски.

Ограничения в ORDER BY и PRIMARY KEY

Nullable-столбцы нельзя использовать в ORDER BY и PRIMARY KEY таблицы. Это прямое ограничение MergeTree:

-- Ошибка: Nullable нельзя в ORDER BY
CREATE TABLE events (
    event_date Date,
    user_id Nullable(UInt64)  -- нельзя, если user_id в ORDER BY
) ENGINE = MergeTree()
ORDER BY (event_date, user_id)  -- DB::Exception

Это означает, что столбцы, участвующие в сортировке и индексировании, не могут быть Nullable.


Альтернативы Nullable

В большинстве случаев Nullable можно заменить на более эффективное решение:

Альтернативы Nullable по типу данных
Default valueПустое значение типаDefault value: самая простая альтернатива. Для String -- пустая строка ''. Для числовых типов -- 0. Для Date -- '1970-01-01'. Фильтрация: WHERE email != '' вместо WHERE email IS NOT NULL. Нет дополнительного mask file, нет overhead.
Sentinel valueСпециальное значение-маркерSentinel value: условное значение, означающее 'данные отсутствуют'. Для age: -1 (невозможный возраст). Для price: 0 или -1. Для timestamp: '1970-01-01'. Требует документирования и осторожности в агрегатных функциях (AVG с sentinel value даст неверный результат).
Indicator columnОтдельный UInt8 флагIndicator column: отдельный столбец has_email UInt8 (0 или 1). Данные хранятся в основном столбце (email String), флаг указывает на наличие. Размер: 1 байт (как mask), но без ограничений Nullable. Можно использовать в ORDER BY и PRIMARY KEY.

Практические примеры замены:

СтолбецNullable-версияАльтернативаКомментарий
emailNullable(String)String DEFAULT ”WHERE email != ”
ageNullable(UInt8)UInt8 DEFAULT 0WHERE age != 0 (или sentinel -1 с Int8)
deleted_atNullable(DateTime)DateTime DEFAULT ‘1970-01-01’WHERE deleted_at != ‘1970-01-01’
middle_nameNullable(String)String DEFAULT ”Пустая строка = нет отчества

Когда Nullable оправдан

Nullable не нужно избегать абсолютно. Есть случаи, когда он оправдан:

  1. Результаты LEFT/RIGHT JOIN — ClickHouse автоматически создаёт NULL для несовпавших строк. Это семантически корректно и неизбежно.
  2. Данные из внешних источников — если источник явно различает “значение 0” и “значение отсутствует”, и это различие критично для бизнес-логики.
  3. Столбцы не в ORDER BY — если столбец не участвует в сортировке и имеет низкую частоту чтения (редко запрашивается), overhead Nullable менее значим.
TIP

Правило принятия решения: если столбец участвует в ORDER BY, PRIMARY KEY, или часто используется в WHERE/GROUP BY — никогда не Nullable. Если столбец читается редко и NULL семантически необходим (не заменим на default) — Nullable допустим.


Ключевые выводы

  1. Nullable добавляет 1 байт на строку через отдельный null mask file — это 25% overhead для UInt32 и ~1 ГБ при миллиарде строк, независимо от количества реальных NULL.
  2. Nullable нельзя в ORDER BY и PRIMARY KEY — прямое ограничение MergeTree, влияющее на проектирование схемы.
  3. Альтернативы обычно лучше: пустая строка ” вместо Nullable(String), 0 вместо Nullable(UInt32), indicator column has_X UInt8 для сложных случаев.
  4. Nullable оправдан для результатов JOIN и редко читаемых столбцов, где семантика NULL незаменима.

Проверьте понимание

Результат: 0 из 0
Аналитический
Вопрос 1 из 3. Таблица с 1 миллиардом строк имеет столбец age Nullable(UInt8). Только 0.1% строк имеют NULL. Какой объём занимает null mask file?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 8