Posts mit dem Label performance werden angezeigt. Alle Posts anzeigen
Posts mit dem Label performance werden angezeigt. Alle Posts anzeigen

Dienstag, 7. April 2009

InnoDB und große Tabellen

Vor kurzem hatte ich die Aufgabe, ein Gewinnspiel umzusetzen, bei dem User sich pro E-Mail-Adresse einen Gewinncode zurückgeben lassen konnten. Die 7 Millionen Gewinncodes kamen dabei vom Kunden und waren alle unique. Jeder Code durfte dabei nur einmal ausgespielt werden. Um das sicherzustellen kamen nur Transaktionen in Frage. Somit war InnoDB Pflicht.

Ok, als erstes hab ich die beiden Tabellen erstellt:

--
-- Tabellenstruktur für Tabelle `codes`
--
CREATE TABLE `codes` (
`code` char(6) character set latin1 collate latin1_bin NOT NULL,
`user_id` int(11) default NULL,
PRIMARY KEY (`code`),
KEY `user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1 ROW_FORMAT=FIXED;


-- --------------------------------------------------------


--
-- Tabellenstruktur für Tabelle `users`
--


CREATE TABLE `users` (
`id` int(11) NOT NULL auto_increment,
`salutation` set('Herr','Frau') NOT NULL,
`firstname` char(50) NOT NULL,
`lastname` char(50) NOT NULL,
`street` char(100) NOT NULL,
`hnr` char(20) NOT NULL,
`zip` char(10) NOT NULL,
`city` char(100) NOT NULL,
`email` char(100) NOT NULL,
`created` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=FIXED AUTO_INCREMENT=1;

Problem 1: Wie importiert man 7 Millionen Datensätze möglichst schnell?

Einzelne Queries sind langsam. Mehrere Queries per Transaktion zu bündeln ist ein wenig schneller. Das schnellste aber scheint LOAD DATA zu sein, welches auch über das Shell-Tool mysqlimport zu verwenden ist. Da man aber bei mysqlimport keinen Fortschritt ausgeworfen bekommt, hab ich fix ein Shell-Skript zusammengeschrieben, welches alle 10.000 Zeilen eine Meldung auswirft.

$data_file = '/tmp/codes.txt';
$db = 'otto_paf_mai2009';
$chunk_file = '/tmp/codes.part';


# split the file into small chunks to output a status report
$line = 0;
$buffer = '';
$handle = fopen ($data_file, 'r');
while (!feof($handle))
{
$line++;
$buffer .= fgets($handle);


if ($line%10000 === 0)
{
file_put_contents($chunk_file, $buffer);
$buffer = '';


# the data file has to have the same name as the table
# it has to be readable by the mysql server (especially the directory)
# mysqlimport [options] [db_name] [data_file]
shell_exec('mysqlimport -uroot --columns=code --verbose '.$db.' '.$chunk_file);


stdout($result);
stdout(number_format($line, 0, ',', '.'));
}


}
fclose ($handle);


function stdout($out){fwrite(STDOUT, $out."\n");}
function stderr($out){fwrite(STDERR, $out."\n");}


Interessant war hierbei schon, wie lange MySQL dafür braucht und wie tödlich ein Unique-Key (in diesem Fall der PRIMARY) sein kann. Der Import in MyISAM ohne Unique-Key dauerte eine halbe Minute. Der Import in InnoDB ohne Unique-Key etwa 5 Minuten. Und der Import in InnoDB MIT dem Unique-Key dauerte letztendlich 5,5 Stunden. Autsch! Allerdings war es ganz gut, dass ich den Unique-Key gesetzt habe, denn bei den ersten Daten des Kunden waren tatsächlich Dubletten in den Codes.

Problem 2: Wie gibt man einen zufälligen Code aus?

Der offensichtlichste und verbreitetste Ansatz ist:

SELECT code
FROM codes
WHERE user_id IS NULL
ORDER BY RAND()
LIMIT 1

Dauert aber leider schon bei 600.000 Datensätzen über eine halbe Sekunde. Und das EXPLAIN macht alles klar. In der Spalte "extra" steht:

Using where; Using index; Using temporary; Using filesort

Das sieht übel aus.

Mein nächster Ansatz war, das ganze in zwei Abfragen auszulagern. Die erste sollte sich die Anzahl der gültigen Codes zurückliefern, die zweite schließlich mit einem zufälligen Offset beim LIMIT arbeiten, der halt zwischen 0 und der Anzahl der gültigen Codes liegen sollte. Das sah etwa so aus:

// get count of codes
$sql = $this->db->result("
SELECT count(*) as count
FROM codes
WHERE user_id IS NULL
");


$offset = rand(0, $sql['RESULT'][0]['count']);


// get code from db
$sql = $this-db->Result("
SELECT code
FROM codes
WHERE user_id IS NULL
LIMIT $offset, 1
");
$code = $sql['RESULT'][0]['code'];

Schon die erste Abfrage dauerte im Schnitt 1,7 Sekunden. Aber warum? Die Antwort findet sich hier: http://www.mysqlperformanceblog.com/2006/12/01/count-for-innodb-tables/
Ein COUNT(*) auf viele Datensätze ist bei InnoDB einfach höllisch langsam. Die nächste Idee war hierbei, die erste Abfrage umzudrehen. Da ich nämlich weiß, wieviele Keys in der DB sind, brauche ich nur die user_ids zählen und von der Gesamtzahl abzuziehen. Das war auch tatsächlich sehr schnell. Selbst die zweite Abfrage...

ABER HALT...

... anscheinend nur bei der ersten Abfrage. Irgendwie schwanken die Zeiten zwischen 0,0003 Sekunden und 2,5 Sekunden. Nach ein bisschen Herumprobieren kam auch hier der Übertäter zum Vorschein: Das LIMIT. Ist der Offset klein, kommt das Ergebnis sehr schnell, ist er groß, dauert es ewig lang. Mist.

Mein derzeitiger Ansatz ist mehr ein Workaround. Da ich weiß, dass die Codes mit einem Klein- oder Großbuchstaben anfangen, wähle ich einfach einen aus und lasse mir nur Codes zurückgeben, die halt diesen am Anfang stehen haben:

$values = array_merge( range('a', 'z'), range('A', 'Z') );
$char = $values[ rand(0, count($values)-1 ) ];


$sql = $this-db->Result("
SELECT code
FROM codes
FORCE INDEX(PRIMARY)
WHERE code LIKE '".$char."%'
AND user_id IS NULL
LIMIT 1
");

Interessant war hierbei das FORCE INDEX. Ohne wollte MySQL lieber den Index für die user_id verwenden als den PRIMARY KEY. Verrückt. Allerdings ist die endgültige Lösung mit 0,007 Sekunden recht schnell. Allerdings bekomme ich nicht wirklich zufällige Ergebnisse heraus.

Wer also eine bessere Lösung kennt oder eine andere Idee hat: Immer her damit!

Dienstag, 10. Februar 2009

Serpent - PHP Template Engine

Nach jahrelangem Durchforsten des Webs nach guten Template Engines, die leider nicht das boten, was ich benötige, bin ich jetzt endlich meiner Standard-Engine Smarty abtrünnig geworden und habe in den letzten Wochen meine eigene Template Engine umgesetzt: Serpent.

Und, nein, ich finde Smarty ganz und gar nicht schlecht. Ich werde es auch wieder einsetzen, wenn ich das Hauptfeature "template security" brauchen sollte, aber bis dahin werd ich wohl recht glücklich mit meiner Engine, die genau die für mich sinvoll wirkenden Features integriert.

Soeben habe ich die erste beta-Version veröffentlicht und wäre natürlich glücklich, wenn sie jemand mal ausprobieren und sein Feedback posten bzw. in den "Issues" bei Google Code einstellen würde.

Was unterscheidet Serpent von anderen Template Engines?

Es integriert zum einen keine Template-Sicherheit, weil ich sie in den letzten 10 Jahren nicht gebraucht habe. Und zum anderen gibt es kein eingebautes Caching-System, weil das Caching des Outputs in vielen Frameworks über das View des MVCs geregelt wird. Und da eine Template Engine nur EINE Möglichkeit der Ausgabe darstellt (neben XML, JSON, CSV usw.) ist das auch nicht Aufgabe der Engine.

Auf der Haben-Seite steht, dass die Template-Sprache PHP ist, was es natürlich extrem flexibel macht. Damit ist es ähnlich wie Savant3, hat aber die Vorteile, dass es nicht auf short_tags aufbaut, um eine kurze Syntax hinzukriegen.

Desweiteren untersützt Serpent die von Django und Dwoo bekannte Template-Vererbung, die von Smarty entliehende Punkt-Syntax für Arrays, Funktionen-Mapping für weniger Schreibarbeit und Resourcen-Handler, weil Templates ja nicht immer aus dem Dateisystem kommen müssen (beim CMS z.B. auch aus einer Datenbank).
E_STRICT-Kompatibilität, gute Objektorientierung und verdammt gute Performance sind selbstverständlich, oder?!

Um ein genaueres Bild von Serpent zu bekommen, empfehle ich, einen Blick auf die Overview-Seite zu werfen:
http://code.google.com/p/serpent-php-template-engine/wiki/Overview

Dann viel Spaß beim Testen.



Serpent - PHP Template Engine
http://code.google.com/p/serpent-php-template-engine/

Donnerstag, 27. November 2008

Clientside Cache Control

Auf techblog.tilllate.com gab es vor kurzer Zeit einen recht interessanten Ansatz zur Cache-Kontrolle durch den User. Dieses ist eine deutsche Erklärung dazu, die noch einen kleinen Schritt weiter geht.

Jeder Webworker hat wohl schon mal Caching in seine Applikationen eingebaut. Spätestens, wenn der Server fast am Krepieren ist und der Kunde Sturm klingelt, weil seine Website nicht mehr zu erreichen ist, ist es soweit. Die mit Abstand häufigste Caching-Form ist dabei wohl das serverseitige Cachen, bei dem die Website-Administratoren dann selbst entscheiden, wie oft sich ein Cache erneuert. Leider sorgt das bei den Besuchern gern mal für Unmut, wenn z.B. der User in dem seiteninternen Messaging-System nicht mehr sieht, ob er schon eine neue Nachricht bekommen hat.

Was tun in einem solchen Fall? Man möchte die User ja nicht vergraulen. Andererseits darf der Server auch nicht in die Knie gehen.

Die Lösung: den User selbst dafür sorgen lassen, dass sich der Server-Cache zur richtigen Zeit erneuert.

Auf die Tastenkürzel kommt es an

Die meisten wissen, dass man z.B. im Firefox per Strg+Shift+R dafür sorgen kann, dass der Browser-eigene Cache übergangen wird. Weniger bekannt ist, dass in diesem Fall auch zusätzliche Request Header an den Server geschickt werden. Anschauen kann man dieses sehr gut, wenn man die verschiedenen Reload-Varianten mal auf requestheaders.com ausprobiert.

Beim Check sieht man, dass entweder der Header cache-control (HTTP/1.1) oder pragma (HTTP/1.0) mitgeschickt wird. Beide entweder mit dem Wert no-cache oder max-age=0.

Wie in dem Original-Bericht habe ich mal überprüft, bei welchen Browsern verwertbare Request Header mitgeschickt werden, wobei ich ein paar Tastenkombinationen mehr unter die Lupe genommen habe. Lustigerweise kommen bei mir zum Originalartikel abweichende Ergebnisse heraus:

Browser URL-Enter F5 Strg+F5 Strg+R Strg+Shift+R
Firefox 3.0.4 O O X O X
IE 7 O O X O -
IE 6 O O O O -
Safari 3.1.2 (Windows) O X - X -
Opera 9.51 O X - X -
Chrome X X X X -

X: cache-control oder pragma wurden mitgeschickt
O: keine zusätzlicher Header wurde mitgeschickt
-: Die Tastenkombination ist in dem Browser nicht möglich

Fazit:

Bei jedem Browser kommt unter bestimmten Bedingungen ein gültiger Wert heraus. Man könnte es also dem User überlassen, ob ein serverseitiger Cache invalide wird. Da die Befehle ja auch nur gesendet werden, wenn ein User explizit einen Reload anordnet, während er die Seite eh schon auf dem Schirm hat, kann das auch als forcierter und gewollter Reload betrachtet werden. Der User will also schauen, ob es eine neue Variante gibt.

Sehr viel mehr Traffic dürfte für den Server im Normalfall nicht herauskommen, weil die User normalerweise nur von Seite zu Seite springen und daher ziemlich selten die benötigten Header absenden.

Ich werde das wohl in Kürze einbauen, weil es auch für mich als Entwickler eine Erleichterung ist, den Cache während der Entwicklung einfach per Tastendruck zu umgehen.

Der Originalartikel: http://techblog.tilllate.com/2008/11/14/clientside-cache-control/
RFC2616: HTTP/1.1: Header Field Definitions
http://www.requestheaders.com