open atlas
↑ К треку
SQL и PostgreSQL вглубь SQL · 01 · 03

NULL и трёхзначная логика

NULL значит «неизвестно», а не «пусто». Это делает логику SQL трёхзначной (TRUE/FALSE/UNKNOWN) и порождает тихий баг NOT IN, возвращающий ноль строк в день, когда появляется один NULL.

SQL Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

Фрод-фильтр год работал чисто: WHERE user_id NOT IN (SELECT user_id FROM blocked_users). Однажды ночью он вернул ноль помеченных аккаунтов — не несколько, ноль — и дежурный решил, что фрод-кольцо затихло. Оно не затихло. Кто-то вставил одну строку blocked_users с NULL user_id, и этот единственный NULL молча превратил весь NOT IN в «не совпадать ни с чем». Ни ошибки, ни предупреждения. Это самый дорогой NULL-баг в SQL, и он прямо следует из одного факта: в SQL NULL значит неизвестно.

NULL — это «неизвестно», а не «пусто» или «ноль»

Единственная идея, на которой держится всё остальное: NULL — не значение, а отсутствие известного значения, «мы не знаем». Это не ноль, не пустая строка, не false. Два NULL не равны друг другу, потому что «неизвестно» нельзя подтвердить равным «неизвестно».

Значит, сравнения с NULL не возвращают TRUE или FALSE — они возвращают третье значение истины, UNKNOWN. 5 = NULL — это UNKNOWN. NULL = NULL — UNKNOWN. NULL <> 3 — UNKNOWN. SQL поэтому — трёхзначная логика: TRUE, FALSE и UNKNOWN.

Жёсткое правило, которое из этого следует: WHERE оставляет строку, только когда её предикат вычисляется в TRUE. UNKNOWN — это не TRUE, так что строка, чей предикат UNKNOWN, отбрасывается — ровно как при FALSE. Эта симметрия — источник почти всех NULL-сюрпризов.

-- users(id, email, deleted_at)  -- deleted_at равен NULL у активных пользователей
SELECT count(*) FROM users WHERE deleted_at = NULL;   -- всегда 0 строк!
SELECT count(*) FROM users WHERE deleted_at IS NULL;  -- корректная проверка

= NULL — никогда не та проверка, что тебе нужна. Чтобы спросить «это неизвестно?», нужно использовать специальные операторы IS NULL / IS NOT NULL, возвращающие настоящие булевы значения.

Трёхзначные таблицы истинности

Каждой связке нужно определить, что она делает, когда операнд UNKNOWN. Ключевые записи:

Две строки короткого замыкания важны на практике: TRUE OR <что угодно> — это TRUE, а FALSE AND <что угодно> — это FALSE, даже когда «что угодно» — UNKNOWN, потому что результат уже определён. Любая другая комбинация, касающаяся UNKNOWN, даёт UNKNOWN.

Ловушка NOT IN, расшифрованная

Теперь инцидент из вступления. x NOT IN (a, b, c) определяется как x <> a AND x <> b AND x <> c. Допустим, список — (1, 2, NULL), а x = 5:

5 <> 1  → TRUE
5 <> 2  → TRUE
5 <> NULL → UNKNOWN      -- потому что сравнение с NULL всегда UNKNOWN
TRUE AND TRUE AND UNKNOWN → UNKNOWN

Всё выражение схлопывается в UNKNOWN, и строка отбрасывается. Даже с одним NULL в списке NOT IN ни одна строка никогда не станет TRUE — запрос возвращает ноль строк, молча, для любого значения. Именно так один NULL в blocked_users обнулил фрод-фильтр.

Фиксы, в порядке предпочтения:

  • Используй NOT EXISTS вместо NOT IN для подзапросов. NOT EXISTS NULL-безопасен (проверяет существование строки, а не равенство значений) и обычно планируется лучше как anti-join:
SELECT * FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM blocked_users b WHERE b.user_id = u.id
);
  • Или исключи NULL из подзапроса: ... NOT IN (SELECT user_id FROM blocked_users WHERE user_id IS NOT NULL).
  • Ещё лучше — сделай колонку NOT NULL, чтобы баг был невозможен по построению.

Все три фикса устраняют одну и ту же корневую причину: как только NULL может попасть в сравнение, логика рушится. Ограничение NOT NULL — сильнейший вариант, оно делает невозможным само возникновение ситуации; NOT EXISTS — самая безопасная замена без изменения схемы.

IS DISTINCT FROM и NULL-aware инструментарий

Иногда тебе действительно нужно «эти два значения различны, считая NULL обычным сравниваемым значением?». Простой <> не может — он уходит в UNKNOWN на NULL. Postgres даёт IS DISTINCT FROM: он возвращает настоящий булев, считая два NULL не различными (равными), а NULL-против-значения — различными.

-- обнаружить изменённую колонку, даже если любая сторона может быть NULL:
SELECT * FROM staging s JOIN target t USING (id)
WHERE s.email IS DISTINCT FROM t.email;   -- TRUE/FALSE, никогда UNKNOWN

Повседневные помощники:

  • COALESCE(a, b, …) возвращает первый не-NULL аргумент — твой инструмент значения по умолчанию: COALESCE(nickname, email, 'anon').
  • NULLIF(a, b) возвращает NULL, когда a = b, иначе a — удобно обойти деление на ноль: total / NULLIF(qty, 0).

И два факта об агрегатах, на которых спотыкаются: агрегаты пропускают NULL. COUNT(col) считает только не-NULL значения col, тогда как COUNT(*) считает строки независимо. AVG(col) усредняет только по не-NULL значениям — так что AVG может отличаться от SUM/COUNT(*), когда есть NULL.

Граничные случаи

NULL и UNIQUE тоже расходятся с интуицией. Ограничение UNIQUE считает NULL различными, так что по умолчанию уникальная колонка допускает много NULL-строк — они не нарушают уникальность, потому что два NULL не «равны». Если нужен максимум один NULL, Postgres 15+ предлагает UNIQUE NULLS NOT DISTINCT, считающий NULL равными для ограничения. Та же корневая причина: NULL = NULL — это UNKNOWN, так что проверка уникальности по умолчанию на NULL никогда не срабатывает.

Викторина

Почему WHERE deleted_at = NULL возвращает ноль строк, даже когда у многих строк deleted_at равен NULL?

Викторина

NOT IN (подзапрос) внезапно возвращает ноль строк после деплоя. Наиболее вероятная причина и лучший фикс?

Закончи аналогию

Заполни пропуск: в SQL NULL значит, что значение _______ — не ноль и не пусто — поэтому x = NULL вычисляется в UNKNOWN, а не TRUE или FALSE.

Вспомните перед уходом
  1. 01
    Почему = NULL никогда не верная проверка и какое третье значение истины тут участвует?
  2. 02
    Объясни точно, почему NOT IN с NULL в списке возвращает ноль строк.
  3. 03
    Противопоставь COUNT(col) и COUNT(*) и скажи, для чего IS DISTINCT FROM.
Итог

NULL — это отсутствие известного значения, «неизвестно», а не ноль и не пусто. Поскольку нельзя подтвердить «неизвестно = чему угодно», сравнения с NULL дают третье значение истины, UNKNOWN, делая SQL трёхзначной логикой. WHEREHAVING, и условия JOIN) оставляют строку, только когда предикат TRUE, так что UNKNOWN-строки отбрасываются, как и FALSE — поэтому = NULL всегда даёт ничего, и нужно использовать IS NULL. Главный режим отказа — NOT IN с NULL в списке: он схлопывается в UNKNOWN для каждой строки и молча возвращает ноль результатов, так что предпочитай NULL-безопасный NOT EXISTS (или отфильтруй NULL, или сделай колонку NOT NULL). Дополни инструментарий COALESCE для значений по умолчанию, NULLIF для обхода деления на ноль и IS DISTINCT FROM для NULL-aware неравенства — и помни, что агрегаты пропускают NULL, так что COUNT(col) и COUNT(*) различаются. Теперь, когда видишь фильтр, который должен находить строки, но возвращает ноль, — сразу задай вопрос: есть ли NULL где-то в цепочке сравнения? Этот единственный вопрос ловит = NULL, NOT IN (подзапрос с NULL) и <> по nullable-колонке — всё одна корневая причина.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.