Les requêtes SOQL lentes sont l'un des goulots d'étranglement de performance les plus courants dans Salesforce. Que vous heurtiez les governor limits, subissiez des timeouts ou soyez simplement frustré par des pages qui ramaient, ce guide vous donne les outils pour diagnostiquer et corriger les problèmes de performance.
Comment Salesforce exécute les requêtes
Avant d'optimiser, comprendre le modèle d'exécution. Salesforce repose sur une base relationnelle (historiquement Oracle) partagée entre les orgs, dans une architecture multi-tenant. Concepts clés :
- Sélectivité : une requête est « sélective » si son filtre correspond à un petit pourcentage du total des enregistrements. Salesforce exige la sélectivité pour utiliser un index.
- Types d'index : les champs standards (Id, Name, SystemModstamp, etc.) sont auto-indexés. Les champs personnalisés peuvent être indexés sur demande.
- Full table scan : sans filtre sélectif, Salesforce parcourt tous les enregistrements — lent et bloqué au-delà d'un certain seuil.
1. Utiliser le Query Plan Tool
Le Query Plan Tool dans la Developer Console est votre premier outil :
- Ouvrez la Developer Console
- Allez dans Help → Preferences → Enable Query Plan
- Exécutez votre requête → cliquez sur l'onglet Query Plan
Lecture des résultats :
| Coût (colonne Cost) | Signification |
|---|---|
| < 1 | Filtre sélectif — l'optimiseur peut utiliser l'index |
| ≥ 1 | Non sélectif — parcours complet de la table probable : à optimiser |
Seuils de sélectivité officiels (part des enregistrements ciblés par le filtre) :
- index standard : 30 % du premier million d'enregistrements, puis 15 % au-delà, plafonné à 1 million ;
- index personnalisé : 10 % du premier million, puis 5 % au-delà, plafonné à 333 333.
2. Toujours filtrer sur des champs indexés
Champs standards toujours indexés :
Id,Name,OwnerId,RecordTypeIdCreatedDate/SystemModstamp(et nonLastModifiedDate, qui n'est pas indexé)- Champs de relation lookup/Master-Detail
// ❌ Non sélectif — full table scan
List<Account> accounts = [
SELECT Id, Name FROM Account WHERE Industry = 'Technology'
];
// ✅ Sélectif — utilise l'index OwnerId
List<Account> accounts = [
SELECT Id, Name FROM Account
WHERE Industry = 'Technology' AND OwnerId = :UserInfo.getUserId()
];3. Pas de SOQL dans les boucles
C'est la cause n°1 de violations des governor limits — et c'est lent.
// ❌ SOQL dans une boucle — 1 requête par enregistrement
for (Account acc : accounts) {
List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :acc.Id];
}
// ✅ Requête en masse — 1 requête pour tous les enregistrements
Set<Id> accountIds = new Set<Id>();
for (Account acc : accounts) accountIds.add(acc.Id);
Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
if (!contactsByAccount.containsKey(c.AccountId)) {
contactsByAccount.put(c.AccountId, new List<Contact>());
}
contactsByAccount.get(c.AccountId).add(c);
}4. Requêtes relationnelles pour réduire les allers-retours
Au lieu de requêter parent et enfant séparément, utilisez des sous-requêtes :
// ❌ Deux requêtes séparées
List<Account> accounts = [SELECT Id, Name FROM Account WHERE OwnerId = :userId];
Set<Id> ids = new Map<Id, Account>(accounts).keySet();
List<Contact> contacts = [SELECT Id, AccountId FROM Contact WHERE AccountId IN :ids];
// ✅ Une seule requête avec sous-sélection
List<Account> accounts = [
SELECT Id, Name,
(SELECT Id, FirstName, LastName, Email FROM Contacts WHERE IsActive__c = true)
FROM Account
WHERE OwnerId = :userId
];Limites à connaître :
- Jusqu'à 5 niveaux de relations parent-enfant (le parent, puis 4 niveaux d'enfants) : depuis l'API 58.0 en REST et SOAP, depuis l'API 61.0 (Summer '24) en Apex ; auparavant, un seul niveau de sous-requête
- Au-delà de 200 enregistrements enfants pour un parent, parcourez la sous-requête dans une boucle
forSOQL : l'affecter directement à une liste lève « Aggregate query has too many rows for direct assignment » - Max 20 sous-requêtes parent-enfant par requête
5. Limiter les résultats
Ne récupérez jamais plus d'enregistrements que nécessaire :
// ❌ Ramène tout
List<Account> all = [SELECT Id, Name FROM Account];
// ✅ Ramène seulement ce dont vous avez besoin
List<Account> recent = [
SELECT Id, Name, CreatedDate
FROM Account
WHERE CreatedDate = LAST_N_DAYS:30
ORDER BY CreatedDate DESC
LIMIT 50
];6. Les littéraux de date vs. les dates dynamiques
Côté performance, un littéral de date et une variable de date liée sont équivalents : les deux restent sélectifs sur un champ indexé. Les littéraux sont toutefois plus lisibles, et ils tiennent compte du fuseau horaire de l'utilisateur et de l'exercice fiscal de l'org :
// ✅ Utiliser les littéraux de date
WHERE CreatedDate = LAST_N_DAYS:30
WHERE CloseDate = THIS_QUARTER
WHERE SystemModstamp > LAST_MONTH // indexé, contrairement à LastModifiedDate
// ✅ Aussi valable — utile quand la borne est calculée
WHERE CreatedDate > :Date.today().addDays(-30)Littéraux de date courants :
TODAY,YESTERDAY,TOMORROWTHIS_WEEK,LAST_WEEK,THIS_MONTH,LAST_MONTHTHIS_QUARTER,THIS_YEAR,LAST_YEARLAST_N_DAYS:n,NEXT_N_DAYS:n
7. Requêtes agrégées et GROUP BY
Les agrégations SOQL réduisent considérablement le volume de données transférées vers Apex. Attention : chaque ligne agrégée compte toujours dans la limite des 50 000 lignes de requête, sauf avec COUNT() sans GROUP BY, qui ne compte que pour une ligne.
// ❌ Récupérer toutes les opportunités pour compter en Apex
List<Opportunity> opps = [SELECT Id, StageName FROM Opportunity WHERE AccountId = :accId];
Integer closedCount = 0;
for (Opportunity o : opps) {
if (o.StageName == 'Closed Won') closedCount++;
}
// ✅ Agréger directement en SOQL
AggregateResult[] results = [
SELECT StageName, COUNT(Id) nb
FROM Opportunity
WHERE AccountId = :accId
GROUP BY StageName
];
for (AggregateResult r : results) {
System.debug(r.get('StageName') + ' : ' + r.get('nb'));
}8. Éviter != et NOT IN sur les grands volumes
Les conditions négatives (!=, NOT IN, NOT LIKE) empêchent l'utilisation des index et forcent un scan complet.
// ❌ Ne peut pas utiliser l'index
WHERE StageName != 'Closed Won'
// ✅ Reformuler en conditions positives
WHERE StageName IN ('Prospecting', 'Qualification', 'Proposal')9. Demander des index personnalisés pour les filtres à fort volume
Si vous filtrez fréquemment sur un champ personnalisé avec des millions d'enregistrements, demandez un index personnalisé via le Support Salesforce. Inutile pour les champs marqués External ID ou Unique : ils sont indexés automatiquement.
Candidats à l'indexation personnalisée :
- Champs filtrés dans des batch jobs traitant > 100k enregistrements
- Champs utilisés dans les WHERE de composants LWC sur des objets à fort volume
Checklist de performance
Avant de déployer une requête en production :
- Query Plan exécuté — coût < 1 sur le plan retenu
- Au moins un champ indexé dans la clause WHERE
- Aucun SOQL dans les boucles
- LIMIT appliqué partout où c'est approprié
- Sous-requêtes utilisées plutôt que plusieurs allers-retours
- Requêtes agrégées pour éviter de récupérer des enregistrements inutiles
- Littéraux de date utilisés pour les filtres temporels
- Conditions négatives (
!=,NOT IN) remplacées si possible
Conclusion
La performance SOQL se résume à un principe : donnez à l'optimiseur quelque chose sur quoi travailler. Filtrez toujours sur des champs indexés, ne mettez jamais de requêtes dans des boucles, et utilisez le Query Plan Tool pour valider avant de déployer. Sur les orgs à fort volume, ces habitudes font la différence entre un système qui passe à l'échelle et un qui s'effondre sous la charge.
Ressources utiles :