DanLevy.net

Внешние ключи: перестаньте спрашивать, быстрые ли они

Спросите, что вы на самом деле оптимизируете.

Самая дорогая оптимизация базы данных, которую я когда-либо видел, началась с того, что кто-то удалил все внешние ключи.

Не потому что измерили узкое место. Не потому что запись действительно была медленной. Потому что где-то прочитали: «Внешние ключи не масштабируются». Шесть месяцев спустя у них было 2 миллиарда осиротевших записей, биллинговая система, списывающая деньги с удалённых пользователей, и аналитика, ошибающаяся на 40%.

Когда они попытались вернуть ограничения обратно? База данных встала колом, пытаясь валидировать уже повреждённые данные.

В веб-разработке живуча идея, что внешние ключи по своей природе медленные, что это тренировочные колёсики, которые снимаешь, как только переходишь к «настоящим» системам. Но это полностью упускает суть того, зачем нужны ограничения. Вы выбираете не между быстрым и медленным. Вы выбираете между разными режимами отказа.

Подумайте так: безопасное стекло, ремни безопасности и подушки безопасности — всё это добавляет вес вашему автомобилю. Они абсолютно точно делают машину медленнее и менее экономичной. Но вы же не вырываете их, чтобы улучшить разгон до сотни, потому что оптимизируете совсем другое.

Вопрос не в том, замедляют ли вас внешние ключи. Конечно, замедляют. Вопрос в том, что вы получаете взамен и действительно ли вам это нужно.

Чем вы на самом деле торгуете

Приведу конкретный пример. Вы строите систему мониторинга погоды с таблицами для метеостанций, сенсорных устройств, показаний датчиков и штатов США.

Связывать ли всё внешними ключами? Давайте подумаем, что на самом деле меняется и каковы последствия:

Штаты США, скорее всего, не меняются. Вайоминг вряд ли скоро переименуют. Вам не нужен внешний ключ для валидации кодов штатов при каждой вставке, если справочные данные статичны. Это бессмысленные накладные расходы.

Метеостанции добавляют, перемещают и выводят из эксплуатации. Но вот вопрос: хотите ли вы, чтобы исторические показания «потеряли» свою станцию, если кто-то случайно удалит запись станции? Может, вы как раз хотите, чтобы данные оставались нетронутыми, даже если станция удалена. Это значит, что вы относитесь к показаниям как к историческому снимку, а не к живой ссылке — что меняет вопрос о том, имеет ли внешний ключ вообще смысл.

Показания датчиков вставляются тысячи раз в минуту. Каждая проверка внешнего ключа — это поиск. Каждый поиск создаёт конкуренцию за ваши таблицы. Если медленная валидация приводит к переполнению очереди вставки и потере данных реального времени, это другой вид потери данных, чем осиротевшая запись.

Вы видите, куда это клонит. Выбор не между производительностью и корректностью как абстрактными понятиями. Он о том, какой конкретный отказ вы готовы терпеть с учётом ваших реальных ограничений и реальных последствий.

Если неверные ссылки означают повреждение биллинговых данных или нарушение регуляторных требований, вам, вероятно, нужны внешние ключи, защищающие вас, независимо от стоимости производительности. Если медленная валидация означает потерю данных реального времени навсегда из-за переполнения очереди, возможно, валидация — неправильный компромисс.

Когда быстрая запись действительно имеет значение

Итак, вы решили, что вам нужна максимальная скорость записи. Очередь растёт, транзакции таймаутятся, и проверки внешних ключей действительно вызывают проблемы, которые вы измерили (а не просто теоретизируете).

У вас есть несколько вариантов. Можно изменить уровень изоляции транзакций с SERIALIZABLE на READ COMMITTED — это быстрее, но жертвует некоторыми гарантиями согласованности. Можно группировать коммиты, вставляя 1000 строк за транзакцию вместо по одной, чтобы амортизировать накладные расходы FK. Или можно денормализовать в структуру лога только для записи, где вы даже не пытаетесь валидировать ссылки.

Третий вариант, кстати, не жульничество. Это просто другой дизайн:

CREATE TABLE sensor_log (
id BIGSERIAL PRIMARY KEY,
recorded_at TIMESTAMPTZ NOT NULL,
data JSONB NOT NULL -- { station_id, sensor_id, temp, humidity, ... }
);
CREATE INDEX ON sensor_log USING GIN (data);
CREATE INDEX ON sensor_log (recorded_at);

Никаких соединений. Никаких проверок внешних ключей. Просто добавляете данные и запрашиваете по диапазону времени или GIN-индексу по JSONB-блобу. Это «лучшая практика»? Наверное, нет в том смысле, в каком учат учебники по базам данных. Работает ли это, когда вы вставляете 50 000 строк в минуту на Raspberry Pi? Абсолютно.

Разрыв происходит, когда люди относятся к «лучшей практике» как к моральному императиву, а не как к паттерну, который хорошо работает в типичных сценариях, но может не подойти вам.

Ловушка нормализации

Курсы по базам данных обожают учить нормализации. Избегайте дублирования любой ценой. Третья нормальная форма или смерть.

В итоге получается что-то вроде: OrdersOrderItemsProductsVariantsColorsSizes

Шесть соединений таблиц, чтобы ответить на вопрос: «Я заказал красную или синюю футболку на прошлое Рождество?» И не дай бог вам нужно включить название продукта — это ещё три соединения вглубь иерархии каталога.

Но погодите. Обоснование обычно такое: «А что если бренд изменит маркировку синего?» Если это произойдёт, вы правда хотите, чтобы исторические заказы ретроспективно поменяли цвет? Конечно нет. Когда кто-то оформлял заказ, он покупал «Синюю футболку, размер M» в том виде, в каком она существовала в тот момент, а не как абстрактную ссылку на запись каталога, которая может обновиться позже.

На этом стоит остановиться, потому что это тонкий момент. Некоторые данные по своей природе являются снимком, а не ссылкой. Когда вы относитесь к данным-снимку как к живой ссылке, вы получаете абсурдное разрастание соединений для реконструкции того, что следовало просто денормализовать в момент записи.

Храните {"color": "blue", "size": "M"} прямо в заказе. Вы закончили.

Как распознать данные-снимок

Как понять, что данные должны быть снимком? Спросите себя, является ли это записью момента времени:

Заказы фиксируют детали продукта на момент покупки. Аудиторские логи записывают состояние пользователя в момент выполнения действия. Таблицы истории сохраняют состояние записи до обновления. Потоки событий фиксируют, что произошло, когда и с какими данными.

Если ответ «да, это фиксация момента времени» — прекратите нормализовать. Начните делать снимки.

Непрозрачные блобы

Есть ещё одна категория помимо снимков: данные, в которые вы никогда не заглядываете при запросах. Вы просто храните их и извлекаете целиком.

Конфигурации моделей LLM вроде {"model": "gpt-4", "temperature": 0.7, "max_tokens": 2000} — это не то, по чему вы запрашиваете по температуре. Вы извлекаете всю конфигурацию по ID запроса, когда она нужна. JWT-полезные нагрузки после декодирования, логи запросов/ответов API для отладки, объекты пользовательских предпочтений с настройками темы и флагами уведомлений. Всё это непрозрачные блобы. Вам не нужна нормализация. Вам не нужны внешние ключи. Запихните их в JSONB и живите дальше.

Соединение 6 таблиц, чтобы узнать, какого цвета была заказанная футболка? Это не правильная нормализация. Это путаница между тем, храните ли вы ссылку или значение.

(Хотя будьте осторожны: это может обернуться катастрофой, если позже вам понадобится запрашивать эти данные. См. Соблазн JSONB о том, когда этот подход создаёт собственный кошмар.)

Масштаб — это контекст

Вы услышите, как люди говорят: «Внешние ключи не масштабируются». Но масштаб полностью относителен к вашему оборудованию и архитектуре.

Raspberry Pi, логирующий 10 000 показаний датчиков в минуту на microSD-карту? Это действительно высокий масштаб для такого оборудования. AWS Aurora с выделенными IOPS, обрабатывающая миллиарды строк? Вы можете пройти через это с внешними ключами, не вспотев.

Настоящий жёсткий предел — не количество строк и не объём записи. Это шардирование.

Когда ваша таблица Users живёт на Сервере A, а таблица Orders — на Сервере B, внешние ключи физически не могут работать. У базы данных нет механизма для обеспечения ограничения через сетевые границы. К этому моменту вы уже запускаете фоновые задачи для поиска осиротевших записей и реализуете паттерны согласованности в конечном счёте.

Это происходит в многопользовательских SaaS-системах, где каждый арендатор получает свою изолированную базу данных для соответствия требованиям, или в IoT-развёртываниях, где у вас 50 000 периферийных устройств, каждое из которых локально запускает SQLite. Как только вы там, внешние ключи отпадают (буквально), независимо от соображений производительности.

Но пока вы не уперлись в эту архитектурную границу, может, не стоит заниматься преждевременной оптимизацией под проблемы Netflix, когда вы строите внутренний инструмент на 10 пользователей.

Как это на самом деле выглядит на практике

Вместо вопроса «стоит ли использовать внешние ключи» попробуйте задать три других:

Что сломается, если эта ссылка окажется неверной? Это судебный иск, повреждённый биллинг, нарушение регуляторных требований? Или это просто отсутствующее соединение, возвращающее null в вашей аналитической панели?

Что сломается, если валидация будет медленной? Вы теряете незаменимые данные реального времени? Или ваши запросы просто занимают лишние 50 миллисекунд?

Эти данные — снимок или ссылка? Вы записываете, как что-то выглядело в конкретный момент, или указываете на авторитетное текущее значение?

Отсюда паттерны вырисовываются довольно естественно:

Финансовые транзакции, сессии аутентификации, всё, где повреждение данных означает юридическую ответственность — вероятно, требует внешние ключи независимо от накладных расходов на производительность.

Высокообъёмные логи, временные ряды только для записи, всё, где вы записываете миллион событий в минуту — вероятно, не нуждается в накладных расходах на валидацию при каждой записи.

Исторические снимки вроде заказов и аудиторских логов, данные, которые вы всегда извлекаете как полный блоб вроде пользовательских предпочтений, схемы, которые вы не контролируете, вроде полезной нагрузки вебхуков от внешних API… они часто работают лучше денормализованными.

Но обратите внимание: я сказал «вероятно» и «часто». Потому что контекст имеет значение, и ваш контекст отличается от моего.

Итоговые мысли

Внешние ключи — это не проблема производительности. Это компромисс между скоростью записи и целостностью данных, и имеет ли этот компромисс смысл, зависит исключительно от ваших конкретных узких мест и ваших конкретных последствий.

Настоящая проблема в том, когда люди удаляют внешние ключи из-за чего-то прочитанного про «web scale», не измерив фактически, есть ли у них проблема производительности записи, и не обдумав, чем они жертвуют. В итоге вы получаете карго-культ архитектуры Netflix на новом проекте, обрабатывающем 100 транзакций в день.

Может, стоимость производительности стоит того для вашего случая. Может, нет. Но хотя бы примите это решение на основе того, что вы на самом деле оптимизируете, а не того, что, как вам кажется, вы должны оптимизировать.

Что вы оптимизируете?

Ресурсы