Performance Analyse mit strace

Ich hatte neulich einen Kunden am Telefon, der sich über ein langsam ablaufendes Script beschwerte. Es handelte sich dabei um einen Import, welcher in der Regel mehrere Stunden läuft.

Der Quellcode stammt nicht von mir und das Script greift auf mehrere Datenbanken auf verschiedenen Servern zu.

Die üblichen Tools für eine Performance-Analyse konnten hier nicht zum Einsatz kommen: Für mindestens eine Datenbank haben wir nicht genügend Rechte, um z.B. das MySQL Slow Query Log einzuschalten und zu überwachen.

Nach einem ersten Einblick in den Quellcode sehen wir, dass das Script sehr viele Queries verschickt sowie auch viele Dateien kopiert und verarbeitet. Wir müssen also zuerst herausfinden, was wirklich langsam ist. Ist es wirklich die Datenbank? Oder am Ende der Datentransfer?

Irrwege

Der erste Instinkt ist in solchen Fällen häufig, die SQL-Queries auf ineffiziente Patterns zu checken und die Datenbanken auf fehlende Indizes zu prüfen.

Einige der Queries hatten in der Tat solche Patterns – zum Beispiel fällt die folgende Bedingung schon früh als verdächtig auf:

    WHERE `p`.`product_ean` LIKE '%123456789'

Wie man relativ früh in der Schule lernt, können solche Abfragen nicht mittels Index abgedeckt werden und sind daher immer mit einem Table Scan verbunden – was bei wenigen Daten vielleicht noch geht, aber mit zunehmender Datenmenge garantiert zu Problemen führt.

Aber: Welches dieser Queries im Code ist wirklich problematisch? Und: Wie dürfte man die Queries korrigieren? Der obere Fall deutet auf eine EAN hin und wir können die Vermutung anstellen, dass hier wohl führende Nullen abgedeckt werden sollen. Aber Vermutungen können zu Fehleinschätzungen führen, daher fahren wir weiter mit dem Fakten-Check…

Ziele setzen

Wichtig ist, dass wir das Ziel nicht aus den Augen verlieren. Welche dieser ineffizienten Queries sind wirklich problematisch? Und welche davon können wir ignorieren? Und wie häufig werden all diese Queries ausgeführt?

Ich bin seit langer Zeit Fan des Tools strace. strace zeichnet alle System Calls eines Programms auf. Man sieht also damit, welche Auswirkungen ein Programm wirklich auf seine Umgebung hat. Da unser Script vermutlich primär aufgrund der Interaktionen mit seinen Umsystemen langsam wird, denke ich, dass wir hiermit das richtige Tool haben.

Debugging

Ich werfe also Strace an und hänge es mit dem Parameter -p $PID an den bereits laufenden Prozess.

# strace -p 933950
Process 933950 attached
write(1, "Produkt wurde erfolgreich aktual"..., 133) = 133
sendto(5, "\334\4\0\0\3\n\t\t\tSELECT\n\t\t\t\tentryID AS I"..., 1248, MSG_DONTWAIT, NULL, 0) = 1248
poll([{fd=5, events=POLLIN|POLLERR|POLLHUP}], 1, 1471228928) = 1 ([{fd=5, revents=POLLIN}])
recvfrom(5, "\1\0\0\1\30+\0\0\2\3def\6mmdb15\1t\5Entry\2ID\7"..., 1139, MSG_DONTWAIT, NULL, NULL) = 1139
poll([{fd=5, events=POLLIN|POLLERR|POLLHUP}], 1, 1471228928) = 1 ([{fd=5, revents=POLLIN}])
recvfrom(5, "\31\3def\6mmdb15\1t\5Entry\10statusID\10st"..., 1519, MSG_DONTWAIT, NULL, NULL) = 69
sendto(5, "\304\0\0\0\3\n\t\t\t\tSELECT\n\t\t\t\t\tEAN,\n\t\t\t\t\t"..., 200, MSG_DONTWAIT, NULL, 0) = 200
poll([{fd=5, events=POLLIN|POLLERR|POLLHUP}], 1, 1471228928) = 1 ([{fd=5, revents=POLLIN}])
recvfrom(5, "\1\0\0\1\10,\0\0\2\3def\6mmdb15\5Track\5Track"..., 1450, MSG_DONTWAIT, NULL, NULL) = 443

Okay, ich sehe hier viele Interaktionen, aber gefühlt bleibt der Output immer mal wieder für den Bruchteil einer Sekunde stehen. Was genau passiert da? Ich vermute ja schon, dass das Script auf die Antwort einer Datenbank wartet. Können wir prüfen, wo das Script genau hängt?

Stellt sich heraus – ja, man kann: strace kennt eine Option -c, welche nicht „live“ einen Output generiert, sondern am Ende (nachdem man strace abbricht, oder der Zielprozess fertig ist) eine Statistik ausgibt. Klingt gut, testen wir das mal:

# strace -p 933950 -c
Process 933950 attached
^CProcess 933950 detached
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 46.43    0.000970          10        99           poll
 45.14    0.000943         236         4           stat
  3.49    0.000073           5        16           lstat
  3.45    0.000072           1        71           sendto
  0.91    0.000019           0       100           recvfrom
  0.19    0.000004           0        48           write
  0.19    0.000004           0        16           open
  0.05    0.000001           0        22           lseek
  0.05    0.000001           0         6           mmap

Und hier sehen wir auch schon den ersten Verdächtigen: poll wird sehr oft aufgerufen und verbraucht damit den Grossteil der Zeit! stat hingegen können wir wohl vorerst vernachlässigen, da er nur 4 mal aufgerufen wird. poll wird verwendet um auf Antworten vom Netzwerk zu warten. Ein Programm braucht diesen System Call, um zu schlafen, bis von einer Gegenstelle wieder Daten geliefert werden. Aber welche Daten? Und wie kommen wir anhand dieser Informationen wieder zu möglichen Lösungen für unser eigentliches Problem?

Wenn wir den Output von oben nochmals anschauen, sehen wir ein Pattern von System Calls: Es wird immer ein sendto gemacht, gefolgt von poll und schlussendlich recvfrom. Wie die Namen andeuten, sind diese System Calls für das Senden und Empfangen von Daten via Netzwerk zuständig.

Wir könnten jetzt also schauen, wie lange es zwischen einem sendto– und recvfrom-Paar genau dauert. Vielleicht gibt uns das ja Aufschluss! Aber wie geht das am einfachsten?

Manpage to the rescue!

Nach etwas lesen in der Manpage von strace finde ich eine Option, die mir sehr nützlich erscheint:

-r Print a relative timestamp upon entry to each system call.
This records the time difference between the beginning of
successive system calls.

Mit dieser Option sehe ich also, wieviel Zeit seit dem letzten System Call verging. Etwas Nachdenken, dann der Geistesblitz: Was passiert wohl, wenn ich mit strace nur die beiden System Calls sendto und recvfrom anzeige und den Rest ausfiltere? Mal schauen:

strace -p 933950 -r -e recvfrom,sendto
Process 933950 attached

     0.000169 sendto(4, "\v\1\0\0\3\n\t\t\tSELECT\n\t\t\t\t`image_name`"..., 271, MSG_DONTWAIT, NULL, 0) = 271
     1.036864 recvfrom(4, "\1\0\0\1\1^\0\0\2\3def\21sometable\1"..., 131, MSG_DONTWAIT, NULL, NULL) = 131
     0.000088 recvfrom(4, "0.jpg\5\0\0\5\376\0\0\"\0", 173, MSG_DONTWAIT, NULL, NULL) = 14

Perfekt! Und bereits einen Verdächtigen erwischt! Da haben wir ein Query (man sieht ein SELECT) und irgendwelche Tabellennamen im Output (recvfrom-Zeile). Interessant. Aber vielleicht war es ja nur ein statistischer Ausreisser. Denn die anderen Calls brauchen viel weniger und ich sehe auch nicht das ganze Query.

Du bist umzingelt!

Wir wissen also wonach wir suchen müssen. Jetzt muss die Falle nur noch richtig gestellt werden und dann schauen was passiert. Mein Plan ist folgender:

Wir lassen strace in eine Datei schreiben (Option -o), sortieren dann die Aufrufe nach Zeit und sollten so, statistisch gesehen, die langsamsten Queries finden.

Und wenn wir schon dabei sind, möchten wir dann auch sehen, welche Queries daran schuld sind. Der obige Output zeigt uns, dass strace lange String-Argumente stark zusammenkürzt. In der Manpage finden wir den Parameter -s, mit welchem man angeben kann, wie stark die Kürzung ist. Wir wollen möglicht die ganzen Daten; ich gebe also einen extrem langen Parameter von 10000 Zeichen an. Also:

# strace -p 933950 -r -e sendto,recvfrom -s 10000 -o import_db_stats.txt
Process 933950 attached

Wir lassen den Trace etwas laufen und brechen dann mit Ctrl-C ab. Anschliessend sortieren wir die Aufzeichnung numerisch und schauen uns die letzten paar Zeilen an:

# sort -n import_db_stats.txt | tail -n5
     0.749176 recvfrom(4, "\1\0\0\1\1^\0\0\2\3def\21sometable\1i\"product_images\nimage_name\nimage_name\f!\0\375\2\0\0\375\1P\0\0\0\5\0\0\3\376\0\0\"\0\24\0\0\4\0231234567890_1.jpg\5\0\0\5\376\0\0\"\0", 159, MSG_DONTWAIT, NULL, NULL) = 145
     0.769669 recvfrom(4, "\1\0\0\1\1^\0\0\2\3def\21sometable\1i\"product_images\nimage_name\nimage_name\f!\0\375\2\0\0\375\1P\0\0\0\5\0\0\3\376\0\0\"\0\24\0\0\4\0231234567890_0.jpg\5\0\0\5\376\0\0\"\0", 172, MSG_DONTWAIT, NULL, NULL) = 145
     0.803661 recvfrom(4, "\1\0\0\1\1^\0\0\2\3def\21sometable\1i\"product_images\n", 69, MSG_DONTWAIT, NULL, NULL) = 69
     0.814297 recvfrom(4, "\1\0\0\1\1^\0\0\2\3def\21me", 16, MSG_DONTWAIT, NULL, NULL) = 16
     0.947148 recvfrom(4, "\1\0\0\1\1^\0\0\2\3def\21sometable\1i\"product_images\nimage_name\nimage_name\f!\0\375\2\0\0\375\1P\0\0", 102, MSG_DONTWAIT, NULL, NULL) = 102

Oh, wunderbar! Wir haben also die fünf langsamsten Calls (respektive diejenigen mit der längsten Wartezeit vorher), alle sind recvfrom und zudem von der gleichen Datenbank (der erste Parameter entspricht dem File Handle respektive dem Socket). Bonus: Vier dieser Calls enthalten Daten der gleichen Tabelle! Jackpot!

Moment – wie sehe ich jetzt aber die Queries, welche langsam sind? Die recvfrom betreffen ja nur das Auslesen der Resultate. Wir erinnern uns – das Query wird in der Regel direkt vorher mittels sendto abgeschickt. Wir müssen also nur im (unsortierten!) Output nach den entsprechenden Zeilen suchen und den Vorgänger finden.

Ich denke, dass die relativen Timestamps über die kurze Zeit wohl eindeutig genug sind um als Suchparameter zu dienen. Also holen wir von den obigen Resultaten mal nur diese raus:

# sort -n import_db_stats.txt | tail -n5 | awk '{print $1}'
0.749176
0.769669
0.803661
0.814297
0.947148

Perfekt! Und mit grep können wir jetzt die Vorgänger-Zeilen dazu finden. Dabei hilft uns der Parameter -B. Weil wir die Resultate nicht wollen, filtern wir diese gleich wieder raus:

# sort -n import_db_stats.txt | tail -n5 | awk '{print $1}' > slowest_times.txt
> for time in $(cat slowest_times.txt); do
>     grep -B1 $time import_db_stats.txt | grep -v $time
> done
     0.000091 sendto(4, "\v\1\0\0\3\n\t\t\tSELECT\n\t\t\t\t`image_name`\n\t\t\tFROM\n\t\t\t\t`product_images` AS `i`\n\t\t\tJOIN\n\t\t\t\t`products` AS `p`\n\t\t\t\t\tUSING (`product_id`)\n\t\t\tWHERE\n\t\t\t\t`image_name` LIKE '12345678896_1.jpg'\n\t\t\t\tAND\n\t\t\t\t`p`.`product_ean` LIKE '%12345678896'\n\t\t", 271, MSG_DONTWAIT, NULL, 0) = 271
     0.000222 sendto(4, "\v\1\0\0\3\n\t\t\tSELECT\n\t\t\t\t`image_name`\n\t\t\tFROM\n\t\t\t\t`product_images` AS `i`\n\t\t\tJOIN\n\t\t\t\t`products` AS `p`\n\t\t\t\t\tUSING (`product_id`)\n\t\t\tWHERE\n\t\t\t\t`image_name` LIKE '1234567890134_0.jpg'\n\t\t\t\tAND\n\t\t\t\t`p`.`product_ean` LIKE '%1234567890134'\n\t\t", 271, MSG_DONTWAIT, NULL, 0) = 271
     0.000196 sendto(4, "\v\1\0\0\3\n\t\t\tSELECT\n\t\t\t\t`image_name`\n\t\t\tFROM\n\t\t\t\t`product_images` AS `i`\n\t\t\tJOIN\n\t\t\t\t`products` AS `p`\n\t\t\t\t\tUSING (`product_id`)\n\t\t\tWHERE\n\t\t\t\t`image_name` LIKE '1234567890189_0.jpg'\n\t\t\t\tAND\n\t\t\t\t`p`.`product_ean` LIKE '%1234567890189'\n\t\t", 271, MSG_DONTWAIT, NULL, 0) = 271
     0.000089 sendto(4, "\v\1\0\0\3\n\t\t\tSELECT\n\t\t\t\t`image_name`\n\t\t\tFROM\n\t\t\t\t`product_images` AS `i`\n\t\t\tJOIN\n\t\t\t\t`products` AS `p`\n\t\t\t\t\tUSING (`product_id`)\n\t\t\tWHERE\n\t\t\t\t`image_name` LIKE '1234567890134_1.jpg'\n\t\t\t\tAND\n\t\t\t\t`p`.`product_ean` LIKE '%1234567890134'\n\t\t", 271, MSG_DONTWAIT, NULL, 0) = 271
     0.000167 sendto(4, "\v\1\0\0\3\n\t\t\tSELECT\n\t\t\t\t`image_name`\n\t\t\tFROM\n\t\t\t\t`product_images` AS `i`\n\t\t\tJOIN\n\t\t\t\t`products` AS `p`\n\t\t\t\t\tUSING (`product_id`)\n\t\t\tWHERE\n\t\t\t\t`image_name` LIKE '1234567890103_0.jpg'\n\t\t\t\tAND\n\t\t\t\t`p`.`product_ean` LIKE '%1234567890103'\n\t\t", 271, MSG_DONTWAIT, NULL, 0) = 271

Wunderbar. Wie bereits ganz am Anfang vermutet, ist es eines der Queries, welches eine LIKE-Suche mit einem Wildcard-Anfang macht.

Jetzt müssen wir also nur noch nach dem entsprechenden Query im Code suchen und uns überlegen, wie wir dies optimieren können. Aber das muss ich jetzt nicht mehr erklären; heute ging es darum, ein Problem einmal auf eine andere Art zu suchen.

Fazit

Wir lernen also, dass Linux mit strace ein wunderbares Werkzeug zur Verfügung stellt, um nicht nur allgemeine Probleme zu finden, sondern auch für Performance-Analysen. Das Schöne daran ist, dass man hier bereits viel über ein Programm erfahren kann, insbesondere auch wenn man auf den Quellcode keinen Zugriff hat.

Ich hatte strace bisher primär für die Fehlersuche bei hängenden Programmen verwendet oder zur Analyse was ein Programm genau tut.

Die wichtigsten Parameter von strace nochmals zusammengefasst:

  • -o schreibt den Output in eine Datei
  • -r zeigt die relative Zeit seit dem letzten aufgezeichneten (!) System
    Call
  • -s kann verwendet werden, um die String-Parameter-Kürzung einzustellen
  • -c erstellt ein Call Profiling für die aufgerufenen Calls

strace kann noch viel mehr, es lohnt sich also, die Manpage einfach mal zu
überfliegen.