jeudi 23 juin 2011

SESSION_CACHED_CURSORS

Avant d’en savoir plus sur ce paramètre d’instance il est nécessaire de faire un rappel sur le concept de Parsing des requêtes SQL dans Oracle et sur la notion de curseur.

Le parsing est la première étape dans le process d’exécution d’une requête SQL.
Durant cette étape Oracle effectue d’abord les tâches suivantes :
- Il vérifie la syntaxe de la requête
- Il vérifie que l’utilisateur exécutant la requête a bien les droits nécessaires pour accéder aux objets référencés.
- Il vérifie si cette même requête a déjà été exécutée auparavant et si un curseur partageable existe déjà dans la shared pool.

Si durant la dernière étape Oracle trouve un curseur partageable dans la SQL Area de la shared pool, alors il s’agit d’un SOFT PARSE c'est-à-dire qu’Oracle n’a pas besoin de demander au CBO de lui calculer un plan pour cette requête car il en existe déjà un en mémoire. Par contre s’il n’existe pas de curseur partageable alors le CBO doit calculer un plan pour la requête et stocker toutes les informations du nouveau curseur dans la shared pool pour qu’il soit réutilisable par la suite. Ce type de parsing est appelé HARD PARSE et est très gourmand en CPU.

Idéalement une requête SQL est hard parsée une seule fois lors de la première exécution, et uniquement soft parsée lors des exécutions suivantes. Toutefois le SOFT PARSE bien que beaucoup moins gourmand que le HARD PARSE consomme de la CPU et est également synonyme de latch au niveau de la « library cache ».

Comment faire mieux que le SOFT PARSE ?
C’est là où la notion de « SESSION CACHED CURSORS » entre en jeu.

Ce qu’il faut savoir c’est que lorsque la requête est exécutée la première fois un curseur est ouvert dans la mémoire privée de la session. Cette mémoire est appelée UGA et se trouve dans la PGA lorsqu’on est en mode DEDICATED SERVER ou dans la SGA lorsqu’on est en mode SHARED SERVER. Ce curseur ouvert côté session correspond à un pointeur vers le curseur de la shared pool contenant toutes les informations nécessaires à l’exécution de la requête et notamment le plan d’exécution.
A la fin de l’exécution de la requête, le curseur ouvert peut être mis dans le cache des curseurs fermés (toujours côté UGA). Quand la requête est réexecutée une recherche est effectuée dans le cache des curseurs fermés. Si le curseur existe il est remis dans le cache des curseurs ouvert et le pointeur vers le curseur de la shared pool est utilisé pour exécuter la requête. On évite le Hard Parse mais on évite aussi la vérification syntaxique et sémantique du SOFT PARSE. Oracle comptabilise cette méthode comme un soft parse même s’il est plus efficace que le SOFT PARSE classique. Tom KYTE appelle ce type de parsing le « Softer Soft Parse ».

Le nombre de curseurs cachables pour chaque session dans l’UGA est défini par le paramètre d’instance SESSION_CACHED_CURSORS qui est par défaut setté à 50 en 11g.

La statistique « session cursor cache count » dans V$SESSTAT nous donne le nombre de curseurs cachés dans l’UGA pour une session donnée.

La statistique « session cursor cache hits » indique le nombre de fois où un curseur a été trouvé dans le cache de session.

La statistique « parse count (total) » indique le nombre de « parse call » (HARD PARSE, SOFT PARSE ou SOFTER SOFT PARSE).

Il faut savoir que pour qu'un curseur soit placé dans le cache des curseurs fermés, il doit avoir été exécuté 3 fois. Le but étant d'éviter de mettre en cache des curseurs qui ne seront exécutés qu'une seule fois.

En se basant sur ces statistiques il est possible de construire un petit test case permettant de comprendre le fonctionnement des curseurs en cache.

Test case :
Dans Session 1:
SQL> select sid from v$mystat where rownum=1;

       SID
----------
       136


Depuis une autre session je regarde les stats existantes pour cette session :
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 ('session cursor cache hits',
  5   'session cursor cache count',
  6   'parse count (total)')
  7   AND ss.sid =136;

NAME                                                                  VALUE
---------------------------------------------------------------- ----------
session cursor cache hits                                                 0
session cursor cache count                                               18
parse count (total)                                                      29

Le nombre de curseurs dans le cache de ma session est de 18 et aucun parse call n'a pour l'instant profité du cache des curseurs (session cursor cache hits ).

J'exécute une simple requête dans ma 1ère session:
SQL> select count(1) from t1;

  COUNT(1)
----------
       100

Je regarde les stats:
SQL> /

NAME                                                                  VALUE
---------------------------------------------------------------- ----------
session cursor cache hits                                                 0
session cursor cache count                                               18
parse count (total)                                                      31

Seul le nombre de « parse call » a bougé. J'ai toujours le même nombre de curseur dans le cache.

J'exécute une 2è fois ma requête:
SQL> select count(1) from t1;

  COUNT(1)
----------
       100

SQL> /

NAME                                                                  VALUE
---------------------------------------------------------------- ----------
session cursor cache hits                                                 0
session cursor cache count                                               18
parse count (total)                                                      32

Idem que pour la 1ère exécution.
J'exécute une 3ème fois la requête dans ma 1ère session puis je regarde les stats:
SQL> /

NAME                                                                  VALUE
---------------------------------------------------------------- ----------
session cursor cache hits                                                 0
session cursor cache count                                               18
parse count (total)                                                      33

Idem que précedemment.

J'exécute une 4ème fois la même requête:
SQL> /

NAME                                                                  VALUE
---------------------------------------------------------------- ----------
session cursor cache hits                                                 1
session cursor cache count                                               24
parse count (total)                                                      34
Cette fois le nombre de curseurs dans le cache a bougé et surtout mon "session cursor cache hits" est passé à 1. Donc c'est uniquement à la 4ème exécution que le SOFT PARSE est évité au profit d'un « softer soft parse ».

Après la 5ème exécution:
SQL> /

NAME                                                                  VALUE
---------------------------------------------------------------- ----------
session cursor cache hits                                                 2
session cursor cache count                                               24
parse count (total)                                                      35

Le "session cursor cache hits" est passé à 2.
En faisant le test pour 10 exécutions j'obtiens un "session cursor cache hits" de 7.
(10-7 = 3) donc 3 est bien le nombre d'exécutions nécessaires pour que le curseur soit caché dans l’UGA.

Quelle est la valeur optimale pour SESSION_CACHED_CURSORS ?
La réponse à cette question dépend de votre application.
Par défaut en 11g le paramètre est setté à 50. Si vous avez une base de donnée transactionnelle où les mêmes requêtes sont exécutées plusieurs fois, il est possible que 50 ne soit pas suffisant.
Pour en être sûr vous pouvez vérifier la valeur de la statistique « session cursor cache count » dans V$SESSTAT. Si pour certaines sessions la valeur de cette stat est de 50, c’est que surement ces sessions pourraient bénéficier d’un cache de curseur plus important.

Dans l’exemple ci-dessous je récupère pour une base (dont le paramètre SESSION_CACHED_CURSORS est à 100) les sessions dont le cache des curseurs a atteint le max :
SQL> SELECT ss.sid,ss.value
  2   FROM v$statname sn, v$sesstat ss
  3   WHERE sn.statistic# = ss.statistic#
  4   AND sn.name = 'session cursor cache count'
  5   and ss.value=(select value from v$parameter where name='session_cached_cursors');

       SID      VALUE
---------- ----------
       444        100
       451        100
       458        100
       460        100
       465        100
       466        100
       468        100
       471        100
       475        100
       493        100

10 rows selected.

SQL> select count(distinct sid) from v$sesstat;

COUNT(DISTINCTSID)
------------------
                45 

10 sessions sur 45 n’ont pu bénéficier d’un cache de curseur plus important.
On peut alors se demander si ça ne vaudrait pas le coup d’augmenter le paramètre SESSION_CACHED_CURSORS.
Le frein à tout ça c’est qu’en augmentant ce paramètre on aura besoin de plus de place dans l’UGA. Donc tout dépend des capacités en mémoire dont on dispose. Un juste équilibre doit être trouvé.


CONCLUSION :

Il est clair que le cache des curseurs permet de réduire le temps lié au parsing et donc de réduire le temps global d’exécution des requêtes SQL. De plus comme le fait de cacher les curseurs évite d’avoir à rechercher un curseur partageable dans la shared pool, cela réduit par le même effet les latchs posés au niveau de la library cache. Ca peut être très bénéfique pour les applications hyper transactionnelles en les rendant plus scalables.

Il est possible applicativement d’éviter les « parse call » et donc d’éliminer à la fois SOFT PARSE et HARD PARSE en utilisant par exemple les « prepared statements ». Mais il s’agit là d’un autre sujet.

mardi 7 juin 2011

ANTI-JOINS : NOT IN vs NOT EXISTS

Une anti-jointure est une jointure entre 2 tables où les lignes de la première table sont retournées s’il n’existe pas de correspondance dans la 2ème table.

Les anti-jointures s’écrivent essentiellement en utilisant soit la clause NOT IN soit la clause NOT EXISTS.

Voici le script de création des 2 tables T1 et T2 que je vais utiliser pour illustrer les anti-jointures:

create table t1 (c1 number, c2 varchar2(50));

create table t2 (c1 number, c2 number);

-- t1
insert into t1 values (1,'TEST1');
insert into t1 values (2,'TEST2');
insert into t1 values (3,'TEST3');

-- t2
insert into t2 values (1,1);
insert into t2 values (2,2);
insert into t2 values (3,NULL);

commit;

create index idx_t1 on T1(c1);
create index idx_t2 on T2(c2);


Si on décide de joindre les tables T1 et T2 en utilisant T1.C1 et T2.C2 seules 2 lignes matchent :
SQL> select * from t1,t2 where t1.c1=t2.c2;

        C1 C2                 C1         C2
---------- ---------- ---------- ----------
         1 TEST1               1          1
         2 TEST2               2          2

2 rows selected. 

L’anti-jointure entre T1 et T2 doit retourner à priori la ligne TEST3 puisque cette ligne est la seule qui n’a pas de correspondance dans T2.
Voyons ce que donne l’anti-join en utilisant la clause NOT EXISTS:
SQL> select /* NOT_EXISTS */ * from t1 where not exists (select 1 from t2 where t1.c1=t2.c2);

        C1 C2
---------- ----------
         3 TEST3

1 row selected. 

On a bien la ligne TEST3 qui est retourné.

Voyons maintenant ce que donne l’anti-join avec la clause NOT IN :
SQL> select /* NOT_IN*/ * from t1 where t1.c1 not in (select t2.c2 from t2);

no rows selected

Cette fois ci le résultat est différent du NOT EXISTS.
Surpris ????

La première conclusion de ce test c’est que le NOT EXISTS et le NOT IN ne sont pas équivalent fonctionnellement. Dans un article précédent sur les semi-joins j’avais montré que les clauses IN et EXISTS étaient équivalent aussi bien fonctionnellement que d’un point de vue performances.
Pour les anti-joins ça peut être le cas mais pas toujours (comme le montre l’exemple ci-dessus).

Voyons pourquoi le NOT IN et le NOT EXISTS ne retournent pas le même résultat dans notre exemple.
L’explication réside dans la manière dont Oracle traite les valeurs NULL avec la clause NOT IN.
En effet, avec la clause NOT IN Oracle compare chaque valeur de T1.C1 avec T2.C2. Si T1.C1 a une correspondance dans T2 alors la ligne de T1 n’est pas retournée. Par contre si T1.C1 est comparé avec NULL, Oracle ne sait pas faire et retourne toujours FALSE pour ce test. Il faut rappeler que NULL dans le monde d’Oracle n’est pas une vraie valeur et veut juste dire : « je ne sais pas ».
Regardez le test PL/SQL ci-dessous.
SQL> set serveroutput on
SQL> declare
  2  v1 varchar2(10);
  3  v2 varchar2(10):='toto';
  4  begin
  5     IF v1<>v2 THEN
  6        dbms_output.put_line('DIFFERENT');
  7     ELSE
  8        dbms_output.put_line('EGAL');
  9     END IF;
 10  end;
 11  /
EGAL

PL/SQL procedure successfully completed.

On a une variable V1 qui est non settée donc NULL, et une variable V2 qui vaut ‘TOTO’. Quand on fait le test « IF V1<>V2 » on ne rentre pas dans le test alors que pour nous V1 est vraiment différent de V2. C’est pour ça qu’il faut toujours faire attention aux valeurs NULL dans Oracle. Que ce soit au niveau des colonnes ou bien au niveau des variables.

La clause NOT EXISTS, elle, ne tient pas compte des NULL et retourne donc un résultat plus logique pour nous.

Lorsqu’on utilise la clause NOT IN il faut donc prendre en compte la possibilité que la sous-requête puisse retourner des valeurs NULL.
Pour ce faire vous pouvez :
- soit ajouter une clause « WHERE NOM_COLONNE IS NOT NULL » dans la sous-requête
- soit ajouter une contrainte NOT NULL au niveau de la colonne
- soit appliquer la fonction NVL à la colonne

Exemple :
SQL> select /* NOT_IN_NOT_NULL*/ * from t1 where t1.c1 not in (select t2.c2 from t2 where t2.c2 is not null);

        C1 C2
---------- ----------
         3 TEST3

1 row selected.

SQL> select /* NOT_IN_NVL*/ * from t1 where t1.c1 not in (select NVL(t2.c2,-1) from t2);

        C1 C2
---------- ----------
         3 TEST3

1 row selected.

Maintenant que nous avons vu la subtilité fonctionnelle entre NOT IN et NOT EXISTS, voyons ce que ça donne d’un point de vue performances en analysant de plus près les plans d’exécution.

Les plans ci-dessous sont les plans pour la version 11g :
SQL> explain plan for
  2  select /* NOT_EXISTS */ * from t1 where not exists (select 1 from t2 where t1.c1=t2.c2);

Explained.

SQL> select * from table(dbms_xplan.display);

Plan hash value: 1534930707

-----------------------------------------------------------------------------
| Id  | Operation          | Name   | Rows  | Bytes | Cost (%CPU)| Time     |
-----------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |     2 |    24 |     3   (0)| 00:00:01 |
|   1 |  NESTED LOOPS ANTI |        |     2 |    24 |     3   (0)| 00:00:01 |
|   2 |   TABLE ACCESS FULL| T1     |     3 |    27 |     3   (0)| 00:00:01 |
|*  3 |   INDEX RANGE SCAN | IDX_T2 |     1 |     3 |     0   (0)| 00:00:01 |
-----------------------------------------------------------------------------

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

   3 - access("T1"."C1"="T2"."C2")
       filter("T2"."C2" IS NOT NULL)

16 rows selected.

SQL> explain plan for
  2  select /* NOT_IN*/ * from t1 where t1.c1 not in (select t2.c2 from t2);

Explained.

SQL> select * from table(dbms_xplan.display);

Plan hash value: 1275484728

---------------------------------------------------------------------------
| Id  | Operation          | Name | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |      |     2 |    24 |     7  (15)| 00:00:01 |
|*  1 |  HASH JOIN ANTI NA |      |     2 |    24 |     7  (15)| 00:00:01 |
|   2 |   TABLE ACCESS FULL| T1   |     3 |    27 |     3   (0)| 00:00:01 |
|   3 |   TABLE ACCESS FULL| T2   |     3 |     9 |     3   (0)| 00:00:01 |
---------------------------------------------------------------------------

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

   1 - access("T1"."C1"="T2"."C2")

15 rows selected.

Comme nous l’avions vu pour les semi-jointures, l’anti-jointure est une forme d’optimisation appliquée aux méthodes de jointures classiques telles que NESTED LOOP ou HASH JOIN. L’avantage par exemple du NESTED LOOP ANTI par rapport au NESTED LOOP classique c’est que la recherche dans la sous-requête s’arrête aussitôt qu’une correspondance a été trouvée dans cette table. C’est comme si on avait une clause EXIT dans le code d'une boucle imbriquée.
La nouveauté en 11G est pour le plan du NOT IN avec l’opération « ANTI NA ». NA signifiant NULL AWARE. Cette nouveauté permet d’appliquer la méthode ANTI-JOIN même lorsque la sous-requête retourne des valeurs NULL. Comme on peut le voir ci-dessous, en 10g l’optimiseur ne pouvait pas utiliser d’anti-jointures pour les clauses NOT IN (à moins bien sûr qu’une contrainte NOT NULL soit spécifié pour la colonne récupérée) :

SQL> alter session set optimizer_features_enable='10.2.0.4';

Session altered.

SQL> explain plan for
  2  select /* NOT_IN*/ * from t1 where t1.c1 not in (select t2.c2 from t2);

Explained.

SQL> @plan

Plan hash value: 895956251

---------------------------------------------------------------------------
| Id  | Operation          | Name | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |      |     1 |    20 |     5   (0)| 00:00:01 |
|*  1 |  FILTER            |      |       |       |            |          |
|   2 |   TABLE ACCESS FULL| T1   |     3 |    60 |     3   (0)| 00:00:01 |
|*  3 |   TABLE ACCESS FULL| T2   |     3 |    39 |     2   (0)| 00:00:01 |
---------------------------------------------------------------------------

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

   1 - filter( NOT EXISTS (SELECT 0 FROM "T2" "T2" WHERE
              LNNVL("T2"."C2"<>:B1)))
   3 - filter(LNNVL("T2"."C2"<>:B1))

Note
-----
   - dynamic sampling used for this statement (level=2)

Ici c’est la clause FILTER qui est utilisée. Ce plan est beaucoup moins performant que celui avec l’anti-join.

Voyons maintenant ce que donne les plans pour les 2 clauses NOT IN et NOT EXISTS lorsqu’on garantit via une contrainte NOT NULL que la sous-requête ne peut pas retourner de valeurs NULL :

SQL> update t2 set c2=5 where c1=3;

1 row updated.

SQL> commit;

Commit complete.

SQL> alter table t2 modify c2 not null;

Table altered.

SQL> alter table t1 modify c1 not null;

Table altered.

SQL> explain plan for
  2  select /* NOT_IN */ * from t1 where t1.c1 not in (select t2.c2 from t2);

Explained.

SQL> select * from table(dbms_xplan.display);

Plan hash value: 1534930707

-----------------------------------------------------------------------------
| Id  | Operation          | Name   | Rows  | Bytes | Cost (%CPU)| Time     |
-----------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |     2 |    24 |     3   (0)| 00:00:01 |
|   1 |  NESTED LOOPS ANTI |        |     2 |    24 |     3   (0)| 00:00:01 |
|   2 |   TABLE ACCESS FULL| T1     |     3 |    27 |     3   (0)| 00:00:01 |
|*  3 |   INDEX RANGE SCAN | IDX_T2 |     2 |     6 |     0   (0)| 00:00:01 |
-----------------------------------------------------------------------------

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

   3 - access("T1"."C1"="T2"."C2")

15 rows selected.

SQL> explain plan for
  2  select /* NOT_EXISTS */ * from t1 where not exists (select 1 from t2 where t1.c1=t2.c2);

Explained.

SQL> select * from table(dbms_xplan.display);

Plan hash value: 1534930707

-----------------------------------------------------------------------------
| Id  | Operation          | Name   | Rows  | Bytes | Cost (%CPU)| Time     |
-----------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |     2 |    24 |     3   (0)| 00:00:01 |
|   1 |  NESTED LOOPS ANTI |        |     2 |    24 |     3   (0)| 00:00:01 |
|   2 |   TABLE ACCESS FULL| T1     |     3 |    27 |     3   (0)| 00:00:01 |
|*  3 |   INDEX RANGE SCAN | IDX_T2 |     2 |     6 |     0   (0)| 00:00:01 |
-----------------------------------------------------------------------------

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

   3 - access("T1"."C1"="T2"."C2")


En plus du même résultat on obtient désormais le même plan pour les 2 requêtes.

Cas pratique :

J’ai récemment eu à traiter un pb de performance sur une requête impliquant une clause NOT IN.
Voici le corps de cette requête avec son plan non-optimal (il s’agissait d’une base 10g):
SELECT  ti.ticker_symbol FROM TICKER_OPTION tf, ticker ti WHERE tf.ticker_id =
ti.ticker_id AND ti.provider_id = 2 AND tf.expiration_dt > SYSDATE AND tf.underlying_ticker_id = 2140
AND tf.ticker_id NOT IN (SELECT inv.ticker_id FROM inventory inv, to_do2 td WHERE  inv.inventory_id =
td.inventory_id AND inv.frequency = 1 AND td.how = 'INTRADAY_BIDASK_NIGHTLY')

Plan hash value: 1691322504

-----------------------------------------------------------------------------------------------------------------
| Id  | Operation                     | Name                  | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
-----------------------------------------------------------------------------------------------------------------
|*  1 |  FILTER                       |                       |      1 |        |   1272 |00:01:56.13 |    7469K|
|   2 |   NESTED LOOPS                |                       |      1 |    552 |   1810 |00:00:00.13 |    6360 |
|*  3 |    TABLE ACCESS BY INDEX ROWID| TICKER_OPTION         |      1 |    552 |   1810 |00:00:00.07 |     843 |
|*  4 |     INDEX RANGE SCAN          | TICKOPT_TICKER_ID_IDX |      1 |  17464 |  17832 |00:00:00.02 |     134 |
|*  5 |    TABLE ACCESS BY INDEX ROWID| TICKER                |   1810 |      1 |   1810 |00:00:00.06 |    5517 |
|*  6 |     INDEX UNIQUE SCAN         | TICKER_PK             |   1810 |      1 |   1810 |00:00:00.04 |    3707 |
|*  7 |   TABLE ACCESS BY INDEX ROWID | TO_DO2                |   1810 |      1 |    538 |00:01:55.96 |    7463K|
|   8 |    NESTED LOOPS               |                       |   1810 |      1 |   4984 |00:01:55.93 |    7462K|
|*  9 |     TABLE ACCESS FULL         | INVENTORY             |   1810 |      2 |   2761 |00:01:55.84 |    7458K|
|* 10 |     INDEX RANGE SCAN          | TO_DO2_INVID          |   2761 |      1 |    951 |00:00:00.06 |    4572 |
-----------------------------------------------------------------------------------------------------------------

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

   1 - filter( IS NULL)
   3 - filter("TF"."EXPIRATION_DT">SYSDATE@!)
   4 - access("TF"."UNDERLYING_TICKER_ID"=2140)
   5 - filter("TI"."PROVIDER_ID"=2)
   6 - access("TF"."TICKER_ID"="TI"."TICKER_ID")
   7 - filter("TD"."HOW"='INTRADAY_BIDASK_NIGHTLY')
   9 - filter(("INV"."FREQUENCY"=1 AND LNNVL("INV"."TICKER_ID"<>:B1)))
  10 - access("INV"."INVENTORY_ID"="TD"."INVENTORY_ID")

Cette requête générait plus de 7 millions de logical reads pour retourner 1272 lignes.
Le plan nous montre que la clause FILTER était utilisée et donc que l’optimisation ANTI-JOIN n’était pas effectuée. En jetant un œil sur la définition de la table INVENTORY je me suis aperçu que la colonne TICKER_ID était NULLABLE. On a vu précédemment qu’en 10g et lorsqu’on n’avait pas de preuve que la sous-requête ne pouvait pas retourner de valeur NULL, le CBO ne pouvait pas utiliser l’optimisation ANTI-JOIN via un la méthode NESTED LOOP ANTI ou HASH JOIN ANTI. En 11g il aurait pu utiliser la méthode ANTI NA.

Pour améliorer les perfs de cette requête j’ai ajouté la fonction NVL à la colonne TICKER_ID pour traiter explicitement les valeurs NULL. On peut voir ci-dessous que l’optimiseur s’en sort avec un meilleur plan utilisant notamment la méthode HASH JOIN ANTI. Le nombre de logical read passe alors de 7 millions à 9742 :
SELECT ti.ticker_symbol FROM TICKER_OPTION tf, ticker ti WHERE tf.ticker_id = ti.ticker_id AND ti.provider_id = 2
AND tf.expiration_dt > SYSDATE AND tf.underlying_ticker_id = 2140 AND tf.ticker_id NOT IN (SELECT nvl(inv.ticker_id,-10) FROM
inventory inv, to_do2 td WHERE  inv.inventory_id = td.inventory_id AND inv.frequency = 1 AND td.how = 'INTRADAY_BIDASK_NIGHTLY')

Plan hash value: 398754701

--------------------------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                     | Name                  | Starts | E-Rows | A-Rows |   A-Time   | Buffers |  OMem |  1Mem | Used-Mem |
--------------------------------------------------------------------------------------------------------------------------------------------
|   1 |  NESTED LOOPS                 |                       |      1 |      1 |   1272 |00:00:00.65 |    9742 |       |       |          |
|*  2 |   HASH JOIN ANTI              |                       |      1 |      1 |   1272 |00:00:00.64 |    5840 |  1452K|  1452K| 1288K (0)|
|*  3 |    TABLE ACCESS BY INDEX ROWID| TICKER_OPTION         |      1 |    552 |   1810 |00:00:00.08 |     676 |       |       |          |
|*  4 |     INDEX RANGE SCAN          | TICKOPT_TICKER_ID_IDX |      1 |  17464 |  17832 |00:00:00.03 |      49 |       |       |          |
|   5 |    VIEW                       | VW_NSO_1              |      1 |  17690 |  17799 |00:00:00.54 |    5164 |       |       |          |
|*  6 |     HASH JOIN                 |                       |      1 |  17690 |  17799 |00:00:00.52 |    5164 |  1573K|  1573K| 1713K (0)|
|*  7 |      TABLE ACCESS FULL        | TO_DO2                |      1 |  17690 |  17800 |00:00:00.02 |     983 |       |       |          |
|*  8 |      TABLE ACCESS FULL        | INVENTORY             |      1 |    501K|    502K|00:00:00.01 |    4181 |       |       |          |
|*  9 |   TABLE ACCESS BY INDEX ROWID | TICKER                |   1272 |      1 |   1272 |00:00:00.01 |    3902 |       |       |          |
|* 10 |    INDEX UNIQUE SCAN          | TICKER_PK             |   1272 |      1 |   1272 |00:00:00.01 |    2630 |       |       |          |
--------------------------------------------------------------------------------------------------------------------------------------------

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

   2 - access("TF"."TICKER_ID"="$nso_col_1")
   3 - filter("TF"."EXPIRATION_DT">SYSDATE@!)
   4 - access("TF"."UNDERLYING_TICKER_ID"=2140)
   6 - access("INV"."INVENTORY_ID"="TD"."INVENTORY_ID")
   7 - filter("TD"."HOW"='INTRADAY_BIDASK_NIGHTLY')
   8 - filter("INV"."FREQUENCY"=1)
   9 - filter("TI"."PROVIDER_ID"=2)
  10 - access("TF"."TICKER_ID"="TI"."TICKER_ID")


CONCLUSION :

Ce qu'il faut retenir de cet article c'est que lorsque les contraintes NOT NULL sont bien en place alors le choix entre NOT IN et NOT EXISTS n’a pas d’importance. L’optimiseur saura utiliser l’anti-jointure.
Par contre, si la sous-requête peut retourner des valeurs NULL alors il vaut mieux privilégier le NOT EXISTS pour éviter des erreurs fonctionnels (quel que soit la version d'Oracle) et des problèmes de performance (pour les versions antérieures à la 11g ne prenant pas en compte la méthode ANTI NA).

A lire aussi:
SEMI-JOINS: IN vs EXISTS
Sur le blog d'Arkzoyd: Pourquoi NOT IN est sémantiquement différent de NOT EXISTS ?

vendredi 3 juin 2011

Indexer les clés étrangères : mythe ou bonne pratique ?

En voyant ce genre de question dans un blog Oracle, souvent on s’attend à ce qu’il s’agisse d’une légende Oracle que l’auteur de l’article souhaite démystifier.
Pourtant il s’agit bien ici d’une bonne pratique Oracle et je vais essayer d’en expliquer les raisons dans cet article.


Eviter les locks:
La première raison pour laquelle il est recommandé d’indexer les Foreign Keys est que lorsqu’on modifie une table « PARENT » (modification de la PK ou suppression d’une ligne) et qu’il n’existe pas d’index sur la FK de la table « ENFANT », alors Oracle effectue un lock de toute la table « ENFANT », ceci afin d’empêcher toute modification de cette table le temps de la mise à jour de la table « PARENT ».

La preuve en exemple :

(SESSION1)
SQL> create table t1 (c1 number primary key);

Table created.

SQL> create table t2 (c1 references t1 on delete cascade);

Table created.

SQL> insert into t1 values (1);

1 row created.

SQL> insert into t1 values (2);

1 row created.

SQL> insert into t1 values (3);

1 row created.

SQL> commit;

Commit complete.

SQL> insert into t2 values (1);

1 row created.

SQL> insert into t2 values (2);

1 row created.

SQL> commit;

Commit complete.


J’ai donc 3 lignes dans la table T1 et 2 dans la table T2.
Maintenant dans la même session, j’update une ligne dans la table T2 (la table « ENFANT ») sans commiter (ma transaction reste donc ouverte) :

(SESSION 1)
SQL> update t2 set c1=3 where c1=2;

1 row updated.

Dans une autre session je tente de supprimer une ligne dans la table « PARENT » T1 :
(SESSION 2)
SQL> delete t1 where c1=1;


La session est bloquée car comme je le disais précédemment Oracle tente de locker la table T2 avant d’effectuer le DELETE sur T1. Or la table T2 est déjà lockée au niveau ROW par ma première session.
On peut confirmer cela en regardant dans V$SESSION l’évènement d’attente pour la session 2 :
SQL> select sid,event, p2,blocking_session from v$session where status='ACTIVE' and program like '%sqlplus%';

       SID EVENT                                                                    P2 BLOCKING_SESSION
---------- ---------------------------------------------------------------- ---------- ----------------
       135 enq: TM - contention                                                  75248              143
       143 SQL*Net message from client                                               1

SQL> select object_name from user_objects where object_id=75248;

OBJECT_NAME
--------------------------------------------------------------------------------------------------------------------
T2

On a la confirmation que notre session 2 (sid 135) est bloquée par la session 1 (sid 143) et en attente sur l’évènement « enq: TM – contention ». La 2ème requête nous confirme que l’objet locké est bien la table T2.

En ajoutant un index à la Foreign key Oracle ne fera plus de lock au niveau table mais au niveau ligne uniquement, ce qui améliore nettement les accès concurrents.

Tout d’abord on effectue un rollback dans nos 2 sessions précédentes, puis on crée un index sur T2.C1 :
SQL> create index idx_t2_c1 on T2(C1);

Index created.

On effectue ensuite le même test que précédemment.

(session 1)
SQL> update t2 set c1=3 where c1=2;

1 row updated.

(session 2)
SQL> delete t1 where c1=1;

1 row deleted.

Cette fois ci le DELETE sur T1 n’est plus bloquée par l’update de T2 de la session 1.

Améliorer les perfs des DELETE:
La 2ème raison pour laquelle il peut être préférable de créer un index sur une Foreign Key c’est lorsque la FK est définie en mode ON DELETE CASCADE. Ce mode indique que lorsqu’une PK est supprimée dans la table « PARENT » alors on laisse à Oracle le soin de supprimer en même temps les clés étrangères qui référencent les PK supprimées. Pour supprimer les lignes correspondantes dans la table « ENFANT », Oracle doit d’abord les localiser. S’il n’existe pas d’index pour la FK concernée alors Oracle est obligé d’effectuer un Full Table Scan de la table « ENFANT » pour chaque ligne supprimée dans la table « PARENT ». D’où l’intérêt d’avoir un index sur la FK.
Pour en avoir la preuve on peut tracer une commande DELETE et regarder ce que donnent les requêtes récursives impliquées par ce DELETE dans le cas où il n’existe pas d’index sur la clé étrangère :
SQL> drop index idx_t2_c1;

Index dropped.

SQL> ALTER SESSION SET events '10046 trace name context forever, level 12';

Session altered.

SQL> delete t1 where c1=1;

1 row deleted.

SQL> ALTER SESSION SET events '10046 trace name context off';

Session altered.


Un petit TKPROF sur la trace générée avec l’option SYS=YES (pour afficher les requêtes récursives) nous donne :
SQL ID: 7q7vx5nwhv6wu
Plan Hash: 897490302
delete t1 
where
 c1=1


call     count       cpu    elapsed       disk      query    current        rows
------- ------  -------- ---------- ---------- ---------- ----------  ----------
Parse        1      0.00       0.01          0          0          0           0
Execute      1      0.00       0.03          0          2          7           1
Fetch        0      0.00       0.00          0          0          0           0
------- ------  -------- ---------- ---------- ---------- ----------  ----------
total        2      0.00       0.04          0          2          7           1

Misses in library cache during parse: 1
Optimizer mode: ALL_ROWS
Parsing user id: 84  

Rows     Row Source Operation
-------  ---------------------------------------------------
      0  DELETE  T1 (cr=9 pr=0 pw=0 time=0 us)
      1   INDEX UNIQUE SCAN SYS_C0011644 (cr=1 pr=0 pw=0 time=0 us cost=0 size=3 card=1)(object id 75250)


Elapsed times include waiting on following events:
  Event waited on                             Times   Max. Wait  Total Waited
  ----------------------------------------   Waited  ----------  ------------
  SQL*Net message to client                       1        0.00          0.00
  SQL*Net message from client                     1        1.29          1.29
********************************************************************************

SQL ID: 04w09dv7av2y5
Plan Hash: 1451993194
delete from "TOTO"."T2" 
where
 "C1" = :1


call     count       cpu    elapsed       disk      query    current        rows
------- ------  -------- ---------- ---------- ---------- ----------  ----------
Parse        1      0.00       0.00          0          0          2           0
Execute      1      0.01       0.01          0          7          1           1
Fetch        0      0.00       0.00          0          0          0           0
------- ------  -------- ---------- ---------- ---------- ----------  ----------
total        2      0.01       0.01          0          7          3           1

Misses in library cache during parse: 1
Misses in library cache during execute: 1
Optimizer mode: CHOOSE
Parsing user id: SYS   (recursive depth: 1)

Rows     Row Source Operation
-------  ---------------------------------------------------
      0  DELETE  T2 (cr=7 pr=0 pw=0 time=0 us)
      1   TABLE ACCESS FULL T2 (cr=7 pr=0 pw=0 time=0 us cost=3 size=3 card=1)


Elapsed times include waiting on following events:
  Event waited on                             Times   Max. Wait  Total Waited
  ----------------------------------------   Waited  ----------  ------------
  Disk file operations I/O                        1        0.00          0.00
********************************************************************************

Le plan pour le DELETE utilise l’index unique de la PK, ce qui est logique.
Par contre on se rend compte que pour la requête récursive qui va d’abord supprimer la ligne dans la table « ENFANT » T2, un TABLE ACCESS FULL a été effectué.
Alors imaginons qu’on souhaite supprimer plusieurs lignes dans la table « PARENT » et que la table « ENFANT » est beaucoup plus grosse que notre table T2 alors ce FULL SCAN peut valoir très cher en termes de performances.

Maintenant si on crée un index sur la FK on se rend compte que la requête récursive utilise l’index :
SQL ID: 04w09dv7av2y5
Plan Hash: 1995814670
delete from "TOTO"."T2" 
where
 "C1" = :1


call     count       cpu    elapsed       disk      query    current        rows
------- ------  -------- ---------- ---------- ---------- ----------  ----------
Parse        1      0.00       0.00          0          0          0           0
Execute      1      0.00       0.00          0          1          3           1
Fetch        0      0.00       0.00          0          0          0           0
------- ------  -------- ---------- ---------- ---------- ----------  ----------
total        2      0.00       0.00          0          1          3           1

Misses in library cache during parse: 1
Misses in library cache during execute: 1
Optimizer mode: CHOOSE
Parsing user id: SYS   (recursive depth: 1)

Rows     Row Source Operation
-------  ---------------------------------------------------
      0  DELETE  T2 (cr=1 pr=0 pw=0 time=0 us)
      1   INDEX RANGE SCAN IDX_T2_C1 (cr=1 pr=0 pw=0 time=0 us cost=1 size=3 card=1)(object id 75428)


Améliorer les perfs des requêtes impliquant la FK:
Dans la même logique il est très fréquent qu’au niveau applicatif on soit amené à effectuer des requêtes en filtrant sur la FK ou bien en joignant la table PARENT avec la table ENFANT. Dans ce cas un index est clairement le bienvenu :
SQL> explain plan for
  2  select * from t1,t2 where t1.c1=t2.c1 and t1.c1=1;

Explained.

SQL> select * from table(dbms_xplan.display);

Plan hash value: 1513984157

--------------------------------------------------------------------------
| Id  | Operation         | Name | Rows  | Bytes | Cost (%CPU)| Time     |
--------------------------------------------------------------------------
|   0 | SELECT STATEMENT  |      |     1 |     3 |     3   (0)| 00:00:01 |
|*  1 |  TABLE ACCESS FULL| T2   |     1 |     3 |     3   (0)| 00:00:01 |
--------------------------------------------------------------------------

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

   1 - filter("T2"."C1"=1)

SQL> create index idx_t2_c1 on T2(C1);

Index created.

SQL> explain plan for
  2  select * from t1,t2 where t1.c1=t2.c1 and t1.c1=1;

Explained.

SQL> select * from table(dbms_xplan.display);

Plan hash value: 43022878

------------------------------------------------------------------------------
| Id  | Operation        | Name      | Rows  | Bytes | Cost (%CPU)| Time     |
------------------------------------------------------------------------------
|   0 | SELECT STATEMENT |           |     1 |     3 |     1   (0)| 00:00:01 |
|*  1 |  INDEX RANGE SCAN| IDX_T2_C1 |     1 |     3 |     1   (0)| 00:00:01 |
------------------------------------------------------------------------------

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

   1 - access("T2"."C1"=1)


Conclusion:
Les index sur les FK sont très utiles pour éviter les problèmes de lock (voire de Dead Lock), améliorer les performances sur les requêtes effectuant des DELETE sur les tables « PARENT » et sur les requêtes impliquant les FK.

Bien sûr si dans votre application vous ne supprimez jamais de lignes dans une table « PARENT », que vous ne modifiez jamais la clé primaire de cette table et que vous n’effectuez jamais de requêtes impliquant la FK de la table « ENFANT » alors "OUI" vous pouvez vous abstenir de créer un index pour la FK de la table « ENFANT ».