TPH vs TPT vs TPC для маппинга наследования в EF Core 11: что выбрать?
В EF Core 11 по умолчанию используйте TPH почти для любой иерархии, обращайтесь к TPC только когда вы преимущественно запрашиваете один листовой тип и бенчмарк доказывает его выигрыш, а TPT применяйте только когда к этому вынуждает внешнее ограничение.
В EF Core 11 (с .NET 11 и C# 14) маппьте иерархию классов с помощью table-per-hierarchy (TPH), если у вас нет измеренной причины поступить иначе. TPH помещает всю иерархию в одну таблицу со столбцом-дискриминатором, поэтому чтения представляют собой сканирование одной таблицы без join. Обращайтесь к table-per-concrete-type (TPC) только когда ваш код преимущественно запрашивает один листовой тип и бенчмарк на ваших данных показывает, что он превосходит TPH. Используйте table-per-type (TPT) только когда к этому вынуждает внешнее ограничение, потому что собственный бенчмарк Microsoft ставит TPT примерно в два раза по времени и почти вдвое по аллокациям относительно TPH при запросе базового типа. Правило в одну строку: TPH по умолчанию, TPC для нагрузок, ориентированных на листовой тип, которые измеряются быстрее, TPT никогда по своему выбору.
Эта статья о решении, а не о полном разборе настройки. Если вам нужен API дискриминатора, общие столбцы и механика столбцов, допускающих null, во всех подробностях, прочитайте как настроить маппинг наследования table-per-hierarchy (TPH) в EF Core 11. Здесь мы ставим три стратегии рядом, показываем схему, которую генерирует каждая, и называем ограничения, которые принимают решение за вас.
Матрица возможностей на один экран
Возьмём двухуровневую иерархию: базовый класс Blog и производный RssBlog, добавляющий RssUrl. Три стратегии маппят это на три совершенно разные схемы, и каждый компромисс ниже вытекает из этой формы.
| Измерение | TPH | TPT | TPC |
|---|---|---|---|
| Создаваемые таблицы | одна, вся иерархия | одна на тип (включая абстрактные) | одна на конкретный тип |
| Столбец-дискриминатор | да | нет | нет |
| Столбцы производного типа | допускают null, общая таблица | своя таблица, могут быть NOT NULL | своя таблица, могут быть NOT NULL |
Запрос базового типа (context.Blogs) | один SELECT, без join | LEFT JOIN по всем таблицам | UNION ALL по конкретным таблицам |
Запрос одного листа (OfType<RssBlog>) | предикат дискриминатора | join базовой + листовой таблицы | одна таблица, без фильтра |
| Форма хранения | широкая, разреженная, много null | нормализованная, без null | денормализованная, столбцы повторяются |
| Генерация ключей | любая (Identity подходит) | любая (Identity на базе) | общая последовательность, без простого Identity |
| Ограничение FK на базовый тип | да | да | нет (ключ живёт в листовой таблице) |
| Комплексные типы / столбцы JSON | да | да (ново в EF Core 11) | да (ново в EF Core 11) |
| Чтение базового типа: относительная скорость | самое быстрое (эталон) | ~в 2 раза медленнее | ~как TPH |
| Позиция Microsoft | рекомендуемый по умолчанию | ”только если вынуждены” | хорошо для запросов одного листа |
Закономерность не тонкая. TPH выигрывает или идёт вровень почти в каждой значимой строке, TPC равен ему, кроме случаев межтиповых запросов, а TPT меняет более чистую на вид схему на join, которые обходятся вам во время запроса. Три из этих ячеек изменились в EF Core 11: комплексные типы и столбцы JSON теперь работают в иерархиях TPT и TPC, что ранее не поддерживалось и толкало людей обратно к владеемым сущностям для любого унаследованного объекта-значения. Это закрывает одну из последних не связанных с производительностью причин избегать TPT и TPC, но не меняет вердикт по производительности.
Что каждая стратегия действительно пишет в базу данных
Схемы делают абстрактные компромиссы конкретными. TPH — это одна таблица с дискриминатором и допускающими null производными столбцами:
-- TPH: EF Core 11, SQL Server
CREATE TABLE [Blogs] (
[BlogId] int NOT NULL IDENTITY,
[Url] nvarchar(max) NULL,
[Discriminator] nvarchar(max) NOT NULL,
[RssUrl] nvarchar(max) NULL, -- nullable: base Blogs have no RssUrl
CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);
TPT разделяет каждый тип на свою таблицу, связанные внешним ключом на общем первичном ключе:
-- TPT: EF Core 11, SQL Server
CREATE TABLE [Blogs] (
[BlogId] int NOT NULL IDENTITY,
[Url] nvarchar(max) NULL,
CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);
CREATE TABLE [RssBlogs] (
[BlogId] int NOT NULL,
[RssUrl] nvarchar(max) NULL,
CONSTRAINT [PK_RssBlogs] PRIMARY KEY ([BlogId]),
CONSTRAINT [FK_RssBlogs_Blogs_BlogId] FOREIGN KEY ([BlogId])
REFERENCES [Blogs] ([BlogId]) ON DELETE NO ACTION
);
TPC даёт каждому конкретному типу самодостаточную таблицу с повторением каждого унаследованного столбца, с ключами на основе общей последовательности:
-- TPC: EF Core 11, SQL Server
CREATE TABLE [Blogs] (
[BlogId] int NOT NULL DEFAULT (NEXT VALUE FOR [BlogSequence]),
[Url] nvarchar(max) NULL,
CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);
CREATE TABLE [RssBlogs] (
[BlogId] int NOT NULL DEFAULT (NEXT VALUE FOR [BlogSequence]),
[Url] nvarchar(max) NULL, -- inherited column, repeated here
[RssUrl] nvarchar(max) NULL,
CONSTRAINT [PK_RssBlogs] PRIMARY KEY ([BlogId])
);
Настройка каждой — это одна строка на корневой сущности. TPH — это значение по умолчанию, и ему ничего не нужно; TPT и TPC включаются вызовом стратегии маппинга:
// EF Core 11: choosing a strategy on the root entity type
modelBuilder.Entity<Blog>().UseTphMappingStrategy(); // default, can be omitted
modelBuilder.Entity<Blog>().UseTptMappingStrategy(); // one table per type
modelBuilder.Entity<Blog>().UseTpcMappingStrategy(); // one table per concrete type
Когда выбирать TPH
TPH — правильный ответ для подавляющего большинства иерархий. Выбирайте его, когда:
- Вы запрашиваете по иерархии. Любой код, читающий базовый тип (список всех строк
Payment, панель, смешивающаяCardPaymentиBankTransferPayment), под TPH — это сканирование одной индексированной таблицы. Нет ни join, ниUNION. Это самый частый паттерн доступа, и именно здесь TPT проваливается. - Иерархия неглубокая или производные типы добавляют мало столбцов. Два или три подтипа, каждый добавляющий горстку свойств, дают лишь слегка разреженную таблицу. Базы данных хорошо справляются с пустыми столбцами, а в SQL Server вы можете пометить редко заполняемые столбцы TPH как разреженные столбцы (sparse columns), чтобы вернуть место.
- Вам нужны простейшие записи. Вставка в TPH — это одна строка в одной таблице.
ExecuteUpdateиExecuteDeleteпротив производного типа применяют предикат дискриминатора за вас и затрагивают одну таблицу, что и есть чистый путь массовой записи, описанный в как использовать ExecuteUpdate и ExecuteDelete для массовых записей в EF Core 11. - Вам нужен внешний ключ на базовый тип. Поскольку каждая строка живёт в одной таблице, связь, указывающая на базовый тип, получает настоящее ограничение FK. TPC не может обеспечить это ограничение, как рассмотрено ниже.
Единственная цена, которую вы принимаете, — обязательное свойство на производном типе всё равно маппится на допускающий null столбец, потому что соседние строки оставляют его пустым. Если обеспеченная базой данных ненулевость на производных свойствах — жёсткое требование, это классическая причина уйти от TPH, и она указывает на TPT.
Когда выбирать TPC
TPC — специалист. Он вплотную сравнивается с TPH на межтиповых запросах и вырывается вперёд в одной конкретной форме:
- Вы почти всегда запрашиваете один листовой тип. Если ваш горячий путь — это
context.RssBlogs.Where(...)и редкоcontext.Blogs, TPC читает самодостаточную таблицу без фильтра дискриминатора и без join. Руководство Microsoft однозначно: TPC блистает “при запросе сущностей одного листового типа”. Измерьте его против TPH на ваших данных, прежде чем принимать решение, потому что выигрыш зависит от нагрузки. - Вам нужны ненулевые производные столбцы без join, как у TPT. Каждая таблица TPC содержит все столбцы конкретного типа inline, поэтому обязательное производное свойство может быть
NOT NULLв своей таблице, и чтение этого типа остаётся однотабличным. Это то свойство, которое TPT покупает с join, а TPC покупает без него.
Цена — денормализованная схема и неудобные ключи. TPC не может использовать простой столбец Identity, потому что нет единой таблицы, владеющей последовательностью; EF Core 11 по умолчанию использует общую последовательность базы данных (NEXT VALUE FOR [BlogSequence]), чтобы ключи оставались уникальными между соседними таблицами. В SQLite, где нет последовательностей, генерация целочисленных ключей для TPC недоступна, и вы переходите на GUID, генерируемые на стороне клиента. А поскольку первичный ключ базового типа может жить в любой конкретной таблице, внешний ключ, ссылающийся на базовый тип, вообще не может быть обеспечен ограничением базы данных. Если все ваши записи проходят через EF Core с навигациями, это обычно нормально, но это реальная потеря целостности на уровне базы данных.
Когда выбирать TPT (и почему ответ обычно “не выбирайте”)
TPT производит схему, которая больше всего похожа на вашу диаграмму классов: одна таблица на тип, соединённые по ключу. Эта эстетика — ловушка. Обращайтесь к TPT только когда:
- Внешнее ограничение диктует схему. DBA требует нормализованную таблицу на тип, устаревшая схема, которую вы не можете изменить, уже выглядит так, или другая система читает потиповые таблицы напрямую. Это те случаи “вынуждены поступить так из-за внешних факторов”, которые называет Microsoft.
- Вам действительно нужны потиповые таблицы с ограничениями FK и ненулевыми производными столбцами, а межтиповые запросы редки. Это узкое пересечение, и даже тогда сначала стоит сделать бенчмарк против TPC.
Не выбирайте TPT потому что он ощущается чище. Каждый запрос базового типа делает join по всему набору таблиц, а join — один из главных источников проблем производительности в реляционных базах. Цифры это подтверждают, о чём следующий раздел.
Бенчмарк: TPT обходится примерно в 2x
Это не пустые слова. Собственный бенчмарк наследования от Microsoft строит иерархию из 7 типов, засевает 5000 строк на тип (всего 35000 строк) и загружает каждую строку из базы данных. Результаты:
| Метод | Среднее | Аллокировано |
|---|---|---|
| TPH | 149.0 ms | 40 MB |
| TPT | 312.9 ms | 75 MB |
| TPC | 158.2 ms | 46 MB |
TPT примерно в 2,1 раза медленнее TPH и аллокирует почти вдвое больше памяти, потому что загрузка иерархии делает join семи таблиц. TPC в этом запросе всех типов держится в пределах примерно 6 процентов от TPH и вырвался бы вперёд в запросе одного листового типа, где он читает одну таблицу, а TPH всё ещё сканирует общую таблицу с фильтром дискриминатора. Методология важна: это запрос базового типа, затрагивающий каждую таблицу, что является самым слабым случаем и для TPC, и для TPT, поэтому разрыв, который вы увидите на своей нагрузке, зависит от того, как часто вы запрашиваете межтипово против одного листа. И всё же вывод стабилен между прогонами: TPT платит налог на join, которого TPH и TPC не платят, и никакой аргумент эстетики схемы его не возвращает.
Запустите бенчмарк против собственной модели, прежде чем принимать необратимое решение. Смена стратегии наследования после появления промышленных данных означает миграцию схемы, перемещающую строки между таблицами, поэтому это решение стоит измерить один раз, на раннем этапе.
Ловушки, которые решают за вас
Три ограничения могут решить выбор стратегии независимо от предпочтений.
Первое — обеспеченная базой данных ненулевость на производном свойстве. TPH не может этого сделать, потому что общий столбец должен допускать null для соседних строк. Если вам нужно, чтобы база данных (а не только ваше приложение) гарантировала, что у каждого CardPayment есть Last4, вам нужен этот столбец в своей таблице, что означает TPT или TPC.
Второе — генерация ключей на вашей базе данных. TPC нужны последовательности для целочисленных ключей. В SQL Server это автоматически, но в SQLite вы вообще не можете использовать целочисленные ключи-идентификаторы с TPC и должны перейти на GUID. Если вы на SQLite и хотите целочисленные ключи, TPC отпадает.
Третье — целостность внешнего ключа на базовый тип. Если другие таблицы ссылаются на ваш базовый тип и вы хотите, чтобы база данных обеспечивала эти ссылки, TPC не может дать вам ограничение. TPH и TPT могут. Одно это исключает TPC для многих нормализованных схем.
Одно одинаково для всех трёх: вы не можете изменить тип сущности во время выполнения. Превратить CardPayment в BankTransferPayment — это удаление плюс вставка в любой стратегии, потому что дискриминатор (или сама таблица) кодирует тип. Это реальность моделирования, а не отличительная черта.
Рекомендация, сказанная прямо
Используйте TPH по умолчанию. Он самый быстрый для частого межтипового запроса, самый простой для записи, единственная стратегия без трения при генерации ключей и рекомендуемый Microsoft вариант по умолчанию для широкого спектра сценариев. Обращайтесь к TPC только когда ваша нагрузка доминируется запросами одного листового типа и бенчмарк на ваших данных показывает, что он превосходит TPH, и примите денормализованную схему, ключи из общей последовательности и отсутствие ограничения FK на базовый тип, которые к этому прилагаются. Используйте TPT только когда внешний фактор не оставляет вам выбора, и делайте это, зная, что вы платите налог на запрос примерно в 2x за схему, которая выглядит опрятнее.
Ментальная модель та же, что навязывают цифры: одна таблица быстра, много таблиц, соединённых join, медленны, а много таблиц без join быстры, но денормализованы. Если это решение — часть более широкого обновления версии, изменения наследования и маппинга обычно всплывают вместе с теми, что описаны в руководстве по миграции с EF Core 6 на EF Core 11.
Похожее чтение
- Как настроить маппинг наследования table-per-hierarchy (TPH) в EF Core 11 — полный разбор TPH: API дискриминатора, общие столбцы и правило столбцов, допускающих null.
- Комплексные типы vs владеемые сущности в EF Core 11 охватывает маппинг объектов-значений, который теперь работает внутри иерархий TPT и TPC.
- Как маппить и запрашивать столбцы JSON в EF Core 11 объясняет хранение JSON, которое иерархии наследования получили в EF Core 11.
- Как использовать ExecuteUpdate и ExecuteDelete для массовых записей в EF Core 11 показывает однотабличный путь массовой записи, который TPH делает чистым.
- Как обнаружить запросы N+1 в EF Core 11 помогает поймать нагруженные join паттерны запросов, которые может поощрять TPT.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.