Объектно-ориентированные языки поддерживают наследование классов напрямую. В реляционных базах данных такого встроенного механизма нет. Разработчикам приходится самостоятельно решать, как отобразить иерархию объектов на плоские таблицы. Для этого придуманы три основных паттерна:
- Single Table Inheritance (STI) — всё лежит в одной общей таблице;
- Class Table Inheritance (CTI) — под каждый класс создаётся своя таблица;
- Concrete Table Inheritance (TPC) — отдельная таблица для каждого конкретного класса, дублирующая все унаследованные поля.
Каждый подход — это компромисс между простотой реализации, скоростью чтения и записи, а также строгостью соблюдения нормальных форм. Разберём их по порядку.
Single Table Inheritance (STI) — Одна таблица для всех
Как это работает. Все атрибуты — и из базового класса, и из всех наследников — собираются в одной таблице. Чтобы различать, к какому именно типу относится строка, добавляют столбец-дискриминатор (например, type). При вставке объекта конкретного подкласса заполняются только те колонки, которые имеют для него смысл. Остальные остаются пустыми (NULL).
Плюсы. У паттерна два главных достоинства:
- Простота поддержки схемы. Добавление нового подкласса или общего атрибута — это изменение всего одной таблицы. Миграции проходят легко.
- Высокая скорость чтения. Вся информация об объекте любого типа лежит в одной строке. Запросу не нужны соединения (
JOIN), оптимизатор СУБД отработает быстро.
Минусы. За простоту приходится платить:
- Много NULL‑значений. Таблица становится «разреженной»: поля, специфичные для грузовиков, пустуют у автобусов, и наоборот. Дисковое пространство используется неэффективно.
- Слабая целостность данных на уровне БД. Нельзя, например, объявить
NOT NULLдля поляcargo_capacity, потому что для автобусов оно должно оставатьсяNULL. Ограничения вроде «грузоподъёмность обязательна только для грузовиков» приходится реализовывать в коде приложения. Это повышает риск ошибок. - Технические лимиты. У СУБД есть предельное количество столбцов в таблице. При очень широкой иерархии можно упереться в это ограничение.
Когда применять. Иерархия неглубокая и редко меняется, производительность чтения критична, а общих полей гораздо больше, чем специфических.
Факт: некоторые компании (например, GitLab) рекомендуют избегать STI в новых проектах из-за долгосрочных проблем с сопровождаемостью.
Class Table Inheritance (CTI) — Таблица для каждого класса
Как это работает. Для базового класса создаётся таблица с общими атрибутами. Каждый подкласс получает свою таблицу, где хранятся только его уникальные свойства. Дочерняя таблица связана с родительской через внешний ключ, который одновременно является её первичным ключом.
Плюсы. Этот подход ближе всего к академическим принципам проектирования баз данных:
- Высокая целостность. Для каждой таблицы можно задать полноценные ограничения
NOT NULL,CHECKи внешние ключи. Например, полеpassenger_countбудет обязательно заполнено только у автобусов. - Эффективное хранение. NULL‑значения сводятся к минимуму. Схема обычно удовлетворяет нормальной форме Бойса — Кодда (BCNF), усиленной версии третьей нормальной формы.
- Удобное расширение. Добавление нового подкласса требует лишь создания ещё одной таблицы с внешним ключом. Общий атрибут меняется только в родительской таблице.
Минусы. За нормализацию тоже приходится платить:
- Чтение требует соединений. Чтобы собрать полный объект, нужно выполнить
JOINкак минимум двух таблиц. При полиморфном запросе (выборке объектов разных типов) количество соединений растёт. На практике современные оптимизаторы и индексы справляются с этим хорошо, но на очень больших объёмах или при плохом проектировании индексов скорость может упасть. - Более дорогая запись. При вставке нового объекта приходится обновлять несколько таблиц в одной транзакции. Это означает, как минимум, две записи в журнал предзаписи (WAL), что создаёт дополнительную нагрузку на подсистему ввода-вывода. В высоконагруженных системах с огромным потоком вставок это может стать узким местом.
Когда применять. Иерархия сложная и будет расширяться, целостность данных критически важна, а у подклассов много уникальных атрибутов.
Concrete Table Inheritance (TPC) — Таблица для каждого конкретного класса
Как это работает. Каждый конечный (неабстрактный) класс получает собственную таблицу, которая содержит полный набор столбцов — и свои, и все унаследованные. Абстрактный базовый класс таблицы может не иметь.
Плюс. Скорость чтения такая же высокая, как у STI: вся информация об объекте лежит в одной строке без каких‑либо соединений.
Минусы. Недостатки серьёзные:
- Дублирование общих полей. Атрибуты вроде
brandиmodelкопируются в каждую таблицу‑потомок. Это увеличивает занимаемое место и усложняет хранение. - Очень сложная поддержка схемы. Добавление одного общего атрибута требует изменения структуры всех таблиц конкретных классов. Миграция становится трудоёмкой и чревата ошибками.
- Нет единого источника истины для общих полей. Если потребуется массово обновить значение общего атрибута для объектов разных типов, придётся менять данные в нескольких таблицах — есть риск расхождений.
Когда применять. Иерархия классов абсолютно стабильна и никогда не будет меняться; полиморфные запросы практически не используются; операции с объектами конкретных типов должны выполняться максимально быстро.
Примеры всех трёх схем
Представим базовый класс Vehicle и два подкласса: Truck и Bus. Вот как будут выглядеть таблицы в каждом паттерне.
STI — всё в одной таблице vehicles:
| id | type | brand | model | cargo_capacity | passenger_count |
|---|---|---|---|---|---|
| 1 | Truck | MAN | TGS | 20.5 | NULL |
| 2 | Bus | PAZ | 3205 | NULL | 42 |
CTI — три таблицы с разделением общих и специфичных полей:
vehicles (базовая):
| id | brand | model |
|---|---|---|
| 1 | MAN | TGS |
| 2 | PAZ | 3205 |
trucks (внешний ключ id → vehicles.id):
| id | cargo_capacity |
|---|---|
| 1 | 20.5 |
buses:
| id | passenger_count |
|---|---|
| 2 | 42 |
TPC — две полностью независимые таблицы:
trucks:
| id | brand | model | cargo_capacity |
|---|---|---|---|
| 1 | MAN | TGS | 20.5 |
buses:
| id | brand | model | passenger_count |
|---|---|---|---|
| 2 | PAZ | 3205 | 42 |
Короткие советы по выбору
- Выбирайте STI, если иерархия маленькая и стабильная, производительность чтения критична, а строгая нормализация не важна. Но помните о долгосрочных рисках сопровождения.
- Выбирайте CTI, если строите сложную, расширяемую систему, для которой целостность данных и минимум NULL — приоритет. Вы готовы мириться с соединениями и чуть более дорогой записью.
- Рассматривайте TPC, только когда нужна скорость STI, а недостатки STI неприемлемы, но при этом классы сильно различаются, иерархия никогда не меняется и полиморфные запросы не используются.
- Анализируйте свои запросы. Если в приложении преобладают полиморфные выборки (
SELECT * FROM vehicles), CTI с множеством JOIN может действительно оказаться медленнее. Если вы чаще работаете с конкретными типами (SELECT * FROM trucks), CTI или TPC покажут себя лучше. - Учитывайте поддержку вашего ORM. Hibernate, Doctrine, ActiveRecord и другие фреймворки имеют встроенные средства для работы с этими паттернами. Проверьте документацию: иногда возможности или ограничения ORM предопределяют выбор.