Freitag, 10. Oktober 2008

Speicherverbrauch und große Arrays in PHP

Heute hatte ich das Problem, dass ich einem Kunden etwa 10.000 Datensätze aus einer Online-MySQL-Datenbank als Download zur Verfügung stellen sollte. Dieser Download sollte alle Datensätze live ausgeben, natürlich als Excel-Datei.

Wie immer habe ich dafür unser firmeneigenes MVC-Framework verwendet. Im Controller alle Datensätze geholt, in ein Array geschrieben und ans View übergeben, damit dieses daraus eine HTML-Tabelle generiert, welche bekanntlich problemlos in Excel importiert werden kann.

Allerdings schmierte PHP regelmäßig bei etwa 8.000 Datensätzen ab: Speicher voll. 40MB standen auf dem Hosting-Paket pro Script zur Verfügung, 50MB wollte PHP belegen.

Schritt 1:
Überall den Speicherverbrauch mit memory_get_usage() gemessen. Klar, das Array aus der Datenbank ist groß, etwa 18 MB. Aber diese Daten brauche ich nun mal. Also in der View-Komponente geschaut, was sich da machen lässt.

Die View-Komponente erstellt erst die HTML-Tabelle und baut dann am Ende einfach die html-Tags für eine gültige Seite drumherum, also

$output = ''.$content.'';


Aha, in dieser Zeile schmiert PHP ab. Begründung:
Bug #44069 Huge memory usage with concatenation using . instead of .=

Also umgebaut auf:

$output = '';
$output .= $content;
$output .= '';

Speicherverbrauch kleiner, aber Problem noch nicht gelöst.

Schritt 2:
Das Framework verwendet standardmäßig gzip-Kompression, um die Daten auszuliefern. Die Kompression dieser Masse an Daten kostet Speicher. Also die Kompression ausgeschaltet. Resultat: Speicherverbrauch kleiner.

Eine Zeit lang gehts gut. Dann werden die Kunden-Daten mehr und der Download schmiert schon wieder ab. OK, also weiter schauen.

Schritt 3:
Eine Auffälligkeit im Controller:

[sourcecode language='php']
echo memory_get_usage(); // ergibt 18 MB

// überflüssigen Key löschen, den der Kunde nicht in seiner Tabelle braucht
foreach ($data as $key=>$row) {
unset($data[$key]['id']);
}

echo memory_get_usage(); // ergibt 23 MB
[/sourcecode]

Was ist denn nu los? Ich lösche Daten aus dem Array, gebe den Speicher frei, aber der Speicherverbrauch steigt? OK, dafür habe ich immer noch keine Erklärung. Also, die MySQL-Query so umgebaut, dass ich das id-Feld gar nicht erst holen muss. Schleife gelöscht. Speicherverbrauch bleibt bei 18 MB. Schonmal besser, aber das reicht nicht.

Schritt 4:
Nach langem Suchen zu Speicherproblemen bei großen Arrays bin ich letztendlich zu der Lösung gekommen: PHP braucht einfach viel Speicher, um große Arrays zu verwalten. Bei 10.000 Datensätzen mit etwa 15 Feldern muss PHP 150.000 Variablen verwalten. Ups, sorry, PHP.

Also hab ich, nachdem ich alle Datensätze geholt habe, jeden Datensatz als String gespeichert:

[sourcecode language='php']
echo memory_get_usage(); // ergibt 18 MB

// Datensätze als String speichern
foreach ($data as $key=>$row) {
$output[$key] = implode('|', $row);
}

echo memory_get_usage(); // ergibt 2 MB
[/sourcecode]

Bestens. Hab dann ein neues View geschrieben, dass dementsprechend jeden Datensatz durchgeht, ein explode() durchführt, um die Felder wieder einzeln zu bekommen, und schon liegt mein Speicherverbrauch am Ende bei 2 MB.

Fazit:
  1. Aufpassen bei sehr großen Arrays. Der Speicherverbrauch steigt enorm an.
  2. Das MVC-Pattern hat eine Schwachstelle. Man kann nicht sauber die Ergebnisse einer MySQL-Query streamen (damit hätte ich das Problem ja auch nicht gehabt), weil ja immer der Controller seine Daten an das View übergeben muss. Das wird irgendwann immer zu einem Speicherproblem führen.
    Für Lösungen oder Hinweise dazu bin ich immer dankbar.

2 Kommentare:

  1. Um den Speicherverbrauch zu reduzieren, solltest du "unbuffered" Queries durchführen. Dann muss PHP nicht alle Ergebnisse im Speicher halten und dein PHP-Script kann direkt weiterlaufen, ohne auf die Antwort des MySQL-Servers zu warten. Außerdem solltest du den String ($output) nach ca 1 MB ausgeben und wieder auf einen leeren String setzen.

    Infos und Hinweise zu "unbuffered" Queries findest du in der Doku zu mysql_unbuffered_query() oder PDO::MYSQL_ATTR_USE_BUFFERED_QUERY

    AntwortenLöschen
  2. Das löst aber doch nicht das Problem, das ich im Fazit in Punkt 2 angesprochen habe. Theoretisch müsste das Holen der Daten und das Ausgeben bei dir ein Vorgang sein. Doch durch das MVC-Prinzip geht das ja nicht.
    Klar, man könnte jetzt alles im Controller machen, aber genau das will ich ja nicht, weil ich ja so nicht mehr den Komfort des View Handlers habe.
    Hmmm... man könnte es so machen, dass man die aufbereiteten Daten in eine Textdatei schreibt und der View-Handler dann eine Zeile nach der anderen durchkaut...

    AntwortenLöschen