Составной первичный ключ в мультитенантной базе данных SQL Server


16

Я создаю мультитенантное приложение (одна база данных, одна схема) с использованием ASP Web API, Entity Framework и базы данных SQL Server / Azure. Это приложение будет использоваться 1000-5000 клиентов. Все таблицы будут иметь поле TenantId(Guid / UNIQUEIDENTIFIER). Прямо сейчас я использую единственное поле Primary Key, которое является Id (Guid). Но используя только поле Id, я должен проверить, являются ли данные, предоставленные пользователем, от / для правильного арендатора. Например, у меня есть SalesOrderтаблица с CustomerIdполем. Каждый раз, когда пользователи публикуют / обновляют заказ на продажу, я должен проверить, принадлежит ли CustomerIdон одному и тому же арендатору. Становится хуже, потому что у каждого арендатора может быть несколько торговых точек. Тогда я должен проверить TenantIdи OutletId. Это действительно кошмар обслуживания и плохо для производительности.

Я думаю добавить TenantIdв Первичный Ключ вместе с Id. И, возможно, добавить OutletIdтоже. Таким образом, первичный ключ в SalesOrderтаблице будет: Id, TenantIdи OutletId. В чем недостаток этого подхода? Сильно ли повредит производительность при использовании составного ключа? Имеет ли значение составной порядок ключей? Есть ли лучшие решения для моей проблемы?

Ответы:


34

Работая в крупномасштабной мультитенантной системе (федеративный подход с распределением клиентов по 18+ серверам, каждый сервер имеет одинаковую схему, просто разные клиенты и тысячи транзакций в секунду на каждый сервер), я могу сказать:

  1. Есть некоторые люди (по крайней мере, несколько), которые согласятся с выбором GUID в качестве идентификаторов для «TenantID» и любого другого «ID» объекта. Но нет, не очень хороший выбор. Помимо других соображений, один только этот выбор повредит нескольким причинам: фрагментация, с которой нужно начинать, огромное количество неиспользуемого пространства (не говорите, что диск стоит дешево, если думать о корпоративном хранилище - SAN - или запросы занимают больше времени из-за каждой страницы данных удержание меньшего числа строк, чем при использовании одного INTили BIGINTдаже нескольких элементов, более сложная поддержка и обслуживание и т. д. GUID отлично подходят для переносимости. Генерируются ли данные в одной системе, а затем переносятся в другую? Если нет, то перейти к более компактного типа данных (например TINYINT, SMALLINT, INT, или даже BIGINT), и приращение последовательно с помощью IDENTITYилиSEQUENCE,

  2. Если исключить пункт 1, вам действительно нужно иметь поле TenantID в КАЖДОЙ таблице, содержащей пользовательские данные. Таким образом, вы можете фильтровать что угодно, не нуждаясь в дополнительном JOIN. Это также означает, что ВСЕ запросы к таблицам клиентских данных должны иметь TenantIDусловие in JOIN и / или предложение WHERE. Это также помогает гарантировать, что вы случайно не смешаете данные от разных клиентов или не отобразите данные об арендаторе A от арендатора B.

  3. Я думаю добавить TenantId в качестве первичного ключа вместе с Id. И, возможно, добавить OutletId тоже. Таким образом, первичными ключами в таблице заказов на продажу будут Id, TenantId, OutletId.

    Да, ваши кластеризованные индексы в таблицах клиент-данных должны быть составными ключами, включая TenantIDи ID ** . Это также гарантирует, что он TenantIDесть в каждом некластеризованном индексе (поскольку он включает ключи кластерного индекса), который вам в любом случае понадобится, поскольку 98,45% запросов к таблицам данных клиента потребуются TenantID(главное исключение - когда сбор мусора происходит на основе старых данных). на CreatedDateи не заботясь о TenantID).

    Нет, вы не включили бы такие FK, как OutletIDв PK. PK должен однозначно идентифицировать строку, и добавление в FK не поможет с этим. Фактически, это увеличило бы шансы для дублированных данных, предполагая, что OrderID уникален для каждого TenantID, а не уникален для каждого OutletIDв каждом TenantID.

    Кроме того, нет необходимости добавлять OutletIDв PK, чтобы гарантировать, что Розетки Арендатора A не перепутаны с Арендатором B. Поскольку все таблицы пользовательских данных будут иметь TenantIDв PK, это означает, что TenantIDони также будут в FK. , Например, в Outletтаблице есть PK (TenantID, OutletID), а в Orderтаблице - PK (TenantID, OrderID) и FK, на (TenantID, OutletID)которые ссылается PK в Outletтаблице. Правильно определенные FK предотвратят смешивание данных Арендатора.

  4. Имеет ли значение составной порядок ключей?

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

    • Сначала ID: это самое избирательное (то есть самое уникальное) поле. Но, будучи полем автоматического увеличения (или случайным, если все еще используют GUID), данные каждого клиента распределяются по каждой таблице. Это означает, что иногда клиенту требуется 100 строк, а для этого требуется почти 100 страниц данных, считываемых с диска (не быстро) в буферный пул (занимающих больше места, чем 10 страниц данных). Это также увеличивает конкуренцию на страницах данных, так как более частым потребностям нескольких пользователей потребуется обновить одну и ту же страницу данных.

      Однако обычно вы не сталкиваетесь с таким количеством проблем, связанных с анализом параметров / плохим кэшированием плана, а с тем, что статистика по различным значениям идентификаторов довольно непротиворечива. Возможно, вы не получите самые оптимальные планы, но у вас будет меньше шансов получить ужасные. Этот метод существенно жертвует производительностью (незначительно) для всех клиентов, чтобы получить выгоду от менее частых проблем.

    • TenantID первый:Это очень не избирательно вообще. Может быть очень мало вариаций на 1 миллион строк, если у вас есть только 100 TenantID. Но статистика для этих запросов является более точной, поскольку SQL Server будет знать, что запрос для Арендатора A отзовет 500 000 строк, но этот же запрос для Арендатора B - только 50 строк. Вот где главная болевая точка. Этот метод значительно повышает вероятность возникновения проблем с отслеживанием параметров, когда первый запуск хранимой процедуры выполняется для арендатора A и действует соответствующим образом на основе оптимизатора запросов, который просматривает эту статистику и знает, что она должна быть эффективной для получения строк по 500 тыс. Но когда запускается Арендатор B, имеющий только 50 строк, этот план выполнения больше не подходит и, на самом деле, совершенно неуместен. И, так как данные не вставляются в порядке ведущего поля,

      Однако для первого TenantID, выполняющего хранимую процедуру, производительность должна быть выше, чем в другом подходе, поскольку данные (по крайней мере, после обслуживания индекса) будут физически и логически организованы таким образом, что для удовлетворения требований потребуется гораздо меньше страниц данных. запросы. Это означает, что меньше физического ввода-вывода, меньше логических чтений, меньше конфликтов между арендаторами за одни и те же страницы данных, меньше потраченного впустую пространства в буферном пуле (следовательно, улучшенная продолжительность жизни страниц) и т. Д.

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

      Чтобы противодействовать возросшим проблемам отслеживания параметров, необходимо разделить планы выполнения между арендаторами. Упрощенный подход заключается в использовании WITH RECOMPILEна процессах или OPTION (RECOMPILE)подсказке запроса, но это удар по производительности, который может уничтожить все выгоды, достигнутые на TenantIDпервом месте. Метод, который я нашел, работал лучше всего - использовать параметризованный динамический SQL через sp_executesql. Причина, по которой нужен динамический SQL, заключается в том, чтобы разрешить объединение TenantID в текст запроса, в то время как все другие предикаты, которые обычно являются параметрами, по-прежнему являются параметрами. Например, если вы искали определенный ордер, вы бы сделали что-то вроде:

      DECLARE @GetOrderSQL NVARCHAR(MAX);
      SET @GetOrderSQL = N'
        SELECT ord.field1, ord.field2, etc.
        FROM   dbo.Orders ord
        WHERE  ord.TenantID = ' + CONVERT(NVARCHAR(10), @TenantID) + N'
        AND    ord.OrderID = @OrderID_dyn;
      ';
      
      EXEC sp_executesql
         @GetOrderSQL,
         N'@OrderID_dyn INT',
         @OrderID_dyn = @OrderID;

      В результате создается план запроса многократного использования только для этого TenantID, который будет соответствовать объему данных этого конкретного Tenant. Если тот же Арендатор A снова выполнит хранимую процедуру для другой, @OrderIDон будет повторно использовать этот кэшированный план запросов. Другой Арендатор, выполняющий ту же Хранимую Процедуру, сгенерирует текст запроса, который отличается только по значению TenantID, но любой разницы в тексте запроса достаточно для создания другого плана. И план, сгенерированный для Арендатора B, будет не только соответствовать объему данных для Арендатора B, но также будет многократно использоваться для Арендатора B для различных значений @OrderID(так как этот предикат все еще параметризован).

      Недостатками этого подхода являются:

      • Это немного больше работы, чем просто ввод простого запроса (но не все запросы должны быть Dynamic SQL, только те, которые заканчиваются проблемой перехвата параметров).
      • В зависимости от того, сколько Арендаторов в системе, это увеличивает размер кэша планов, поскольку для каждого запроса теперь требуется 1 план на каждый вызывающий его TenantID. Это не может быть проблемой, но, по крайней мере, о чем-то нужно знать.
      • Динамический SQL разрывает цепочку владения, что означает, что доступ на чтение / запись к таблицам не может быть получен при наличии EXECUTEразрешения на хранимую процедуру. Простое, но менее безопасное решение - дать пользователю прямой доступ к таблицам. Это, конечно, не идеально, но обычно это быстрый и легкий компромисс. Более безопасный подход заключается в использовании безопасности на основе сертификатов. То есть, создать сертификат, затем создать пользователя из этого сертификата, предоставить этому пользователю необходимые разрешения (пользователь или логин на основе сертификата не могут подключиться к SQL Server самостоятельно), а затем подписать хранимые процедуры, использующие динамический SQL, с этим тот же сертификат через ДОБАВИТЬ ПОДПИСЬ .

        Для получения дополнительной информации о подписании модулей и сертификатах, пожалуйста, смотрите: ModuleSigning.Info
         

    Пожалуйста, смотрите раздел ОБНОВЛЕНИЕ ближе к концу для дополнительных тем, связанных с проблемой решения проблем статистики, вытекающих из этого решения.


** Лично я действительно не люблю использовать просто «ID» для имени поля PK в каждой таблице, поскольку оно не имеет смысла, и оно не является единообразным для всех FK, поскольку PK всегда является «ID» и поле в дочерней таблице должно включите имя родительской таблицы. Например: Orders.ID-> OrderItems.OrderID. Я считаю, что гораздо проще иметь дело с моделью данных, которая имеет: Orders.OrderID-> OrderItems.OrderID. Он более читабелен и сокращает количество раз, когда вы получите ошибку «неоднозначная ссылка на столбец» :-).


ОБНОВИТЬ

  • Поможет ли OPTIMIZE FOR UNKNOWN Query Hint (введенный в SQL Server 2008) в любом порядке составного PK?

    На самом деле, нет. Эта опция позволяет обойти проблемы с анализом параметров, но она просто заменяет одну проблему другой. В этом случае, вместо запоминания статистической информации для значений параметров начального запуска хранимой процедуры или параметризованного запроса (что, безусловно, отлично подходит для некоторых, но потенциально посредственно для некоторых и потенциально ужасно для некоторых), он использует общие статистика распределения данных для оценки количества строк. Это случайность того, сколько (и в какой степени) запросов будут затронуты положительно, отрицательно или вообще не затронуты. По крайней мере, с анализом параметров некоторые запросы гарантированно выиграли. Если в вашей системе есть Арендаторы с сильно различающимися объемами данных, это может снизить производительность для всех запросов.

    Эта опция выполняет то же самое, что и копирование входных параметров в локальные переменные, а затем использование локальных переменных в запросе (я проверил это, но здесь для этого нет места). Дополнительную информацию можно найти в этом сообщении блога: http://www.brentozar.com/archive/2013/06/optimize-for-unknown-sql-server-parameter-sniffing/ . Читая комментарии, Даниэль Пеперманс пришел к выводу, аналогичному моему, относительно использования динамического SQL, который имеет ограниченные вариации.

  • Если идентификатор является ведущим полем в кластерном индексе, будет ли полезно / достаточно иметь некластеризованный индекс (TenantID, ID) или просто (TenantID), чтобы иметь точную статистику для запросов, обрабатывающих много строк одного арендатора?

    Да, это поможет. Большая система, над которой я работал несколько лет, основывалась на дизайне индекса, в котором IDENTITYполе было ведущим, поскольку оно было более избирательным и уменьшало проблемы с анализом параметров. Однако, когда нам нужно было работать с хорошей частью данных конкретного арендатора, производительность не поддерживалась. Фактически, проект по переносу всех данных в новые базы данных пришлось приостановить, поскольку контроллеры SAN были доведены до максимума с точки зрения пропускной способности. Исправление состояло в том, чтобы добавить некластеризованные индексы во все таблицы данных арендатора, чтобы они были справедливыми (TenantID). В этом нет необходимости (TenantID, ID), поскольку ID уже находится в кластерном индексе, поэтому внутренняя структура некластерного индекса была естественной (TenantID, ID).

    Хотя это и решило насущную проблему, заключающуюся в возможности выполнять запросы на основе TenantID гораздо более эффективно, они все же были не такими эффективными, какими они могли бы быть, если бы это был Кластерный индекс в том же порядке. И теперь у нас был еще один индекс для каждой таблицы. Это увеличило объем используемого пространства SAN, увеличило размер наших резервных копий, сделало резервное копирование дольше, увеличило вероятность блокировок и взаимоблокировок, снизило производительность INSERTи DELETEоперации и т. Д.

    И мы все еще оставались с общей неэффективностью распределения данных Арендатора по многим страницам данных, смешанных со многими данными Арендатора. Как я упоминал выше, это увеличивает количество конфликтов на этих страницах и заполняет буферный пул множеством страниц данных, содержащих 1 или 2 полезных строки, особенно когда некоторые строки на этих страницах предназначены для клиентов, которые были неактивны, но еще не были собраны мусором. В этом подходе гораздо меньше возможностей для повторного использования страниц данных в пуле буферов, поэтому наша ожидаемая продолжительность жизни страниц была довольно низкой. А это означает, что больше времени будет возвращаться на диск для загрузки большего количества страниц


2
Вы рассматривали или тестировали ОПТИМИЗАЦИЮ ДЛЯ НЕИЗВЕСТНО в этом проблемном пространстве? Просто любопытно.
RLF

1
@RLF Да, мы исследовали этот вариант, и он должен быть, по крайней мере, не лучше, а, возможно, и хуже, чем неоптимальная производительность, которую мы получили, имея поле IDENTITY первым. Я не помню, где я читал это, но он, вероятно, дает те же «средние» характеристики, что и переназначение входного параметра для локальной переменной. Но в этой статье рассказывается, почему этот вариант не решает проблему: brentozar.com/archive/2013/06/… Прочитав комментарии, Даниэль Пеперманс пришел к аналогичному выводу: динамический SQL с ограниченными вариациями :)
Соломон Руцки

3
Что, если кластеризованный индекс включен, (ID, TenantID)и вы также создаете некластеризованный индекс (TenantID, ID)или просто (TenantID)хотите получать точную статистику для запросов, которые обрабатывают большинство строк одного арендатора?
Владимир Баранов

1
@VladimirBaranov Отличный вопрос. Я обратился к нему в новом разделе ОБНОВЛЕНИЕ ближе к концу ответа :-).
Соломон Руцкий

4
Приятный момент о динамическом sql для генерации планов для каждого клиента.
Макс Вернон
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.