» FRANĂAIS | ENGLISH
J’ai dĂ©couvert tout rĂ©cemment une optimisation un peu Ă©tonnante que l’on peut faire avec SQLite. Mais tout d’abord, qu’est-ce qu’un index couvrant (ou covering index) ? C’est un index qui contient toutes les colonnes nĂ©cessaires pour rĂ©pondre Ă la requĂȘte sans que le moteur SQL ait besoin d’accĂ©der Ă la table. Cela permet Ă©videmment d’Ă©conomiser du temps. Mais comme pour tout index, on va en perdre Ă l’insertion, pour autant qu’on considĂšre que c’est significatif pour notre application, ce qui n’est pas un problĂšme dans mon cas.
Il n’y a rien de particulier Ă avoir des index couvrants. Mais avant d’aller plus loin, je vais vous prĂ©senter une table trĂšs simple comme vous pourriez en voir dans de nombreux logiciels.
CREATE TABLE data (
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT,
payload JSON,
commitId TEXT DEFAULT NULL
);
CREATE INDEX commitId ON data (commitId);
Cette table a un index qui est la clef primaire rowid et auto-incrémentée. La
colonne rowid est spĂ©ciale dans SQLite car elle existe toujours mĂȘme si on ne
la dĂ©clare pas explicitement dans le schĂ©ma et qu’on ne demande pas
explicitement une table sans rowid (WITHOUT ROWID).
Le but avec cette table est que le rowid ne soit jamais recyclé en cas de
suppression de lignes. En ne déclarant pas explicitement une colonne
INTEGER PRIMARY KEY dans le schĂ©ma, il est tout Ă fait possible qu’un rowid
soit réutilisé. Pour notre usage ici, on va garantir que le rowid ne fait que
s’incrĂ©menter. Le rowid Ă©tant un identifiant 64 bits utilisĂ© au niveau du
stockage, ce n’est pas un index comme les autres. Les accĂšs aux donnĂ©es en se
basant sur le rowid sont bien plus rapides et optimisĂ©s qu’avec un index
ordinaire.
Voici une requĂȘte nĂ©cessaire dans cette application. Celle-ci va compter combien
il y a de lignes avec un rowid plus grand que le plus grand rowid qui existe
pour un commitId donnĂ©. Il faut comprendre qu’un mĂȘme commitId peut
apparaĂźtre plusieurs fois dans la table ; d’ailleurs, ce n’est pas un index
unique.
SELECT count(*)
FROM data
WHERE rowid > (
SELECT max(rowid)
FROM data
WHERE commitId = 'abcd'
);
Exemple avec quelques données :
| rowid | timestamp | payload | commitId | |
|---|---|---|---|---|
| 1 | 2025-08-21T09:52:11.245Z | {…} | abcd | |
| 2 | 2025-08-21T09:52:11.245Z | {…} | abcd | |
| 3 | 2025-08-21T09:52:11.245Z | {…} | ef01 | |
| 11 | 2025-08-21T09:52:11.245Z | {…} | abcd | |
| 13 | 2025-08-21T09:52:11.245Z | {…} | ef01 | x |
| 14 | 2025-08-21T09:52:11.245Z | {…} | 2345 | x |
Pour le commitId abcd, la requĂȘte va retourner la valeur 2 (les rowid 13
et 14 sont plus grands que le plus grand rowid utilisé par le commitId
abcd qui vaut 11).
Je pense que comme moi, vous trouvez tout ceci assez simple et ordinaire. La
seule particularitĂ© de cette requĂȘte c’est l’utilisation d’une sous-requĂȘte pour
rĂ©cupĂ©rer le plus grand rowid associĂ© Ă un commitId. Vous ĂȘtes certainement
d’accord avec moi que ça devrait ĂȘtre assez efficace car la sous-requĂȘte peut
exploiter l’index commitId pour rĂ©duire la quantitĂ© de donnĂ©es dans laquelle
retrouver le max. D’ailleurs, dans l’application, il n’y a que trĂšs rarement
beaucoup de lignes pour un mĂȘme commitId (moins d’une dizaine).
En utilisant EXPLAIN QUERY PLAN avec notre SELECT ... on va pouvoir vérifier
que je ne vous raconte pas de bĂȘtises.
| id | parent | detail |
|---|---|---|
| 3 | 0 | SEARCH data USING INTEGER PRIMARY KEY (rowid>?) |
| 6 | 0 | SCALAR SUBQUERY 1 |
| 11 | 6 | SEARCH data USING COVERING INDEX commitId (commitId=?) |
Ceci paraĂźt trĂšs bien et mĂȘme merveilleux. La sous-requĂȘte utilise bien l’index
commitId et en plus en mode couvrant car on n’accĂšde Ă rien d’autre qu’Ă
l’index pour rĂ©soudre la condition commitId = 'abcd'. Concernant la condition
avec rowid > (...) on accĂšde Ă l’index de la clef primaire. On peut imaginer
que c’est trĂšs performant car on utilise directement le rowid de la table de
SQLite.
Jetons alors un Ćil aux performances avec une table qui contient environ 200'000
lignes et un commitId qui se situe presque au milieu de celle-ci. Il n’y a que
3 lignes (avec la sous-requĂȘte) pour ce commitId.
$ sqlite3 data.db
SQLite version 3.46.1 2024-08-13 09:16:08
Enter ".help" for usage hints.
sqlite> .timer on
sqlite> SELECT count(*)
FROM data
WHERE rowid > (
SELECT max(rowid)
FROM data
WHERE commitId = '81b5f0ae-7f4a-4db2-ae3f-dea5bd35e0ad'
);
93356
Run Time: real 0.031 user 0.007897 sys 0.023278
sqlite> SELECT count(*) ...
93356
Run Time: real 0.038 user 0.007570 sys 0.030460
sqlite> SELECT count(*) ...
93356
Run Time: real 0.038 user 0.003727 sys 0.033857
sqlite> SELECT count(*) ...
93356
Run Time: real 0.033 user 0.011919 sys 0.020516
L’instruction .timer on permet d’activer un minuteur pour chaque requĂȘte
exécutée. Voici ce qui en ressort :
| délai | |
|---|---|
| 0 | 31 ms |
| 1 | 38 ms |
| 2 | 38 ms |
| 3 | 33 ms |
Je ne sais pas ce que vous en pensez, mais moi je ne suis pas satisfait du rĂ©sultat. Je peux exĂ©cuter cette requĂȘte des dizaines de fois, elle ne descend jamais en dessous des 30 ms. Ne trouvez-vous pas que c’est un peu lent ? Il n’y a pas tant de lignes que ça, le schĂ©ma est simple, les requĂȘtes sont simples, le planificateur de requĂȘtes est satisfait.
C’est une excellente question et c’est tout le sujet de cet article. Il y a un
moyen d’amĂ©liorer drastiquement les performances de cette requĂȘte. Voici une
mesure avec le changement que je vais vous présenter plus bas (ne sautez pas
directement Ă la fin de l’article, essayez d’imaginer comment amĂ©liorer les
performances de ce SELECT en allant revoir le schéma et ce que le
planificateur de requĂȘtes a dit).
$ sqlite3 data.db
SQLite version 3.46.1 2024-08-13 09:16:08
Enter ".help" for usage hints.
sqlite> .timer on
sqlite> SELECT count(*)
FROM data
WHERE rowid > (
SELECT max(rowid)
FROM data
WHERE commitId = '81b5f0ae-7f4a-4db2-ae3f-dea5bd35e0ad'
);
93356
Run Time: real 0.002 user 0.000577 sys 0.001397
sqlite> SELECT count(*) ...
93356
Run Time: real 0.002 user 0.000456 sys 0.001106
sqlite> SELECT count(*) ...
93356
Run Time: real 0.005 user 0.004119 sys 0.000222
sqlite> SELECT count(*) ...
93356
Run Time: real 0.004 user 0.004134 sys 0.000268
| avant | aprĂšs | |
|---|---|---|
| 0 | 31 ms | 2 ms |
| 1 | 38 ms | 2 ms |
| 2 | 38 ms | 5 ms |
| 3 | 33 ms | 4 ms |
On passe de plus de 30 ms Ă quelques millisecondes. Pour comprendre ce qui se
passe ici, il faut réétudier ce que le planificateur de requĂȘtes nous a dit tout
Ă l’heure et surtout la premiĂšre ligne
SEARCH data USING INTEGER PRIMARY KEY (rowid>?). Ici il n’est pas dit que
SQLite va utiliser un index couvrant, mais uniquement que SQLite va utiliser
l’index sur la clef primaire. Mais finalement, qu’est-ce que ça signifie dans ce
cas ?
Cette clef primaire n’est pas un index ordinaire car c’est l’identifiant qui
permet de retrouver un enregistrement dans la table. Ce qui veut dire qu’il faut
accĂ©der Ă la table pour accĂ©der Ă cette clef. C’est trĂšs performant si vous
voulez extraire un enregistrement de la table comme par exemple avec un simple
SELECT * FROM data WHERE rowid = 101256;. Un SELECT de ce type doit
forcément aller chercher les données et il vaut mieux connaßtre directement
l’identifiant et ne pas perdre de temps avec un index intermĂ©diaire.
Mais que faire pour le SELECT count(*) ... ? Il nous faut un index couvrant.
Ainsi SQLite ne va pas accéder aux pages du stockage pour retrouver les rowid.
Ne pas accĂ©der au stockage permet d’Ă©viter le chargement des autres colonnes de
la table. Si vous regardez bien le schéma, il y a une colonne payload. Cette
colonne peut contenir de gros documents JSON. C’est une colonne assez lourde
et ici, elle est suffisante pour rendre la requĂȘte moins efficace bien qu’on ne
s’intĂ©resse pas du tout au payload.
CREATE INDEX rowId ON data (rowId);
OMG ?! đ±
Nous sommes en train de crĂ©er un index par-dessus une clef primaire. Sommes-nous devenus fous ? MĂȘme le Grand Richard Hipp (auteur de SQLite) nous dit :
(2) By Richard Hipp (drh) on 2020-09-26 18:18:07 in reply to 1
An INTEGER PRIMARY KEY becomes the actual key used in the B-tree that stores your table. So no index is required for efficient operation.
You should always omit indexes on PRIMARY KEY columns, regardless of the type. The best case for an index on a PRIMARY KEY is that it will be a no-op. The worst case is that it will make things run slower. So avoid the worst-case and just leave it off.
Voyons voir ce que nous dit le planificateur de requĂȘtes…
| id | parent | detail |
|---|---|---|
| 3 | 0 | SEARCH data USING COVERING INDEX rowId (rowid>?) |
| 6 | 0 | SCALAR SUBQUERY 1 |
| 11 | 6 | SEARCH data USING COVERING INDEX commitId (commitId=?) |
DĂ©sormais c’est bien l’index couvrant qui est utilisĂ©. Nous sommes face alors Ă
un cas de figure oĂč ajouter un index par-dessus une clef primaire va amĂ©liorer
drastiquement les performances de ce SELECT.
Ă noter que si je supprime la colonne payload et que je reteste la requĂȘte
sans l’index couvrant sur rowid, j’obtiens environ 9 ms au lieu de plus de 30
ms.
Ce qu’il faut retenir c’est qu’avec un INTEGER PRIMARY KEY, SQLite doit
charger les pages complĂštes (avec payload). Mais avec l’index couvrant
(covering index), SQLite ne lit plus que l’index lĂ©ger.
Ces diffĂ©rentes expĂ©rimentations montrent une particularitĂ© peu connue du moteur SQLite. On peut probablement considĂ©rer ce comportement comme un cas limite avec des subtilitĂ©s entre l’optimiseur de requĂȘtes, les index couvrants et les opĂ©rations d’agrĂ©gation avec de gros volumes. Je ne vais pas vous conseiller de le faire, mais je vous encourage Ă inspecter et mesurer vos requĂȘtes avec et sans cet index qui semble pourtant mal venu.