Databases
Trage MySQL lezen: vier EXPLAIN-signalen die je echt helpen
Het is vrijdag, het dashboard doet veertien seconden, en iemand opent een ticket met 'graag een index toevoegen.' Draai eerst EXPLAIN. Zo lees je het.

Het is vrijdagmiddag bij een Nederlandse logistieke speler met dertig vrachtwagens en een Symfony-app die niemand sinds 2018 heeft herschreven. Het financiële dashboard, dat ooit binnen een seconde laadde, doet nu veertien. De operations lead heeft een ticket geopend dat in zijn geheel luidt: 'graag een index toevoegen.' Een redelijke gok. En de verkeerde plek om te beginnen.
De juiste plek is EXPLAIN. Het is het enige eerlijke antwoord dat MySQL je geeft over wat hij werkelijk doet met je query, en de meeste trage queries die wij bij ABN voorgeschoteld krijgen, zijn binnen vijf minuten te triëren door vier kolommen uit de output te lezen. Het lastige is weten welk signaal zegt dat je een index moet toevoegen, welk signaal zegt dat je de query moet herschrijven, en welk signaal zegt dat het schema zelf het probleem is.
De vier kolommen die alles bepalen
EXPLAIN geeft je een stuk of twaalf kolommen. Vier daarvan dragen bijna al het diagnostische gewicht: type, Extra, rows naast filtered, en key naast possible_keys. Lees ze in die volgorde. Elke kolom wijst naar een andere fix, en ze door elkaar halen is hoe teams een sprint kwijt zijn aan indexen die de optimizer vervolgens weigert te gebruiken.
De MySQL-handleiding is hier, voor de verandering, echt bruikbaar. De EXPLAIN output reference houd je het beste open in een tab terwijl je werkt. We leunen hieronder op dezelfde woordenschat.
Signaal één: een full table scan in de type-kolom
De type-kolom vertelt je hoe MySQL van plan is bij de benodigde rijen te komen. De hiërarchie loopt, van beste naar slechtste, ruwweg system, const, eq_ref, ref, range, index, en als laatste ALL. Die laatste betekent een full table scan. Op een lookup-tabel van 5.000 rijen is dat prima. Op een invoices-tabel van 5 miljoen rijen is dat de reden dat je dashboard timeouts geeft.
EXPLAIN
SELECT id, total_cents, issued_at
FROM invoices
WHERE customer_id = 4821
AND issued_at >= '2026-01-01';
Als dat type: ALL teruggeeft zonder key, mist er een index. Voeg een composite index toe op (customer_id, issued_at), in die volgorde, omdat de equality-predicate vóór de range-predicate hoort. Draai EXPLAIN opnieuw. Je hoort type te zien zakken naar range of ref en de rows-schatting met ordes van grootte te zien dalen.
Full table scan plus een duidelijke equality- of range-predicate betekent 'voeg een index toe.' Bijna elke andere combinatie betekent: stop en lees eerst de volgende drie signalen.
Signaal twee: filesort of temporary in de Extra-kolom
De Extra-kolom is waar MySQL dingen toegeeft die hij liever onopgemerkt had gelaten. De twee zinnen die je moeten doen pauzeren zijn Using filesort en Using temporary. Ze betekenen niet dat de query stuk is. Ze betekenen dat MySQL tussenresultaten in geheugen of op disk moet materialiseren voordat hij je antwoord kan geven, en die kosten schalen slecht mee met de tabelgrootte.
Using filesort wijst bijna altijd op een ORDER BY die door geen enkele index kan worden afgehandeld. Using temporary verschijnt meestal bij GROUP BY of DISTINCT op kolommen die de optimizer niet kan streamen. Geen van beide los je op door een single-column index toe te voegen. Je lost ze op door de query te herschrijven of door de kolommen van een bestaande composite index zo te herordenen dat de sort of groupering meelift op de index-walk.
-- Dit doet filesort, zelfs met een index op (customer_id):
SELECT id FROM invoices
WHERE customer_id = 4821
ORDER BY issued_at DESC
LIMIT 20;
-- Een composite index op (customer_id, issued_at) laat MySQL
-- de index achterstevoren lopen en de sort overslaan.
Use The Index, Luke van Markus Winand is de beste gratis referentie voor dit patroon. Als je ops-team eens per kwartaal SQL aanraakt, stuur ze dan de hoofdstukken over sorting en grouping voordat je ze iets anders stuurt.
Signaal drie: onderzochte rijen die de uitkomst ver overtreffen
Het derde signaal is een ratio, geen losse waarde. Kijk naar rows (de schatting van de optimizer hoeveel rijen hij zal onderzoeken) naast filtered (het percentage dat hij verwacht over te houden na de WHERE-clause). Vermenigvuldig ze en vergelijk met wat de query daadwerkelijk teruggeeft.
Een query die 240.000 rijen onderzoekt om er 18 terug te geven, doet dertienduizend keer meer werk dan nodig. Dat is zelden een indexprobleem. Het is een querystructuur-probleem, meestal een van drie: een join die uitwaaiert voordat hij filtert, een IN-subquery die een EXISTS hoort te zijn, of een WHERE-clause die de geïndexeerde kolom verstopt in een functie zoals DATE(created_at) = '2026-06-01', waardoor de index onbruikbaar wordt.
Elke functieaanroep aan de linkerkant van een vergelijking sloopt de index. WHERE YEAR(issued_at) = 2026 is een gegarandeerde full scan. Herschrijf het als een range: WHERE issued_at >= '2026-01-01' AND issued_at < '2027-01-01'.
Als de ratio slecht is en de querystructuur al schoon, draai dan ANALYZE TABLE. De statistieken van de optimizer driften, vooral na een grote bulk import, en een verouderd histogram kan ervoor zorgen dat hij een plan kiest dat tien keer meer rijen onderzoekt dan nodig.
Signaal vier: een missende of verrassende keuze voor key
Het laatste paar om te lezen is possible_keys en key. possible_keys noemt elke index die de optimizer heeft overwogen. key is degene die hij uiteindelijk gebruikte. Drie patronen tellen.
Eén: possible_keys is NULL. Geen enkele index dekt deze query. Voeg er een toe, of accepteer de scan als de tabel klein is en de query zeldzaam.
Twee: possible_keys noemt drie of vier indexen en key kiest de verkeerde. Dat gebeurt na schemawijzigingen, na een grote delete, of als histogrammen verouderd zijn. Probeer eerst ANALYZE TABLE. Als de optimizer alsnog slecht kiest, is FORCE INDEX een legitieme, zij het botte, oplossing. Zet een comment boven de query met de reden.
Drie, en die verdient een eigen alinea: key heeft een index gekozen, maar de query is nog steeds traag. Kijk naar key_len. Als die korter is dan je verwacht, gebruikt de optimizer alleen de eerste kolommen van je composite index. Meestal omdat een latere predicate zit op een kolom die vóór de kolom staat waarop je filtert. Dat is een schemasignaal. De index moet opnieuw worden geordend, of je hebt een tweede covering index nodig.
De beslisboom, op één pagina
Zet de vier signalen naast elkaar en de triage wordt mechanisch:
type=ALL, key=NULL -> voeg een index toe
Extra: Using filesort/temporary -> herschrijf query of herorden index
rows examined >> rows returned -> fix querystructuur, dan ANALYZE
key gekozen is fout of te kort -> ANALYZE, dan FORCE INDEX of herbouw
Hier heb je geen DBA voor nodig. Wel iemand die vier kolommen op volgorde leest en de drang weerstaat om een index toe te voegen voordat je weet naar welk signaal je kijkt. De teams die dit verkeerd doen, zijn de teams met een migrations/-directory vol add_index_to_x-bestanden en een dashboard dat nog steeds traag is.
Toen we vorig kwartaal het backoffice herbouwden voor een Nederlandse groothandel, stonden er tachtig verschillende queries in de slow-query log. Zestig daarvan kwamen neer op twee missende composite indexen en één YEAR()-aanroep in een WHERE-clause. De resterende twintig vroegen om echte herschrijvingen, het soort werk dat in onze legacy-migraties zit. Het punt is dat we pas wisten wat wat was nadat we EXPLAIN hadden gelezen.
Het kleinste wat je vandaag kunt doen: open je slow-query log, pak de ergste, en draai EXPLAIN erop. Als je binnen zestig seconden kunt benoemen welk van de vier signalen die query afvuurt, weet je al welke fix je gaat shippen.
Kern
Lees de kolommen type, Extra, rows-versus-filtered en key van EXPLAIN op volgorde. Elke kolom wijst naar een andere fix; ze door elkaar halen kost je een sprint.
FAQ
Werkt EXPLAIN hetzelfde op MariaDB?
Grotendeels wel voor de vier kolommen die hier aan bod komen. MariaDB voegt eigen Extra-flags toe zoals 'Using index condition', maar de type-woordenschat en de redenering deelt hij met MySQL 8.
Wanneer pak ik EXPLAIN ANALYZE in plaats daarvan?
Zodra je een verdachte query hebt afgebakend. EXPLAIN ANALYZE draait het statement echt en rapporteert echte tijden per stap, dus hij lockt en kost net als de echte query. Gebruik het ter bevestiging, niet voor triage.
Hoe vaak moet ik ANALYZE TABLE draaien?
Na elke bulk insert of delete die meer dan tien procent van een tabel raakt. Anders driften de rij-schattingen van de optimizer en kiest hij plannen die er in EXPLAIN goed uitzien, maar slecht draaien.
Is FORCE INDEX veilig in productie?
Veilig maar broos. De query blijft de geforceerde index gebruiken, ook nadat je een betere hebt toegevoegd. Zet dus een code-comment met de datum en de reden. Behandel het als een dragende beslissing, niet als snelle fix.