12. Работа с NULL в SQL
«В таких языках, как C или Go, null — это просто адрес: буквальный ноль в регистре указателя. В реляционной теории NULL — это эпистемологическое утверждение: оно означает не ноль и не пустоту, а „нам неизвестно“. И вычисления над неизвестностью подчиняются собственной логике».
Природа пустоты: Почему NULL ломает интуицию
В императивных языках программирования мы привыкли к детерминированной концепции отсутствия значения. В Go это константа nil, в C++ — типизированный nullptr или макрос NULL (разворачивающийся в целочисленный 0). Во всех этих случаях «отсутствие» — это конкретный адрес в памяти (нулевой указатель), который можно легко проверить через if ptr == nil. В таком мире над указателем определена обычная булева алгебра.
В реляционных базах данных NULL — это не значение, не число ноль, не пустая строка и не нулевой указатель в памяти.
NULL — это мета-маркер, обозначающий эпистемологическое состояние «Неизвестно» (Unknown / Missing Information).
Если вы спросите базу данных: «Равен ли неизвестный возраст Ивана неизвестному возрасту Петра?» (NULL = NULL), процессор базы данных не ответит «Да» и не ответит «Нет». Он ответит строго: «Я не знаю» (UNKNOWN).
Непонимание трехзначной логики — источник самых коварных, трудноуловимых багов в бэкенде, способных годами прятаться в кодовой базе и приводить к незаметной потере бизнес-данных.
Трехзначная логика (3VL)
В классических языках программирования вроде Go логика строго двузначна (Boolean): переменная может принимать значение либо true, либо false.
Стандарт SQL реализует трехзначную логику (Three-Valued Logic — 3VL), оперирующую тремя состояниями истинности: TRUE, FALSE и UNKNOWN.
Любая арифметическая операция или предикат сравнения с участием маркера NULL неизбежно порождает UNKNOWN:
1 + NULL→UNKNOWN(точнее, арифметический результатNULL, предикат проверки которого даетUNKNOWN)NULL = NULL→UNKNOWNNULL != 1→UNKNOWN
Таблицы истинности для UNKNOWN
В реляционном конвейере предложение WHERE пропускает кортеж дальше в обработку только и исключительно в том случае, если итоговое логическое выражение вычислено в строгое TRUE. Значения FALSE и UNKNOWN безжалостно отбрасываются.
| Логическое выражение | Итоговый результат | Инженерная мнемоника (Mechanical Intuition) |
|---|---|---|
TRUE AND UNKNOWN |
UNKNOWN |
Если неизвестное окажется FALSE, итог FALSE. Если TRUE — итог TRUE. Результат полностью зависит от неизвестности. |
FALSE AND UNKNOWN |
FALSE |
Ложь в конъюнкции доминирует над всем: ложь, умноженная на неизвестность, всегда дает ложь (короткое замыкание). |
TRUE OR UNKNOWN |
TRUE |
Истина в дизъюнкции доминирует над всем: истина плюс неизвестность гарантированно дает истину (короткое замыкание). |
FALSE OR UNKNOWN |
UNKNOWN |
Ложь не дает определенности: итоговый статус целиком зависит от значения неизвестного операнда. |
NOT UNKNOWN |
UNKNOWN |
Отрицание неизвестности оставляет нас в состоянии той же самой неизвестности. |
Под капотом: Как физически хранится NULL
С позиций принципа Mechanical Sympathy, как современные СУБД (например, PostgreSQL) физически размещают «пустоту» в файлах на диске и в страницах буферного пула?
Наивно предполагать, будто колонка VARCHAR(255) со значением NULL заполняется 255 байтами нулей. Это привело бы к колоссальной деградации дисковой подсистемы.
В PostgreSQL каждый кортеж (Tuple) на дисковой странице имеет строгий бинарный формат:
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Tuple["Строка - Tuple"]:::process --> Header["Tuple Header<br>Метаданные транзакций"]
Tuple --> Bitmap["Null Bitmap<br>1 бит на каждую колонку"]
Tuple --> Payload["Data Payload<br>Только реальные данные"]
Bitmap -->|"Бит 1"| Col1["Колонка 1: NOT NULL"]
Bitmap -->|"Бит 0"| Col2["Колонка 2: IS NULL<br>Не занимает место в Payload"]
- Заголовок кортежа (
Tuple Header): Содержит служебные поля транзакций (xmin,xmax) и битовую маску флагов. - Битовая карта NULL-значений (
Null Bitmap): Если в таблице хотя бы одна колонка объявлена без ограниченияNOT NULL, в заголовок кортежа помещается компактная битовая шкала (t_bits). Каждый бит строго соответствует порядковому номеру колонки:- Бит
1— в колонке присутствуют физические данные; - Бит
0— значение колонки равноNULL.
- Бит
- Область данных (
Data Payload): Если бит в маске равен0, в полезную нагрузку кортежа не записывается ни одного байта! КолонкаNULLзанимает 0 байт в дисковом пространстве полезных данных. - Аппаратная скорость проверки: Проверка условий
IS NULLилиIS NOT NULL— это молниеносная побитовая инструкция процессора (bitwise AND над байтом заголовка). Процессору даже не требуется обращаться к телу строки и десериализовать байты данных.
Функции спасения: Управление NULL
Поскольку традиционные операторы сравнения (=, !=, <>) в связке с NULL парализованы трехзначной логикой, стандарт SQL предоставляет специализированный математический инструментарий.
1. IS NULL / IS NOT NULL
Единственный синтаксически и семантически корректный способ проверки атрибутов на статус неопределенности:
-- ✅ Правильно: проверяет бит в Null Bitmap
SELECT email FROM users WHERE deleted_at IS NULL;
-- ❌ Неправильно: всегда возвращает UNKNOWN, запрос вернет 0 строк!
SELECT email FROM users WHERE deleted_at = NULL;
2. COALESCE (Слияние)
Функция COALESCE принимает переменное число аргументов и возвращает первый аргумент, отличный от NULL. Это фундаментальный инструмент бэкенд-инженера для подстановки значений по умолчанию прямо на уровне ядра базы данных:
-- Если balance равен NULL, вернет 0
SELECT COALESCE(balance, 0) AS current_balance FROM accounts;
-- Цепочка фоллбэков: берет телефон, если нет — email, если нет — дефолт
SELECT COALESCE(phone, email, 'Нет контактов') FROM users;
COALESCE вычисляется по принципу короткого замыкания: как только встречен первый не-NULL аргумент, вычисление остальных прекращается.
3. NULLIF (Предохранитель)
Функция NULLIF(a, b) сравнивает два аргумента:
- Если $a = b$, функция возвращает
NULL; - В противном случае возвращается значение $a$.
Это идеальный, элегантный предохранитель от ошибок деления на ноль (Division by Zero), выступающий лаконичной альтернативой громоздкому выражению CASE:
-- Если total_orders = 0, NULLIF вернет NULL.
-- Деление числа на NULL дает NULL, и запрос не падает с ошибкой деления на ноль!
SELECT total_spent / NULLIF(total_orders, 0) FROM users;
Агрегация и NULL: Невидимые данные
Агрегатные функции стандарта SQL (рассмотренные в статье 7. GROUP BY и агрегатные функции) содержат встроенный механизм обработки неопределенности, о котором постоянно забывают на собеседованиях.
Функции SUM, AVG, MIN, MAX полностью игнорируют значения NULL при расчетах.
Представьте таблицу зарплат сотрудников из трех строк: 100, 200 и NULL.
SUM(salary)вернет300(сложит только известные величины).AVG(salary)вернет150($300 / 2$), а не $100$ ($300 / 3$)! Движок базы делит сумму на количество фактически присутствующих значений ($2$), а не на общее число строк в таблице.
Единственным исключением является функция COUNT(*), которая подсчитывает физическое количество кортежей (строк), видимых транзакции, полностью игнорируя содержимое колонок:
COUNT(*)для этой таблицы вернет3;COUNT(salary)вернет2.
Go Idioms: Борьба с NULL на бэкенде
Десериализация колонок, содержащих NULL, в строго типизированные структуры Go — источник частых паник (panic: runtime error) и неэффективного расхода памяти.
При извлечении nullable-колонки bio TEXT в распоряжении разработчика есть три стратегии:
Подход 1: Использование указателей (Удар по GC)
var bio *string
err := rows.Scan(&bio)
Если в базе данных NULL, драйвер присвоит указателю значение nil. Если строка присутствует — выделит память под строку и вернет указатель.
- Недостаток (Mechanical Sympathy): Передача указателя на указатель в метод
rows.Scanломает оптимизацию компилятора Go (Escape Analysis). Строка гарантированно аллоцируется в куче (Heap), а не на быстром стеке горутины. При выборках в сотни тысяч строк это создает колоссальное давление на сборщик мусора Go. Кроме того, прикладной код обрастает бесконечными защитными проверкамиif bio != nil.
Подход 2: Использование sql.Null* (Идиоматично, но многословно)
Стандартный пакет database/sql предоставляет набор специализированных оберток: sql.NullString, sql.NullInt64, sql.NullFloat64, sql.NullTime:
var bio sql.NullString
err := rows.Scan(&bio)
if bio.Valid {
fmt.Println("Биография:", bio.String)
} else {
fmt.Println("Биографии нет")
}
- Преимущество: Тип
sql.NullStringпредставляет собой простую плоскую структуру (struct { String string; Valid bool }). Этоvalue-type, который компилятор размещает на стеке, избавляя Garbage Collector от лишней работы. - Недостаток: Засорение доменных моделей чистой бизнес-логики инфраструктурными типами из пакета
database/sql.
Подход 3: Делегирование через COALESCE (Mechanical Sympathy)
Самый зрелый архитектурный паттерн для большинства прикладных задач: вообще не впускать NULL в рантайм Go, если доменной логике не требуется строго различать «отсутствие значения» и «пустое значение»:
-- Преобразуем NULL в пустую строку непосредственно в ядре СУБД
SELECT COALESCE(bio, '') FROM users;
// В коде Go используем нативный легковесный примитив string
var bio string
err := rows.Scan(&bio) // Всегда чистая строка, 0 проверок на nil!
Этот паттерн упрощает код, снижает сетевой оверхед (протокол не передает флаги NULL-значений) и оставляет структуры данных Go кристально чистыми.
Итог
NULL— это не значение, это маркер «Неизвестно» (Unknown).- Любая операция сравнения с
NULLчерез=дает результатUNKNOWN. Для фильтрации применяйте толькоIS NULLиIS NOT NULL. - Остерегайтесь оператора
NOT IN: появление хотя бы одногоNULLв списке приводит к аннулированию всей выборки (возвращается 0 строк). - На физическом уровне в PostgreSQL
NULLпочти не расходует места на диске благодаря битовой карте Null Bitmap в заголовке кортежа. - Агрегатные функции (
SUM,AVG,MIN,MAX) полностью игнорируютNULL, меняя расчет средней величины. - Для проверки равенства с учетом NULL используйте оператор
IS NOT DISTINCT FROM. - В Go избегайте указателей на примитивы при сканировании nullable-колонок. Отдавайте предпочтение
COALESCEна стороне СУБД либо структурамsql.Null*для стековой аллокации.
Осознав механику пустоты, мы готовы перейти к завершающей базовой операции SQL, которая регулярно провоцирует споры о производительности памяти и процессора — устранению дубликатов с помощью команды 13. DISTINCT и дубликаты.