Les index de base de données Room en 2026 : trouver la requête réellement lente et la corriger
Un guide pratique pour indexer une base Room/SQLite sur Android — lire EXPLAIN QUERY PLAN, ajouter @Index sans deviner, et les erreurs qui neutralisent un index en silence.
Une application basée sur Room qui semblait instantanée en test peut se mettre à ramer des mois plus tard, une fois qu’un utilisateur réel a mille transactions au lieu de la douzaine que vous aviez utilisée pour les essais. Le réflexe habituel est d’ajouter un index quelque part et d’espérer. Ça marche à peu près aussi souvent que ça ne marche pas, parce qu’un index n’accélère pas une table — il accélère un motif d’accès précis, et le mauvais choix ajoute un coût à l’écriture pour aucun bénéfice en lecture. Voici comment trouver la requête réellement lente, le confirmer, et l’indexer correctement.
Ne devinez pas — mesurez
SQLite vous dit exactement comment il compte exécuter une requête si vous le lui demandez. Préfixez n’importe quelle requête avec EXPLAIN QUERY PLAN et exécutez-la via adb shell ou une requête Room brute :
@RawQuery
fun explain(query: SupportSQLiteQuery): List<ExplainRow>
EXPLAIN QUERY PLAN
SELECT * FROM transactions WHERE category_id = 7 ORDER BY date DESC;
La ligne de sortie raconte toute l’histoire. SCAN transactions signifie que SQLite lit chaque ligne de la table et vérifie la condition sur chacune — le coût augmente linéairement avec la taille de la table. SEARCH transactions USING INDEX idx_transactions_category (category_id=?) signifie qu’il saute directement aux lignes correspondantes. Toute la question de l’indexation revient à transformer les SCAN en SEARCH pour les requêtes que vous exécutez souvent, et à laisser le reste tranquille.
L’erreur à éviter ici est d’indexer selon la colonne qui semble importante. Une colonne notes est rarement filtrée ; un category_id utilisé dans la clause WHERE de chaque écran de liste, c’est une tout autre histoire. Lancez EXPLAIN QUERY PLAN sur vos cinq ou six requêtes DAO les plus sollicitées avant de toucher à quoi que ce soit — celles qui construisent les écrans de liste principaux et s’exécutent à chaque ouverture de l’app.
Ajouter l’index dans Room
Room expose la commande CREATE INDEX de SQLite via le paramètre indices de l’annotation @Entity :
@Entity(
tableName = "transactions",
indices = [Index(value = ["category_id"]), Index(value = ["date"])],
)
data class Transaction(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val categoryId: Long,
val date: Long,
val amountCents: Long,
)
C’est un changement de schéma, donc il nécessite une Migration, comme pour l’ajout d’une colonne :
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id)")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date)")
}
}
L’export de schéma de Room (exportSchema = true plus l’argument de compilation room.schemaLocation) signalera un écart entre les index de votre @Entity et une migration écrite à la main s’ils divergent — c’est généralement ainsi que ce bug est détecté avant d’être publié.
Le piège de l’index composé
Une requête qui filtre sur category_id et trie par date — exactement le motif ci-dessus — ne tire pas pleinement parti de deux index à colonne unique séparés. SQLite ne peut utiliser qu’un seul index par table et par requête dans la plupart des cas, il choisit donc le plus sélectif et doit quand même trier les résultats en mémoire. Un index composé couvrant les deux colonnes, dans le bon ordre, permet à SQLite d’utiliser l’index à la fois pour le filtre et pour retourner les lignes déjà triées :
indices = [Index(value = ["category_id", "date"])]
L’ordre compte ici. Cet index sert WHERE category_id = ? et WHERE category_id = ? ORDER BY date, parce que les deux conditions lisent l’index de gauche à droite. Il n’aide pas une requête qui filtre uniquement sur date — pour cela, il vous faudrait toujours l’index à colonne unique sur date, ou un second index composé avec date en premier. Revérifiez EXPLAIN QUERY PLAN après avoir ajouté un index composé ; si vous voyez encore USE TEMP B-TREE FOR ORDER BY dans le plan, l’ordre des colonnes ne correspond pas à ce dont la requête a besoin.
Les index ne sont pas gratuits
Chaque index que SQLite maintient doit être mis à jour à chaque INSERT, UPDATE ou DELETE touchant une colonne indexée. Pour une table comme transactions dans une application de budget, les écritures sont relativement rares par rapport aux lectures — une poignée d’ajouts par jour contre des dizaines d’affichages de liste — donc le compromis est facile. Pour une table écrite en continu et rarement lue (un journal d’événements, une file de synchronisation), les trois mêmes index qui aidaient la table transactions peuvent ralentir les écritures de façon mesurable, sans aucun bénéfice de lecture perceptible. Indexez les tables interrogées à chaque ouverture d’écran, pas chaque table du schéma.
La clé primaire reçoit déjà un index implicite — l’inclure à nouveau dans indices est redondant. Idem pour une colonne marquée @PrimaryKey ou déjà déclarée unique = true sur un autre index ; SQLite crée automatiquement l’index sous-jacent.
Où ça compte vraiment
J’ai découvert ça de la manière la plus simple, sur Granyn : une liste de transactions qui semblait instantanée à quelques centaines de lignes a commencé à marquer un temps d’arrêt visible lors du filtrage par catégorie, une fois l’usage réel passé quelques milliers de lignes. EXPLAIN QUERY PLAN sur la requête DAO montrait un SCAN complet — le filtre category_id n’avait aucun index à utiliser. Ajouter l’index composé ci-dessus a fait passer la requête filtrée d’un balayage complet de table à une recherche indexée, et le blocage a disparu. Aucun changement d’architecture, aucune nouvelle bibliothèque, juste les deux bonnes lignes dans une migration.
La leçon dépasse cette seule table : avant de vous tourner vers la pagination, la mise en cache ou une réécriture quand une application local-first ralentit, lancez EXPLAIN QUERY PLAN sur la requête réellement lente. La plupart du temps, la solution est un index, elle est petite, et réversible si vous vous trompez.
// À lire aussi
D’autres notes du journal
Les TypeConverters Room en 2026 : stocker enums, dates et listes sans corrompre votre schéma
Un guide pratique des TypeConverters Room sur Android — enums, Instant/LocalDate, et listes — ainsi que les erreurs qui transforment un converter en bug silencieux de corruption de données.
@Relation de Room : interroger des données un-à-plusieurs sur Android sans requêtes N+1
Un guide pratique de l'annotation @Relation de Room — modéliser des données un-à-plusieurs comme des catégories et des entrées sans requêtes N+1 ni jointures manuelles.
La recherche plein texte dans Room : ajouter une recherche instantanée à une app Android local-first en 2026
Un guide pratique du support FTS4 de Room sur Android — construire une table virtuelle de recherche, la synchroniser avec des triggers, et pourquoi FTS5 exige une migration manuelle.