Efficacité des index composites
(sujet mis à jour avec la version 11)
La malédiction de la 2ème colonne...
Dans "SQL, au coeur des performances" traduit en français par Guillaume Lelarge de Dalibo, Markus Winand écrit : "lordre dun index à deux colonnes ressemble à lordre dun annuaire téléphonique : il est tout dabord trié par le nom, puis par le prénom. Cela signifie quun index à deux colonnes ne permet pas une recherche sur la deuxième colonne seule. Cela reviendrait à rechercher dans un annuaire en ayant seulement le prénom."
De ce raisonnement découle la règle suivante : si des recherches sont parfois faites sur les 2 colonnes et parfois sur 1 des 2 colonnes dun index concaténé alors la 1ère colonne (leading column) de lindex doit être celle sur laquelle sont parfois faites les recherches portant sur une seule colonne. Bien sûr, le principe général est juste et si vous êtes développeur je vous conseille de lappliquer. Cependant, dans certains cas, les moteurs Oracle et PostgreSQL peuvent bénéficier dun index même si la recherche porte uniquement sur la 2ème colonne. Il faut savoir que cest possible, ne serait-ce que pour ne pas se satisfaire dun plan qui comprendrait "INDEX QUELQUE CHOSE" en se disant quil est automatiquement optimal.
Pour reprendre lexemple de lannuaire téléphonique, imaginons une table perso avec comme colonnes id INTEGER, nom CHARACTER VARYING(128), prenom CHARACTER VARYING(128), ville CHARACTER VARYING(128), adr CHARACTER VARYING(128), val1 CHARACTER VARYING(128), val2 CHARACTER VARYING(128), val3 CHARACTER VARYING(128). Dans un premier temps nous allons supposer quil ny a que 2 noms de famille possibles les LI et les LEFEVRE. Les prénoms sont en revanche choisis aléatoirement. On veut trouver les "val1" uniquement gràce à un prénom alors que la table na pas dindex, puis un index perso_i1 sur (prenom, nom) et enfin un index perso_i2 sur (nom, prenom).
Tout dabord Oracle Database 11.2.0.2 dans sa version Express Edition :
Connected to:
Oracle Database 11g Express Edition Release 11.2.0.2.0 - 64bit Production
create table perso(
id INTEGER PRIMARY KEY,
nom CHARACTER VARYING(128),
prenom CHARACTER VARYING(128),
ville CHARACTER VARYING(128),
adr CHARACTER VARYING(128),
val1 CHARACTER VARYING(128),
val2 CHARACTER VARYING(128),
val3 CHARACTER VARYING(128));
Table created.
create sequence perso_id;
Sequence created.
declare
x number := dbms_random.value();
begin
for i in 1..100000 loop
if x <= 0.5 then
insert into perso values(perso_id.nextval, 'LI', dbms_random.string('P', 32), dbms_random.string('P', 8), dbms_random.string('P', 32), dbms_random.string('P', 32), dbms_random.string('P', 32), dbms_random.string('P', 32));
else
insert into perso values(perso_id.nextval, 'LEFEVRE', dbms_random.string('P', 32), dbms_random.string('P', 8), dbms_random.string('P', 32), dbms_random.string('P', 32), dbms_random.string('P', 32), dbms_random.string('P', 32));
end if;
x := dbms_random.value();
end loop;
end;
PL/SQL procedure successfully completed.
commit;
Commit complete.
EXEC DBMS_STATS.GATHER_TABLE_STATS(OWNNAME => 'SYSTEM', TABNAME => 'PERSO', cascade => TRUE);
PL/SQL procedure successfully completed.
EXPLAIN PLAN FOR
SELECT val1 FROM perso WHERE prenom = 'ABCDEFGH';
Explained.
SELECT * FROM table(dbms_xplan.display);
PLAN_TABLE_OUTPUT
--------------------------------------------------------------------------------
Plan hash value: 2016054137
---------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time |
---------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 66 | 716 (1)| 00:00:09 |
|* 1 | TABLE ACCESS FULL| PERSO | 1 | 66 | 716 (1)| 00:00:09 |
---------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
PLAN_TABLE_OUTPUT
--------------------------------------------------------------------------------
1 - filter("PRENOM"='ABCDEFGH')
CREATE INDEX perso_i1 ON perso(nom, prenom);
Index created.
EXEC DBMS_STATS.GATHER_TABLE_STATS(OWNNAME => 'SYSTEM', TABNAME => 'PERSO', cascade => TRUE);
PL/SQL procedure successfully completed.
EXPLAIN PLAN FOR
SELECT val1 FROM perso WHERE prenom = 'ABCDEFGH';
Explained.
SELECT * FROM table(dbms_xplan.display);
SQL> SELECT * FROM table(dbms_xplan.display);
PLAN_TABLE_OUTPUT
-----------------------------------------------------------------------------------------
Plan hash value: 2842113710
----------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time |
----------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 66 | 6 (0)| 00:00:01 |
| 1 | TABLE ACCESS BY INDEX ROWID| PERSO | 1 | 66 | 6 (0)| 00:00:01 |
|* 2 | INDEX SKIP SCAN | PERSO_I1 | 1 | | 4 (0)| 00:00:01 |
----------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
PLAN_TABLE_OUTPUT
-----------------------------------------------------------------------------------------
---------------------------------------------------
2 - access("PRENOM"='ABCDEFGH')
filter("PRENOM"='ABCDEFGH')
DROP INDEX perso_i1;
Index dropped.
CREATE INDEX perso_i2 ON perso(prenom, nom);
Index created.
EXEC DBMS_STATS.GATHER_TABLE_STATS(OWNNAME => 'SYSTEM', TABNAME => 'PERSO', cascade => TRUE);
PL/SQL procedure successfully completed.
Explained.
EXPLAIN PLAN FOR
SELECT val1 FROM perso WHERE prenom = 'ABCDEFGH';
SELECT * FROM table(dbms_xplan.display);
PLAN_TABLE_OUTPUT
-----------------------------------------------------------------------------------------
Plan hash value: 3456061618
----------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time |
----------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 66 | 4 (0)| 00:00:01 |
| 1 | TABLE ACCESS BY INDEX ROWID| PERSO | 1 | 66 | 4 (0)| 00:00:01 |
|* 2 | INDEX RANGE SCAN | PERSO_I2 | 1 | | 2 (0)| 00:00:01 |
----------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
PLAN_TABLE_OUTPUT
2 - access("PRENOM"='ABCDEFGH')
Que remarque-t-on ? Oracle est capable depuis la 9i de tirer parti de lindex composé perso_i1 en "sautant" la "leading column", on parle de "SKIP SCAN". Pour simplifier le moteur fait un RANGE SCAN pour chacune des valeurs possibles de la 1ère colonne, ce qui implique que cela nest vraiment efficace que si la première colonne a une faible cardinalité. Dans cet exemple le cas est extrêmeme favorable au SKIP SCAN qui est très efficace et permet un énorme gain par rapport au balayage complet de la table. Dans la réalité dune table contenant des noms et des prénoms ce ne serait évidemment pas le cas.
Pour illustrer cela, nous allons générer complètement aléatoirement les noms et les prénoms, toujours avec Oracle Express Edition 11gR2 :
Connected to:
Oracle Database 11g Express Edition Release 11.2.0.2.0 - 64bit Production
create table perso(
id INTEGER PRIMARY KEY,
nom CHARACTER VARYING(128),
prenom CHARACTER VARYING(128),
ville CHARACTER VARYING(128),
adr CHARACTER VARYING(128),
val1 CHARACTER VARYING(128),
val2 CHARACTER VARYING(128),
val3 CHARACTER VARYING(128));
Table created.
create sequence perso_id;
Sequence created.
begin
for i in 1..100000 loop
insert into perso values(perso_id.nextval, dbms_random.string('P', 5), dbms_random.string('P', 32), dbms_random.string('P', 8), dbms_random.string('P', 32), dbms_random.string('P', 32), dbms_random.string('P', 32), dbms_random.string('P', 32));
end loop;
end;
/
PL/SQL procedure successfully completed.
commit;
Commit complete.
EXEC DBMS_STATS.GATHER_TABLE_STATS(OWNNAME => 'SYSTEM', TABNAME => 'PERSO', cascade => TRUE);
PL/SQL procedure successfully completed.
EXPLAIN PLAN FOR
SELECT val1 FROM perso WHERE prenom = 'ABCDEFGH';
Explained.
SELECT * FROM table(dbms_xplan.display);
PLAN_TABLE_OUTPUT
---------------------------------------------------------------------------
Plan hash value: 2016054137
---------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time |
---------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 66 | 716 (1)| 00:00:09 |
|* 1 | TABLE ACCESS FULL| PERSO | 1 | 66 | 716 (1)| 00:00:09 |
---------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
PLAN_TABLE_OUTPUT
---------------------------------------------------------------------------
1 - filter("PRENOM"='ABCDEFGH')
CREATE INDEX perso_i1 ON perso(nom, prenom);
Index created.
EXEC DBMS_STATS.GATHER_TABLE_STATS(OWNNAME => 'SYSTEM', TABNAME => 'PERSO', cascade => TRUE);
PL/SQL procedure successfully completed.
EXPLAIN PLAN FOR
SELECT val1 FROM perso WHERE prenom = 'ABCDEFGH';
Explained.
SELECT * FROM table(dbms_xplan.display);
PLAN_TABLE_OUTPUT
-----------------------------------------------------------------------------------------
Plan hash value: 2842113710
----------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time |
----------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 66 | 702 (0)| 00:00:09 |
| 1 | TABLE ACCESS BY INDEX ROWID| PERSO | 1 | 66 | 702 (0)| 00:00:09 |
|* 2 | INDEX SKIP SCAN | PERSO_I1 | 1 | | 701 (0)| 00:00:09 |
----------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
PLAN_TABLE_OUTPUT
-----------------------------------------------------------------------------------------
---------------------------------------------------
2 - access("PRENOM"='ABCDEFGH')
filter("PRENOM"='ABCDEFGH')
DROP INDEX perso_i1;
Index dropped.
CREATE INDEX perso_i2 ON perso(prenom, nom);
Index created.
EXEC DBMS_STATS.GATHER_TABLE_STATS(OWNNAME => 'SYSTEM', TABNAME => 'PERSO', cascade => TRUE);
PL/SQL procedure successfully completed.
Explained.
EXPLAIN PLAN FOR
SELECT val1 FROM perso WHERE prenom = 'ABCDEFGH';
SELECT * FROM table(dbms_xplan.display);
PLAN_TABLE_OUTPUT
-----------------------------------------------------------------------------------------
Plan hash value: 3456061618
----------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time |
----------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 66 | 3 (0)| 00:00:01 |
| 1 | TABLE ACCESS BY INDEX ROWID| PERSO | 1 | 66 | 3 (0)| 00:00:01 |
|* 2 | INDEX RANGE SCAN | PERSO_I2 | 1 | | 2 (0)| 00:00:01 |
----------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
PLAN_TABLE_OUTPUT
-----------------------------------------------------------------------------------------
---------------------------------------------------
2 - access("PRENOM"='ABCDEFGH')
Que remarque-t-on ? Encore une fois Oracle réaliserait un "SKIP SCAN" avec lindex perso_i1 mais le coût estimé est proche de celui du balayage complet de la table, ce qui rend lindex quasi inutile dans ce cas. Dans tous les cas voir "SKIP SCAN" dans un plan doit attirer votre attention. Est-ce une requête peu exécutée ou une requête fréquemment utilisée ? Le "SKIP SCAN" est-il suffisamment efficace pour quun index plus adapté ne soit pas nécessaire ?
Place à PostgreSQL à présent. Reprenons le 1er exemple avec la version 9.5 :
select version();
version
-------------------------------------------------------------
PostgreSQL 9.5.4, compiled by Visual C++ build 1800, 64-bit
(1 ligne)
create table perso(
id SERIAL PRIMARY KEY,
nom CHARACTER VARYING(128),
prenom CHARACTER VARYING(128),
ville CHARACTER VARYING(128),
adr CHARACTER VARYING(128),
val1 CHARACTER VARYING(128),
val2 CHARACTER VARYING(128),
val3 CHARACTER VARYING(128));
CREATE TABLE
do
$$
begin
for i in 1..100000 loop
INSERT INTO perso(nom, prenom, ville, adr, val1, val2, val3) VALUES (CASE WHEN random() <= 0.5 THEN 'LEFEVRE' ELSE 'LI' END, md5(random()::text), substr(md5(random()::text),1,8), md5(random()::text), md5(random()::text), md5(random()::text), md5(random()::text)) ;
end loop;
end$$;
DO
postgres=# ANALYZE perso;
ANALYZE
postgres=# EXPLAIN ANALYZE SELECT val1 FROM perso WHERE prenom = 'ABCDEFGH';
QUERY PLAN
-----------------------------------------------------------------------------------------------------
Seq Scan on perso (cost=0.00..3948.00 rows=1 width=33) (actual time=41.526..41.526 rows=0 loops=1)
Filter: ((prenom)::text = 'ABCDEFGH'::text)
Rows Removed by Filter: 100000
Planning time: 0.122 ms
Execution time: 41.565 ms
CREATE INDEX perso_i1 ON perso(nom, prenom);
CREATE INDEX
EXPLAIN ANALYZE SELECT val1 FROM perso WHERE prenom = 'ABCDEFGH';
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------
Index Scan using perso_i1 on perso (cost=0.42..3862.43 rows=1 width=33) (actual time=14.647..14.647 rows=0 loops=1)
Index Cond: ((prenom)::text = 'ABCDEFGH'::text)
Planning time: 0.161 ms
Execution time: 14.695 ms
\d+
Liste des relations
Schéma | Nom | Type | Propriétaire | Taille | Description
--------+---------------+----------+--------------+------------+-------------
public | perso | table | postgres | 21 MB |
\di+
Liste des relations
Schéma | Nom | Type | Propriétaire | Table | Taille | Description
--------+------------+-------+--------------+---------+---------+-------------
public | perso_i1 | index | postgres | perso | 6216 kB |
DROP INDEX perso_i1;
DROP INDEX
CREATE INDEX perso_i2 ON perso(prenom, nom);
CREATE INDEX
\di+
Liste des relations
Schéma | Nom | Type | Propriétaire | Table | Taille | Description
--------+------------+-------+--------------+---------+---------+-------------
public | perso_i2 | index | postgres | perso | 6224 kB |
EXPLAIN ANALYZE SELECT val1 FROM perso WHERE prenom = 'ABCDEFGH';
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------
Index Scan using perso_i2 on perso (cost=0.42..8.44 rows=1 width=33) (actual time=0.051..0.051 rows=0 loops=1)
Index Cond: ((prenom)::text = 'ABCDEFGH'::text)
Planning time: 0.160 ms
Execution time: 0.091 ms
Que remarque-t-on ? PostgreSQL a tiré parti de lindex mais le gain attendu (et obtenu) est inférieur à celui observé avec Oracle dans le même cas. En fait PostgreSQL a effectué un balayage complet de lindex perso_i1 et le gain par rapport à un balayage complet de la table provient seulement du fait que lindex a une taille inférieure à la table. En revanche, lindex perso_i2 est parcouru dune manière tirant parti de sa structure et le temps de réponse est excellent.
Logiquement, puisque PostgreSQL réalise un balayage complet de lindex perso_i1, les résultats devaient être similaires si le nom est généré aléatoirement, au contraire de ce qui se passe avec Oracle :
select version();
version
-------------------------------------------------------------
PostgreSQL 9.5.4, compiled by Visual C++ build 1800, 64-bit
(1 ligne)
create table perso(
id SERIAL PRIMARY KEY,
nom CHARACTER VARYING(128),
prenom CHARACTER VARYING(128),
ville CHARACTER VARYING(128),
adr CHARACTER VARYING(128),
val1 CHARACTER VARYING(128),
val2 CHARACTER VARYING(128),
val3 CHARACTER VARYING(128));
CREATE TABLE
do
$$
begin
for i in 1..100000 loop
INSERT INTO perso(nom, prenom, ville, adr, val1, val2, val3) VALUES (substr(md5(random()::text),1,5), md5(random()::text), substr(md5(random()::text),1,8), md5(random()::text), md5(random()::text), md5(random()::text), md5(random()::text)) ;
end loop;
end$$;
DO
postgres=# ANALYZE perso;
ANALYZE
postgres=# EXPLAIN ANALYZE SELECT val1 FROM perso WHERE prenom = 'ABCDEFGH';
QUERY PLAN
-----------------------------------------------------------------------------------------------------
Seq Scan on perso (cost=0.00..3882.00 rows=1 width=33) (actual time=40.878..40.878 rows=0 loops=1)
Filter: ((prenom)::text = 'ABCDEFGH'::text)
Rows Removed by Filter: 100000
Planning time: 0.142 ms
Execution time: 40.929 ms
CREATE INDEX perso_i1 ON perso(nom, prenom);
CREATE INDEX
EXPLAIN ANALYZE SELECT val1 FROM perso WHERE prenom = 'ABCDEFGH';
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------
Index Scan using perso_i1 on perso (cost=0.42..3650.43 rows=1 width=33) (actual time=13.892..13.892 rows=0 loops=1)
Index Cond: ((prenom)::text = 'ABCDEFGH'::text)
Planning time: 0.171 ms
Execution time: 13.938 ms
\d+
Liste des relations
Schéma | Nom | Type | Propriétaire | Taille | Description
--------+---------------+----------+--------------+------------+-------------
public | perso | table | postgres | 21 MB |
\di+
Liste des relations
Schéma | Nom | Type | Propriétaire | Table | Taille | Description
--------+------------+-------+--------------+---------+---------+-------------
public | perso_i1 | index | postgres | perso | 5792 kB |
DROP INDEX perso_i1;
DROP INDEX
CREATE INDEX perso_i2 ON perso(prenom, nom);
CREATE INDEX
\di+
Liste des relations
Schéma | Nom | Type | Propriétaire | Table | Taille | Description
--------+------------+-------+--------------+---------+---------+-------------
public | perso_i2 | index | postgres | perso | 5792 kB |
EXPLAIN ANALYZE SELECT val1 FROM perso WHERE prenom = 'ABCDEFGH';
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------
Index Scan using perso_i2 on perso (cost=0.42..8.44 rows=1 width=33) (actual time=0.116..0.116 rows=0 loops=1)
Index Cond: ((prenom)::text = 'ABCDEFGH'::text)
Planning time: 0.563 ms
Execution time: 0.154 ms
Comme prévu, pas de différence entre les 2 cas avec PostgreSQL si lindex perso_i1 est utilisé pour une recherche portant uniquement sur le prénom. Il apporte un gain significatif par rapport à un balayage complet de la table mais il est bien moins efficace que lindex perso_i2.
Conclusion : Markus Winand avait tout à fait raison en énonçant le principal général. Certes, PostgreSQL et surtout Oracle dans certains cas favorables, peuvent tirer parti des index composés lorsquune recherche ne comporte pas la 1ère colonne comme critère. Mais ce nest jamais aussi efficace quavec un index sur la colonne seule ou concaténé en fonction de ce type de recherche. Les exemples confirment bien que voir "INDEX" dans un plan nest pas la garantie davoir obtenu le meilleur plan possible.
Mise à jour : 03/09/2016