Effizienter CSV-Dateien verarbeiten, mit Ruby und generell
Wednesday, 20. May 2020
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.
Netz - Rettung - Recht am : Wellenreiten 05/2020
Vorschau anzeigen