Ein Exportskript sammelt Rechnungen, Positionen und Zahlungen ein und bricht bei den drei größten Kunden mit Allowed memory size exhausted ab. Jede einzelne Abfrage sieht harmlos aus, keine holt mehr als ein paar tausend Zeilen. Das Problem sind die Ergebnismengen davor, die alle noch im Speicher liegen. PHP mysqli_free_result räumt sie weg, sobald sie verarbeitet sind. Wie eine Abfrage mit Platzhaltern abgesetzt wird, zeigt das Tutorial zu Prepared Statements.
Alle Zahlen in diesem Tutorial stammen aus einem Messlauf mit PHP 8.4 gegen MySQL 8.0.46, an einer Tabelle mit 2000 Zeilen und einem Speicherlimit von 128 MB.
Was PHP mysqli_free_result() macht
mysqli_free_result() gibt den Arbeitsspeicher frei, den eine Ergebnismenge belegt, und zwar sofort statt am Skriptende. Danach ist das Ergebnisobjekt verbraucht. Die Funktion gibt nichts zurück, eine Prüfung auf einen Rückgabewert ergibt also keinen Sinn.
Im objektorientierten Stil gibt es die Freigabe unter drei Namen, die alle dasselbe tun: free(), close() und free_result(). Prozedural heißt der Aufruf mysqli_free_result($r), objektorientiert $r->free(); beide Formen laufen auf denselben Code hinaus. Das ist keine Spitzfindigkeit, sondern der Grund, warum man in fremdem Code drei Schreibweisen nebeneinander findet.
Syntax, Parameter und Rückgabewert im Original
<?php
mysqli_free_result(mysqli_result $result): void
/* result: a result set returned by
mysqli_query(), mysqli_store_result()
or mysqli_use_result()
frees the memory associated with the
result immediately, returns nothing
object style, three names, one behaviour:
$r->free(), $r->close(), $r->free_result() */
Der Rückgabetyp void ist der erste wichtige Punkt. Eine Konstruktion wie if (mysqli_free_result($r)) prüft nichts, weil es nichts zu prüfen gibt. Der zweite Punkt steckt im Parametertyp: Die Funktion nimmt eine Ergebnismenge entgegen, keine Verbindung. Wer sie mit mysqli_close() verwechselt, räumt an der falschen Stelle auf, und genau dieser Irrtum kostet weiter unten 1,312 MiB.
Muss man das überhaupt aufrufen?
Im gewöhnlichen Webrequest nein. PHP gibt am Skriptende alles frei, was es belegt hat, und PHP mysqli_free_result() ändert daran nichts. Ein Skript, das eine Liste holt, sie ausgibt und endet, wird durch den Aufruf keinen Deut sparsamer. Nachmessen kostet drei Zeilen und beantwortet die Frage für jeden Einzelfall.
Interessant wird es an dem Punkt, an dem die Aussage kippt. Sie hängt an einer einzigen Voraussetzung, nämlich daran, dass es ein Skriptende gibt und dass zwischen Anfang und Ende nur wenig Speicher gleichzeitig gebraucht wird. Trifft eine der beiden Bedingungen nicht zu, wird die Freigabe vom Schmuck zum Werkzeug.
Speicher messen statt raten
memory_get_usage() vor und nach der Abfrage, dazu memory_get_peak_usage() für den Höchststand, mehr braucht der Messrahmen nicht. Wer zusätzlich die Laufzeit im Blick behalten will, findet die passenden Werkzeuge im Tutorial zu microtime().
<?php
$mib = fn($b) => number_format(
$b / 1048576, 3, ',', ''
);
$vor = memory_get_usage();
$r = $db->query($sql);
$nach = memory_get_usage();
mysqli_free_result($r);
$frei = memory_get_usage();
echo 'Abfrage: ' . $mib($nach - $vor); /* 1,344 */
echo 'Freigabe: ' . $mib($nach - $frei); /* 1,344 */
Gemessen an SELECT id, REPEAT(name, 40) über 2000 Zeilen: Die Abfrage kostet 1,344 MiB, und PHP mysqli_free_result() gibt exakt dieselben 1,344 MiB zurück. Der Messrahmen ist damit die halbe Miete, denn er beantwortet die Eingangsfrage für jedes Skript einzeln. Dieselbe Tabelle mit SELECT * statt der verbreiterten Spalte kommt auf 0,094 MiB. Nicht die Zeilenzahl entscheidet also, sondern die Datenmenge, und das erklärt, warum eine Abfrage mit einer einzigen Textspalte teurer sein kann als zehn Abfragen mit Zahlen.
Ein Hinweis zur Einordnung: memory_get_usage() misst, was PHP sich selbst zuschreibt. Wer parallel mit Werkzeugen des Betriebssystems misst, sieht andere Zahlen, weil freigegebener Speicher nicht sofort an das System zurückgeht.
Drei Lagen, in denen der Aufruf zählt
Die erste Lage ist die Schleife. Fragt ein Skript fünfzig Ergebnismengen nacheinander ab und hält sie alle fest, liegen am Ende 67,198 MiB im Speicher, bei einem Höchststand von 67,639 MiB. Bei einem Limit von 128 MB ist damit ausgerechnet, ab welcher Kundenzahl das Exportskript stirbt. Dieselbe Schleife mit PHP mysqli_free_result() nach jeder Verarbeitung endet bei 0,000 MiB Zuwachs.
<?php
/* ohne Freigabe bleiben alle 50 liegen */
$halde = [];
for ($i = 0; $i < 50; $i++) {
$halde[] = $db->query($sql);
}
echo $mib(memory_get_usage() - $vor);
/* 67,198 MiB, Höchststand 67,639 MiB */
/* mit Freigabe */
for ($i = 0; $i < 50; $i++) {
$r = $db->query($sql);
verarbeite($r);
mysqli_free_result($r);
}
echo $mib(memory_get_usage() - $vor); /* 0,000 */
Die zweite Lage ist der Prozess ohne Ende. Ein Worker, ein Daemon oder ein CLI-Skript, das stundenlang läuft, kennt kein Skriptende, auf das sich die Entwarnung von oben stützt. Dort wächst der Verbrauch mit jedem Durchlauf, bis das Limit greift. Wer solchen Code schreibt, ruft PHP mysqli_free_result() nicht vorsichtshalber auf, sondern weil es die einzige Stelle ist, an der der Speicher zurückkommt.
Die dritte Lage sprengt den Rahmen der Funktion. Ist eine einzelne Ergebnismenge größer als das Limit, hilft die Freigabe nicht mehr, denn der Speicher ist schon beim mysqli_query() belegt. PHP mysqli_free_result() löst die ersten beiden Lagen, für die dritte braucht es einen anderen Modus. Der steht weiter unten.
PHP mysqli_free_result(), unset oder mysqli_close
Diese drei werden regelmäßig durcheinandergebracht, und die Messung liefert ein überraschendes Bild. unset() leistet genau dasselbe wie PHP mysqli_free_result(), mysqli_close() dagegen fast nichts.
<?php
$r = $db->query($sql);
unset($r); /* 1,344 MiB werden frei */
$r = $db->query($sql);
$db->close(); /* nur 0,032 MiB werden frei */
$zeile = $r->fetch_assoc();
var_dump($zeile['id']); /* liefert weiter Daten */
| Aufruf | Von 1,344 MiB frei | Ergebnis danach | Einordnung |
| mysqli_free_result($r) | 1,344 MiB | verbraucht, jeder Zugriff wirft | der ausdrückliche Weg, auch für Leser des Codes |
| unset($r) | 1,344 MiB | Variable existiert nicht mehr | gleichwertig, sagt aber weniger über die Absicht |
| $r = nächste Abfrage | 1,328 MiB | trägt das neue Ergebnis | Überschreiben räumt die alte Menge mit ab |
| mysqli_close($db) | 0,032 MiB | liefert weiter Zeilen | kein Ersatz, 1,312 MiB bleiben belegt |
Die letzte Zeile widerlegt eine verbreitete Annahme. Das Schließen der Verbindung beendet die Verbindung und sonst nichts. Die gepufferte Ergebnismenge liegt in PHP und nicht auf dem Server, und nur PHP mysqli_free_result() räumt sie weg. Ein fetch_assoc() danach liefert weiter Daten, was einerseits praktisch ist und andererseits zeigt, dass der Speicher noch belegt sein muss.
Gepuffert und ungepuffert: MYSQLI_USE_RESULT
Standardmäßig holt mysqli alle Zeilen auf einen Schlag zu PHP herüber. Mit MYSQLI_USE_RESULT bleiben sie auf dem Server, und PHP zieht sich eine Zeile nach der anderen. Das ist der Ausweg für die dritte Lage von oben, und die Freigabe bekommt dort eine zweite Aufgabe, die mit Speicher nichts zu tun hat.
flowchart TD
A[Abfrage absetzen] --> B{Modus?}
B -- gepuffert --> C[Alle Zeilen im RAM]
C --> D[Sprung möglich]
B -- ungepuffert --> E[Eine Zeile im RAM]
E --> F[Nur vorwärts]
D --> G[free_result]
F --> G
G --> H[Verbindung frei]
<?php
$u = $db->query($sql, MYSQLI_USE_RESULT);
/* nach der Abfrage: +0,017 MiB */
$datei = fopen('export.csv', 'w');
while ($zeile = $u->fetch_row()) {
fputcsv($datei, $zeile, ';');
}
fclose($datei);
/* nach 2000 Zeilen: immer noch 0,017 MiB */
mysqli_free_result($u);
1,344 MiB gegen 0,017 MiB bei derselben Abfrage, und der Verbrauch bleibt auch nach dem vollständigen Durchlauf flach. Das ist rund ein Achtzigstel. Bezahlt wird sie mit Einschränkungen, und die sind hart. Wer eine ungepufferte Abfrage in einen Generator verpackt, bekommt übrigens beides zusammen, Speichersparsamkeit und eine bequeme Schleife; dazu passt das Tutorial zu yield und Generatoren.
| Merkmal | MYSQLI_STORE_RESULT | MYSQLI_USE_RESULT |
| Speicher nach der Abfrage | +1,344 MiB | +0,017 MiB |
| Speicher nach 2000 Zeilen | unverändert | +0,017 MiB, bleibt flach |
| mysqli_num_rows() | liefert sofort 2000 | wirft, nach dem Durchlauf aber 2000 |
| mysqli_data_seek() | arbeitet normal | wirft, auch nach dem Durchlauf |
| zweites foreach | liefert erneut alle Zeilen | Warnung, liefert nichts |
| weitere Abfrage auf der Verbindung | jederzeit | erst nach der Freigabe, sonst Fehler 2014 |
Die beiden Meldungen sehen gleich aus und sind es nicht. mysqli_num_rows() wirft im ungepufferten Betrieb einen Error mit dem Text mysqli_num_rows() cannot be used in MYSQLI_USE_RESULT mode. mysqli_data_seek() verhält sich trotz gleich klingender Meldung anders: Nach dem vollständigen Durchlauf liefert die Zeilenzählung plötzlich doch die richtige Zahl, der Sprung dagegen wirft weiterhin. Warum der Ergebniszeiger dabei eine Rolle spielt, ist ein eigenes Thema.
Commands out of sync und wie man es loswird
Hier hört das Speicherthema auf und die Funktion wird unverzichtbar. Solange eine ungepufferte Ergebnismenge offen ist, nimmt die Verbindung keine zweite Abfrage an. Gemessen wirft sie seit PHP 8.1 eine mysqli_sql_exception mit der Fehlernummer 2014 und dem Text Commands out of sync; bei abgeschaltetem Meldemodus liefert dieselbe Abfrage stattdessen false. Ein Aufruf von PHP mysqli_free_result() räumt den Rest ab und macht die Verbindung sofort wieder nutzbar; die Freigabe dauerte dabei 1,70 Millisekunden.
<?php
$u = $db->query($sql, MYSQLI_USE_RESULT);
try {
$db->query('SELECT 1');
} catch (mysqli_sql_exception $e) {
echo $e->getCode(); /* 2014 */
echo $e->getMessage();
/* Commands out of sync; you can't run
this command now */
}
mysqli_free_result($u); /* 1,70 ms */
$zweite = $db->query('SELECT 1');
echo mysqli_errno($db); /* 0 */
Dieselbe Sperre kennt der Aufruf einer gespeicherten Prozedur. Ein CALL liefert mehrere Ergebnismengen nacheinander, und solange nicht jede davon gelesen oder freigegeben und mit mysqli_next_result() weitergeschaltet wurde, antwortet die Verbindung mit derselben 2014.
Wer im ungepufferten Betrieb arbeitet, braucht also eine Regel: erst die Ergebnismenge zu Ende lesen oder freigeben, dann die nächste Abfrage. Eine zweite Verbindung für Zwischenabfragen ist der andere gangbare Weg, kostet aber einen Platz im Verbindungslimit des Servers. Die Freigabe ist hier schlicht billiger.
Was nach der Freigabe passiert: Error statt Warnung
Nichts Gutmütiges. Ein Zugriff auf ein freigegebenes Ergebnis wirft einen Error mit dem Text mysqli_result object is already closed, und ein zweiter Aufruf von PHP mysqli_free_result() auf demselben Objekt wirft dieselbe Meldung. Ein stilles doppeltes Freigeben gibt es nicht.
<?php
$r = $db->query($sql);
mysqli_free_result($r);
try {
$r->fetch_assoc();
} catch (Error $e) {
echo $e->getMessage();
/* mysqli_result object is already closed */
}
Für Aufräumcode im finally-Block heißt das: Entweder man merkt sich in einer Variablen, ob schon freigegeben wurde, oder man setzt die Variable danach auf null und prüft darauf. Ein letzter Hinweis zum Speicher: mysqli_fetch_all() lädt dieselben Daten ein zweites Mal, nämlich als PHP-Array. Gemessen belegt die Ergebnismenge 0,094 MiB und das Array daraus zusätzlich 0,905 MiB. Wer fetch_all() nutzt und danach freigibt, hatte den Speicher trotzdem kurzzeitig doppelt belegt.
Fazit
Die ehrliche Antwort auf die Eingangsfrage lautet: im normalen Seitenaufruf nicht nötig, in drei Lagen unverzichtbar. Bei vielen Ergebnismengen hintereinander entscheidet PHP mysqli_free_result() zwischen 67 MiB und null, bei Langläufern verhindert es das langsame Vollaufen, und im ungepufferten Betrieb ist es die einzige Art, die Verbindung wieder freizubekommen. unset() leistet dasselbe, mysqli_close() gerade nicht. Und wer wissen will, wie viel es im eigenen Fall bringt, misst es mit drei Zeilen selbst nach, statt zu raten.