Start Debugging

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. Три стратегии маппят это на три совершенно разные схемы, и каждый компромисс ниже вытекает из этой формы.

ИзмерениеTPHTPTTPC
Создаваемые таблицыодна, вся иерархияодна на тип (включая абстрактные)одна на конкретный тип
Столбец-дискриминаторданетнет
Столбцы производного типадопускают null, общая таблицасвоя таблица, могут быть NOT NULLсвоя таблица, могут быть NOT NULL
Запрос базового типа (context.Blogs)один SELECT, без joinLEFT 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 — правильный ответ для подавляющего большинства иерархий. Выбирайте его, когда:

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

Когда выбирать TPC

TPC — специалист. Он вплотную сравнивается с TPH на межтиповых запросах и вырывается вперёд в одной конкретной форме:

Цена — денормализованная схема и неудобные ключи. TPC не может использовать простой столбец Identity, потому что нет единой таблицы, владеющей последовательностью; EF Core 11 по умолчанию использует общую последовательность базы данных (NEXT VALUE FOR [BlogSequence]), чтобы ключи оставались уникальными между соседними таблицами. В SQLite, где нет последовательностей, генерация целочисленных ключей для TPC недоступна, и вы переходите на GUID, генерируемые на стороне клиента. А поскольку первичный ключ базового типа может жить в любой конкретной таблице, внешний ключ, ссылающийся на базовый тип, вообще не может быть обеспечен ограничением базы данных. Если все ваши записи проходят через EF Core с навигациями, это обычно нормально, но это реальная потеря целостности на уровне базы данных.

Когда выбирать TPT (и почему ответ обычно “не выбирайте”)

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

Не выбирайте TPT потому что он ощущается чище. Каждый запрос базового типа делает join по всему набору таблиц, а join — один из главных источников проблем производительности в реляционных базах. Цифры это подтверждают, о чём следующий раздел.

Бенчмарк: TPT обходится примерно в 2x

Это не пустые слова. Собственный бенчмарк наследования от Microsoft строит иерархию из 7 типов, засевает 5000 строк на тип (всего 35000 строк) и загружает каждую строку из базы данных. Результаты:

МетодСреднееАллокировано
TPH149.0 ms40 MB
TPT312.9 ms75 MB
TPC158.2 ms46 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.

Похожее чтение

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад