# Garder un FULLSCAN sur certaines statistiques avec IndexOptimize

> Quand une seule table a besoin de statistiques en FULLSCAN pour garder le bon plan : comment la trouver, fixer son échantillon avec PERSIST_SAMPLE_PERCENT et garder un seul job IndexOptimize.
>
> Source : https://www.pachadata.com/docs/howtos/maintenance-des-bases/statistiques-fullscan-persistant/
> Auteur : Rudi Bruchez · publié le 2026-10-20 · mis à jour le 2026-10-24
> Version Markdown de la page, pour les outils et les LLM. La page HTML fait référence.


*Article coécrit avec Claude, qui a mené les mesures et la lecture du code d'IndexOptimize.*

> TL;DR :
>
> Si l'optimiseur produit un bon plan seulement après un `UPDATE STATISTICS ... WITH FULLSCAN` sur une table précise, fixez l'échantillon de cette statistique avec `PERSIST_SAMPLE_PERCENT = ON`. Le job `IndexOptimize` d'Ola Hallengren peut rester un seul appel, sans `@StatisticsSample` : SQL Server applique de lui-même le `FULLSCAN` à cette statistique et l'échantillon par défaut aux statistiques sans taux fixé, mises à jour automatiques comprises.


## L'échantillon des statistiques

Quand SQL Server calcule une statistique sans qu'on lui dise combien de lignes lire, il lit un échantillon de pages, c'est-à-dire quelques pourcents d'une grosse table. C'est le cas de la mise à jour automatique (`AUTO_UPDATE_STATISTICS`) et d'un `UPDATE STATISTICS` sans option. Sur la plupart des colonnes, l'échantillon suffit. Sur une colonne dont les valeurs sont groupées sur les pages, par exemple un identifiant de commande dans une table de lignes écrites à la suite, l'échantillon voit chaque valeur bien plus souvent qu'elle n'apparaît en réalité. Il sous-estime alors le nombre de valeurs distinctes, et surestime le nombre de lignes par valeur. Le chapitre [Statistiques et cardinalité](https://www.pachadata.com/docs/optimiser-sql-server/chapitres/065.statistiques-et-cardinalite/) explique ce que l'optimiseur fait de ces chiffres.

L'option `PERSIST_SAMPLE_PERCENT` d'[`UPDATE STATISTICS`](https://learn.microsoft.com/fr-fr/sql/t-sql/statements/update-statistics-transact-sql) existe depuis SQL Server 2016 SP1 CU4 et SQL Server 2017 CU1. Elle enregistre le taux d'échantillonnage d'une statistique, et toutes les mises à jour qui ne précisent pas de taux le reprennent ensuite. Vous voyez le taux enregistré dans la colonne `persisted_sample_percent` de `sys.dm_db_stats_properties`.

Cet article part d'un cas réel observé chez un client : une requête avec une jointure sur six tables, devenue lente un matin après le changement d'échantillonnage du recalcul de statistiques dans les scripts d'Ola Hallengren.
Il peut être tentant de forcer globalement le recalcul de statistiques en `FULLSCAN`, mais cela peut produire de très longues plages de maintenance. Chez ce client, cela durait quatre à cinq heures chaque nuit. Nous avons donc décidé d'enlever le `FULLSCAN` pour laisser l'échantillonnage par défaut.
Mais le lendemain, une requête est devenue beaucoup plus lente.

En regardant le plan d'exécution, nous avons essayé de recalculer les statistiques en `FULLSCAN` d'une première table qui semblait être un bon candidat, sans succès.
Ce n'est qu'après avoir essayé la troisième table que le plan a changé et que la requête a retrouvé sa durée habituelle.

Restait à agir de façon chirurgicale dans le plan de maintenance d'Ola Hallengren, sans faire passer toutes les bases en `FULLSCAN` chaque nuit.

## Reproduire le changement de plan

Pour la démonstration, je reproduis un cas similaire en utilisant la base `PachaDataFormation`, ma base de données d'exemple habituelle, sur SQL Server 2022 CU26.
La table `Elearning.Activite` contient 7,8 millions de lignes, environ 107 par inscription, écrites à la suite pour chaque inscription.
Je crée d'abord un index sur la colonne de jointure (une dizaine de secondes) :

```sql
USE PachaDataFormation;
CREATE INDEX nix_activite_inscription ON Elearning.Activite (InscriptionId);
```

Je recalcule sa statistique avec l'échantillon par défaut, puis je regarde combien de lignes ont été lues et la densité obtenue :

```sql
USE PachaDataFormation;
UPDATE STATISTICS Elearning.Activite nix_activite_inscription;

SELECT sp.rows, sp.rows_sampled, sp.persisted_sample_percent
FROM sys.dm_db_stats_properties(OBJECT_ID('Elearning.Activite'),
     INDEXPROPERTY(OBJECT_ID('Elearning.Activite'), 'nix_activite_inscription', 'IndexID')) AS sp;

DBCC SHOW_STATISTICS ('Elearning.Activite', nix_activite_inscription) WITH DENSITY_VECTOR;
```

L'échantillon lit 225 160 lignes sur 7 805 406, soit 2,9 %. La densité vaut 2,97E-5, ce qui correspond à 33 600 inscriptions distinctes, alors que la table en contient 72 578. L'optimiseur compte donc en moyenne 232 activités par inscription, au lieu de 107.

Voici la requête, qui additionne les durées d'activité des inscrits à une série de sessions :

```sql
USE PachaDataFormation;
SET STATISTICS IO, TIME ON;

SELECT SUM(a.DureeSecondes)
FROM Inscription.Inscription AS i
JOIN Elearning.Activite AS a ON a.InscriptionId = i.InscriptionId
WHERE i.SessionId BETWEEN 10000 AND 10020
OPTION (RECOMPILE);

SET STATISTICS IO, TIME OFF;
```

Pour se rendre compte de l'erreur d'estimation de cardinalité, il faut afficher le plan après exécution (`Ctrl+M` dans SSMS, ou `SET STATISTICS XML ON`).
Avec l'échantillon par défaut, l'optimiseur estime 185 inscriptions et environ 232 activités pour chacune : 42 910 lignes à la sortie de la jointure. Il choisit une jointure de hachage (*Hash Match*) et parcourt toute la table `Elearning.Activite` dans la phase de sondage (*probe*), qui parcourt la table pour chercher les correspondances une fois la table de hachage construite : 60 043 lectures logiques, 1 000 ms de CPU, 515 ms écoulées. La jointure produit en réalité 16 810 lignes, pour 196 inscriptions.

Plan d'exécution : Échantillon par défaut : jointure de hachage et parcours de `Elearning.Activite`. ([plan-1-echantillon-hachage.sqlplan](https://www.pachadata.com/docs/howtos/maintenance-des-bases/statistiques-fullscan-persistant/plan-1-echantillon-hachage.sqlplan), showplan XML)


## Trouver la table sur laquelle agir

Dans le plan réel, cherchez l'opérateur où les lignes estimées s'éloignent le plus des lignes réelles. Ensuite, recalculez en `FULLSCAN` les statistiques d'une seule table à la fois, et recompilez la requête entre deux essais. Vous saurez ainsi laquelle compte. Ici, la table d'en-tête n'y est pour rien :

```sql
USE PachaDataFormation;
UPDATE STATISTICS Inscription.Inscription WITH FULLSCAN;
-- relancer la requête : toujours la jointure de hachage et 60 043 lectures sur Activite
```

La table de détail, elle, change tout :

```sql
USE PachaDataFormation;
UPDATE STATISTICS Elearning.Activite nix_activite_inscription WITH FULLSCAN;
-- relancer la requête
```

Avec la statistique calculée sur toutes les lignes, l'estimation tombe à 19 879 lignes, proche des 16 810 réelles. L'optimiseur passe à une boucle imbriquée (*Nested Loops*) : une recherche dans l'index par inscription, puis une recherche de ligne dans le tas (*RID Lookup*). La requête lit 17 526 pages au lieu de 60 043, et s'exécute en 17 ms au lieu de 515.

Plan d'exécution : Statistique en `FULLSCAN` : boucle imbriquée, recherche dans l'index et *RID Lookup*. ([plan-4-fullscan-boucle.sqlplan](https://www.pachadata.com/docs/howtos/maintenance-des-bases/statistiques-fullscan-persistant/plan-4-fullscan-boucle.sqlplan), showplan XML)


La jointure de hachage n'est pas plus rapide en soi : elle l'est quand la jointure utilise une grande partie de la table. Ici, elle lit les 7,8 millions d'activités pour n'en garder que 16 810, soit 0,2 %. La boucle ne touche que ces 16 810 lignes. Le travail réel est d'ailleurs le même quelle que soit l'estimation, puisque la requête renvoie les mêmes lignes : avec l'échantillon par défaut et l'indicateur `WITH (FORCESEEK)` sur `Elearning.Activite`, le plan en boucle lit aussi 17 526 pages et s'exécute en 16 à 33 ms. Un simple `OPTION (LOOP JOIN)` ne suffit pas : il impose la boucle, mais le plan garde le parcours complet de `Elearning.Activite` (60 043 lectures) et la requête prend 2,5 s, cinq fois plus que le hachage. C'est la recherche dans l'index qui fait gagner la boucle, pas la boucle elle-même. L'optimiseur l'a écartée parce que son modèle de coût compte chaque *RID Lookup* comme une lecture aléatoire sur disque, et qu'à 42 910 lignes estimées ces lectures coûtaient plus cher que le parcours de la table. En réalité, les pages sont en mémoire, et les activités d'une même inscription, écrites à la suite, tombent dans les mêmes pages. Une surestimation pousse ainsi vers le hachage et le parcours complet ; une sous-estimation pousse vers la boucle, qui devient alors la mauvaise solution.

Plan d'exécution : Échantillon par défaut avec `WITH (FORCESEEK)` : la boucle et la recherche dans l'index. ([plan-2-echantillon-forceseek.sqlplan](https://www.pachadata.com/docs/howtos/maintenance-des-bases/statistiques-fullscan-persistant/plan-2-echantillon-forceseek.sqlplan), showplan XML)


Plan d'exécution : Échantillon par défaut avec `OPTION (LOOP JOIN)` : la boucle, mais avec le parcours complet de `Elearning.Activite`. ([plan-3-echantillon-loop-join.sqlplan](https://www.pachadata.com/docs/howtos/maintenance-des-bases/statistiques-fullscan-persistant/plan-3-echantillon-loop-join.sqlplan), showplan XML)


Un essai ne suffit pas toujours à conclure, parce que le plan ne bascule qu'au-delà d'un seuil. Avec l'échantillon par défaut, l'optimiseur passe au hachage entre 100 et 150 inscriptions estimées ; avec le `FULLSCAN`, entre 270 et 360. En dessous de 100, les deux statistiques donnent le même plan. Mesurez donc sur la requête réelle, avec ses paramètres réels.

## Fixer l'échantillon de cette statistique

Une fois la statistique trouvée, fixez son échantillon. En T-SQL :

```sql
USE PachaDataFormation;
UPDATE STATISTICS Elearning.Activite nix_activite_inscription
WITH FULLSCAN, PERSIST_SAMPLE_PERCENT = ON;
```

Ou avec `IndexOptimize`, en un appel ponctuel limité à cette statistique par `@Indexes` (nom en quatre parties : base, schéma, table, index) :

```sql
EXECUTE dbo.IndexOptimize
    @Databases = 'PachaDataFormation',
    @FragmentationLow = NULL,
    @FragmentationMedium = NULL,
    @FragmentationHigh = NULL,
    @UpdateStatistics = 'ALL',
    @Indexes = 'PachaDataFormation.Elearning.Activite.nix_activite_inscription',
    @StatisticsSample = 100,
    @StatisticsPersistSample = 'Y';
```

`IndexOptimize` (version du 12 septembre 2026) traduit cet appel en `UPDATE STATISTICS [Elearning].[Activite] [nix_activite_inscription] WITH FULLSCAN, PERSIST_SAMPLE_PERCENT = ON`. `@StatisticsPersistSample` ne s'utilise qu'avec `@StatisticsSample`, jamais avec `@StatisticsResample = 'Y'`, et la procédure le refuse avant SQL Server 2017 CU1. Sur un SQL Server 2016, passez par le T-SQL.

Pour lister les statistiques dont l'échantillon est fixé dans une base :

```sql
USE PachaDataFormation;
SELECT OBJECT_SCHEMA_NAME(s.object_id) AS schema_name,
       OBJECT_NAME(s.object_id) AS table_name,
       s.name AS statistique,
       sp.rows, sp.rows_sampled, sp.persisted_sample_percent
FROM sys.stats AS s
CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp
WHERE sp.persisted_sample_percent > 0;
```

## Le job de maintenance

Le job de nuit ne change pas : un seul appel à `IndexOptimize`, sans `@StatisticsSample`. Sans ce paramètre, la procédure écrit `UPDATE STATISTICS [schéma].[table] [statistique]` sans clause `WITH`, et SQL Server choisit l'échantillon. Chaque fois que le job met à jour la statistique fixée, il reprend son `FULLSCAN` ; pour les statistiques sans taux fixé, il prend l'échantillon par défaut. Avec `@OnlyModifiedStatistics = 'Y'`, une statistique dont aucune ligne n'a changé est simplement sautée.

```sql
EXECUTE dbo.IndexOptimize
    @Databases = 'USER_DATABASES',
    @UpdateStatistics = 'ALL',
    @OnlyModifiedStatistics = 'Y';
```

Je l'ai vérifié sur les deux tables de la démonstration, avec `@Indexes` limité à elles : après le passage, `nix_activite_inscription` a lu ses 7 805 406 lignes, alors que `pk_Activite` et les statistiques de `Inscription.Inscription`, qui étaient en `FULLSCAN` avant le passage, sont revenues à l'échantillon par défaut (225 160 et 83 319 lignes).

Sans `PERSIST_SAMPLE_PERCENT`, il faudrait deux appels : un premier sur la table qui compte, avec `@Indexes = 'PachaDataFormation.Elearning.Activite'` et `@StatisticsSample = 100`, un second sur tout le reste, avec `@Indexes = 'ALL_INDEXES, -PachaDataFormation.Elearning.Activite'`. Cela fonctionne, mais seulement la nuit : la mise à jour automatique suivante, en journée, revient à l'échantillon par défaut et le plan bascule de nouveau. Le taux fixé sur la statistique, lui, s'applique aussi à la mise à jour automatique.

## Ce qui garde l'échantillon, et ce qui l'efface

Mesuré sur SQL Server 2022 CU26, sur une table d'un million de lignes dont une statistique est fixée à 100 % :

| Opération | Lignes lues ensuite | Taux enregistré |
|---|---|---|
| `UPDATE STATISTICS` sans option (forme d'`IndexOptimize`) | toutes | 100 |
| `UPDATE STATISTICS ... WITH RESAMPLE` | toutes | 100 |
| Mise à jour automatique, après modification d'un tiers des lignes | toutes | 100 |
| `ALTER INDEX ... REBUILD` | toutes | 100 |
| `WITH SAMPLE 10 PERCENT` explicite | 10 % cette fois, toutes à la mise à jour suivante | 100 |
| `WITH SAMPLE 10 PERCENT, PERSIST_SAMPLE_PERCENT = OFF` | échantillon par défaut ensuite | 0 |
| `TRUNCATE TABLE`, rechargement, puis mise à jour | toutes | 100 |
| `TRUNCATE TABLE`, mise à jour sur la table vide, rechargement, mise à jour | échantillon par défaut | 0 |

La reconstruction d'index gardait mal le taux sur les anciennes versions : la documentation indique qu'il n'est conservé que depuis SQL Server 2016 SP2 CU17, 2017 CU26 et 2019 CU10. Et la documentation dit qu'un `TRUNCATE` ramène les statistiques à l'échantillon par défaut. Sur 2022 CU26, je mesure que le taux survit au `TRUNCATE` : il ne disparaît que si une mise à jour a lieu pendant que la table est vide. Sur une table vidée puis rechargée par un traitement, vérifiez `persisted_sample_percent` après le premier chargement.

Attention aussi à `@StatisticsSample` dans le job de nuit : avec `@StatisticsPersistSample = 'N'`, `IndexOptimize` ajoute `PERSIST_SAMPLE_PERCENT = OFF` à chaque statistique et efface les taux fixés.

## Le coût

Sur `Elearning.Activite`, la mise à jour de la statistique dure 2,2 secondes en `FULLSCAN`, et 0,2 seconde avec un échantillon de 3 %. La nuit, c'est négligeable. En journée, la mise à jour automatique est synchrone par défaut : elle se fait pendant la compilation de la requête qui a vu la statistique périmée, et cette requête attend la fin du `FULLSCAN`. Cette attente grandit avec la table.

Réservez donc le taux fixé aux statistiques dont vous avez mesuré l'effet sur un plan, pas à toute une base. Si l'attente pose problème, l'option de base `AUTO_UPDATE_STATISTICS_ASYNC` fait recalculer la statistique en arrière-plan : la requête compile avec l'ancienne, et la suivante profite de la nouvelle.

## Le hint du Query Store

En attendant de trouver la bonne table, on pose souvent un hint sur la requête par le [Query Store](https://www.pachadata.com/docs/howtos/performances/query-store/), avec `sys.sp_query_store_set_hints` (SQL Server 2022 et suivants). Une fois les statistiques corrigées, il ne sert peut-être plus à rien. Avant de le retirer, notez son texte, pour pouvoir le remettre :

```sql
USE PachaDataFormation;
SELECT query_hint_id, query_id, query_hint_text, source_desc,
       last_query_hint_failure_reason_desc
FROM sys.query_store_query_hints;
```

Retirez-le ensuite, en remplaçant `42` par le `query_id` de la requête :

```sql
USE PachaDataFormation;
EXEC sys.sp_query_store_clear_hints @query_id = 42;
```

Comparez enfin le plan et la durée des exécutions suivantes avec celles d'avant, dans le Query Store. Si le plan reste bon, le hint est inutile. S'il se dégrade, remettez-le avec `sys.sp_query_store_set_hints` et le texte noté : il garde alors la requête dans une forme que les statistiques seules ne donnent pas. Gardez-le, en le réévaluant quand la distribution des données change : un hint s'applique à toutes les exécutions de la requête, quels que soient ses paramètres.

## Nettoyer

Pour finir, supprimez l'index de la démonstration :

```sql
USE PachaDataFormation;
DROP INDEX IF EXISTS nix_activite_inscription ON Elearning.Activite;
```


---

## L'auteur

Rudi Bruchez, consultant SQL Server indépendant depuis 2006 : audit de performance, optimisation, migrations, haute disponibilité et formation. Pour un audit ou une intervention sur votre serveur : https://www.pachadata.com/contact/ (audit à distance : https://www.pachadata.com/services/audit-sql-server-a-distance/).
