vendredi 26 août 2011

Restaurer des statistiques antérieures

Depuis la 10g, à chaque fois que vous calculez des statistiques dans Oracle via le package DBMS_STATS les statistiques précédentes sont sauvegardées dans le dictionnaire de données pendant une durée par défaut de 31 jours. L’intérêt de cette historisation des statistiques c’est qu’il est alors possible de les restaurer facilement.

La restauration de statistiques antérieures peut être utile lorsqu’après un calcul de stats sur une ou plusieurs tables on se retrouve avec des nouveaux plans moins performants. Le fait de restaurer les statistiques précédentes peut nous permettre de revenir aux performances correctes observées précédemment en attendant d’analyser le problème de performances lié à ces nouvelles stats.

Voici un exemple très simple pour comprendre le fonctionnement de l’historisation des statistiques.
SQL> select table_name,last_analyzed from user_tables where table_name='T1';
 
TABLE_NAME                     LAST_ANAL
------------------------------ ---------
T1                             25-JUL-11
 
SQL> select TABLE_NAME,NUM_ROWS
  2  from user_tab_statistics where table_name='T1';
 
TABLE_NAME                       NUM_ROWS
------------------------------ ----------
T1                                  72742

Je vois que les dernières stats sur ma table T1 datent du 25/07/2011 et que le nombre de lignes dans la table est estimé à 72742.
Maintenant je vide la table et je recalcule des stats :
SQL> truncate table T1;
 
Table truncated.
 
SQL> exec dbms_stats.gather_table_stats(USER,'T1');
 
PL/SQL procedure successfully completed.
 
SQL> select TABLE_NAME,NUM_ROWS
  2  from user_tab_statistics where table_name='T1';
 
TABLE_NAME                       NUM_ROWS
------------------------------ ----------
T1                                      0

J’ai bien maintenant 0 lignes dans ma table et les stats reflètent bien cet état de fait.

L’historique des statistiques est visualisable dans la vue USER_TAB_STATS_HISTORY :
SQL> select STATS_UPDATE_TIME
  2  from user_tab_stats_history where TABLE_NAME='T1';
 
STATS_UPDATE_TIME
---------------------------------------------------------------------------
25-JUL-11 02.40.18.819000 PM +02:00
25-JUL-11 02.45.31.305000 PM +02:00
25-JUL-11 02.45.47.274000 PM +02:00
25-AUG-11 01.44.58.074000 PM +02:00

En requêtant la vue USER_TAB_STATS_HISTORY on voit que lors des 31 derniers jours les statistiques ont été collectées 4 fois sur la table T1.

Ces stats sont sauvegardées et sont donc restaurables en utilisant la procédure RESTORE_TABLE_STATS du package DBMS_STATS.
SQL> BEGIN
  2  DBMS_STATS.RESTORE_TABLE_STATS(    OWNNAME=>USER, TABNAME=>'T1', as_of_timestamp=>sysdate-1);
  3  END;
  4  /
 
PL/SQL procedure successfully completed.

La requête ci-dessus restaure l’état des statistiques de la table T1 de la veille.
D’ailleurs si j’interroge la vue USER_TAB_STATISTICS je vois que les statistiques courante de T1 reflètent l’existence de 72742 lignes alors que ma table est vide.
SQL> select TABLE_NAME,NUM_ROWS
  2  from user_tab_statistics where table_name='T1';
 
TABLE_NAME                       NUM_ROWS
------------------------------ ----------
T1                                  72742

Il existe des procédures équivalentes à RESTORE_TABLE_STATS pour restaurer les statistiques au niveau de toute la base ou bien au niveau schéma, dictionnaire de données etc. (voir la doc Oracle).

jeudi 11 août 2011

Les statistiques étendues

Statistiques étendues sur des colonnes dépendantes

Ceux qui ont un peu d’expérience en matière de performance savent que les plans d’exécution non optimaux que peut être amené à choisir le CBO sont souvent liés à des erreurs dans l'estimation des cardinalités. La cardinalité d’une opération (par exemple un Full Table Scan) correspond au nombre de lignes retournées par cette opération après application des filtres. Le CBO estime cette cardinalité en multipliant le nombre de lignes de la table par la sélectivité de l’opération.

Exemple :
Soit une table CLIENT de 1 millions de lignes avec 90% d’hommes et 10% de femmes. Si j’exécute une requête me retournant la liste des clients hommes, le CBO va estimer le nombre de lignes retournées par cette requête afin de choisir un plan d’exécution adapté. Pour ce faire (en partant du principe qu’un histogramme a été calculé pour la colonne SEXE de la table CLIENT) il va multiplier la sélectivité (les 90%) par le nombre de lignes de la table. Il obtiendra une cardinalité de 900 000 et décidera surement d’opter pour un FULL TABLE SCAN pour récupérer ces lignes. Si à l’inverse, on voulait lister les clients femmes la cardinalité estimée aurait été de 100 000, et peut-être que le CBO aurait alors choisi un accès indexé.

Lorsqu’il y’a plusieurs colonnes impliquées dans la clause WHERE d’une requête le CBO va multiplier les sélectivités de chaque colonne pour calculer la cardinalité. Par exemple, si dans ma table j’ai 30% des clients qui vivent à Paris, on pourrait dire statistiquement parlant que j’ai 27% de clients hommes qui vivent à Paris (30% multiplié par 90%). Ce calcul est tout à fait raisonnable car les colonnes SEXE et VILLE ne sont pas directement liées.
Le problème se présente lorsque les filtres sont effectués sur des colonnes corrélées comme la VILLE et la REGION. Un client qui habite à Paris, habite forcément en Ile De France. C’est évident pour nous tous mais ça ne l’est pas pour l’optimiseur car il n’a aucune idée de ce que signifient les colonnes. Donc si on a dans nos stats 30% des clients qui vivent à Paris et 50% des clients qui vivent en Ile De France, et qu’on exécute une requête avec la clause WHERE suivante « WHERE VILLE = ’PARIS’ AND REGION = ‘IDF’ » l’optimiseur va calculer la cardinalité en utilisant une sélectivité de 15% (50% multiplié par 30%) au lieu de 30%.

Avant la 11g, pour obtenir une bonne cardinalité lorsqu’une requête impliquait des colonnes corrélées, il fallait qu’on ait un index composite sur ces deux colonnes ou bien qu’on force le Dynamic Sampling pour cette requête. Avec la 11g il est désormais possible de calculer des statistiques étendues sur ces colonnes corrélées (Multicolumn Extended Statistics) permettant à l’optimiseur de reconnaitre ces dépendances lors du parsing de la requête.

TEST CASE :
L’exemple ci-dessous montre comment les stats étendues permettent d’obtenir une cardinalité plus juste lorsque 2 colonnes dépendantes sont utilisées dans une requête.

Je vais d’abord créer ma table CLIENT en me basant sur la table CUSTOMER du schéma SH d’Oracle :
SQL> create table CLIENT(ID,PRENOM,NOM,SEXE,PAYS,VILLE,REGION)
  2  as
  3  select cu.CUST_ID, cu.CUST_FIRST_NAME, cu.CUST_LAST_NAME,
  4  cu.CUST_GENDER, co.COUNTRY_NAME, cu.CUST_CITY, cu.CUST_STATE_PROVINCE
  5  from customers cu, countries co
  6  where cu.COUNTRY_ID = co.COUNTRY_ID;
 
Table created.
 
SQL> select count(1) from CLIENT;
 
  COUNT(1)
----------
     55500
 
SQL> exec dbms_stats.gather_table_stats(user,'CLIENT');
 
PL/SQL procedure successfully completed.

Comme les colonnes VILLE et REGION sont skewed je vais calculer des histogrammes pour ces 2 colonnes:
SQL> exec dbms_stats.gather_table_stats(user,'CLIENT',method_opt => 'for columns size 254 VILLE, size 145 REGION');
 
PL/SQL procedure successfully completed.
 
SQL>  select column_name,histogram from user_tab_col_statistics where table_name='CLIENT';
 
COLUMN_NAME                    HISTOGRAM
------------------------------ ---------------
ID                             NONE
PRENOM                         NONE
NOM                            NONE
SEXE                           NONE
PAYS                           NONE
VILLE                          HEIGHT BALANCED
REGION                         FREQUENCY

Voyons ce qu’estime le CBO pour les clients qui vivent à Paris :
SQL> select count(*) from client where ville='Paris';
 
  COUNT(*)
----------
        77
 
1 row selected.
 
SQL> select * from table(dbms_xplan.display_cursor(null,null,'allstats last'));
 
SQL_ID  2stvn1str5n8g, child number 0
-------------------------------------
select count(*) from client where ville='Paris'
 
Plan hash value: 3543756331
 
---------------------------------------------------------------------------------------
| Id  | Operation          | Name   | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
---------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |      1 |        |      1 |00:00:00.01 |     470 |
|   1 |  SORT AGGREGATE    |        |      1 |      1 |      1 |00:00:00.01 |     470 |
|*  2 |   TABLE ACCESS FULL| CLIENT |      1 |     82 |     77 |00:00:00.01 |     470 |
---------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
 
   2 - filter("VILLE"='Paris')

Il y’a 77 clients qui vivent à Paris et le CBO en estime 82. On peut dire que l’estimation est juste.

Voyons ensuite les clients qui vivent en région Ile De France :
SQL> select count(*) from client where region='Ile-de-France';
 
  COUNT(*)
----------
      1034
 
1 row selected.
 
SQL> select * from table(dbms_xplan.display_cursor(null,null,'allstats last'));
 
SQL_ID  bd28djabdux6b, child number 0
-------------------------------------
select count(*) from client where region='Ile-de-France'
 
Plan hash value: 3543756331
 
---------------------------------------------------------------------------------------
| Id  | Operation          | Name   | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
---------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |      1 |        |      1 |00:00:00.01 |     470 |
|   1 |  SORT AGGREGATE    |        |      1 |      1 |      1 |00:00:00.01 |     470 |
|*  2 |   TABLE ACCESS FULL| CLIENT |      1 |    892 |   1034 |00:00:00.01 |     470 |
---------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
 
   2 - filter("REGION"='Ile-de-France')

Le nombre de clients franciliens est de 1034 et le CBO en estime 892. Là aussi on a une estimation plutôt raisonnable.

Voyons maintenant ce que donne la requête lorsqu’on combine les 2 critères :
SQL> select count(*) from client where ville='Paris' and region='Ile-de-France';
 
  COUNT(*)
----------
        77
 
1 row selected.
 
SQL> select * from table(dbms_xplan.display_cursor(null,null,'allstats last'));
 
SQL_ID  0y19xgnav3auh, child number 0
-------------------------------------
select count(*) from client where ville='Paris' and
region='Ile-de-France'
 
Plan hash value: 3543756331
 
---------------------------------------------------------------------------------------
| Id  | Operation          | Name   | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
---------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |      1 |        |      1 |00:00:00.01 |     470 |
|   1 |  SORT AGGREGATE    |        |      1 |      1 |      1 |00:00:00.01 |     470 |
|*  2 |   TABLE ACCESS FULL| CLIENT |      1 |      1 |     77 |00:00:00.01 |     470 |
---------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
 
   2 - filter(("VILLE"='Paris' AND "REGION"='Ile-de-France'))

Là le CBO se trompe complètement car en multipliant les 2 sélectivités il en arrive à estimer que la requête ne retournera qu’une seule ligne au lieu de 77. Cette erreur vient du fait qu’il n’est pas informé de la corrélation qui existe entre la VILLE et la REGION.

Calculons des statistiques étendues pour ces 2 colonnes :
SQL> BEGIN
  2     DBMS_STATS.gather_table_stats(user,'CLIENT',
  3     method_opt => 'FOR ALL COLUMNS FOR COLUMNS (VILLE,REGION)',
  4     NO_INVALIDATE => FALSE
  5     );
  6  END;
  7  /
 
PL/SQL procedure successfully completed.

Le fait de calculer des stats étendues sur ces 2 colonnes va engendrer la création d’une colonne virtuelle appelée EXTENSION. La vue USER_STAT_EXTENSIONS permet d’avoir des informations sur ces extensions :
SQL> SELECT extension_name, extension
  2   FROM user_stat_extensions
  3   WHERE table_name = 'CLIENT';
 
EXTENSION_NAME                 EXTENSION
------------------------------ --------------------------------------------------------------------------------
SYS_STUE_OO2MD$OBR5EQSB7NBV$RO ("VILLE","REGION")

Le nom donné à l’extension correspond au nom de la colonne virtuelle qu’Oracle a automatiquement définie et qu’on peut également voir dans la liste des colonnes de la table :
SQL> select column_name,histogram from user_tab_col_statistics where table_name='CLIENT';
 
COLUMN_NAME                    HISTOGRAM
------------------------------ ---------------
ID                             HEIGHT BALANCED
PRENOM                         HEIGHT BALANCED
NOM                            HEIGHT BALANCED
SEXE                           FREQUENCY
PAYS                           FREQUENCY
VILLE                          HEIGHT BALANCED
REGION                         HEIGHT BALANCED
SYS_STUE_OO2MD$OBR5EQSB7NBV$RO HEIGHT BALANCED

Maintenant que le CBO a des informations sur la corrélation existante ente les colonnes VILLE et REGION, voyons ce que donne ses estimations pour notre requête précédente :
SQL> select count(*) from client where ville='Paris' and region='Ile-de-France';
 
  COUNT(*)
----------
        77
 
1 row selected.
 
SQL> select * from table(dbms_xplan.display_cursor(null,null,'allstats last'));
 
SQL_ID  0y19xgnav3auh, child number 0
-------------------------------------
select count(*) from client where ville='Paris' and
region='Ile-de-France'
 
Plan hash value: 3543756331
 
---------------------------------------------------------------------------------------
| Id  | Operation          | Name   | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
---------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |      1 |        |      1 |00:00:00.01 |     470 |
|   1 |  SORT AGGREGATE    |        |      1 |      1 |      1 |00:00:00.01 |     470 |
|*  2 |   TABLE ACCESS FULL| CLIENT |      1 |     87 |     77 |00:00:00.01 |     470 |
---------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
 
   2 - filter(("VILLE"='Paris' AND "REGION"='Ile-de-France'))

La cardinalité estimée est 87, ce qui est bien mieux que le 1 obtenu sans les stats étendues.
Bien sûr pour cette requête, la cardinalité estimée ne change rien au plan d’exécution mais imaginons qu’on ait des jointures en plus dans la requête avec des tables volumineuses: avec une cardinalité fausse on peut se retrouver facilement avec un plan complètement inappropriée avec par exemple un MERGE JOIN CARTESIAN entre 2 tables retournant chacune plusieurs milliers de lignes.

Au lieu de créer les extensions au moment du calcul de stats via la procédure GATHER_TABLE_STATS, on pourrait créer directement ces extensions en utilisant la nouvelle fonction CREATE_EXTENDED_STATS du package DBMS_STATS :
SQL> exec dbms_stats.drop_extended_stats(ownname => user,tabname => 'CLIENT',extension => '(VILLE,REGION)');
 
PL/SQL procedure successfully completed.
 
SQL> SELECT
  2  dbms_stats.create_extended_stats(ownname => user,tabname => 'CLIENT',extension => '(VILLE,REGION)') EXTENSION_NAME
  3  FROM dual;
 
EXTENSION_NAME
-----------------------------------------------------------
SYS_STUE_OO2MD$OBR5EQSB7NBV$RO

La fonction retourne le nom de la colonne virtuelle (extension).
A cette étape les stats ne sont pas calculées pour l’extension. Elle le seront lors du prochain calcul de stats effectués sur la table.

Si vous êtes face à un problème lié à la dépendance de colonnes et que vous êtes en 10g il ne vous sera pas possible de créer des statistiques étendues. La solution consistera à forcer le dynamic sampling via un hint ou bien en ne calculant pas de stats pour la table.

Exemple avec dynamic sampling :
SQL> exec dbms_stats.drop_extended_stats(ownname => user,tabname => 'CLIENT',extension => '(VILLE,REGION)');
 
PL/SQL procedure successfully completed.
 
SQL> exec dbms_stats.delete_table_stats(user,'CLIENT');
 
PL/SQL procedure successfully completed.
 
SQL> select count(*) from client where ville='Paris' and region='Ile-de-France';
 
  COUNT(*)
----------
        77
 
1 row selected.
 
SQL> select * from table(dbms_xplan.display_cursor(null,null,'allstats last'));
 
SQL_ID  0y19xgnav3auh, child number 0
-------------------------------------
select count(*) from client where ville='Paris' and
region='Ile-de-France'
 
Plan hash value: 3543756331
 
---------------------------------------------------------------------------------------
| Id  | Operation          | Name   | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
---------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |      1 |        |      1 |00:00:00.01 |     470 |
|   1 |  SORT AGGREGATE    |        |      1 |      1 |      1 |00:00:00.01 |     470 |
|*  2 |   TABLE ACCESS FULL| CLIENT |      1 |     77 |     77 |00:00:00.01 |     470 |
---------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
 
   2 - filter(("VILLE"='Paris' AND "REGION"='Ile-de-France'))
 
Note
-----
   - dynamic sampling used for this statement (level=2)

La section NOTE à la fin du plan indique bien que le dynamic sampling a été utilisé pour cette requête et surtout on voit que la cardinalité estimée est parfaite.


Statistiques étendues sur une expression :

Un autre cas où le CBO est incapable d'estimer une bonne cardinalité c’est lorsqu’on applique une fonction à une colonne. Le CBO n’a alors aucune idée de l’impact de cette fonction sur la sélectivité de la colonne. Dans ce cas et d’après le livre de Jonathan LEWIS, le CBO applique une séléctivité de 1% c'est-à-dire que pour une table de 1000 lignes il va estimer qu’une requête avec une fonction appliquée sur une colonne va retourner 10 lignes.
Reprenons notre table CLIENT et appliquons dans notre requête la fonction UPPER à la colonne VILLE :
SQL> select count(*) from client where UPPER(ville)='PARIS';
 
  COUNT(*)
----------
        77
 
1 row selected.
 
SQL> select * from table(dbms_xplan.display_cursor(null,null,'allstats last'));
 
SQL_ID  59bf7bswaqxvh, child number 0
-------------------------------------
select count(*) from client where UPPER(ville)='PARIS'
 
Plan hash value: 3543756331
 
---------------------------------------------------------------------------------------
| Id  | Operation          | Name   | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
---------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |      1 |        |      1 |00:00:00.01 |     470 |
|   1 |  SORT AGGREGATE    |        |      1 |      1 |      1 |00:00:00.01 |     470 |
|*  2 |   TABLE ACCESS FULL| CLIENT |      1 |    555 |     77 |00:00:00.01 |     470 |
---------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
 
   2 - access("CLIENT"."SYS_NC00008quot;='PARIS')

On voit bien que la séléctivité de 1% a été appliquée puisque la table CLIENT contient 55 500 lignes et que le CBO a estimé une cardinalité de 555 (55500*0.01=555).

Calculons maintenant des stats étendues sur la fonction UPPER appliquée à la colonne VILLE :
SQL> BEGIN
  2  DBMS_STATS.gather_table_stats
  3  (ownname => USER,
  4  tabname => 'CLIENT',
  5  method_opt => 'FOR ALL COLUMNS FOR COLUMNS (UPPER(VILLE))'
  6  );
  7  END;
  8  /
 
PL/SQL procedure successfully completed.
 
SQL> SELECT extension_name, extension
  2   FROM user_stat_extensions
  3   WHERE table_name = 'CLIENT';
 
EXTENSION_NAME                 EXTENSION
------------------------------ ----------------------------------------------------------
SYS_STUV9T3PPLW$4FMKHYOB4#YU$D (UPPER("VILLE"))
 

Une colonne virtuelle a été crée correspondant à la fonction « UPPER(VILLE)».
A noter qu’on aurait pu là aussi utiliser la fonction CREATE_EXTENDED_STATS pour créer l’extension correspondant à la fonction UPPER.
Si on exécute de nouveau la requête on verra que la cardinalité estimée est bien plus proche de la réalité :
SQL> select count(*) from client where UPPER(ville)='PARIS';
 
  COUNT(*)
----------
        77
 
1 row selected.
 
SQL> select * from table(dbms_xplan.display_cursor(null,null,'allstats last'));
 
SQL_ID  59bf7bswaqxvh, child number 1
-------------------------------------
select count(*) from client where UPPER(ville)='PARIS'
 
Plan hash value: 3543756331
 
---------------------------------------------------------------------------------------
| Id  | Operation          | Name   | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
---------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |      1 |        |      1 |00:00:00.01 |     470 |
|   1 |  SORT AGGREGATE    |        |      1 |      1 |      1 |00:00:00.01 |     470 |
|*  2 |   TABLE ACCESS FULL| CLIENT |      1 |     87 |     77 |00:00:00.01 |     470 |
---------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
 
   2 - filter(UPPER("VILLE")='PARIS')

Ces stats étendues peuvent être très utiles pour les applications où on applique des fonctions « maison » dans les clauses WHERE des requêtes. Toutefois, pour que ces stats étendues soient applicables ces fonctions doivent absolument être de type DETERMINISTIC.

CONCLUSION :
Lorsque vous êtes face à un problème de performance lié à l’utilisation d’un plan non-optimal pour une requête donnée, avant d’accuser le CBO de tous les maux, demandez-vous si le CBO dispose bien de toutes les informations nécessaires pour accomplir son travail. La 11g avec la possibilité de calculer des stats étendues sur un groupe de colonnes ou sur une expression peut aider dans certains cas le CBO à estimer des cardinalités plus justes et donc proposer un plan plus optimal.

jeudi 28 juillet 2011

Un DELETE si long sur une petite table

On m’a remonté cette semaine un problème sur un DELETE qui mettait 2h20 pour supprimer 10 148 lignes dans une table en contenant 22 558.
SQL> delete from varvolcurvevaluehs where var_id= 101; 

10148 rows deleted. 

Elapsed: 02:19:52.86 
 

Comment une simple requête supprimant juste 10 148 lignes peut-elle mettre autant de temps à s’exécuter?
Jetons un œil sur l’évènement d’attente qui a le plus contribué à l’exécution de cette requête.
Pour obtenir cette information on peut par exemple interroger les données ASH (historique des sessions actives) pour cette requête :
SQL> select event, count(1) 
  2    from dba_hist_active_sess_history 
  3   where sql_id = '48cu5tz204jad' 
  4   group by event; 

EVENT                            COUNT(1) 
------------------------------ ---------- 
                                                     418 
db file sequential read                  1 
db file scattered read                418 
log buffer space                            1 

On voit que la requête a généré essentiellement une attente sur l’évènement « db file scattered read ». Cet évènement signifie que la requête pendant 2h20 a effectué essentiellement des Full Table Scan.

La table à deleter ne contenait que 22000 lignes et elle n’était pas fragmentée du tout ce qui signifie que tous les blocks alloués au segment contenaient des lignes. Le nombre de blocks pour cette table s’élevait à 226. De plus il n’existait pas de trigger ON DELETE sur cette table. Le Full Table Scan ne peut donc pas être lié à cette table. Mais alors quelle est la table concernée par ce Full Table Scan ?

Si j’exécute de nouveau la requête et que je regarde les paramètres P1 et P2 associés à cet évènement d’attente je peux savoir quelle est la table réellement incriminée.
SQL> select event,p1text,p1,p2text,p2,p3text,p3 
  2  from v$session where sid=441; 

EVENT                          P1TEXT                  P1 P2TEXT                  P2 P3TEXT               P3 
------------------------------ --------------- ---------- --------------- ---------- --------------- ---------- 
db file scattered read         file#                  117 block#               99305 blocks               32 

A noter que ces informations sont aussi disponibles dans les données ASH (V$ACTIVE_SESSION_HISTORY et/ou DBA_HIST_ACTIVE_SESS_HISTORY).

Avec le numéro du datafile et le numéro du block ci-dessus il est possible d’interroger la vue DBA_EXTENTS pour retrouver le nom de l’objet concerné par le Full Table Scan :
SQL> select segment_name 
  2  from   dba_extents 
  3  where  73385 between block_id and (block_id + blocks - 1) 
  4  and    file_id = 117; 

SEGMENT_NAME 
--------------------------------------------------------------------------------- 
VARVOLCURVEPOINTVALUEHS 

Il ne s’agit donc pas de la table VARVOLCURVEVALUEHS mais d’une autre table (même si les 2 tables ont un nom très similaire).
Cette table doit surement être une table enfant de la table VARVOLCURVEVALUEHS référençant la Primary Key via une clé étrangère.

La requête ci-dessous nous permet de récupérer les Foreign Key référençant notre table VARVOLCURVEVALUEHS :
SQL> select table_name, CONSTRAINT_NAME 
  2    from user_constraints 
  3   where R_CONSTRAINT_NAME in 
  4         (select CONSTRAINT_NAME 
  5            from user_constraints 
  6           where table_name = 'VARVOLCURVEVALUEHS') 
  7     and CONSTRAINT_TYPE = 'R'; 

TABLE_NAME                     CONSTRAINT_NAME 
------------------------------ ------------------------------ 
VARVOLCURVEPOINTVALUEHS        FK1_VVCPVH_VVCVH 

Ce qu’il se passe en fait au moment du DELETE c’est qu’Oracle va vérifier pour chaque ligne supprimée dans VARVOLCURVEVALUEHS s’il n’existe pas de lignes dans la table enfant VARVOLCURVEPOINTVALUEHS qui référencent la ligne à supprimer. Pour effectuer cette vérification Oracle va exécuter une requête dite récursive sur la table VARVOLCURVEPOINTVALUEHS. Comme il n’existe pas d’index sur la FK, un Full Scan est effectué pour chaque ligne à supprimer (environ 10 000 fois). Comme le nombre de blocks dans la table VARVOLCURVEPOINTVALUEHS est très important (environ 50 000 blocks), ces 10 000 full table scans sont extrêmement couteux. De plus comme le buffer cache est assez petit sur la base concernée, ces blocks ne sont pas gardés en cache et des I/O physiques sont effectués à chaque Full Scan pour remonter les blocks du disque. 

 
J'avais écrit il y' a quelques semaines un article sur l'intérêt d’indexer les FK. Ce problème de performance en est une bonne illustration.

En créant un index sur la FK la requête s’exécute en moins d’une seconde :
SQL> create index IDX_CURVE_ID on VARVOLCURVEPOINTVALUEHS(CURVE_ID); 

Index created. 

SQL> set timin on 

SQL> delete from varvolcurvevaluehs where var_id= 101; 

10148 rows deleted. 

Elapsed: 00:00:00.89 

lundi 25 juillet 2011

Forcer un hard parse

Une des bonnes pratiques que tout le monde connait en matière de performances c’est de limiter le nombre de Hard Parses. L’idéal étant d’avoir juste un hard parse lors de la première exécution puis uniquement des soft parses (voire pas de parse call du tout) pour les autres exécutions.

Toutefois, il peut arriver qu’on souhaite forcer le hard parse à chaque exécution pour une requête en particulier.
Imaginons que vous ayez une requête qui se base notamment sur une table temporaire alimentée préalablement par la session. Le contenu d’une table temporaire étant propre à chaque session, vous pouvez avoir une première session exécutant cette requête avec une table temporaire contenant 100 lignes. Comme c’est la première fois que la requête est exécutée celle-ci sera hard parsée et un plan tenant compte des 100 lignes dans la table temporaire sera calculé par le CBO (dynamic sampling effectué sur la table temporaire pour estimer le nombre de lignes retournées). Maintenant une autre session va exécuter la même requête mais cette fois la table temporaire contiendra 1 million de lignes. Comme la même requête a déjà été exécutée auparavant par l’autre session (au niveau du texte c’est exactement la même requête) et qu’un curseur partageable est déjà présent dans la library cache, la requête sera seulement soft parsée et donc le plan qui avait été calculé pour la première session sera réutilisé. Ce plan qui avait été calculé en partant du principe que la table temporaire ne contenait que 100 lignes risque fortement de ne pas être approprié pour cette exécution. Il serait en effet moins couteux ici d’avoir un hard parse supplémentaire avec un plan optimal, plutôt qu’un soft parse mais avec un plan désastreux.

Voici un petit cas simple pour illustrer ce genre de situation que j’ai personnellement déjà rencontré par le passé.
Je crée d’abord une table temporaire T1_TEMP et une table T1 contenant 100 lignes :
SQL> CREATE GLOBAL TEMPORARY TABLE T1_TEMP
  2  ON COMMIT PRESERVE ROWS
  3  AS SELECT * FROM DBA_OBJECTS WHERE 1=2;

Table created.

SQL> create index IDX_OBJ_TYP on T1_TEMP(OBJECT_TYPE);

Index created.

SQL> create table t1
  2  as select * from dba_objects
  3  where rownum<=100;

Table created.
Dans une session 1 j’insère dans la table temporaire 100 lignes et j’exécute une requête basée sur cette table temporaire :
SQL> -- session1
SQL> insert into T1_TEMP
  2  SELECT * FROM T1;

100 rows created.

SQL> insert into t1
  2  select * from T1_TEMP
  3  where OBJECT_TYPE='CLUSTER';

5 rows created.

Elapsed: 00:00:00.01
  
SQL> select * from table(dbms_xplan.display_cursor(null,null,'allstats last'));

PLAN_TABLE_OUTPUT
-----------------------------------------------------------------------------------------------------------------------
-------------------------------------------------------------------------------------
SQL_ID  7df6acdvufgud, child number 0
-------------------------------------
insert into t1 select * from T1_TEMP where OBJECT_TYPE='CLUSTER'

Plan hash value: 3185690956

-------------------------------------------------------------------------------------------------------
| Id  | Operation                | Name    | Starts | E-Rows | A-Rows |   A-Time   | Buffers | Reads  |
-------------------------------------------------------------------------------------------------------
|   0 | INSERT STATEMENT         |         |      1 |        |      0 |00:00:00.02 |      28 |      2 |
|   1 |  LOAD TABLE CONVENTIONAL |         |      1 |        |      0 |00:00:00.02 |      28 |      2 |
|*  2 |   TABLE ACCESS FULL      | T1_TEMP |      1 |      5 |      5 |00:00:00.01 |       4 |      0 |
-------------------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - filter("OBJECT_TYPE"='CLUSTER')

Note
-----
   - dynamic sampling used for this statement (level=2)
La partie SELECT de la requête retourne 5 lignes mais comme la table temporaire contient peu de lignes, le CBO a judicieusement opté pour un Full Table Scan. D’ailleurs le nombre de lignes estimées par le CBO ( E-ROWS) correspond bien au nombre de lignes réellement traitées par le moteur SQL (A-ROWS). Maintenant j’ouvre une autre session et je vais exécuter la même requête sauf que cette fois je vais insérer 1 million de lignes dans la table temporaire :
SQL> -- session2
SQL> insert into t1_temp
  2  select t1.* from t1,t1 t2,t1 t3;

1000000 rows created.

SQL> insert into t1
  2  select * from T1_TEMP
  3  where OBJECT_TYPE='CLUSTER';

50000 rows created.

Elapsed: 00:00:02.93
  
SQL> select * from table(dbms_xplan.display_cursor(null,null,'allstats last'));

PLAN_TABLE_OUTPUT
---------------------------------------------------------------------------------------------------------------------------
-------------------------------------------------------------------------------------
SQL_ID  7df6acdvufgud, child number 0
-------------------------------------
insert into t1 select * from T1_TEMP where OBJECT_TYPE='CLUSTER'

Plan hash value: 3185690956

-------------------------------------------------------------------------------------------------------
| Id  | Operation                | Name    | Starts | E-Rows | A-Rows |   A-Time   | Buffers | Reads  |
-------------------------------------------------------------------------------------------------------
|   0 | INSERT STATEMENT         |         |      1 |        |      0 |00:00:02.94 |   18507 |  10198 |
|   1 |  LOAD TABLE CONVENTIONAL |         |      1 |        |      0 |00:00:02.94 |   18507 |  10198 |
|*  2 |   TABLE ACCESS FULL      | T1_TEMP |      1 |      5 |  50000 |00:00:02.83 |   11314 |  10196 |
-------------------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - filter("OBJECT_TYPE"='CLUSTER')

Note
-----
   - dynamic sampling used for this statement (level=2)
Comme la même requête avait déjà été exécutée par une autre session, cette requête n’a pas été hard parsée et c’est donc le plan déjà existant dans la shared pool qui a été utilisé. Le Full San n’est pas approprié et on voit que le nombre de lignes estimées correspond aux lignes retournées par la requête de la première session. Si cette requête avait été hard parsée on aurait surement eu un plan avec un accès indexé. Pour s’en assurer on peut flusher la shared pool et relancer la même requête :
SQL> rollback;

Rollback complete.

Elapsed: 00:00:36.39   

SQL> alter system flush shared_pool;

System altered.

SQL> insert into t1
  2  select * from T1_TEMP
  3  where OBJECT_TYPE='CLUSTER';

50000 rows created.

Elapsed: 00:00:01.92

SQL> select * from table(dbms_xplan.display_cursor(null,null,'allstats last'));

PLAN_TABLE_OUTPUT
---------------------------------------------------------------------------------------------------------------------------
-------------------------------------------------------------------------------------
SQL_ID  7df6acdvufgud, child number 0
-------------------------------------
insert into t1 select * from T1_TEMP where OBJECT_TYPE='CLUSTER'

Plan hash value: 1045437032

---------------------------------------------------------------------------------------------------------------
| Id  | Operation                    | Name        | Starts | E-Rows | A-Rows |   A-Time   | Buffers | Reads  |
---------------------------------------------------------------------------------------------------------------
|   0 | INSERT STATEMENT             |             |      1 |        |      0 |00:00:01.68 |    8285 |    747 |
|   1 |  LOAD TABLE CONVENTIONAL     |             |      1 |        |      0 |00:00:01.68 |    8285 |    747 |
|   2 |   TABLE ACCESS BY INDEX ROWID| T1_TEMP     |      1 |  29962 |  50000 |00:00:01.53 |    1027 |    744 |
|*  3 |    INDEX RANGE SCAN          | IDX_OBJ_TYP |      1 |  29962 |  50000 |00:00:00.62 |     441 |    195 |
---------------------------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   3 - access("OBJECT_TYPE"='CLUSTER')

Note
-----
   - dynamic sampling used for this statement (level=2)
Un hard parse a été effectué et le CBO a estimé une cardinalité plus proche de la réalité. Le plan contient un accès indexé et est bien meilleur puisqu’on passe de 18507 logical reads à 8285. Cet exemple est assez basique mais il montre bien que dans certains cas il peut être intéressant d’avoir un hard parse à chaque exécution plutôt que de réutiliser toujours le même plan.  

Comment donc forcer un hard parse ?

Il n’existe pas dans Oracle de moyen explicite de le faire : pas de hint /*+ FORCE_HARD_PARSE */ ni de paramètre caché « _HARD_PARSE » qu’on pourrait mettre à TRUE au niveau de la session. Il faut donc réfléchir un peu et se poser la question suivante: Quand est-ce qu’un Hard parse est effectué ?
Réponses :
1) Lorsqu’il n’existe pas de curseur associé à la requête dans la library cache c'est-à-dire lorsque la requête n’a jamais été exécutée ou bien que le curseur a été flushé de la shared pool pour faire de la place.
2) Lorsque le curseur a été invalidé à cause par exemple d’un calcul de stats sur une table référencée dans la requête
3) Lorsque l’environnement d’exécution est différent (ex : modification d’un paramètre de l’optimiseur au niveau de la session)

Dans mon exemple précédent j’ai utilisé la commande « ALTER SYSTEM FLUSH SHARED_POOL », mais il s‘agit là d’une solution un peu « bourrin » qu’il ne faut surtout pas utiliser dans une application puisqu’il vide tous les curseurs partagés dans la library cache. Avec cette solution on forcerait le hard parse pour toutes les requêtes ce qui dégraderait fortement les performances générales (latches, CPU….).

Solution 1 : Générer un SQL_ID différent
L’idéal pour forcer le hard parse c’est de faire en sorte que chaque exécution de la même requête devienne aux yeux d’Oracle une exécution différente. On pourrait par exemple utiliser une requête SQL dynamique avec une clause WHERE qui serait toujours vraie mais serait différente pour chaque exécution. Par exemple on pourrait ajouter une clause « WHERE :random_value = :random_value » où la variable random_value contiendrait un nombre aléatoire. Cette clause étant toujours vraie, elle n’aurait pas d’incidence sur le résultat de la requête et permettrait de générer un SQL_ID différent à chaque exécution. Imaginons que la requête soit exécutée dans du code PL/SQL, la partie du code qui exécute la requête pourrait ressembler à ceci :
DECLARE
 random_value number;
 BEGIN
 random_value := dbms_random.value;
 execute immediate 'insert /*test_PL */ into t1_temp select * from T1_TEMP where OBJECT_TYPE=''CLUSTER'' and ' || random_value ||  '=' || random_value;
 END;
 /
Rejouons notre test précédent en utilisant ce bloc PL/SQL pour l’exécution de la requête qu’on souhaiterait voir hard parsée à chaque exécution :
-- session 1
SQL> insert into T1_TEMP
  2  SELECT * FROM T1;

100 rows created.

Elapsed: 00:00:00.04
SQL>  Declare
  2     random_value number;
  3   BEGIN
  4     random_value := dbms_random.value;
  5     execute immediate 'insert /*test_PL */ into t1_temp select * from T1_TEMP where OBJECT_TYPE=''CLUSTER'' and ' || random_value ||  '=' || random_value;
  6   END;
  7   /

PL/SQL procedure successfully completed.

Elapsed: 00:00:00.00
SQL> select sql_id from v$sql where sql_text like 'insert /*test_PL%';

SQL_ID
-------------
80y71zqh47kr4

1 row selected.

Elapsed: 00:00:00.01
SQL> @plan_curs
SQL> SET LINES 500
SQL> SET PAGES 500
SQL>
SQL> select * from table(dbms_xplan.display_cursor('&sql_id','&child_number','allstats last'));
Enter value for sql_id: 80y71zqh47kr4
Enter value for child_number:
old   1: select * from table(dbms_xplan.display_cursor('&sql_id','&child_number','allstats last'))
new   1: select * from table(dbms_xplan.display_cursor('80y71zqh47kr4','','allstats last'))

PLAN_TABLE_OUTPUT
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-------------------------------------------------------------------------------------
SQL_ID  80y71zqh47kr4, child number 0
-------------------------------------
insert /*test_PL */ into t1_temp select * from T1_TEMP where
OBJECT_TYPE='CLUSTER' and .63062214722457742739970953577612375345=.63062
214722457742739970953577612375345

Plan hash value: 3185690956

----------------------------------------------------------------------------------------------
| Id  | Operation                | Name    | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
----------------------------------------------------------------------------------------------
|   0 | INSERT STATEMENT         |         |      1 |        |      0 |00:00:00.01 |       8 |
|   1 |  LOAD TABLE CONVENTIONAL |         |      1 |        |      0 |00:00:00.01 |       8 |
|*  2 |   TABLE ACCESS FULL      | T1_TEMP |      1 |      5 |      5 |00:00:00.01 |       4 |
----------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - filter("OBJECT_TYPE"='CLUSTER')

Note
-----
   - dynamic sampling used for this statement (level=2)
On voit que pour la session où la table temporaire contient 100 lignes on a bien un plan avec le Full table Scan. Le SQL_ID correspondant à cette requête est le 80y71zqh47kr4. Maintenant exécutons la requête pour la session 2 (celle qui contient 1 millions de lignes) :
-- session 2
SQL> insert into t1_temp
  2  select t1.* from t1,t1 t2,t1 t3;

1000000 rows created.

Elapsed: 00:00:43.79
SQL> Declare
  2     random_value number;
  3   BEGIN
  4     random_value := dbms_random.value;
  5     execute immediate 'insert /*test_PL */ into t1_temp select * from T1_TEMP where OBJECT_TYPE=''CLUSTER'' and ' || random_value ||  '=' || random_value;
  6   END;
  7   /

PL/SQL procedure successfully completed.

Elapsed: 00:00:03.12
SQL> select sql_id from v$sql where sql_text like 'insert /*test_PL%';

SQL_ID
-------------
5a5mryn0ug6sg
80y71zqh47kr4

2 rows selected.

Elapsed: 00:00:00.00
SQL> @plan_curs
SQL> SET LINES 500
SQL> SET PAGES 500
SQL>
SQL> select * from table(dbms_xplan.display_cursor('&sql_id','&child_number','allstats last'));
Enter value for sql_id: 5a5mryn0ug6sg
Enter value for child_number:
old   1: select * from table(dbms_xplan.display_cursor('&sql_id','&child_number','allstats last'))
new   1: select * from table(dbms_xplan.display_cursor('5a5mryn0ug6sg','','allstats last'))

PLAN_TABLE_OUTPUT
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-------------------------------------------------------------------------------------
SQL_ID  5a5mryn0ug6sg, child number 0
-------------------------------------
insert /*test_PL */ into t1_temp select * from T1_TEMP where
OBJECT_TYPE='CLUSTER' and .97829861972676033156035029701963270324=.97829
861972676033156035029701963270324

Plan hash value: 1045437032

---------------------------------------------------------------------------------------------------------------
| Id  | Operation                    | Name        | Starts | E-Rows | A-Rows |   A-Time   | Buffers | Reads  |
---------------------------------------------------------------------------------------------------------------
|   0 | INSERT STATEMENT             |             |      1 |        |      0 |00:00:02.75 |   13565 |    775 |
|   1 |  LOAD TABLE CONVENTIONAL     |             |      1 |        |      0 |00:00:02.75 |   13565 |    775 |
|   2 |   TABLE ACCESS BY INDEX ROWID| T1_TEMP     |      1 |    109K|  50000 |00:00:02.51 |    1047 |    775 |
|*  3 |    INDEX RANGE SCAN          | IDX_OBJ_TYP |      1 |    109K|  50000 |00:00:01.04 |     461 |    206 |
---------------------------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   3 - access("OBJECT_TYPE"='CLUSTER')

Note
-----
   - dynamic sampling used for this statement (level=2)
La requête a bien été hard parsée puisqu’on obtient directement notre plan avec accès indexé. Le SQL_ID vaut 5a5mryn0ug6sg et est bien différent du premier SQL_ID. Au fur et à mesure des exécutions le SQL_ID générée sera différent et donc un hard parse sera effectué à chaque fois. Il est important dans le bloc PL/SQL de ne pas utiliser de clause USING et d’avoir un paramètre CURSOR_SHARING à EXACT, sinon la variable contenant la valeur aléatoire deviendrait une Bind Variable, et on aurait un seul curseur partagée réutilisée (et donc un même plan) à chaque exécution de la requête.

Solution 2 : Invalider le curseur
Au lieu de générer un nouveau SQL_ID à chaque exécution une autre solution consisterait à invalider le curseur déjà dans la shared pool. Pour ce faire vous pouvez utiliser la procédure SET_TABLE_STATS du package DBMS_STATS.
Voyons ça avec un exemple :
-- session 1
SQL> drop table t1;

Table dropped.

SQL>
SQL> create table t1 as select * from dba_objects;

Table created.

SQL>
SQL> create index t1_idx on t1(object_id);

Index created.

SQL>
SQL> exec dbms_stats.gather_table_stats(user,'T1');

PL/SQL procedure successfully completed.

SQL> select sid from v$mystat where rownum=1;

       SID
----------
       136
Dans une session 1 j’ai crée une table T1et j’ai récupéré l’identifiant de ma session (SID=136). Dans une 2ème session je regarde les statistiques « parse count (hard) » et « parse count (total) » pour ma première session :
-- session 2
SQL> SELECT sn.name, ss.value
  2   FROM v$statname sn, v$sesstat ss
  3   WHERE sn.statistic# = ss.statistic#
  4   AND sn.name IN (
  5   'parse count (hard)','parse count (total)')
  6   AND ss.sid =136;

NAME                                                                  VALUE
---------------------------------------------------------------- ----------
parse count (total)                                                    1500
parse count (hard)                                                      269
Dans ma session 1 j’exécute une requête sur la table T1 :
-- session 1
SQL> select object_id from t1 where object_id= 20;

 OBJECT_ID
----------
        20
Je regarde maintenant les stats sur mon autre session et je constate qu’un Hard Parse a bien été effectué (la statistique « parse count (hard) » a été incrémenté de 1) :
-- session 2
SQL> /

NAME                                                                  VALUE
---------------------------------------------------------------- ----------
parse count (total)                                                    1501
parse count (hard)                                                      270
J‘exécute de nouveau ma requête dans ma 1ère session, puis je regarde les stats :
-- session 1
SQL> select object_id from t1 where object_id= 20;

 OBJECT_ID
----------
        20
  
-- Session 2
SQL> /

NAME                                                                  VALUE
---------------------------------------------------------------- ----------
parse count (total)                                                    1502
parse count (hard)                                                      270
Cette fois la statistique « parse count (total) » a été incrémenté mais pas la stats « parse count (hard) », il s’agit donc d’un soft parse. C’est normal puisque un curseur partageable existait déjà dans la library cache depuis que la requête a été exécutée la 1ère fois.
Maintenant nous allons invalider ce curseur grâce au package DBMS_STATS :
SQL> -- invalidation curseur
SQL>  exec dbms_stats.set_table_stats(USER, 'T1', numrows=>null, no_invalidate=>false);


-- Session 1
SQL> select object_id from t1 where object_id= 20;

 OBJECT_ID
----------
        20
  
-- Session 2
SQL> SELECT sn.name, ss.value
  2   FROM v$statname sn, v$sesstat ss
  3   WHERE sn.statistic# = ss.statistic#
  4   AND sn.name IN (
  5   'parse count (hard)','parse count (total)')
  6   AND ss.sid =136;

NAME                                                                  VALUE
---------------------------------------------------------------- ----------
parse count (total)                                                    1503
parse count (hard)                                                      271
Après avoir invalidé le curseur et relancer la même requête sur T1 on constate que la statistique « parse count (hard) » a de nouveau été incrémentée de 1. C’est donc que le Hard Parse a bien eu lieu. Il est important de mettre le paramètre NO_INVALIDATE de la procédure à FALSE afin de forcer l’invalidation du curseur. Vous noterez comme moi qu’Oracle aime parfois compliquer les choses. Au lieu d’avoir un paramètre INVALIDATE à TRUE ou FALSE ils ont préféré un paramètre NO_INVALIDATE. C’est de la triple négation que j’ai du mal à comprendre…enfin bref. L’inconvénient de cette solution c’est que tous les curseurs correspondant aux requêtes qui référencent la table T1 seront invalidés. Alors qu’on souhaiterait uniquement agir sur une requête en particulier, avec cette solution on risque d’impacter beaucoup plus de requêtes.

3ème solution : Flusher le SQL_ID
La 3ème solution qui me vient à l’esprit serait de récupérer avant chaque exécution l’identifiant de la requête à hard parser et de flusher le curseur associé grâce à la procédure dbms_shared_pool.purge. J’avais déjà parlé de cette astuce en Décembre 2010 dans un article intitulé « Flusher un curseur de la shared pool avec DBMS_SHARED_POOL».


CONCLUSION :

Il existe différents moyens de forcer un hard parse pour une requête donnée :
- Faire en sorte de générer un SQL_ID différent à chaque exécution
- Invalider le curseur présent dans la shared pool
- Flusher le curseur de la shared ppol

Lorsque vous décidez de forcer un hard parse pour une requête donnée il faut bien avoir à l’esprit du risque encouru : contentions au niveau de la library cache, parse call plus long et consommation accrue de CPU.
Le hard parse forcé est justifié quand l’utilisation d’un unique plan pour une requête donnée conduit à des performances désastreuses. Il peut arriver aussi qu’on souhaite forcer un hard parse mais sans qu’on ait la possibilité de toucher au code. Randolf Geist a mis en évidence dans son blog comment il était possible de générer des clauses WHERE de manière dynamique en utilisant le VPD (Virtual Private Database). Cet article est disponible ici.

lundi 18 juillet 2011

Ajouter un hint sans modifier la requête

Dans un post précédent j’avais montré comment forcer, pour une requête donnée, un plan d’exécution déjà existant en base en utilisant la procédure dbms_sqltune.import_sql_profile.
Dans ce post je vais tenter d'expliquer comment on peut forcer l’utilisation d’un hint sans toucher au corps de la requête en utilisant là aussi la procédure dbms_sqltune.import_sql_profile.

Pour illustrer cette astuce je vais partir d’une optimisation que j’ai eu à réaliser récemment sur une base 10g.

La requête à optimiser était la suivante (j’ai supprimé volontairement la partie SELECT qui était trop longue) :
SELECT
….
FROM ORDER_DISPATCH_STATUS, VMAT_FLAT_EVENT_DESCS
 WHERE ods_order_sending_type_id = :"SYS_B_22"
   AND ods_state = :"SYS _B_23"
   AND viev_root_id = ods_event_id
   AND TRUNC(lyx_day) = TRUNC(SYSDATE)
   AND (TO_NUMBER(TO_CHAR(SYSDATE, :"SYS_B_24")) -
       TO_NUMBER(TO_CHAR(ods_sending_date_theo, :"SYS_B_25"))) *
       :"SYS_B_26" + TO_NUMBER(TO_CHAR(SYSDATE, :"SYS_B_27")) -
       TO_NUMBER(TO_CHAR(ods_sending_date_theo, :"SYS_B_28")) > :"SYS_B_29"
   AND ods_message IS NULL;

Les stats ci-dessous montrent que cette requête a toujours utilisé un seul plan d ‘exécution et que ce plan a été exécuté 47 621 lors des 40 derniers jours (40 jours est la durée de rétention du référentiel AWR appliquée à cette base) et génère en moyenne 61562 logical reads par exécution :

SQL> @awr_plan_stats
Enter value for sql_id: 0tsubdyu2zbaz

SQL_ID        PLAN_HASH_VALUE        EXECS    AVG_ETIME AVG_CPU_TIME        AVG_LIO      AVG_PIO
------------- --------------- ------------ ------------ ------------ -------------- ------------
0tsubdyu2zbaz      2703446289       47,621        1.582        1.608       61,562.2           .4

En exécutant cette requête avec les bonnes valeurs des binds variables, j’obtiens 61000 logical reads pour aucune ligne retournée. C’est très très cher payé.
Plan hash value: 2703446289

-------------------------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                    | Name                  | Starts | E-Rows | A-Rows |   A-Time   | Buffers |  OMem |  1Mem | Used-Mem |
-------------------------------------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT             |                       |      1 |        |      0 |00:00:02.10 |   61413 |       |       |          |
|*  1 |  HASH JOIN                   |                       |      1 |    481 |      0 |00:00:02.10 |   61413 |   894K|   894K|  207K (0)|
|*  2 |   TABLE ACCESS BY INDEX ROWID| ORDER_DISPATCH_STATUS |      1 |    481 |      0 |00:00:02.10 |   61413 |       |       |          |
|*  3 |    INDEX RANGE SCAN          | IDX_ODS_SND_TYPE_ID   |      1 |   4163 |   2367K|00:00:00.01 |    6280 |       |       |          |
|   4 |   TABLE ACCESS BY INDEX ROWID| VMAT_FLAT_EVENT_DESCS |      0 |    313K|      0 |00:00:00.01 |       0 |       |       |          |
|*  5 |    INDEX RANGE SCAN          | IDX_TRUNC_LYX_DAY     |      0 |      1 |      0 |00:00:00.01 |       0 |       |       |          |
-------------------------------------------------------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   1 - access("VIEV_ROOT_ID"="ODS_EVENT_ID")
   2 - filter(("ODS_STATE"=:SYS_B_23 AND (TO_NUMBER(TO_CHAR(SYSDATE@!,:SYS_B_24))-TO_NUMBER(TO_CHAR(INTERNAL_FUNCTION("ODS_SENDING_
              DATE_THEO"),:SYS_B_25)))*:SYS_B_26+TO_NUMBER(TO_CHAR(SYSDATE@!,:SYS_B_27))-TO_NUMBER(TO_CHAR(INTERNAL_FUNCTION("ODS_SENDING_DATE_TH
              EO"),:SYS_B_28))>:SYS_B_29 AND "ODS_MESSAGE" IS NULL))
   3 - access("ORDER_DISPATCH_STATUS"."SYS_NC00012$"=SYS_OP_DESCEND(:SYS_B_22))
       filter(SYS_OP_UNDESCEND("ORDER_DISPATCH_STATUS"."SYS_NC00012$")=:SYS_B_22)
   5 - access("VMAT_FLAT_EVENT_DESCS"."SYS_NC00116$"=SYS_OP_DESCEND(TRUNC(SYSDATE@!)))
       filter(SYS_OP_UNDESCEND("VMAT_FLAT_EVENT_DESCS"."SYS_NC00116$")=TRUNC(SYSDATE@!))

La complexité des clauses WHERE pour cette requête fait que le CBO estime mal les cardinalités retournées pour chaque opération et choisit donc la mauvaise table comme table leader, en l’occurrence la table ORDER_DISPATCH_STATUS au lieu de VMAT_FLAT_EVENT_DESCS.

Voyons ce que donne le plan en forçant le CBO à choisir la table VMAT_FLAT_EVENT_DESCS comme table directrice. Pour ce faire on peut utiliser le hint LEADING : /*+ leading(VMAT_FLAT_EVENT_DESCS) */
Plan hash value: 1318622414

----------------------------------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                    | Name                  | Starts | E-Rows | A-Rows |   A-Time   | Buffers | Reads  |  OMem |  1Mem | Used-Mem |
----------------------------------------------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT             |                       |      1 |        |      0 |00:00:00.02 |       4 |      8 |       |       |          |
|*  1 |  HASH JOIN                   |                       |      1 |    481 |      0 |00:00:00.02 |       4 |      8 |  1011K|  1011K|  554K (0)|
|   2 |   TABLE ACCESS BY INDEX ROWID| VMAT_FLAT_EVENT_DESCS |      1 |    313K|      0 |00:00:00.02 |       4 |      8 |       |       |          |
|*  3 |    INDEX RANGE SCAN          | IDX_TRUNC_LYX_DAY     |      1 |      1 |      0 |00:00:00.02 |       4 |      8 |       |       |          |
|*  4 |   TABLE ACCESS BY INDEX ROWID| ORDER_DISPATCH_STATUS |      0 |    481 |      0 |00:00:00.01 |       0 |      0 |       |       |          |
|*  5 |    INDEX RANGE SCAN          | IDX_ODS_SND_TYPE_ID   |      0 |      1 |      0 |00:00:00.01 |       0 |      0 |       |       |          |
----------------------------------------------------------------------------------------------------------------------------------------------------

On voit que cette fois la table conductrice est bien VMAT_FLAT_EVENT_DESCS et que le nombre de logical reads est passé de 61413 à 4.

Mon souci ici était que bien que j’avais la solution je ne pouvais pas toucher au corps de la requête pour implémenter ce hint. Il me fallait donc un moyen d’ajouter ce hint sans toucher au code SQL.
La solution consiste à créer un SQL profile qui va contenir ce hint dans l’OUTLINE DATA. L’OUTLINE DATA contient l’ensemble des hints qui déterminent le plan d’exécution d’une requête.

Affichons l’OUTLINE DATA de la requête contenant le hint LEADING :
select * from table(dbms_xplan.display_cursor('6s3y9wn6basrf',0,'OUTLINE'));

Outline Data
-------------

  /*+
      BEGIN_OUTLINE_DATA
      IGNORE_OPTIM_EMBEDDED_HINTS
      OPTIMIZER_FEATURES_ENABLE('10.2.0.5')
      OPT_PARAM('_optimizer_cost_model' 'io')
      OPT_PARAM('_complex_view_merging' 'false')
      OPT_PARAM('optimizer_index_cost_adj' 20)
      OPT_PARAM('optimizer_index_caching' 30)
      ALL_ROWS
      OUTLINE_LEAF(@"SEL$1")
      INDEX_RS_ASC(@"SEL$1" "VMAT_FLAT_EVENT_DESCS"@"SEL$1" "IDX_TRUNC_LYX_DAY")
      INDEX_RS_ASC(@"SEL$1" "ORDER_DISPATCH_STATUS"@"SEL$1" "IDX_ODS_SND_TYPE_ID")
      LEADING(@"SEL$1" "VMAT_FLAT_EVENT_DESCS"@"SEL$1" "ORDER_DISPATCH_STATUS"@"SEL$1")
      USE_HASH(@"SEL$1" "ORDER_DISPATCH_STATUS"@"SEL$1")
      END_OUTLINE_DATA
  */
  

La partie de l’outline data qui nous interesse est celle contenant le mot LEADING :
LEADING(@"SEL$1" "VMAT_FLAT_EVENT_DESCS"@"SEL$1" "ORDER_DISPATCH_STATUS"@"SEL$1")

C’est ce hint qu’on souhaiterait associer via un profile à la requête exécutée par l’application c'est-à-dire celle qui ne contient pas le hint. Pour atteindre ce but on peut utiliser le script de Kerry OSBORNE que j'ai recopié ici:
accept sql_id -
       prompt 'Enter value for sql_id: ' -
       default 'X0X0X0X0'
accept profile_name -
       prompt 'Enter value for profile_name (PROFILE_sqlid_MANUAL): ' -
       default 'X0X0X0X0'
accept category -
       prompt 'Enter value for category (DEFAULT): ' -
       default 'DEFAULT'
accept force_matching -
       prompt 'Enter value for force_matching (false): ' -
       default 'false'


set sqlblanklines on

declare
l_profile_name varchar2(30);
cl_sql_text clob;
l_category varchar2(30);
l_force_matching varchar2(3);
b_force_matching boolean;
begin

select
sql_text
into
cl_sql_text
from
dba_hist_sqltext
where
sql_id = '&&sql_id';

select decode('&&profile_name','X0X0X0X0','PROFILE_'||'&&sql_id'||'_MANUAL','&&profile_name')
into l_profile_name
from dual;

dbms_sqltune.import_sql_profile(
sql_text => cl_sql_text, 
profile => sqlprof_attr('&hint'),
category => '&&category',
name => l_profile_name,
-- use force_match => true
-- to use CURSOR_SHARING=SIMILAR
-- behaviour, i.e. match even with
-- differing literals
force_match => &&force_matching
);

dbms_output.put_line('Profile '||l_profile_name||' created.');

end;
/

undef profile_name
undef sql_id
undef category
undef force_matching

La variable SQL_ID doit contenir le SQL_ID de la requête qui s ‘exécute mal en production.
La variable PROFILE_NAME contient le nom du SQL profile qu’on souhaite créer.
La variable CATEGORY correspond à la catégorie du SQL profile qu’on veut créer.
La variable FORCE_MATCHING permet d’indiquer si on souhaite forcer le plan aux autres requêtes qui diffèrent sur la valeur littéral utilisée dans la clause WHERE.
La variable HINT doit être settée avec le hint qu’on a récupéré précédemment de l’OUTLINE DATA du plan contenant le bon ordre de jointure.

Dans mon cas voici les valeurs que j’ai indiquées pour ces variables :
SQL_ID = 0tsubdyu2zbaz      
PROFILE_NAME = PROFILE_0tsubdyu2zbaz_MANUAL
CATEGORY = DEFAULT
FORCE_MATCHING = TRUE
HINT= LEADING(@"SEL$1" "VMAT_FLAT_EVENT_DESCS"@"SEL$1" "ORDER_DISPATCH_STATUS"@"SEL$1")

Une fois le script exécuté, je vérifie que le SQL profile existe bien en base:
SQL> select name,category,created,force_matching
  2  from dba_sql_profiles
  3  where name='PROFILE_0tsubdyu2zbaz_MANUAL';

NAME                           CATEGORY                       CREATED   FOR
------------------------------ ------------------------------ --------- ---
PROFILE_0tsubdyu2zbaz_MANUAL   DEFAULT                        18-JUL-11 YES

Maintenant lorsque je réexecute ma requête initiale voici ce que j’obtiens :
Plan hash value: 1318622414

-------------------------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                    | Name                  | Starts | E-Rows | A-Rows |   A-Time   | Buffers |  OMem |  1Mem | Used-Mem |
-------------------------------------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT             |                       |      1 |        |      0 |00:00:00.01 |       4 |       |       |          |
|*  1 |  HASH JOIN                   |                       |      1 |    516 |      0 |00:00:00.01 |       4 |  1011K|  1011K|  572K (0)|
|   2 |   TABLE ACCESS BY INDEX ROWID| VMAT_FLAT_EVENT_DESCS |      1 |    314K|      0 |00:00:00.01 |       4 |       |       |          |
|*  3 |    INDEX RANGE SCAN          | IDX_TRUNC_LYX_DAY     |      1 |      1 |      0 |00:00:00.01 |       4 |       |       |          |
|*  4 |   TABLE ACCESS BY INDEX ROWID| ORDER_DISPATCH_STATUS |      0 |    516 |      0 |00:00:00.01 |       0 |       |       |          |
|*  5 |    INDEX RANGE SCAN          | IDX_ODS_SND_TYPE_ID   |      0 |   4181 |      0 |00:00:00.01 |       0 |       |       |          |
-------------------------------------------------------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   1 - access("VIEV_ROOT_ID"="ODS_EVENT_ID")
   3 - access("VMAT_FLAT_EVENT_DESCS"."SYS_NC00116$"=SYS_OP_DESCEND(TRUNC(SYSDATE@!)))
       filter(SYS_OP_UNDESCEND("VMAT_FLAT_EVENT_DESCS"."SYS_NC00116$")=TRUNC(SYSDATE@!))
   4 - filter(("ODS_STATE"=:SYS_B_23 AND (TO_NUMBER(TO_CHAR(SYSDATE@!,:SYS_B_24))-TO_NUMBER(TO_CHAR(INTERNAL_FUNCTION("ODS_SENDING_
              DATE_THEO"),:SYS_B_25)))*:SYS_B_26+TO_NUMBER(TO_CHAR(SYSDATE@!,:SYS_B_27))-TO_NUMBER(TO_CHAR(INTERNAL_FUNCTION("ODS_SENDING_DATE
              EO"),:SYS_B_28))>:SYS_B_29 AND "ODS_MESSAGE" IS NULL))
   5 - access("ORDER_DISPATCH_STATUS"."SYS_NC00012$"=SYS_OP_DESCEND(:SYS_B_22))
       filter(SYS_OP_UNDESCEND("ORDER_DISPATCH_STATUS"."SYS_NC00012$")=:SYS_B_22)

Note
-----
   - cpu costing is off (consider enabling it)
   - SQL profile "PROFILE_0tsubdyu2zbaz_MANUAL" used for this statement

On voit que le bon plan est pris en compte.
Dans la partie Note tout en bas du plan d’exécution il est indiqué que le SQL profile PROFILE_0tsubdyu2zbaz_MANUAL a été utilisée pour l’exécution de cette requête.


CONCLUSION :
En utilisant des SQL profile "manuels", il est tout à fait possible d’ajouter des hints à une requête sans avoir à toucher au corps de la requête.
Ce genre de solutions est pratique lorsqu’on utilise un progiciel pour lequel on ne peut pas toucher au code source ou bien lorsqu’on veut rapidement améliorer l’exécution d’une requête en production sans attendre la livraison d’un patch ou la prochaine livraison applicative.