Comment nous avons diagnostiqué les requêtes lentes, implémenté des index composés sur les tables multi-tenant et éradiqué les goulots d'étranglement N+1 en production.
À mesure que les bases de données SaaS multi-tenant atteignent des millions d'enregistrements, les requêtes non indexées et les problèmes de requêtes répétitives (N+1) dégradent fortement les performances.
Lors de la récupération de données filtrées par company_id (factures, mouvements, articles), l'absence d'index composés provoque un balayage complet de la table (full table scan). Voici comment nous avons optimisé l'exécution des requêtes SQL.
Dans un schéma multi-tenant, les requêtes associent quasi systématiquement le champ company_id à d'autres filtres comme status ou created_at. Les index à colonne unique s'avèrent inefficaces.
class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// database/migrations/2026_03_30_000000_add_composite_indexes_to_invoices_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('invoices', function (Blueprint $table) {
class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// Index composé ordonné par (company_id, status, created_at)
$table->index(['company_id', 'status', 'created_at'], 'idx_invoices_tenant_status_date');
class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// Index composé pour les recherches par client
$table->index(['company_id', 'customer_id'], 'idx_invoices_tenant_customer');
});
}
public function down(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->dropIndex('idx_invoices_tenant_status_date');
$table->dropIndex('idx_invoices_tenant_customer');
});
}
};Le problème N+1 survient lorsqu'une boucle parcourt une liste de modèles sans charger au préalable leurs relations (eager loading).
class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// Exécute 1 requête pour les factures + N requêtes individuelles pour chaque client !
$invoices = Invoice::where('company_id', $companyId)->get();
foreach ($invoices as $invoice) {
echo $invoice->customer->name; class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// Déclenche une requête SQL séparée par ligne !
}class=class="text-emerald-500 dark:text-emerald-400 font-medium">"text-slate-500 italic">// Exécute exactement 2 requêtes SQL optimisées quel que soit le nombre de factures !
$invoices = Invoice::where('company_id', $companyId)
->with(['customer:id,name,email', 'items:id,invoice_id,quantity,unit_price'])
->select(['id', 'company_id', 'customer_id', 'total_amount', 'status', 'created_at'])
->paginate(25);En utilisant l'analyse EXPLAIN sous MySQL, vérifiez que vos index composés évitent le parcours complet de la table :
EXPLAIN SELECT id, total_amount, status
FROM invoices
WHERE company_id = 42 AND status = 'PAID'
ORDER BY created_at DESC;idx_invoices_tenant_status_dateL'intégration d'index composés et du chargement préalable des relations a éliminé les pics de latence de la base de données et garanti des temps de réponse sous les 50 ms.