Artikel mit Tag
Linksammlung 23/2022
Diese Woche fand ich besonders erwähnenswert:
Man sollte sich vor Augen halten, was als Wöchentlicher Lagebericht des RKI zur Coronavirus-Krankheit-2019 veröffentlicht wird. Die Zahlen sind immer noch hoch, die Entspannung diesen Sommer völlig ungenügend um sich in Sicherheit zu wiegen.
Mir gefiel dieser Bericht über den Carver S+. Ein nicht-zu-sehr-Auto zu suchen ist mir ziemlich sympathisch.
I Built a Ruby Parser berichtet nicht nur darüber, wie der Parser geschrieben wurde, und ist aus dieser Perspektive aufschlussreich. Sondern so ein Ruby-Kompilierer könnte am Ende auch noch wirklich hilfreich sein.
Das ganz frisch präsentierte The Feedgram ist ein auf Empfehlungen setzender Feedreader. Sicher noch mit Fehlern, aber den Ansatz sah ich vorher mehrfach gesucht.
Oga als Alternative zu Nokogiri
Der bekannteste XML-Verarbeitungshelfer im Rubyland ist sicherlich Nokogiri. Mit Nokogiri kann man schnell Informationen aus XML und HTML herausholen, per xpath oder CSS-Selektor. Ein Beispiel von der Webseite:
doc = Nokogiri::HTML(open('https://nokogiri.org/tutorials/installing_nokogiri.html')) puts "### Search for nodes by css" doc.css('nav ul.menu li a', 'article h2').each do |link| puts link.content end puts "### Search for nodes by xpath" doc.xpath('//nav//ul//li/a', '//article//h2').each do |link| puts link.content end
Gegen Nokogiri spricht eigentlich nur die Installation. Basierend auf der libxml, ist es desöfteren mindestens eine zusätzliche Abhängigkeit, an die man im Zweifel bei der Servereinrichtung denken muss. Manchmal führt es auch zu ganz komischen Problemen.
Hier setzt Oga an. Es hat diese Abhängigkeit einfach nicht. Das macht die Installation unproblematischer. Die API ist etwas anders als bei Nokogiri, einige der Unterschiede sind in einer Dokumentationsdatei beschrieben. Es ist aber schon sehr ähnlich:
onli@fallout:~$ irb 2.5.3 :001 > require 'oga' => true 2.5.3 :002 > xml = Oga.parse_xml('<a><b test="def">abc</b></a>') => Document( children: NodeSet(Element(name: "a" children: NodeSet(Element(name: "b" attributes: [Attribute(name: "test" value: "def")] children: NodeSet(Text("abc")))))) ) 2.5.3 :003 > xml.xpath('/a/b') => NodeSet(Element(name: "b" attributes: [Attribute(name: "test" value: "def")] children: NodeSet(Text("abc")))) 2.5.3 :004 > xml.at_xpath('/a/b') => Element(name: "b" attributes: [Attribute(name: "test" value: "def")] children: NodeSet(Text("abc"))) 2.5.3 :005 > xml.at_xpath('/a/b').get('test') => "def" 2.5.3 :006 > xml.css('b') => NodeSet(Element(name: "b" attributes: [Attribute(name: "test" value: "def")] children: NodeSet(Text("abc"))))
Überraschenderweise bin ich mit Oga selbst noch in keinerlei Probleme gelaufen, seitdem ich beispielsweise das Blogsystem ursprung darauf umgestellt habe. Es fehlt allerdings an Dokumentation und Beispielen. Bei Nokogiri ist die offizielle Dokumentation auch schon spärlich, bei Oga ist es nochmal weniger, und es fehlen die zu Nokogiri vorhandenen vielen Stackoverflowantworten. Wie beim SAX-Parser, als ich für Oga in den Quellcode schauen musste um die implementierten Events herauszusuchen und Nokogiri wenigstens einen erklärenden Absatz auf der Webseite hatte.
Trotzdem etabliert sich Oga mittlerweile als Bestandteil meiner Rubyprojekte. Installationsprobleme zu vermeiden ist mir sehr wertvoll. Wem das ähnlich geht oder wer eine Alternative zu Nokogiri sucht sollte Oga eine Chance geben.
Effizienter CSV-Dateien verarbeiten, mit Ruby und generell
Vor kurzem schrieb ich darüber, wie ich mit dem SAX-Parser besser mit XML-Dateien umgehen konnte. Besser bedeutete, mit weniger Speicherbedarf schneller die gesuchten Informationen aus teils relativ großen XML-Dateien zu holen. Es half, aber der Server hatte immer noch spürbare Last durch die anderen Datenquellen: Den CSV-Dateien. Sie benutzen manche der Datenausgeber statt XML, und auch bei ihnen führte das naive Vorgehen zu extremen Speicher- und Prozessorbedarf.
Das naive Vorgehen war grob so:
hardwares = Database.instance.getHardwares hardwares.each do |hardware| csv = cache.getset('csvApi') do csvGz = open("https://url/zur/csv.gz") unzippedCsv = Zlib::GzipReader.new(csvGz).read csv = CSV.parse(unzippedCsv, :headers => true) csv end return csv.detect{|line| line['id'] == hardware.id } end
Es gab also ein Array mit den Bezugsobjekten, zu denen die Zeile mit ihrer ID aus der CSV-Datei gezogen werden soll. Optimiert ist da bereits, dass die CSV-Datei nicht mehrfach heruntergeladen wird. Dafür sorgt der lru-cache.
Wie geht es besser?
1. Speichereffizienter parsen
Der erste Schritt ist das Parsen der CSV-Datei. Der bisherige Code macht das in einem Rutsch und baut – ähnlich wie bei XML-Dateien – ein CSV-Objekt. Wenn wir stattdessen Zeile für Zeile durchgehen entsteht eine Chance, den Speicherbedarf zu reduzieren. Dalibor Nasevic hat dazu Codebeispiele und Benchmarkergebnisse. Der Code ändert sich so:
unzippedCsv = Zlib::GzipReader.new(csvGz) csvFile = CSV.new(unzippedCsv, headers: true) while line = csvFile.shift # do something end
Der GzipReader liest nicht mehr die Datei auf einmal in den Speicher, mit diesem neuen Startpunkt geht der CSV-Parser zeilenweise durch die Datei. Wenn wir jetzt einfach das CSV-Objekt nachbauen bringt das nicht viel, aber es gibt uns die Möglichkeit etwas besseres zu bauen.
2. Mit fastcsv schneller parsen
Doch bleiben wir erstmal beim Parsen selbst. Derzeit benutzt der Code das in Ruby integrierte CSV-Modul. Doch es gibt Alternativen, insbesondere fastcsv. Das Gem kann in vielen Fällen das normale CSV-Modul direkt ersetzen und war in meinen Tests etwa doppelt so schnell.
require 'fastcsv' csvFile = FastCSV.new(unzippedCsv, headers: true)
Nett, aber das Parsen der CSV-Datei war gar nicht das Problem. Das sparte ein paar Sekunden. Das eigentliche Problem war das spätere Durchsuchen des erstellten CSV-Objekts.
3. Mit Hash nicht suchen, sondern nachschlagen
Das ist eine Optimierung, die in jeder Sprache funktionieren wird.
Wenn CSV.parse
ein CSV-Objekt erstellt, ist das im Grunde ein großes Array mit Arrays (headers: false
) oder Hashs (headers: true
) in den Arrayeinträgen. Entsprechend durchsucht der Code von oben dieses Array mit dem üblichen Enumerable.detect. Doch das bedeutet, dass für jedes Suchobjekt die CSV-Struktur durchgegangen werden muss, bis etwas gefunden wurde. Oder bis die Struktur durch ist und eben nichts gefunden wurde. Wenn es nur eine Datenstruktur gäbe, die für eine ID direkt die passende Zeile ausgeben könnte…
Die gibt es natürlich, genau das ist in Ruby der Hash. Da wir jetzt zeilenweise durch die CSV-Datei durchgehen und die Struktur selbst bauen können wir sie nutzen:
csv = cache.getset('csvApi') do … csv = {} while line = csvFile.shift csv[line['id']] = line end csv end return csv[hardware.id]
Das hier ist die große Optimierung. Anstatt mehrfach durch die riesige Datenmenge zu stöbern am Anfang mit etwas Mehraufwand die Hashstruktur zu erstellen spart danach so viel Zeit bei jedem Suchvorgang, dass minuten- bis stundenlange Prozesse in wenigen Sekunden fertig werden.
4. Ungenutzte Datenfelder rausschmeißen
Moment, da gibt es noch eine mögliche Optimierung, wieder völlig unabhängig von Ruby. Eventuell braucht es später gar nicht alle Felder, die in der CSV-Datei gespeichert sind. Vielleicht wird später nur nach price und available geschaut. Wenn dem so ist, dann ist genau hier der Moment die überflüssigen Felder zu entfernen und so den Speicherbedarf zu senken:
while line = csvFile.shift csv[line['id']] = line.to_h.keep_if{|k, _| k == 'price' || k == 'available' } end
Die Kombination dieser vier Schritte ist sehr mächtig. Was vorher viele Minuten rödelte und Prozessorkerne voll auslastete ist in ein paar Sekunden erledigt. Aber es ist ja auch ein Idealfall. Es gab genau eine ID, wir in einer Hashmap als Key nutzen und dann nachschlagen konnten. Was, wenn es mehr als einen Key gibt?
5. SQLite für mehrere IDs
In meinem Anwendungsfall gab es manchmal neben der id noch die sku, also einen zweiten Key. Dann reicht ein Hash nicht, denn es gibt keine mir bekannte Möglichkeit, einen zweiten Key einzusetzen. Klar, wir könnten einen zweiten Hash erstellen. Aber würde das nicht den Speicherbedarf verdoppeln? Nein, es wäre besser einen zweiten Key als Index über die alte Hashmap zu legen. In Ruby wüsste ich nicht wie das geht (wenn du schon: Ein Kommentar wäre klasse!). Aber SQLite macht das mit links und ist in jeder Sprache verfügbar.
Die Idee also ist: Statt einer Hashmap erstellen wir eine SQLite-Datenbank im Arbeitsspeicher. Primary Key
wird die id, aber für die sku baut SQLite einen Index. Das Durchsuchen geht dann mit ein bisschen SQL. YAML serialisiert die CSV-Zeile, die im Zweifel auch wieder wie in Schritt 4 speicheroptimiert werden könnte.
csv = cache.getset('csvApi') do … csvFile = FastCSV.new(result, :headers => true) db = SQLite3::Database.new(':memory:') db.execute "CREATE TABLE csv(id TEXT PRIMARY KEY, sku TEXT, line TEXT)" while line = csv.shift db.execute("INSERT OR IGNORE INTO csv(id, sku, line) VALUES(?, ?, ?)", line['id'], line['sku'], YAML::dump(line)) end db.execute "CREATE INDEX csv_sku ON csv(sku)" db.execute "ANALYZE" db end row = csv.execute("SELECT line FROM csv WHERE id = ?", hardware.id).first unless row row = csv.execute("SELECT line FROM csv WHERE sku = ?", hardware.sku).first end if row return YAML::load(row[0]) end
SQLite ist unheimlich schnell, die CSV-Datei wird in sekundenschnelle durchsucht sein, je nach Größe natürlich.
Fazit
Wenn man es mal richtig macht… Ich fand das ein gutes Beispiel für einen Anwendungsfall von Informatik-Grundkenntnissen. Statt ein Array zu durchsuchen die Datenstruktur zu ändern und eine Hashmap zu nehmen ist Grundlagenstoff des Studiums, Standardbeispiel für O(1) statt O(n). Aber ich brauchte einen Moment um zu erkennen, dass das hier möglich ist, das komfortable CSV.parse
hatte mir das versteckt. SQLite einzubauen und nach einem schnelleren Gem zu schauen ist dann vielleicht etwas mehr aus praktischer Erfahrung gezogen, aber liegt wenn man mal optimiert und und nach dem Datenbankkurs auch nicht mehr fern.
Mir hat dabei auch geholfen, diese Aufgabe als eigenes Projekt zu betrachten. Ursprünglich war das nur eine kleine Ecke im Code des Überprojekts (pc-kombo), schnell mal gebaut, abgewandelt aus Code der eine REST-API nach den Informationen fragt (wo solche Optimierungen nicht möglich sind). Jetzt ist die Ecke ausgelagert in ihr eigenes Git-Repository und der Code ist auf genau diese Aufgabe reduziert. Das macht es einfacher, solche Optimierungsmöglichkeiten zu sehen.
Auf jeden Fall lohnt sich der Aufwand. Zusammen mit der Reduzierung der Last durch XML-Dateien kann ich den großen Server bald wieder abschalten, der nach dem Scaleway-Umzug die temporäre Heimat dieses Mikroservice wurde. Aus dem Mikroservice wurde jetzt tatsächlich auch ein kleines Programm, das auf schmalerer Hardware wird laufen können. Das reduziert die Strom- oder die Hostingkosten dann schnell um ein paar hundert Euro im Jahr.
Victor: SVGs mit Ruby erstellen
Vom Code aus Bilder erstellen – da landet man dann schnell bei SVG. Um unter Ruby ein SVG zu erstellen kann victor benutzt werden. Das Readme zeigt direkt wie es geht. Dieser Code:
require 'victor' svg = Victor::SVG.new width: 140, height: 100, style: { background: '#ddd' } svg.build do rect x: 10, y: 10, width: 120, height: 80, rx: 10, fill: '#666' circle cx: 50, cy: 50, r: 30, fill: 'yellow' circle cx: 58, cy: 32, r: 4, fill: 'black' polygon points: %w[45,50 80,30 80,70], fill: '#666' 3.times do |i| x = 80 + i*18 circle cx: x, cy: 50, r: 4, fill: 'yellow' end end svg.save 'pacman'
erstellt diese Grafik:
Cool: Der Code hinter dem gem ist ziemlich minimal, das ist fast nur eine intelligent gestrickte kleine API, die genau richtig den Funken Komplexität versteckt, wegen dem man SVGs anonsten nicht per Hand schreiben will. Samt hilfreichen Beispielen wird es ein praktisches Werkzeug.
Ich finde es toll, wie SVG immer wieder in Projekte von mir reinrutscht. Nicht, weil es bei mir besonders beliebt wäre, sondern schlicht weil es immer wieder gut ein Problem löst.
Pipes ist nun frei
Ich habe mich entschieden, Pipes unter eine freie Lizenz zu stellen. Die Pipes CE ist nun auf Github, steht unter der AGPL und ist einfach installierbar.
Pipes ist ein Projekt von mir, eine Webanwendung, die Yahoo! Pipes nachempfunden ist. Es ist ein grafischer Editor, in den man Daten – gerne RSS-Feeds – hereinzieht und dann bearbeitet, indem Blöcke verbunden werden. Einfachstes Beispiel wäre das Filtern eines Blogs oder eine Nachrichtenseite nach einem bestimmten Thema. Die geteilten Pipes zeigen was andere damit anfangen, und ich hatte im Pipes-Blog eine kleine Artikelsammlung darüber geschrieben, was die Seite so kann.
Auf jeden Fall war diese Webanwendung bisher nicht offen. Auf der einen Seite muss sowas natürlich auch mal okay sein, andererseits ging mir das auch etwas gegen den Strich, und es ist gerade bei Pipes ein bisschen kritisch gewesen. Denn der Vorgänger (mit dem ich nichts zu tun hatte) wurde erst nicht weiterentwickelt, dann abgeschaltet. Klar, dass dann nicht jeder Zeit (oder gar Geld) in eine SaaS-Seite investieren will, bei der das wieder passieren könnte. Die neue freie Version soll gegen solche Bedenken helfen.
Pipes CE ist eine Ruby/Sinatra-Anwendung. Sie kann einfach installiert werden und läuft dann eben auf dem eigenen Rechner oder Server. Der Funktionsumfang ist momentan nicht geringer als bei Pipes, nur Zeug wie die Stripeeinbindung habe ich herausgenommen. Das Design ist auch angepasst, es soll ja nicht identisch aussehen. Im Laufe der Zeit könnte sich noch ändern wie ähnlich die beiden Versionen sich sind. Wie es jetzt weitergeht ist nicht genau durchgeplant und hängt ja auch davon ab, ob noch andere gerne mit der Software spielen wollen.
Mit SAX effizienter XML parsen
Normalerweise nutze ich das DOM-basierte Einlesen von XML-Dateien. Mit oga unter Ruby zum Beispiel so:
require 'oga' doc = Oga.parse_xml('<people> <author> <name>onli</name> <real>yes</real> </author> <author> <name>Unsichtbares Einhorn</name> <real>no</real> </author> </people>')
doc
ist dann eine Baumstruktur, die z.B. mit xpath durchgegangen werden kann:
p doc.xpath("//name").map{|x| x.text } # => ["onli", "Unsichtbares Einhorn"]
So vorzugehen hat aber einen Nachteil: Den Speicherbedarf. Ich arbeitete vorhin mit einer XML-Datei, die als .gz heruntergeladen schlanke 13 MB wog. Entpackt waren es dann schon 164 MB. Der Speicherverbrauch beim direkten Einlesen nach obiger Methode? 4 GB. Viel zu viel für den kleinen Mini-PC, der später regelmäßig diese XML-Datei bearbeiten und bestimmte Einträge finden soll. Denn der hat nur 500 MB Ram.
Ich bin dann schließlich bei SAX gelandet. Grundsätzlich ist das nicht schneller, deswegen hatte ich diesen Ansatz nach einem Erstkontakt vor vielen Jahren nicht mehr in Betracht gezogen. Aber hier passt es: Anstatt die Datei auf einmal zu lesen und eine komplexe Struktur in den Speicher zu packen wird die XML-Datei Zeile für Zeile durchgegangen. Und bei jedem Schritt wird ein Handler benachrichtigt, welches Element das gerade ist. Der Handler kann dann alles ignorieren was für ihn nicht wichtig ist und seine Aufgabe erledigen. Das könnte z.B. sein, spezielle Elemente zu zählen, ein Standardbeispiel für SAX. Oder man kann es auch nutzen, um nur die Daten aus der XML-Datei herauszuholen die interessant sind. Entsprechend gering kann der Speicherverbrauch bleiben.
Wollte ich in der Beispieldatei von oben nur Autorennamen speichern, die real sind, dann könnte der SAX-Parser das so machen:
require 'oga' class RealAuthorNames attr_reader :names attr_reader :currentElement attr_reader :currentText attr_reader :name def initialize @names = end def on_element(namespace, name, attrs = {}) @currentElement = name end def on_text(text) unless text.strip.empty? @currentText = text @name = @currentText if currentElement == 'name' @names.push(@name) if @currentElement == 'real' && @currentText == 'yes' end end end handler = RealAuthorNames.new Oga.sax_parse_xml(handler, '<people> <author> <name>onli</name> <real>yes</real> </author> <author> <name>Unsichtbares Einhorn</name> <real>no</real> </author> </people>') p handler.names # => ['onli']
Die Liste der möglichen SAX-Ereignisse habe ich aus dem obersten Kommentar von ogas sax_parser.rb entnommen. Nokogiri hätte sie im Tutorial gelistet.
Der Code zeigt, warum ich SAX normalerweise vermeide: Schön ist das nicht. Selbst dieses simple Beispiel hat mehrere Zustandsvariablen und verlässt sich darauf, dass die XML-Datei regelmäßig aufgebaut ist. Aber es funktioniert eben: Die größere XML-Datei zu verarbeiten verbraucht jetzt nicht mehr 4 GB Speicher, wenn die für mich relevanten Daten rausgeholt werden, sondern der Arbeitsspeicherverbrauch bleibt unter 80 MB.
Mein Blogsystem ursprung ist jetzt 1.0
Während hier Serendipity läuft, setzt der Blog von pc-kombo auf ursprung. Das ist eine von mir in Ruby/Sinatra geschriebene Blogengine, gestartet als Experiment für Blogs als dezentrales soziales Netzwerk, inzwischen auf das wesentliche reduziert – aber eben nicht minimaler als das, es ist kein Generator für statische Seiten, sondern ein echter Blog mit Kommentaren, Tags, Trackbacks, Designs; samt Spamfilter, Editor und Markdown.
Für mich ist es weiterhin praktisch diese Alternative zu haben, die besser zu meinem bisher üblichen Serverstack passt: Ruby hinter Nginx. Da ist ursprung einfacher einbaubar als die meisten anderen Blogengines.
Die bestehende Installation werkelte jetzt ein paar Jahre fleißig vor sich hin, aber ich hatte das Repository etwas vernachlässigt und nicht alle dann doch angefallenen Patches dort sauber eingebaut. Beispielsweise funktionierte der Installer nicht mehr, weil er noch Browserid aufrufen wollte. Alles derartige ist jetzt bereinigt. Außerdem ging ich die Baustellen an, die mich bei einer Neuinstallation störten: Nokogiri zum Beispiel, das kompiliert werden muss und daran auf den Servern immer wieder scheitert, wurde durch das unproblematischere Oga ersetzt.
Um das zu verdeutlichen bekam ursprung auch endlich ein richtiges Release, die 1.0. 2015(!) hatte ich zuletzt mit der 0.1 einen Entwicklungsstand markiert, ab dem es ein echter Blog war und stabil zu laufen schien.
Das etwas angepasste und übersetzte Changelog:
- Der Editorinhalt wird im Browser zwischengespeichert, damit geschriebene Einträge nicht verlorengehen
- Einige Designs des classless-Projekts werden mitgeliefert.
- Wie oben beschrieben, Oga ersetzte Nokogiri. Dafür wurden auch ein paar andere Abhängigkeiten ausgewechselt.
- Einträge setzen einen Link als canonical, weil ähnlich wie bei Serendipity der Abschnitt hinter der id frei angepasst werden kann (und solcher duplicate content von Google nicht geschätzt wird)
- Der RSS-Feed validiert
- Login: Portier ersetzt Browserid/Persona
- Das Portier-Loginformular ist jetzt auch auf seiner eigenen Seite, ich fand das für Besucher zu störend.
- Fix: Trackbacks verschluckten sich an Leerzeichen in der URL
- Fix: Die Undo-Funktion für gelöschte Einträge ging nicht mehr, weil dabei die inzwischen aus Browsern entfernte Javascript Funktion
contains
aufgerufen wurde.
Im Zweifel ist das nur Aufräumarbeit, die mir zugute kommt wenn ich ursprung woanders einsetze (Pipes z.B. sollte auch einen Blog haben). Wenn es noch jemandem sonst hilft, um so besser.
Rubys FastGettext ist tatsächlich schnell
Ich war mal wieder am Performance-Debuggen von pc-kombo. Die Seite soll schneller laden, wenn der Cache noch nicht befüllt ist, was doch immer wieder Besucher trifft. Dabei stolperte ich über diesen Abschnitt des Flamegraphs:
tt
ist der Übersetzungshelfer, lru-redux der genutzte Cache, Grundlage das Gem i18n. Und dieser Abschnitt machte einen gewichtigen Teil des Seitenladevorgangs aus.
Also habe ich das alte Übersetzungssystem auf FastGettext umgestellt. Der Code in Sinatra sieht in etwa so aus:
helpers do include FastGettext::Translation def t(token, opts = {}) _(token.to_s) % opts end end configure do FastGettext.add_text_domain('pckombo', path: 'locales', type: :yaml) end before do FastGettext.text_domain = 'pckombo' if request.env['HTTP_ACCEPT_LANGUAGE'] languages = HTTP::Accept::Languages.parse(request.env['HTTP_ACCEPT_LANGUAGE']) languages.each do |language| case language.locale when /en[_]*/ FastGettext.locale = "us" break when /de[_]*/ FastGettext.locale = "de" break when /fr[_]*/ FastGettext.locale = "fr" break when /es[_]*/ FastGettext.locale = "es" break end end end end
Die alten yaml-Übersetzungen konnten weiterverwendet werden. So beginnt z.B. die locales/de.yml:
de: cpu: Prozessor
Es ist also fast eine einfach so einsetzbare Alternative mit minimalen Codeänderungen.
Das Ergebnis:
Die Übersetzungen beim ersten Laden brauchen nun einen Bruchteil der Zeit. Das beste daran: Das wird nicht nur den speziellen Seitenaufruf beschleunigen den ich da betrachtet hatte, sondern generell der gesamten Webseite helfen.
Wer Übersetzungen in Ruby umsetzen muss, für den ist FastGettext ist definitiv einen Blick wert.
Wenn ein fehlender Index den Server lahmlegt
In letzter Zeit hatte ich bei meinem PC-Hardwareempfehler pc-kombo mit Performanceproblemen zu kämpfen. Auf meinem Rechner lief die Anwendung lokal gut. Aber wenn mehrere Besucher auf dem Server waren brach desöfteren die Performance ein. Und zwar aller Seiten, die irgendwie auf die SQLite-Datenbank zugriffen oder kompliziertere Berechnungen durchführten.
Bis hierhin hatte ich schon einige Stellen der Serveranwendung optimiert. Die Preisaktualisierung – für die große XML-Dateien geparst werden müssen, was die Serverperformance beeinträchtigen konnte – wurde auf einen zweiten Server ausgelagert, das war die größte Aktion. Geschickter und ebenfalls mit großem Effekt: Ein Cache verhindert das unnötige Neuberechnen des besten PCs für einen bestimmten Preispunkt, solange sich die Komponentenpreise nicht geändert haben. Übersetzungen werden nun genauso im Arbeitsspeicher zwischengespeichert, was die Customize-Funktion beschleunigt. Die Berechnung der Durchschnittsgröße eines Gehäuses wird nur noch durchgeführt, wenn die Detailseite eines Gehäuses mit dem Größenvisualisierer angezeigt wird, und so weiter. All das half, doch es war nicht die Ursache des Grundproblems. Dafür wurde das nun sichtbar.
Schuld an den Performanceeinbrüchen war wohl eine einzelne Datenbankabfrage.
Der Debugweg
Nach all den vorherigen Verbesserungen stach eine Seite als noch besonders langsam heraus: Die Detailansicht einer Komponente, z.B. die des Prozessors i7-8700K. Auf dem lokalen Rechner mit Testdaten war diese Seite allerdings schnell. Ich entschloss mich, trotzdem erstmal dort zu debuggen, da der langsamste Abschnitt auf meinem Rechner ja auf dem Server noch problematischer sein und die längere Verzögerung verursachen könnte.
Zum Performance-Debuggen benutze ich ruby-prof
mit ruby-prof-flamegraph
. Über die Gemseite finden sich gute Erklärungen. Bei mir sieht der gekürzte Code so aus:
get '/:country/product/:type/:ean' do |country, type, ean| if settings.development? require 'ruby-prof' require 'ruby-prof-flamegraph' begin RubyProf.start rescue RuntimeError => re RubyProf.stop RubyProf.start end end html = doStuff() if settings.development? result = RubyProf.stop printer = RubyProf::FlameGraphPrinter.new(result) File.open("profiling/profile_data", 'w+') { |file| printer.print(file) } end html end
Die Datei profiling/profile_data kann ich dann mit flamegraph.pl
visualisieren:
flamegraph.pl --countname=ms --width=1920 < profile_data > product.svg
Und das sah so aus:
Mit 73% ging der größte Teil der Rechenzeit also in Hardware#priceHistory
verloren. Auf meinem Rechner war das weniger als eine Sekunde. Aber auf dem Server hatte das Laden der Seite vorher – unter Last – ~8 Sekunden gedauert. Dort würde diese Funktion wohl ebenfalls den größten Teil der Rechenzeit ausmachen, vermutete ich.
Was macht priceHistory
? In Grunde führt es diesen SQL-Query aus:
SELECT * FROM pmdb.priceHistory WHERE ean = ? AND (vendor = ? OR vendor = ?) ORDER BY date ASC
Ein ziemlich simples Select. Doch der Query-Plan sah nicht gut aus:
EXPLAIN QUERY PLAN SELECT * FROM priceHistory WHERE ean = "05032037108652" AND (vendor = "vendor1" OR vendor = "vendor2") ORDER BY date ASC; 0|0|0|SCAN TABLE priceHistory 0|0|0|USE TEMP B-TREE FOR ORDER BY
SCAN TABLE priceHistory ist der problematische Teil hier. Diese Erklärung bedeutet, dass zum Erfüllen dieser Abfrage die gesamte Tabelle gelesen werden muss. Aber wie groß kann eine solche Tabelle mit etwas Preishistorie schon sein?
du -sh /home/pc-kombo/www.pc-kombo.de/productMeta.db 823M /home/pc-kombo/www.pc-kombo.de/productMeta.db
Upps. Nicht alles davon sind die Preisdaten. Aber der größte Teil.
Um also die Detailseite anzuzeigen musste der Server jedes mal eine 800MB große Tabelle auslesen. Das führte dazu, dass ein Prozessorkern für ein paar Sekunden komplett ausgelastet war (was in Sprachen wie Ruby ohne echten Parallelismus besonders problematisch ist), und auch die Festplatte wurde dadurch natürlich komplett in Beschlag genommen. Kein Wunder, dass dann auch die anderen Seitenaufrufe langsam wurden.
Hier hilft normalerweise ein Index:
CREATE INDEX priceHistory_ean_vendor ON priceHistory(ean, vendor);
Und tatsächlich:
EXPLAIN QUERY PLAN SELECT * FROM priceHistory WHERE ean = "05032037108652" AND (vendor = "vendor1" OR vendor = "vendor2") ORDER BY date ASC; 0|0|0|EXECUTE LIST SUBQUERY 1 0|0|0|USE TEMP B-TREE FOR ORDER BY
Der Index wird direkt genutzt. Und so viel bringt das in Praxis:
Alleine durch diesen einen Index ist die Preishistorie nun nicht mehr der größte Zeitfaktor in der Berechnung, sondern mit 16% einer von mehreren. Und htop
bestätigt das, denn bei dem Seitenaufruf schießt die Prozessorlast des einen Kerns nun nicht mehr auf 100%, sondern bleibt irgendwo unter 10%. Dementsprechend werden auch die anderen Seiten nicht mehr ausgebremst.
Die eigentliche Ursache
Es ist fast immer problematisch, wenn ein Provisorium länger genutzt wird als geplant. Genau das ist hier passiert. Als ich die Preishistorie entwickelte wollte ich die Preise nur für wenige Wochen in der Datenbank speichern. Die eigentliche Lösung sollte eine Zeitseriendatenbank wie rrdtool sein. Solche Datenbanken können die Datenmenge begrenzen, in dem für längere zurückliegende Zeiträume Datenpunkte entfernt werden. Statt den Preis einer Grafikkarte von vor zehn Jahren alle 5 Minuten parat zu haben, speichert rrdtool dann eben nur den Durchschnittspreis einer zehn Jahre zurückliegenden Kalenderwoche. Und bestimmt wäre bei der richtigen Lösung dann auch der Index gesetzt gewesen.
Stattdessen blieb das Provisorium bestehen, bis die Datenmenge so sehr anstieg, dass der fehlende Index – dessen Fehlen beim Entwickeln mit den wenigen Datenpunkten ja noch kein Problem war – den Server ausbremsen konnte.
Ruby und mehrere Kerne
Im Laufe der Zeit habe ich einige Performance-Optimierungen an meinen Ruby/Sinatra-Anwendungen vorgenommen. Insbesondere bei pc-kombo, denn dort würde ohne tiefere Optimierungen die Antwortzeiten eher bei Minuten als bei Sekunden liegen. Der wichtigste Baustein dort ist die Optimierung der Datenbankabfragen und Caching via memoist.
Im Hinterkopf hatte ich auch immer die Idee, dass die moderne Hardware mit ihren vielen Kernen gut genutzt werden sollte. Das macht Ruby ja leider nicht automatisch. Ich hatte mit dem parallel-gem gespielt, und ich benutze für Hintergrundaufgaben einen Threadpool. Aber wie effektiv kann das sein, wenn die reguläre MRI-Ruby-Implementierung mit einem globalen Lock (GIL) arbeitet und daher paralleles Ausführen von Code praktisch nicht geht? Selbst wenn der Linux-Scheduler Threads über mehrere Kerne verteilen kann, mehrere Prozesse dafür nicht nötig sind? Reicht das Warten auf IO und alles andere, während dessen kein Ruby-Code ausgeführt wird, um mit MRI trotz GIL die Prozessorkerne auszunutzen?
Um das durch Vergleich zu beantworten habe ich mir andere Ruby-Implementierungen angeschaut. Die beiden bekannten: jRuby und Rubinius. Beide sollten das MRI-Problem beseitigen und damit neue Performaneoptimierungen ermöglichen. Spoiler: Beide funktionierten für mich nicht, bis zum Benchmarken kam ich gar nicht.
jRuby
jRuby ist Ruby auf der JVM, der virtuellen Maschine die ursprünglich für Java genutzt wurde und inzwischen einige andere Sprachen trägt. Der Vorteil davon soll sein, dass jRuby-Programme von den Optimierungen der JVM profitieren, und das schließt Parallelismus mit ein. jRuby war der Anleitung folgend erstaunlich einfach installiert: Java installieren, jRuby herunterladen, entpacken und ausführen. Auch rvm unterstützt jRuby, als ich beim Ausführen dann nicht durchaute wie ich den PATH
und gems verwalten soll löschte ich die manuelle Installation wieder und installierte es damit.
Es scheiterte dann für mich an sqlite3. Das sqlite3-gem nutzt C-Code, und jRuby kann keine C-Module ausführen. Stattdessen würde man wohl jdbc laden, also das machen wie im Java-Land. Dafür müsste ich meinen Datenbankcode umschreiben, und für ein Experiment ungewissen Ausgangs war das keine Option. Das ist wohl weniger problematisch, wenn man activerecord benutzt, dann stellt man das einfach auf den jdbc-Adapter um.
Rubinius
Rubinius hatte ich mir vor einiger Zeit schonmal angeschaut, damals war es nicht mal installierbar. Inzwischen hat das Projekt wohl etwas Zeit darein investiert, tatsächlich Nutzer zu erreichen, die Installation war ein simples rvm install rbx-3
. Aber Rubinius ist trotzdem noch nicht praxistauglich genug.
Erst hing es sich an meiner Benchmark-Klasse auf. Ich hatte mir den Issue-Tracker angeschaut und fand die Interaktionen dort nett genug, um diesen für mich unerklärlichen Bug zu melden. Dort bekam ich dann auch wirklich gute Hilfe, Benchmark wird derzeit durch die Standard-Lib belegt, ich müsste es in ein Modul packen. Gesagt, getan, nächster Fehler. Nun war es das http-accept-gem, mit dem pc-kombo die Lokalisierung umsetzt, das an einer unvollständigen StringScanner-Implementierung starb. Auch diesen Fehler meldete ich.
Ich bin erfreut über die gute Antwortzeit des Projektes, aber es ist für mich unmöglich einzuschätzen, ob Rubinius auch nur annähernd kompatibel genug ist, eine echte Sinatra-Webanwendung mit ihren weiteren gems auszuführen. Auch absolut unklar ist, ob Rubinius überhaupt einen Performancevorteil bringen würde - neben der GIL-losigkeit kamen viel der berichteten positiven Performanceerfahrungen wohl vom JIT-Interpreter, der aber inzwischen herausgenommen wurde. Könnte immer noch Parallelismus ermöglichen und damit meinen Anwendungen nützen, aber klar ist das nicht.
Alternative: Der MRI-Weg
Außer auf Verbesserungen bei Rubinius zu hoffen sehe ich für mich drei Alternativen:
- Ich könnte einfach nichts machen. Seit der letzten Performance-Tuning-Runde ist die Seite wieder schnell. Durch Debugging hatte ich bemerkt, dass das Problem bei den Datenbankabfragen lag, nicht am Prozessor. Da waren blöde Sachen dabei, wie für jede Hardwarekomponente beim Seitenaufbau nochmal in der Datenbank nach Bildern zu suchen. Solange die Seite keinen Besucheransturm erlebt kann das also erstmal so bleiben. Auch wenn es generell doof ist, die Fähigkeiten moderner Prozessoren mit meiner bevorzugten Sprache nicht gut nutzen zu können.
- Ich könnte auf Ruby 3 hoffen. Großes Ziel der Entwicklung sind Performanceverbesserungen, 3x3 war das Stichwort. Bessere Unterstützung von Parallelimus/Concurrency durch neue Operatoren und Konzepte ist auch dabei, selbst die Beseitigung des GILs wurde ins Auge gefasst. Das könnte ich verfolgen und frühzeitig versuchen einzubauen.
- Wenn jetzt etwas geschehen soll könnte ich auch die Architektur der Anwendung ändern. Derzeit ist das ein Monolith, wobei an ein paar Kernstellen ein Threadpool benutzt wird. Ich könnte es aufsplitten: Eine Anwendung zum Aktualisieren der Preise, eine andere zum Zeichnen der Weboberfläche, eine dritte zum Erstellen der Hardwarezusammenstellungen. Herausforderung wäre das Austauschen der Informationen, aber auch dafür gibt es schon Lösungen.
Theoretisch könnte man natürlich auch die Sprache wechseln, aber dafür ist pc-kombo viel zu groß. Trotzdem etwas für den Hinterkopf für Leser, die diesen Artikel lesen um für eine noch zu bauende Anwendung den Einfluss von Ruby auf die Performance einzuschätzen. Wichtig ist dann die Erkenntnis: Sollen mehrere Prozessorkerne genutzt werden, muss das genau geplant werden - Ruby macht das nicht für dich.
RSS in Ruby Lesen und Erstellen
Für mein derzeitiges Softwareprojekt arbeite ich mit RSS- und Atom-Feeds. Dabei entdeckte ich, dass Ruby in der Standardbibliothek ein Modul zum Arbeiten mit diesen Formaten hat.
Es gibt einen Parser zum Lesen (Beispielcode aus der Dokumentation):
require 'rss' require 'open-uri' url = 'http://www.ruby-lang.org/en/feeds/news.rss' open(url) do |rss| feed = RSS::Parser.parse(rss) puts "Title: #{feed.channel.title}" feed.items.each do |item| puts "Item: #{item.title}" end end
Und eine Klasse zum Erstellen:
require "rss" rss = RSS::Maker.make("rss2.0") do |maker| maker.channel.updated = Time.now.to_s maker.channel.title = 'Testing the RSS 2.0 maker' maker.channel.link = 'http://www.ruby-lang.org/en/feeds/news.rss' maker.channel.description = 'An important description' maker.items.new_item do |newItem| newItem.title = 'Item title' newItem.updated = Time.now.to_s newItem.link = 'https://www.onli-blogging.de/1641/Space-Hulk-Ascension.html' newItem.content_encoded = 'A mediocre game' newItem.guid.content = 'https://www.onli-blogging.de/1641/Space-Hulk-Ascension.html' end end puts rss
Allerdings ist dieses Modul nicht unproblematisch. Abgesehen von der Readme gibt es nahezu keine Dokumentation. Auch Google findet kaum Diskussionen über den Code, das macht das Arbeiten damit schwierig. Ein Beispiel, in das ich heute gerannt bin, steckt im obigen Beispielcode: In bestimmten Situationen gibt der RSS-Bauer einfach nichts aus.
Deswegen ist es eine wichtige maker.channel.description
. Ist diese nämlich nil
oder leer (""
), scheitert die Erstellung ohne Fehlermeldung und es gibt keine Ausgabe.
Eine weitere Stolperstelle steckt in der guid
. Normalerweise würde man die ja einfach über newItem.guid = 'die guid'
setzen. Weil dieser Setter aber nicht implementiert ist, muss stattdessen newItem.guid.content
aufgerufen werden.
Den Parser nutze ich gar nicht erst direkt, weil ich eine Normalisierung von Atom und RSS brauche. Das kleine Gem feedparser kann das, nutzt ihn allerdings intern. Ansonsten hätte ich auf feedjira gesetzt, das normalisiert wohl auch, wirbt aber nicht explizit damit, was mich zögern lässt.
Zum Erstellen hätte ich aber keinen anderen Weg, außer das wie bei ursprung manuell über ein Template zu erledigen.
Trotzdem: Sehr schön, so etwas in der Standardbibliothek zu haben. Jetzt müsste es nur noch dokumentiert sein.