P
imvdmolen.nl
Blog

Memory profiling met Xdebug voor PHP bottlenecks opsporen

Bij een van mijn Laravel-projecten liep ik tegen een vreemd probleem aan: de applicatie crashte met "Fatal error: Allowed memory size exhausted" tijdens het verwerken van relatief kleine datasets. De standaard PHP memory limit van 128MB zou meer dan voldoende moeten zijn, maar ergens ging het mis. Dit soort problemen zijn frustrerend omdat je vaak geen idee hebt waar je moet beginnen met zoeken. Gelukkig heeft Xdebug uitstekende memory profiling mogelijkheden die je precies laten zien waar je applicatie geheugen verbruikt en lekt.

Xdebug memory profiling configureren

Xdebug's memory profiler werkt anders dan de bekendere performance profiler. Waar de performance profiler zich richt op executietijd, houdt de memory profiler bij hoeveel geheugen elke functie alloceert en wanneer dat geheugen weer vrijgegeven wordt. De configuratie is vrij eenvoudig in je php.ini bestand:

xdebug.mode=profile
xdebug.start_with_request=trigger
xdebug.output_dir=/tmp/xdebug
xdebug.profiler_output_name=memory.%R.%u.xdebug
xdebug.memory_profiler_enable=1
xdebug.collect_memory=1
xdebug.show_mem_delta=1

Na het herstarten van je webserver kun je memory profiling activeren door ?XDEBUG_PROFILE=1 toe te voegen aan je URL. Dit genereert een bestand in de opgegeven output directory dat je kunt analyseren met tools zoals QCacheGrind of phpStorm's ingebouwde profiler viewer. Het verschil met gewone profiling is dat je nu gedetailleerde informatie krijgt over memory allocatie per functieaanroep.

Wat vooral handig is: je ziet niet alleen hoeveel geheugen een functie op een bepaald moment gebruikt, maar ook het delta tussen voor en na de functieaanroep. Dit helpt enorm bij het identificeren van functies die veel geheugen alloceren maar niet weer vrijgeven.

Memory usage patterns herkennen

Door de gegenereerde profiling bestanden te analyseren, begin je patronen te herkennen in hoe je applicatie geheugen verbruikt. Ik merk dat er een aantal veelvoorkomende scenarios zijn die tot problemen leiden. Het eerste is het "array growth" probleem: arrays die binnen loops steeds groter worden zonder dat er ooit elementen uit worden gehaald.

function processLargeDataset($items) {
    $results = [];
    $cache = [];
    
    foreach ($items as $item) {
        // Dit groeit onbeperkt door
        $cache[$item->id] = $item;
        $results[] = $this->processItem($item, $cache);
    }
    
    return $results;
}

In dit voorbeeld groeit de $cache array met elke iteratie, terwijl waarschijnlijk alleen recente items relevant zijn. Memory profiling laat precies zien hoeveel geheugen deze functie per iteratie extra gebruikt. Een tweede patroon dat ik regelmatig tegenkom is het laden van te veel gerelateerde data in Eloquent models:

// Dit laadt potentieel duizenden gerelateerde records in het geheugen
$users = User::with(['posts', 'comments', 'followers', 'following'])->get();

foreach ($users as $user) {
    echo $user->name; // We gebruiken alleen de naam
}

Memory profiling toont aan dat de with() calls exponentieel meer geheugen kunnen gebruiken dan verwacht, vooral bij veel-op-veel relaties.

Geheugen lekken identificeren

Een van de krachtigste aspecten van memory profiling is het identificeren van echte memory leaks. Dit zijn situaties waar geheugen wordt gealloceerd maar nooit meer vrijgegeven, zelfs niet door PHP's garbage collector. Circulaire referenties zijn een veelvoorkomende oorzaak:

class Parent {
    public $children = [];
    
    public function addChild($child) {
        $child->parent = $this;
        $this->children[] = $child;
    }
}

class Child {
    public $parent;
}

Wanneer je objecten van deze klassen maakt, ontstaat er een circulaire referentie: het parent object verwijst naar de child, en de child verwijst terug naar de parent. PHP's garbage collector kan dit meestal wel oplossen, maar niet altijd onmiddellijk. Bij grote hoeveelheden data kan dit tot problemen leiden.

Memory profiling laat zien welke objecten in het geheugen blijven hangen na afloop van een functie. Ik controleer altijd of het geheugengebruik weer terugvalt naar het oorspronkelijke niveau na het verwerken van een batch items. Als dat niet gebeurt, is er waarschijnlijk sprake van een memory leak.

Resource handles zijn een andere bron van memory leaks. Database connecties, file handles en cURL resources die niet expliciet gesloten worden, blijven geheugen claimen:

function processFiles($filePaths) {
    foreach ($filePaths as $path) {
        $handle = fopen($path, 'r');
        $content = fread($handle, filesize($path));
        
        // Vergeten om fclose($handle) aan te roepen
        processContent($content);
    }
}

Memory profiling toont precies hoeveel geheugen elke file handle in beslag neemt en of dat geheugen vrijgegeven wordt na gebruik.

Memory profiling in de praktijk toepassen

Wanneer ik een memory probleem probeer op te lossen, volg ik altijd een systematische benadering. Eerst activeer ik memory profiling voor de specifieke functionaliteit die problemen veroorzaakt. Dit doe ik door een geïsoleerde test te schrijven die alleen dat deel van de code uitvoert:

// Isoleer het probleem in een aparte test
public function testMemoryUsage() {
    $this->enableXdebugProfiling();
    
    $startMemory = memory_get_usage();
    
    for ($i = 0; $i < 1000; $i++) {
        $result = $this->problematicFunction($i);
        unset($result); // Probeer geheugen vrij te geven
    }
    
    $endMemory = memory_get_usage();
    
    $this->assertLessThan($startMemory + 1024 * 1024, $endMemory, 
        'Memory usage should not increase by more than 1MB');
}

Door de problematische functie in een loop uit te voeren, wordt een eventuele memory leak veel sneller zichtbaar. Als het geheugengebruik lineair toeneemt met elke iteratie, weet je zeker dat er een probleem is.

Vervolgens analyseer ik de gegenereerde profiling bestanden om te zien welke functies het meeste geheugen alloceren. Ik sorteer altijd op "inclusive memory usage" om functies te vinden die indirect veel geheugen gebruiken door andere functies aan te roepen. Dit geeft vaak verrassende inzichten over waar de echte bottlenecks zitten.

Memory snapshots maken tussen verschillende stappen in je applicatie helpt ook enorm. Je kunt op specifieke momenten memory_get_usage() aanroepen en de resultaten loggen:

public function processLargeDataset($data) {
    $this->logMemory('Start processing');
    
    $chunks = array_chunk($data, 100);
    $this->logMemory('After chunking');
    
    foreach ($chunks as $chunk) {
        $this->processChunk($chunk);
        $this->logMemory('After chunk ' . count($processed));
        
        // Probeer geheugen vrij te geven
        if (memory_get_usage() > 50 * 1024 * 1024) {
            gc_collect_cycles();
            $this->logMemory('After garbage collection');
        }
    }
}

private function logMemory($stage) {
    error_log(sprintf('%s: %s MB', $stage, memory_get_usage(true) / 1024 / 1024));
}

Deze aanpak geeft je een duidelijk beeld van hoe het geheugengebruik zich ontwikkelt tijdens de uitvoering van je code. Vooral het handmatig aanroepen van gc_collect_cycles() kan interessante inzichten opleveren over of er sprake is van circulaire referenties.

Memory profiling heeft mij geholpen om performance problemen op te lossen die ik anders nooit gevonden zou hebben. Het geeft een objectief beeld van hoe je code zich gedraagt en waar de echte problemen zitten, in plaats van te gokken op basis van vermoedens. Ik zet het inmiddels standaard in bij elke applicatie die met grote datasets werkt of waar performance kritiek is.