Clé primaire composite dans la base de données SQL Server à locataires multiples

16

Je crée une application multi-locataire (base de données unique, schéma unique) en utilisant l'API Web ASP, Entity Framework et la base de données SQL Server / Azure. Cette application sera utilisée par 1000 à 5000 clients. Toutes les tables auront un champ TenantId(Guid / UNIQUEIDENTIFIER). En ce moment, j'utilise la clé primaire à champ unique qui est Id (Guid). Mais en utilisant uniquement le champ Id, je dois vérifier si les données fournies par l'utilisateur proviennent de / pour le bon locataire. Par exemple, j'ai une SalesOrdertable qui a un CustomerIdchamp. Chaque fois que les utilisateurs publient / mettent à jour une commande client, je dois vérifier si elle CustomerIdprovient du même locataire. Cela empire car chaque locataire peut avoir plusieurs points de vente. Ensuite, je dois vérifier TenantIdet OutletId. C'est vraiment un cauchemar de maintenance et mauvais pour la performance.

Je pense à ajouter TenantIdà la clé primaire avec Id. Et éventuellement ajouter OutletIdaussi. Ainsi , la clé primaire de la SalesOrdertable sera: Id, TenantIdet OutletId. Quel est l'inconvénient de cette approche? Est-ce que les performances nuiraient gravement à l'utilisation d'une clé composite? L'ordre des clés composites est-il important? Existe-t-il de meilleures solutions à mon problème?

Reynaldi
la source

Réponses:

34

Ayant travaillé sur un système multi-locataire à grande échelle (approche fédérée avec des clients répartis sur 18+ serveurs, chaque serveur ayant un schéma identique, des clients différents et des milliers de transactions par seconde par serveur), je peux dire:

  1. Il y a des gens (quelques-uns, au moins) qui seront d'accord sur votre choix de GUID comme ID pour "TenantID" et pour toute entité "ID". Mais non, pas un bon choix. Toutes les autres considérations mises à part, ce choix à lui seul nuira de plusieurs façons: la fragmentation pour commencer, de grandes quantités d'espace gaspillé (ne dites pas que le disque est bon marché lorsque vous pensez au stockage d'entreprise - SAN - ou que les requêtes prennent plus de temps en raison de chaque page de données contenant moins de lignes que ce ne pourrait être avec INTou BIGINTmême), un support et une maintenance plus difficiles, etc. Les GUID sont parfaits pour la portabilité. Les données sont-elles générées dans un système puis transférées dans un autre? Sinon, passer à un type de données plus compact (par exemple TINYINT, SMALLINT, INTou même .BIGINT ), et augmentation séquentielle par l' intermédiaire IDENTITYouSEQUENCE

  2. Avec l'élément 1 à l'écart, vous devez vraiment avoir le champ TenantID dans CHAQUE table qui contient des données utilisateur. De cette façon, vous pouvez filtrer n'importe quoi sans avoir besoin d'un JOIN supplémentaire. Cela signifie également que TOUTES les requêtes sur les tables de données client doivent avoir la TenantIDcondition JOIN et / ou la clause WHERE. Cela permet également de garantir que vous ne mélangez pas accidentellement les données de différents clients ou que vous n'affichez pas les données du locataire A du locataire B.

  3. Je pense à ajouter TenantId comme clé primaire avec Id. Et éventuellement ajouter OutletId aussi. Les clés primaires de la table des commandes client seront donc Id, TenantId, OutletId.

    Oui, vos index cluster sur les tables de données client doivent être des clés composites, y compris TenantIDet ID ** . Cela garantit également que se TenantIDtrouve dans chaque index NonClustered (car ceux-ci incluent les clés d'index cluster) dont vous auriez besoin de toute façon puisque 98,45% des requêtes sur les tables de données client auront besoin de la TenantID(la principale exception est lorsque la collecte des ordures d'anciennes données est basée sur surCreatedDate et non sur les soins TenantID).

    Non, vous n'incluriez pas les FK tels que OutletIDle PK. Le PK doit identifier la ligne de manière unique, et l'ajout de FK ne serait pas utile. En fait, cela augmenterait les chances de doublons de données, en supposant que OrderID était unique pour chacun TenantID, par opposition à unique pour chaqueOutletID dans chacun TenantID.

    En outre, il n'est pas nécessaire d'ajouter OutletIDau PK afin de garantir que les sorties du locataire A ne soient pas mélangées avec le locataire B. Étant donné que toutes les tables de données utilisateur auront TenantIDdans le PK, cela signifie TenantIDégalement dans les FK . Par exemple, la Outlettable a un PK de (TenantID, OutletID), et la Ordertable a un PK de (TenantID, OrderID) et un FK (TenantID, OutletID)qui fait référence au PK sur la Outlettable. Des FK correctement définis empêcheront les données du locataire de se mélanger.

  4. L'ordre des clés composites est-il important?

    Eh bien, c'est là que ça devient amusant. Il y a un débat sur le domaine qui devrait venir en premier. La règle "typique" pour concevoir de bons index est de choisir le champ le plus sélectif pour être le champ de tête. TenantID, par sa nature même, ne sera pas le domaine le plus sélectif; le IDchamp est le champ le plus sélectif. Voici quelques réflexions:

    • ID en premier: c'est le champ le plus sélectif (c'est-à-dire le plus unique). Mais en étant un champ d'incrémentation automatique (ou aléatoire s'il utilise toujours des GUID), les données de chaque client sont réparties sur chaque table. Cela signifie qu'il y a des moments où un client a besoin de 100 lignes, et cela nécessite près de 100 pages de données lues à partir du disque (pas rapide) dans le pool de mémoire tampon (prenant plus d'espace que 10 pages de données). Cela augmente également les conflits sur les pages de données car il sera plus fréquent que plusieurs clients aient besoin de mettre à jour la même page de données.

      Cependant, vous ne rencontrez généralement pas autant de problèmes de reniflage de paramètres / de plan mis en cache incorrect, car les statistiques sur les différentes valeurs d'ID sont assez cohérentes. Vous n'obtiendrez peut-être pas les plans les plus optimaux, mais vous aurez moins de chances d'en obtenir des horribles. Cette méthode sacrifie essentiellement (légèrement) les performances de tous les clients pour bénéficier de problèmes moins fréquents.

    • TenantID d'abord:Ce n'est pas du tout sélectif. Il peut y avoir très peu de variations sur 1 million de lignes si vous ne disposez que de 100 TenantID. Mais les statistiques de ces requêtes sont plus précises car SQL Server sait qu'une requête pour le locataire A retirera 500 000 lignes, mais que la même requête pour le locataire B n'est que de 50 lignes. C'est là que se situe le principal point douloureux. Cette méthode augmente considérablement les risques de problèmes de reniflage de paramètres lorsque la première exécution d'une procédure stockée concerne le locataire A et agit de manière appropriée en fonction de l'optimiseur de requête qui voit ces statistiques et sait qu'il doit être efficace pour obtenir 500 000 lignes. Mais lorsque le locataire B, avec seulement 50 lignes, s'exécute, ce plan d'exécution n'est plus approprié, et en fait, est tout à fait inapproprié. ET, étant donné que les données ne sont pas insérées dans l'ordre du champ de tête,

      Cependant, pour que le premier TenantID exécute une procédure stockée, les performances doivent être meilleures que dans l'autre approche, car les données (au moins après la maintenance de l'index) seront organisées physiquement et logiquement de sorte que beaucoup moins de pages de données sont nécessaires pour satisfaire la requêtes. Cela signifie moins d'E / S physiques, moins de lectures logiques, moins de conflits entre les locataires pour les mêmes pages de données, moins d'espace gaspillé occupé dans le pool de tampons (d'où une amélioration de l'espérance de vie des pages), etc.

      Il existe deux coûts principaux pour obtenir ces performances améliorées. Le premier n'est pas si difficile: vous devez faire une maintenance régulière de l'index pour contrer l'augmentation de la fragmentation. Le second est un peu moins amusant.

      Afin de contrer l'augmentation des problèmes de détection de paramètres, vous devez séparer les plans d'exécution entre les locataires. L'approche simpliste consiste à utiliser WITH RECOMPILEsur procs ou l' OPTION (RECOMPILE)indice de requête, mais c'est un coup sur les performances qui pourrait effacer tous les gains réalisés en mettant en TenantIDpremier. La méthode que j'ai trouvée la plus efficace consiste à utiliser Dynamic SQL paramétré via sp_executesql. La raison d'avoir besoin de Dynamic SQL est de permettre la concaténation de TenantID dans le texte de la requête, tandis que tous les autres prédicats qui seraient normalement des paramètres sont toujours des paramètres. Par exemple, si vous recherchiez une commande particulière, vous feriez quelque chose comme:

      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;

      Cela a pour effet de créer un plan de requête réutilisable pour cet ID de locataire qui correspondra au volume de données de ce locataire particulier. Si ce même locataire A exécute à nouveau la procédure stockée pour un autre, @OrderIDil réutilisera ce plan de requête mis en cache. Un locataire différent exécutant la même procédure stockée générerait un texte de requête différent uniquement dans la valeur de l'ID de locataire, mais toute différence dans le texte de la requête suffit pour générer un plan différent. Et le plan généré pour le locataire B correspondra non seulement au volume de données pour le locataire B, mais il sera également réutilisable pour le locataire B pour différentes valeurs de @OrderID(puisque ce prédicat est toujours paramétré).

      Les inconvénients de cette approche sont:

      • C'est un peu plus de travail que de simplement taper une simple requête (mais toutes les requêtes ne doivent pas nécessairement être Dynamic SQL, juste celles qui finissent par avoir le problème de reniflage des paramètres).
      • Selon le nombre de clients hébergés sur un système, cela augmente la taille du cache de plan, car chaque requête nécessite désormais 1 plan par TenantID qui l'appelle. Ce n'est peut-être pas un problème, mais c'est au moins quelque chose à savoir.
      • Dynamic SQL rompt la chaîne de propriété, ce qui signifie que l'accès en lecture / écriture aux tables ne peut pas être supposé en ayant l' EXECUTEautorisation sur la procédure stockée. La solution simple mais moins sécurisée consiste simplement à donner à l'utilisateur un accès direct aux tables. Ce n'est certainement pas l'idéal, mais c'est généralement le compromis rapide et facile. L'approche la plus sécurisée consiste à utiliser la sécurité basée sur les certificats. Cela signifie, créez un certificat, puis créez un utilisateur à partir de ce certificat, accordez à cet utilisateur les autorisations souhaitées (un utilisateur ou une connexion basé sur un certificat ne peut pas se connecter à SQL Server seul), puis signez les procédures stockées qui utilisent Dynamic SQL avec ce même certificat via ADD SIGNATURE .

        Pour plus d'informations sur la signature de module et les certificats, veuillez consulter: ModuleSigning.Info
         

    Veuillez consulter la section MISE À JOUR vers la fin pour des sujets supplémentaires liés à la question du traitement des problèmes d'atténuation des statistiques résultant de cette décision.


** Personnellement, je n'aime vraiment pas utiliser simplement "ID" pour le nom du champ PK sur chaque table car il n'est pas significatif et il n'est pas cohérent entre les FK car le PK est toujours "ID" et le champ de la table enfant doit inclure le nom de la table parent. Par exemple: Orders.ID-> OrderItems.OrderID. Je trouve qu'il est beaucoup plus facile de traiter un modèle de données qui a: Orders.OrderID-> OrderItems.OrderID. Il est plus lisible et réduit le nombre de fois que vous obtiendrez l'erreur "référence de colonne ambiguë" :-).


MISE À JOUR

  • Est- ce que OPTIMIZE FOR UNKNOWN Conseil de requête (introduit dans SQL Server 2008) aide soit avec commande du composite PK?

    Pas vraiment. Cette option contourne les problèmes de détection de paramètres, mais elle remplace simplement un problème par un autre. Dans ce cas, plutôt que de se souvenir des informations statistiques pour les valeurs des paramètres de l'exécution initiale de la procédure stockée ou de la requête paramétrée (ce qui est certainement génial pour certains, mais potentiellement médiocre pour certains, et potentiellement horrible pour certains), il utilise un général statistique de la distribution des données pour estimer le nombre de lignes. Il s'agit d'un résultat aléatoire sur le nombre (et dans quelle mesure) les requêtes seront affectées positivement, négativement ou pas du tout. Au moins avec le reniflage de paramètres, certaines requêtes étaient garanties de bénéficier. Si votre système a des locataires avec des volumes de données très variés, cela peut potentiellement nuire aux performances de toutes les requêtes.

    Cette option accomplit la même chose que la copie des paramètres d'entrée dans des variables locales puis l'utilisation des variables locales dans la requête (j'ai testé ceci mais pas de place ici). Des informations supplémentaires peuvent être trouvées dans cet article de blog: http://www.brentozar.com/archive/2013/06/optimize-for-unknown-sql-server-parameter-sniffing/ . En lisant les commentaires, Daniel Pepermans est arrivé à une conclusion similaire à la mienne concernant l'utilisation de Dynamic SQL qui a une variation limitée.

  • Si ID est le champ principal de l'index clusterisé, est-il utile / suffisant d'avoir un index non clusterisé sur (TenantID, ID), ou simplement (TenantID) pour avoir des statistiques précises pour les requêtes qui traitent de nombreuses lignes d'un seul locataire?

    Oui, ça aiderait. Le grand système sur lequel j'ai mentionné travailler pendant des années était basé sur une conception d'index ayant le IDENTITYchamp comme champ principal parce qu'il s'agissait de problèmes de reniflage de paramètres plus sélectifs et réduits. Cependant, lorsque nous avons dû opérer contre une bonne partie des données d'un locataire particulier, la performance n'a pas résisté. En fait, un projet de migration de toutes les données vers de nouvelles bases de données a dû être mis en attente car les contrôleurs SAN étaient au maximum en termes de débit. Le correctif consistait à ajouter des index non clusterisés à toutes les tables de données des locataires pour être juste (TenantID). Pas besoin de le faire (TenantID, ID) car l'ID est déjà dans l'index clusterisé, de sorte que la structure interne de l'index non clusterisé était naturellement (TenantID, ID).

    Bien que cela ait résolu le problème immédiat de pouvoir effectuer des requêtes basées sur TenantID beaucoup plus efficacement, ils n'étaient toujours pas aussi efficaces qu'ils auraient pu l'être si c'était l'Index clusterisé qui était dans le même ordre. Et, maintenant, nous avions encore un index de plus sur chaque table. Cela a augmenté la quantité d'espace SAN que nous utilisions, a augmenté la taille de nos sauvegardes, allongé la durée des sauvegardes, augmenté le potentiel de blocage et de blocages, diminué les performances INSERTet les DELETEopérations, etc.

    ET il nous restait encore l'inefficacité générale d'avoir les données d'un locataire réparties sur de nombreuses pages de données, mélangées avec de nombreuses autres données du locataire. Comme je l'ai mentionné ci-dessus, cela augmente le nombre de conflits sur ces pages et remplit le pool de tampons avec de nombreuses pages de données contenant 1 ou 2 lignes utiles, en particulier lorsque certaines des lignes de ces pages étaient destinées à des clients qui étaient inactifs mais n'avaient pas encore été ramassés. Il y a beaucoup moins de potentiel de réutilisation des pages de données dans le pool de tampons dans cette approche, donc notre espérance de vie de page était assez faible. Et cela signifie plus de temps à retourner sur le disque pour charger plus de pages.

Solomon Rutzky
la source
2
Avez-vous envisagé ou testé OPTIMIZE FOR UNKNOWN dans cet espace problématique? Juste curieux.
RLF
1
@RLF Oui, nous avons recherché cette option, et elle ne devrait au moins pas être meilleure, et peut-être pire, que les performances moins qu'optimales que nous obtenions en ayant d'abord le champ IDENTITY. Je ne me souviens pas où j'ai lu ceci, mais il donne soi-disant les mêmes statistiques "moyennes" que la réaffectation d'un paramètre d'entrée à une variable locale. Mais cet article explique pourquoi cette option ne résout pas vraiment le problème: brentozar.com/archive/2013/06/… En lisant les commentaires, Daniel Pepermans est arrivé à une conclusion similaire concernant Dynamic SQL avec une variation limitée :)
Solomon Rutzky
3
Que se passe-t-il si l'index cluster est activé (ID, TenantID)et que vous créez également un index non clusterisé (TenantID, ID), ou simplement (TenantID)pour avoir des statistiques précises pour les requêtes qui traitent la plupart des lignes d'un locataire unique?
Vladimir Baranov,
1
@VladimirBaranov Excellente question. Je l'ai abordé dans une nouvelle section UPDATE vers la fin de la réponse :-).
Solomon Rutzky
4
joli point sur le sql dynamique pour générer des plans pour chaque client.
Max Vernon