Après avoir blogué sur la façon dont les index filtrés pourraient être plus puissants, et plus récemment sur la façon dont ils peuvent être rendus inutiles par un paramétrage forcé, je reviens sur le sujet des index filtrés/paramétrage. Une solution apparemment trop simple est apparue récemment au travail, et j'ai dû la partager.
Prenons l'exemple suivant, où nous avons une base de données de ventes contenant une table de commandes. Parfois, nous voulons juste une liste (ou un décompte) des seules commandes qui n'ont pas encore été expédiées - qui, au fil du temps, (espérons-le !) représentent un pourcentage de plus en plus petit du tableau global :
CREATE DATABASE Sales; GO USE Sales; GO -- simplified, obviously: CREATE TABLE dbo.Orders ( OrderID int IDENTITY(1,1) PRIMARY KEY, OrderDate datetime NOT NULL, filler char(500) NOT NULL DEFAULT '', IsShipped bit NOT NULL DEFAULT 0 ); GO -- let's put some data in there; 7,000 shipped orders, and 50 unshipped: INSERT dbo.Orders(OrderDate, IsShipped) -- random dates over two years SELECT TOP (7000) DATEADD(DAY, ABS(object_id % 730), '20171101'), 1 FROM sys.all_columns UNION ALL -- random dates from this month SELECT TOP (50) DATEADD(DAY, ABS(object_id % 30), '20191201'), 0 FROM sys.all_columns;
Dans ce scénario, il peut être judicieux de créer un index filtré comme celui-ci (qui permet de traiter rapidement toutes les requêtes qui tentent d'accéder à ces commandes non expédiées) :
CREATE INDEX ix_OrdersNotShipped ON dbo.Orders(IsShipped, OrderDate) WHERE IsShipped = 0;
Nous pouvons exécuter une requête rapide comme celle-ci pour voir comment il utilise l'index filtré :
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0;
Le plan d'exécution est assez simple, mais il y a un avertissement concernant UnmatchedIndexes :
Le nom de l'avertissement est légèrement trompeur - l'optimiseur a finalement pu utiliser l'index, mais suggère qu'il serait « mieux » sans paramètres (que nous n'avons pas explicitement utilisés), même si l'instruction semble avoir été paramétrée :
Si vous le souhaitez vraiment, vous pouvez éliminer l'avertissement, sans aucune différence dans les performances réelles (ce ne serait que cosmétique). Une façon consiste à ajouter un prédicat à impact nul, comme AND (1 > 0)
:
SELECT wadd = OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 AND (1 > 0);
Une autre (probablement plus courante) consiste à ajouter OPTION (RECOMPILE)
:
SELECT wrecomp = OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 OPTION (RECOMPILE);
Ces deux options donnent le même plan (une recherche sans avertissement) :
Jusqu'ici tout va bien; notre index filtré est utilisé (comme prévu). Ce ne sont pas les seules astuces, bien sûr; voir les commentaires ci-dessous pour les autres que les lecteurs ont déjà soumis.
Ensuite, la complication
Étant donné que la base de données est soumise à un grand nombre de requêtes ad hoc, quelqu'un active le paramétrage forcé, tentant de réduire la compilation et d'éliminer les plans à usage faible et à usage unique de la pollution du cache de plans :
ALTER DATABASE Sales SET PARAMETERIZATION FORCED;
Maintenant, notre requête d'origine ne peut pas utiliser l'index filtré ; il est obligé d'analyser l'index cluster :
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0;
L'avertissement concernant les index sans correspondance revient et nous obtenons de nouveaux avertissements concernant les E/S résiduelles. Notez que l'instruction est paramétrée, mais elle semble un peu différente :
C'est par conception, puisque le but de la paramétrisation forcée est de paramétrer des requêtes comme celle-ci. Mais cela va à l'encontre de l'objectif de notre index filtré, car il est destiné à prendre en charge une seule valeur dans le prédicat, et non un paramètre susceptible de changer.
Sottises
Notre requête "astuce" qui utilise le prédicat supplémentaire est également incapable d'utiliser l'index filtré, et se retrouve avec un plan légèrement plus compliqué pour démarrer :
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 AND (1 > 0);
OPTION (RECOMPILER)
La réaction typique dans ce cas, tout comme avec la suppression de l'avertissement plus tôt, est d'ajouter OPTION (RECOMPILE)
à la déclaration. Cela fonctionne, et permet de choisir l'index filtré pour une recherche efficace…
SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0 OPTION (RECOMPILE);
…mais en ajoutant OPTION (RECOMPILE)
et prendre ce coup de compilation supplémentaire contre chaque exécution de la requête ne sera pas toujours acceptable dans les environnements à volume élevé (surtout s'ils sont déjà liés au processeur).
Conseils
Quelqu'un a suggéré d'indiquer explicitement l'index filtré pour éviter les coûts de recompilation. En général, c'est plutôt fragile, car il s'appuie sur l'index qui survit au code; J'ai tendance à l'utiliser en dernier recours. Dans ce cas, il n'est pas valide de toute façon. Lorsque les règles de paramétrage empêchent l'optimiseur de sélectionner automatiquement l'index filtré, elles vous empêchent également de le sélectionner manuellement. Même problème avec un FORCESEEK
générique indice :
SELECT OrderID, OrderDate FROM dbo.Orders WITH (INDEX (ix_OrdersNotShipped)) WHERE IsShipped = 0; SELECT OrderID, OrderDate FROM dbo.Orders WITH (FORCESEEK) WHERE IsShipped = 0;
Les deux génèrent cette erreur :
Msg 8622, Niveau 16, État 1Le processeur de requête n'a pas pu produire de plan de requête en raison des indications définies dans cette requête. Soumettez à nouveau la requête sans spécifier d'indications et sans utiliser SET FORCEPLAN.
Et cela a du sens, car il n'y a aucun moyen de savoir que la valeur inconnue pour le IsShipped
correspondra à l'index filtré (ou prendra en charge une opération de recherche sur n'importe quel index).
SQL dynamique ?
J'ai suggéré que vous puissiez utiliser le SQL dynamique, pour au moins ne payer que ce hit de recompilation lorsque vous savez que vous voulez atteindre le plus petit index :
DECLARE @IsShipped bit = 0; DECLARE @sql nvarchar(max) = N'SELECT dynsql = OrderID, OrderDate FROM dbo.Orders' + CASE WHEN @IsShipped IS NOT NULL THEN N' WHERE IsShipped = @IsShipped' ELSE N'' END + CASE WHEN @IsShipped = 0 THEN N' OPTION (RECOMPILE)' ELSE N'' END; EXEC sys.sp_executesql @sql, N'@IsShipped bit', @IsShipped;
Cela conduit au même plan efficace que ci-dessus. Si vous avez changé la variable en @IsShipped = 1
, vous obtenez alors l'analyse d'index clusterisée la plus coûteuse à laquelle vous devriez vous attendre :
Mais personne n'aime utiliser SQL dynamique dans un cas limite comme celui-ci - cela rend le code plus difficile à lire et à maintenir, et même si ce code était dans l'application, c'est toujours une logique supplémentaire qui devrait être ajoutée là-bas, ce qui le rend moins que souhaitable .
Quelque chose de plus simple
Nous avons brièvement parlé de l'implémentation d'un guide de plan, ce qui n'est certainement pas plus simple, mais un collègue a ensuite suggéré que vous pourriez tromper l'optimiseur en "cachant" l'instruction paramétrée dans une procédure stockée, une vue ou une fonction table en ligne. C'était si simple, je ne pensais pas que ça marcherait.
Mais ensuite j'ai essayé :
CREATE PROCEDURE dbo.GetUnshippedOrders AS BEGIN SET NOCOUNT ON; SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0; END GO CREATE VIEW dbo.vUnshippedOrders AS SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0; GO CREATE FUNCTION dbo.fnUnshippedOrders() RETURNS TABLE AS RETURN (SELECT OrderID, OrderDate FROM dbo.Orders WHERE IsShipped = 0); GO
Ces trois requêtes effectuent la recherche efficace sur l'index filtré :
EXEC dbo.GetUnshippedOrders; GO SELECT OrderID, OrderDate FROM dbo.vUnshippedOrders; GO SELECT OrderID, OrderDate FROM dbo.fnUnshippedOrders();
Conclusion
J'ai été surpris que ce soit si efficace. Bien sûr, cela vous oblige à changer d'application; si vous ne pouvez pas modifier le code de l'application pour appeler une procédure stockée ou référencer la vue ou la fonction (ou même ajouter OPTION (RECOMPILE)
), vous devrez continuer à chercher d'autres options. Mais si vous pouvez modifier le code de l'application, insérer le prédicat dans un autre module peut être la solution.