Nullable: цена удобства
В PostgreSQL или MySQL NULL — естественная часть данных, почти без дополнительных затрат. В ClickHouse ситуация другая. Официальная документация прямо предупреждает:
“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):
- 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-версия | Альтернатива | Комментарий |
|---|---|---|---|
| Nullable(String) | String DEFAULT ” | WHERE email != ” | |
| age | Nullable(UInt8) | UInt8 DEFAULT 0 | WHERE age != 0 (или sentinel -1 с Int8) |
| deleted_at | Nullable(DateTime) | DateTime DEFAULT ‘1970-01-01’ | WHERE deleted_at != ‘1970-01-01’ |
| middle_name | Nullable(String) | String DEFAULT ” | Пустая строка = нет отчества |
Когда Nullable оправдан
Nullable не нужно избегать абсолютно. Есть случаи, когда он оправдан:
- Результаты LEFT/RIGHT JOIN — ClickHouse автоматически создаёт NULL для несовпавших строк. Это семантически корректно и неизбежно.
- Данные из внешних источников — если источник явно различает “значение 0” и “значение отсутствует”, и это различие критично для бизнес-логики.
- Столбцы не в ORDER BY — если столбец не участвует в сортировке и имеет низкую частоту чтения (редко запрашивается), overhead Nullable менее значим.
Правило принятия решения: если столбец участвует в ORDER BY, PRIMARY KEY, или часто используется в WHERE/GROUP BY — никогда не Nullable. Если столбец читается редко и NULL семантически необходим (не заменим на default) — Nullable допустим.
Ключевые выводы
- Nullable добавляет 1 байт на строку через отдельный null mask file — это 25% overhead для UInt32 и ~1 ГБ при миллиарде строк, независимо от количества реальных NULL.
- Nullable нельзя в ORDER BY и PRIMARY KEY — прямое ограничение MergeTree, влияющее на проектирование схемы.
- Альтернативы обычно лучше: пустая строка ” вместо Nullable(String), 0 вместо Nullable(UInt32), indicator column has_X UInt8 для сложных случаев.
- Nullable оправдан для результатов JOIN и редко читаемых столбцов, где семантика NULL незаменима.