Складений первинний ключ у базі даних SQL Server


16

Я будую додаток для кількох орендарів (одна база даних, одна схема) за допомогою веб-API ASP, Entity Framework та бази даних SQL Server / Azure. Цей додаток використовуватимуть 1000-5000 клієнтів. Усі таблиці матимуть TenantId(Guid / UNIQUEIDENTIFIER) поле. Зараз я використовую єдиний первинний ключ, який є Id (Guid). Але, використовуючи лише поле Id, я повинен перевірити, чи дані, надані користувачем, походять від / для правильного орендаря. Наприклад, у мене є SalesOrderтаблиця, в якій є CustomerIdполе. Кожен раз, коли користувачі розміщують / оновлюють замовлення на продаж, я повинен перевіряти, чи CustomerIdє той самий орендар. Це стає гірше, тому що кожен орендар може мати кілька торгових точок. Тоді я повинен перевірити TenantIdі OutletId. Це справді кошмар технічного обслуговування і поганий на продуктивність.

Я думаю додати TenantIdдо Первинного ключа разом із Id. І, можливо OutletId, теж додати . Таким чином, первинний ключ в SalesOrderтаблиці буде: Id, TenantIdі OutletId. У чому полягає недолік цього підходу? Чи не зашкодило б виступ, використовуючи складений ключ? Чи має значення складений порядок ключів? Чи є кращі рішення для моєї проблеми?

Відповіді:


34

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

  1. Є кілька людей (принаймні декілька), які погоджуються з вибором GUID як ідентифікаторів як для "TenantID", так і для будь-якої іншої особи "ID". Але ні, не вдалий вибір. Усі інші міркування, окрім цього, лише один вибір зашкодить декількома способами: фрагментація для початку, велика кількість витраченого простору (не кажіть, що диск коштує дешево, коли замислюєтесь про сховище підприємства - SAN - або запити, що займають більше часу через кожну сторінку даних утримуючи меншу кількість рядків, ніж це могло бути INTабо BIGINTнавіть), більш складна підтримка та обслуговування тощо. Чи дані генеруються в якійсь системі, а потім передаються іншій? Якщо немає, то перейти до більш компактного типу даних (наприклад TINYINT, SMALLINT, INT, або навіть BIGINT), і приріст послідовно з допомогою IDENTITYабоSEQUENCE.

  2. Якщо пункт 1 не виходить, вам дійсно потрібно мати поле TenantID у КОЖНІй таблиці, де є дані користувача. Таким чином ви можете фільтрувати що завгодно, не потребуючи додаткового ПРИЄДНАННЯ. Це також означає, що ВСІ запити проти таблиць даних клієнта повинні містити TenantIDумову JOIN та / або WHERE. Це також допомагає гарантувати, що ви випадково не змішуєте дані різних клієнтів або не показуєте дані орендаря А від орендаря В.

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

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

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

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

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

    Ну ось де весело. Існує певна дискусія щодо того, яке поле має стати першим. "Типовим" правилом проектування хороших індексів є вибір найбільш селективного поля, яке буде провідним. TenantIDза своєю суттю не буде найбільш вибірковим полем; IDполе є найбільш селективним поле. Ось кілька думок:

    • Перший ідентифікатор: Це найбільш селективне (тобто найбільш унікальне) поле. Але, будучи полем автоматичного збільшення (або випадковим, якщо все ще використовуються GUID), дані кожного клієнта поширюються по всій таблиці. Це означає, що бувають випадки, коли клієнту потрібно 100 рядків, і для цього потрібно майже 100 сторінок даних, які читаються з диска (не швидко) в буферний пул (займаючи більше місця, ніж 10 сторінок даних). Це також збільшує суперечки на сторінках даних, оскільки буде частіше, що кілька клієнтів потребуватимуть оновлення однієї сторінки даних.

      Однак, як правило, ви не стикаєтеся з такою кількістю проблем щодо планування нюху / поганого кешування параметрів, оскільки статистика для різних значень ідентифікатора є досить послідовною. Можливо, ви не отримаєте найоптимальніших планів, але у вас буде менше шансів отримати жахливі. Цей метод, по суті, жертвує ефективністю для всіх клієнтів, щоб отримати користь від менш частих проблем.

    • Перший орендар:Це зовсім не вибірково. У 1 мільйон рядків може бути дуже мало варіацій, якщо у вас є лише 100 TenantID. Але статистика цих запитів є більш точною, оскільки SQL Server буде знати, що запит для Орендаря А поверне 500 000 рядків, але той самий запит для Тенатора Б становить лише 50 рядків. Саме тут головна больова точка. Цей метод значно збільшує шанси виникнення проблем з обнюхуванням параметрів, коли перший запуск збереженої процедури призначений для орендаря А і діє належним чином на основі Оптимізатора запитів, який бачить цю статистику і знаючи, що вона повинна бути ефективною, отримуючи 500 к рядків. Але коли орендатор B має лише 50 рядів, то план виконання більше не підходить, а насправді є зовсім недоречним. ТА, оскільки дані не вставляються в порядку головного поля,

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

      Для досягнення цієї покращеної продуктивності є дві основні витрати. Перше не так складно: потрібно регулярно підтримувати індекс, щоб протидіяти посиленій фрагментації. Друга - трохи менш весела.

      Щоб протидіяти збільшенню проблем з обнюхуванням параметрів, вам потрібно розділити плани виконання між Орендарями. Спрощений підхід полягає у використанні WITH RECOMPILEдля проектів або OPTION (RECOMPILE)підказки запиту, але це хіт на продуктивність, який може знищити всі отримані прибутки, поставивши TenantIDпершими. Метод, який я знайшов найкращим, - це використовувати параметризований Dynamic SQL через sp_executesql. Причина необхідності Dynamic 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, який буде відповідати обсягу даних саме цього Орендатора. Якщо той самий Орендар A виконає збережену процедуру ще раз для іншого, @OrderIDвін повторно використає цей кешований план запитів. Інший Орендар, який виконує ту саму Збережену процедуру, генерував би текст запиту, який відрізнявся лише за значенням TenantID, але будь-якої різниці в тексті запиту достатньо для створення іншого плану. І план, згенерований для Орендаря B, не тільки відповідатиме обсягу даних для Орендаря В, але він також буде повторно використаний для Орендаря В для різних значень @OrderID(оскільки цей предикат все ще параметризований).

      Недоліками цього підходу є:

      • Це трохи більше роботи, ніж просто введення простого запиту (але не всі запити повинні бути Dynamic SQL, лише ті, у кого виникають проблеми з нюханням параметрів).
      • Залежно від кількості орендарів у системі, це збільшує розмір кешу плану, оскільки зараз для кожного запиту потрібен 1 план на кожний TenantID, який його викликає. Це може не бути проблемою, але це хоча б щось, що слід пам’ятати.
      • Dynamic SQL розбиває ланцюг власності, що означає, що доступ для читання / запису до таблиць неможливо припустити, маючи EXECUTEдозвіл на процедуру зберігання. Просте, але менш безпечне виправлення - це просто надати користувачеві прямий доступ до таблиць. Це, звичайно, не ідеально, але це, як правило, компроміс швидко і легко. Більш безпечний підхід - використовувати захист на основі сертифікатів. Значить, створіть сертифікат, а потім створіть користувача з цього сертифіката, надайте цьому користувачеві потрібні дозволи (користувач, що базується на сертифікаті, або авторизація не може самостійно підключитися до сервера SQL), а потім підпишіть збережені процедури, що використовують динамічний SQL з цим той самий сертифікат через ДОПОМОГА ПІДПИС .

        Більш детальну інформацію про підписання модуля та сертифікати див. У розділі: ModuleSigning.Info
         

    До кінця див. Розділ " ОНОВЛЕННЯ ", щоб отримати додаткові теми, пов'язані з проблемою пом'якшення питань статистики, що виникають внаслідок цього рішення.


** Особисто мені дуже не подобається використовувати лише "ID" для імені поля ПК на кожній таблиці, оскільки це не має сенсу, і це не відповідає всім FK, оскільки ПК завжди є "ідентифікатором", а поле в дочірній таблиці повинне включити ім'я батьківської таблиці. Наприклад: Orders.ID-> OrderItems.OrderID. Мені набагато простіше працювати з моделлю даних, яка має: Orders.OrderID-> OrderItems.OrderID. Він легше читається і зменшує кількість разів, коли ви отримаєте помилку "неоднозначне посилання на стовпець" :-).


ОНОВЛЕННЯ

  • Чи допоможе OPTIMIZE FOR UNKNOWN Підказка щодо запитів (запроваджена в SQL Server 2008) при впорядкуванні складеного ПК?

    Не зовсім. Цей параметр вирішує проблеми з обнюхуванням параметрів, але він просто замінює одну проблему іншою. У цьому випадку, замість запам'ятовування статистичної інформації для значень параметрів початкового запуску збереженої процедури або параметризованого запиту (що, безумовно, чудово для деяких, але потенційно посереднє для деяких і потенційно жахливе для деяких), воно використовує загальне статистика розподілу даних для оцінки кількості рядків. Це враження пропуску щодо того, на скільки (і в якій мірі) запитів буде впливати позитивно, негативно, або взагалі немає. Принаймні, при обнюхуванні параметрами деякі запити були гарантовано вигідні. Якщо у вашій системі є орендарі з широким обсягом даних, це може потенційно погіршити продуктивність для всіх запитів.

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

  • Якщо ID - це провідне поле в кластерному індексі, чи допоможе / достатньо мати некластеризований індекс на (TenantID, ID) або просто (TenantID), щоб мати точну статистику для запитів, які обробляють багато рядків одного орендаря?

    Так, це допомогло б. Велика система, про яку я згадував, працювала протягом багатьох років, ґрунтувалася на індексному дизайні, що це IDENTITYполе є провідним полем, оскільки воно було більш вибірковим і зменшувало проблеми нюху параметрів. Однак, коли нам потрібно було проводити операції з хорошою частиною конкретних даних Орендаря, продуктивність не витримала. Насправді проект з міграції всіх даних до нових баз даних повинен був зупинитися, оскільки контролери SAN були виведені з точки зору пропускної здатності. Виправлення полягало в тому, щоб додати некластеризовані індекси до всіх таблиць даних орендарів, щоб бути справедливим (TenantID). Не потрібно робити (TenantID, ID), оскільки ID вже є в індексі кластеру, тому внутрішня структура Некластеризованного індексу була природно (TenantID, ID).

    Незважаючи на те, що це вирішило негайну проблему можливості робити запити на основі TenantID набагато ефективніше, вони все ще були не настільки ефективними, як це могло б бути, якби індекс кластеру був у тому ж порядку. І тепер у нас був ще один індекс на кожній таблиці. Це збільшило кількість простору SAN, яке ми використовували, збільшило розмір резервних копій, зробило резервування копій довше, щоб збільшити потенціал для блокування та тупиків, знизила продуктивність INSERTта DELETEоперації тощо.

    І ми все ще залишалися загальною неефективністю розповсюдження даних Орендаря на багатьох сторінках даних, змішаних з багатьма іншими даними Орендаря. Як я вже згадував вище, це збільшує кількість суперечок на цих сторінках, і це заповнює буферний пул безліччю сторінок даних, що містять у них 1 або 2 корисних рядка, особливо коли деякі рядки на цих сторінках були для клієнтів, які були неактивними, але ще не збирали сміття. Існує набагато менший потенціал для повторного використання сторінок даних у буферному басейні при такому підході, тому наша тривалість життя сторінки була досить низькою. А це означає більше часу повернутися на диск, щоб завантажити більше сторінок.


2
Чи розглядали чи тестували ви ОПТИМІЗУВАННЯ ДЛЯ НЕЗНАЧЕНОГО в цьому проблемному просторі? Просто цікаво.
RLF

1
@RLF Так, ми дослідили цей варіант, і він повинен бути принаймні не кращим, а можливо, і гіршим, ніж меншою, ніж оптимальна продуктивність, яку ми отримували спочатку з поля ІДЕНТИЧНОСТІ. Я не пам'ятаю, де я це читав, але він нібито дає таку саму "середню" статистику, як перепризначення вхідного параму локальній змінній. Але в цій статті йдеться про те, чому цей варіант насправді не вирішує проблему: 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.