Skywalker13

Diary of an ex-GeeXboX developer


SQLite et les "covering indices" ~ đŸ‡«đŸ‡· ‱ 🇬🇧

Posted at — Aug 21, 2025

» 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.

Une table ordinaire

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.

Une requĂȘte 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).

Vérifions ce que je vous raconte

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.

Performances de la requĂȘte

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.

Mais que se passe-t-il ?

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.

Le mystÚre résolu

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.

https://sqlite.org/forum/info/8876e4e648bf8f93

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.

Conclusion

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.