Zu große XMLs unter PHP4 – XMLspider

Was tun, wenn man zu große XML-Dateien parsen und die darin befindlichen Daten in eine Datenbank importieren muß.

Problembeschreibung:

Wir lassen bei einem großen Suchmaschinendienst Berichte über geschaltene Werbekampagnen erstellen, holen diese ab und wollen die enthaltenen Datensätze in eine Datenbank überführen, müssen aber zu jedem Datensatz noch eine Schlüsselwortkennnummer (im folgenden KeywordID) aus einer anderen Datenbank holen. Das klingt nicht weiter wild, wird es aber, wenn man bedenkt, dass wir von XML-Dateien reden, die locker 150 MB umfassen und sich darin ca. eine halbe Million Datensätze befinden.

Bedingungen:

LAMP

  • L: Debian Sarge 3.1
  • A: Apache 2
  • M: MySQL 4.1
  • P: PHP 4.3.x

System:

  • Athlon 2400+
  • 1 GB RAM
  • 120 GB HDD
  • (2 GB SWAP)

Als ich zu dem Projekt kam, lief der erste Lösungsversuch soweit ganz gut, fing aber bei XML-Dateien über 40MB an zu straucheln …

Lösungversuch 1:

Das XML wurde von jenem „großen Suchmaschinendienst“ erstellt, wir zogen uns dies per cURL und wandelten es mit PEAR::XML_Serializer in ein array um. Dann folgt ein Arbeitsgang den ich mit Argwohn betrachtete. Mit ein foreach-Schleife wurden alle Datensätze durchgegangen, um die dazugehörige KeywordID aus der Datenbank zu holen. Nach einem kurzen Vortrag über SubSelects/SubQueries in MySQL wurde dieses Thema schnellstens beerdigt.
Nun folgte die Einbringung der Datensätze in die Datenbank… Datensatz für Datensatz für Datensatz .. (wieder mit einer foreach-Schleife durchs array usw ).

Ergebnis:

Für eine 95 MB XML brauchte die Applikation ca. 2h (davon 15min für das Erstellen und Ziehen der XML) der benötigte Speicherverbrauch stieg bis an die Grenzen.
Eine 145MB XML hat die Applikation nicht verarbeiten können. Nachdem der Speicher und der SWAP vollgelaufen waren (insgesamt 3GB) stieg der load dermaßen in die Höhe, dass der Rechner auf gar nichts mehr reagierte. Nach 2h haben wir ihn dann von seinem Leiden erlöst.
Ergo: Diese Lösung ist wirklich nur was für kleine Dateien. Denn die Daten vom PEAR::XML_Serializer wurden ja im Speicher gehalten während die Datensätze in die Datenbank geschoben wurden. PHP und MySQL haben sich quasi gegenseitig den Speicher weggeswapt. 😀

Lösungversuch 2 & 3:

Da wir annahmen, dass unter anderem der PEAR::XML_Serializer sich im Speicher breit macht und diese Wahnsinnsflut an Daten verursacht, haben wir im folgenden dom_xml und xml-line ausprobiert.
Bei einem kleinen Test versuchte ich nur, das XML (wir testeten dann ausschließlich mit dem 145MB XML weiter) mit dom_xml auf einem etwas geringer bestückten Rechner (1GB RAM + 1GB SWAP) zu laden, machte ihn aber damit sehr schnell arbeitsunfähig. Diese Variante hat im Gesamttest ca. 300MB weniger verbraucht als jene mit dem PEAR::XML_Serializer, war aber auf keinen Fall zufrieden stellend, da das Problem des DatenImport immer noch nicht lösbar war.
Mit xml-line, welches für große XML prädestiniert sein soll (Variante 3) stieg der Speicherverbrauch nur langsamer als mit dom_xml, sparte aber gar nichts.

Ergebnis:

Wir haben auch mit diesen Varianten die 145MB XML nicht in einer überschaubaren Zeit verarbeiten können.
Später habe ich bei Nachvorschungn herausgefunden, dass dom_xml alleine schon das Dreifache an Speicher braucht, wie das XML groß ist ….

Finale Variante:

Wir sind dann übereingekommen, dass wir das XML auf keinen Fall in den Speicher holen können, da dies den baldigen Stillstand des Rechners zur Folge hat. Das XML wurde also als Datei gespeichert. Weiterhin konnten wir keine XML-Tools benutzen um das XML zu bearbeiten, also betrachteten wir das XML als string (was es ja von Haus aus ist) und lasen diesen mit einer while-Schleife in 4KB-Schritten ein. Mit einer lustigen Kombination aus strpos, substr und strlen (jedem, der schon mal eine Seite abgespidert hat, sehr gut bekannt), holten wir uns nun die gesuchten Tags aus dem XML und aus den Tags dann die Attribute. Mit den Attributen aus jedem Tag bauten wir dann einen SQL-Query zusammen (mit SubSelect für die KeywordID!), den wir dann … nein, nicht in die Datenbank schoben, sondern in eine Datei schrieben. Dannach wurde per shell_exec der MySQL-Import durchgeführt. Das war das erste Mal, dass der Import der 145MB XML überhaupt funktionierte und das auch noch in einer annehmbaren Zeit.

Ergebnis:

Nachdem wir den ersten Teil des Scriptes auch noch zum einem shell-script gemacht hatte, um den Apache-Overhead ausschließen zu können, ergaben sich folgende Werte.

Erstellen und Ziehen der 145MB XML-Datei: 21min.
Parsen (spidern) der XML-Datei und erstellen der sql-Datei: ca . 4min, max. Speicherverbrauch ca. 15MB
Einspielen der sql-Datei in die Datenbank: ca. 4min, max. Speicherverbrach ca. 13MB

Auswertung:

So schön und einfach zu handhaben viele (nicht alle) XML-Biblotheken, Erweiterungen und Klassen auch sein mögen, mit zu großen XMLs kommen sie einfach nicht klar, da sie alles auf einmal schlucken wollen. In unserem Falle half nur eine etwas unkonventionelle Lösung, die uns nicht nur zum Ziel brachte, sonderm uns nun auch extrem viel Zeit und Aufwand spart. Dieser Test wurde durchgeführt mit einer 145 MB XML und unsere Applikation braucht nur 8min und max. 15 MB Speicher um diese zu verarbeiten. Die meisten XML sind viel viel kleiner …

Für Fragen, Anregungen, Hinweise oder Kritik stehe ich gerne zur Verfügung.

so far – tucci

UPDATE:

Beispiel:


<?php
function microtime_float() {	list($usec, $sec) = explode(" ", microtime()); return ((float)$usec + (float)$sec);}
$time_start = microtime_float();
if (isset($argv))
{
	foreach ($argv as $value)
	{
		switch(TRUE)
		{
			case preg_match('/^--fid/',$value):
				$fileName = substr($value,5);
				break;
			case preg_match('/^--xp/',$value):
				$xmlPath = substr($value,4);
				break;
			case preg_match('/^--sp/',$value):
				$sqlPath = substr($value,4);
				break;
		}
	}
}
else
{
	die('Hey Ya ... keine Argument uebergeben!');
}
if (!isset($fileName)) exit('FileId was not set (--fid).');
if (!isset($xmlPath)) exit('XMLPath was not set (--xp).');
if (!isset($sqlPath)) $sqlPath = $xmlPath;

if (!$fhandle = fopen($xmlPath.$fileName.".xml", "r"))
{
	die("false|konnte Datei nicht zum lesen oeffnen {$xmlPath}{$fileName}.xml");
}
if (!$fhandle_res = fopen($sqlPath.$fileName.".sql", "w+"))
{
	die("false|konnte Datei nicht zum schreiben oeffnen {$sqlPath}{$fileName}.sql");
}

$row_counter    = 0 ;
$block_counter  = 0 ;
$tmp_string     = '';
$next_block     = '';
$row            = '';

fwrite($fhandle_res,"START TRANSACTION;".PHP_EOL);

while ($next_block = fread($fhandle, 4096))
{
	$tmp_string .= $next_block;
	while ($row = exclude_row($tmp_string,''))
	{
		$attribs = _attrib($row);
		$sql = "INSERT INTO wald Values ('".$attribs['irgendeinattribuet']."', ...) ON DUPLICATE KEY UPDATE  ...;".PHP_EOL;
		$row_counter++;
		fwrite($fhandle_res,$sql);
	}
}
fwrite($fhandle_res,"COMMIT;".PHP_EOL);
fclose($fhandle_res);
fclose($fhandle);

$time_end = microtime_float();
(float) $time = $time_end - $time_start;


/*************************************************************************/

function exclude_row(&$string,$start_string,$end_string)
{
	$start_pos = strpos($string,$start_string);
	if ($start_pos !== false)
	{
		$string = substr($string,$start_pos);
		$end_pos = strpos($string,$end_string);
		if ($end_pos !== false)
		{
			$row = substr($string,0,($end_pos + strlen($end_string)));
			$string = substr($string,strlen($row));
			return $row;
		}
		else
		{
			return false;
		}
	}
	else
	{
		return false;
	}
}
function & _attrib ($string)
{
    $string = trim(str_replace(array(''),'',$string));
    $tmpStringLenght = strlen($string);
    $offset  =  0 ;
    $output = array();

    while($offset<$tmpStringLenght)
    {
        $key = substr($string,$offset,strpos($string,'="',$offset)-$offset);
        $offset += strlen($key) + 2;
        $value= substr($string,$offset,strpos($string,'"',$offset)-$offset);
        $offset += strlen($value) + 1;
        $output[trim($key)] = addslashes(trim($value));
    }
	return($output);
}
?>

3 Gedanken zu „Zu große XMLs unter PHP4 – XMLspider“

  1. Hallo!
    Der Ansatz klingt interessant. Ich habe ein ähnliches Problem mit grossen XML-Dateien. Diese sollen für den Import in ein Programm mit DB Anbindung in kleinere Dateien aufgeteilt werden. Den Import erledigt das Programm dann. Ich habe z.B. eine 700 MB grosse Datei und möchte diese in Dateien á ca. 50 MB aufteilen.
    Daher interessiere ich mich brennend für die Lösung, die ihr euch da überlegt habt. Vor allem natürlich für die Anweisung für die while-Schleife und wie ihr das mit der XML-Datei und dem String gemacht habt. Ich programmiere noch nicht so lange mit PHP, daher bitte ich um Nachsicht, falls die Frage blöd klingt.

    Würde mich über eine schnelle Antwort freuen.
    Viele Grüße,
    Nicole

  2. Moin, hab Dir mal unser script (leicht gekürzt) als UPDATE in den post gehängt.

    Fühl dich frei zu fragen, falls noch was unklar ist. 😉

    Viel Grüße,
    tucci

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.