Guide · Salesforce · SOQL

SOQL : écrire des requêtes sélectives sur de gros volumes

Champs indexés, seuils de sélectivité, opérateurs qui empêchent l’index et Query Plan : éviter l’erreur « non-selective query » sur un objet de plusieurs millions de lignes.

L’erreur « non-selective query »

Sur un objet de quelques milliers de lignes, toute requête SOQL est rapide. Au-delà de quelques centaines de milliers, Salesforce exige qu’une requête lancée depuis un trigger soit sélective : qu’elle puisse s’appuyer sur un index au lieu de parcourir toute la table. Sinon :

// Trigger sur Contrat__c (5 millions de lignes) : Statut__c n'est pas indexé.
List<Contrat__c> actifs = [
    SELECT Id, Numero__c
    FROM Contrat__c
    WHERE Statut__c = 'Actif' AND Agence__c != null
];
// System.QueryException: Non-selective query against large object type (more than 200000 rows)

Hors trigger, la requête passe, mais elle reste lente et peut dépasser les limites de temps. Exemple fil rouge : un objet fictif Contrat__c de cinq millions de lignes, rattaché à une Agence__c.

Les champs indexés

Sont indexés d’office :

  • Id, Name, OwnerId, RecordTypeId ;
  • les dates système CreatedDate et SystemModstamp ;
  • les champs de relation (lookup et master-detail) ;
  • les champs marqués External ID ou Unique.

Pour un autre champ souvent filtré, deux options : le déclarer External ID quand c’est un identifiant métier, ou demander un index personnalisé au support Salesforce.

Les seuils de sélectivité

Un index n’est utilisé que si le filtre renvoie une part assez faible de la table :

IndexSeuilPlafond
Standard30 % du premier million de lignes, puis 15 % au-delà1 000 000 de lignes
Personnalisé10 % du premier million de lignes, puis 5 % au-delà333 333 lignes

Sur nos cinq millions de contrats, un filtre sur un champ à index personnalisé doit donc renvoyer moins de 300 000 lignes (10 % de 1 million plus 5 % de 4 millions). Avec plusieurs conditions en AND, il suffit qu’une seule soit sélective ; avec OR, il faut que chacune le soit.

// Filtre d'abord sur des champs indexés (lookup + date système), puis affine.
List<Contrat__c> aRelancer = [
    SELECT Id, Numero__c, Statut__c
    FROM Contrat__c
    WHERE Agence__c = :agenceId                 // lookup : indexé
      AND CreatedDate = LAST_N_DAYS:30          // champ système : indexé
      AND Statut__c = 'Actif'                   // filtre complémentaire
    LIMIT 2000
];

Ce qui empêche l’index

  • Les négations : !=, NOT IN, NOT LIKE, EXCLUDES.
  • Un joker en début de motif : LIKE '%dupont' ; LIKE 'dupont%' reste indexable.
  • La comparaison à null sur un champ indexé, dans la plupart des cas.
  • Les formules non déterministes (qui dépendent de la date du jour ou d’un objet parent) : elles ne peuvent pas être indexées.
  • Les données asymétriques : un statut porté par 90 % des lignes n’est jamais sélectif, même indexé.

Lire le Query Plan

Le Query Plan de la Developer Console, ou le paramètre explain de l’API REST, montre les plans envisagés par l’optimiseur et leur coût relatif :

GET /services/data/v62.0/query?explain=SELECT+Id+FROM+Contrat__c+WHERE+Agence__c='a01...'

{
  "plans": [
    {
      "leadingOperationType": "Index",
      "relativeCost": 0.04,
      "cardinality": 1250,
      "fields": ["Agence__c"],
      "sobjectCardinality": 5000000,
      "sobjectType": "Contrat__c"
    },
    {
      "leadingOperationType": "TableScan",
      "relativeCost": 2.8,
      "cardinality": 1250,
      "fields": [],
      "sobjectCardinality": 5000000,
      "sobjectType": "Contrat__c"
    }
  ]
}

Le plan le moins coûteux est en tête. Un relativeCost inférieur à 1 signale une requête sélective ; un TableScan en tête signifie que la table entière sera parcourue.

Côté Apex

La sélectivité ne sert à rien si la requête est lancée mille fois. Dans un trigger ou un batch, on regroupe les identifiants et on interroge une seule fois :

// À éviter : une requête par enregistrement (limite de 100 requêtes en synchrone).
for (Contrat__c c : Trigger.new) {
    Agence__c a = [SELECT Region__c FROM Agence__c WHERE Id = :c.Agence__c];
}

// Une requête pour tout le lot.
Set<Id> agenceIds = new Set<Id>();
for (Contrat__c c : Trigger.new) {
    agenceIds.add(c.Agence__c);
}
Map<Id, Agence__c> agences = new Map<Id, Agence__c>(
    [SELECT Id, Region__c FROM Agence__c WHERE Id IN :agenceIds]
);

Rappel des limites par transaction : 100 requêtes SOQL en synchrone, 200 en asynchrone, et 50 000 lignes renvoyées au total. Pour parcourir plus de lignes, utiliser un Batch Apex ou la Bulk API 2.0 côté intégration.

🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.