Seitenanfang

Einfach erklärt: MapReduct Tutorial

Dieser Post wurde aus meiner alten WordPress-Installation importiert. Sollte es Darstellungsprobleme, falsche Links oder fehlende Bilder geben, bitte einfach hier einen Kommentar hinterlassen. Danke.


Ich nutze MongoDB schon seit über einem Jahr, bisher aber nur für kleinere Sachen - und ohne MapReduce. Dabei ist es genau diese Funktion, die viel zur Macht dieser ungewöhnlichen Datenbank beiträgt.

Zugegeben, SQL ist einfacher, die Lernkurve ist wesentlich flacher. Bei MapReduce muss man zunächst das Prinzip verstehen, beide Teile davon und ihr Zusammenspiel, und erst dann kann man die ersten Gehversuche wagen, im Gegensatz zu SQL weiß man dann allerdings schon alles wichtige und kann die volle Macht nutzen, bei SQL hört das Lernen nie auf und komplexe JOINs können einem den Schlaf rauben.

Für ein Projekt wollte ich Namen in "sprechende Links" produzieren, also alles was nicht A-Z, a-z, 0-9 oder "." ist in "_" konvertieren. Problematisch wird es erst, wenn jemand einen solchen Link aufruft. Ich könnte zwar einfach in jedem Datensatz den Link mit speichern - aber schön ist das nicht.

Eine Mappingtabelle wäre auch nicht so schön, denn die Namen in den Datensätzen sind nicht immer gleich geschrieben (zum Beispiel Lisa-Marie Mayer vs. Lisa Marie Mayer).

MapReduce erstellt (im Normalfall) eine neue Collection (entspricht einer SQL-Tabelle). Dabei gibt es viele Möglichkeiten um nicht jedes Mal alle Datensätze durcharbeiten zu müssen, aber diese sind bei meiner Datenmenge und für die ersten Schritte unnötig.

Für die folgenden Beispiele verwende ich folgende Liste von Namen, im SQL-Vergleich würden diese in einer Spalte der Tabelle stehen (zusätzliche Felder je Datensatz sind hierfür nicht relevant):

  • Lisa
  • Sina
  • Sina
  • Anna-Sophie
  • Anna Sophie
  • Karl

1. Map

Bei einem MapReduce-Vorgang werden zunächst die Datensätze zu Rohdaten vorbereitet. Am Ende steht immer ein Hash, eine Map, bestehend aus einem Schlüssel und einem Wert. Beide können ein komplettes Dokument bzw. Objekt beinhalten.

Jeder einzelne Rohdatensatz wird der Map-Funktion zugeführt, diese generiert daraus einen oder mehrere Map-Einträge.

In diesem Fall konvertiert die Map-Funktion die Namen zu linktauglichen Werten für den Schlüssel und gibt dan Originalnamen als Wert zurück.

Alle Funktionen werden in Javascript geschrieben. Die Map-Funktion kann den aktuellen Datensatz als "this"-Objekt nutzen und eine emit()-Funktion aufrufen um der Map einen Schlüssel und Wert hinzuzufügen:

function () { emit(this.name.replace(/[^a-zA-Z0-9\.\-]+/g, "_").toLowerCase(), this.name); }

Die emit-Funktion wird also mit folgenden Werten aufgerufen:

  • "lisa", "Lisa"
  • "sina", "Sina"
  • "sina", "Sina"
  • "anna_sophie", "Anna-Sophie"
  • "anna_sophie", "Anna Sophie"
  • "karl", "Karl"

Die Map enthält jeden Schlüssel nur einmal und sammelt dazu alle Werte die der emit-Funktion mit diesem Schlüssel übergeben wurden, im Beispiel sieht die Map jetzt so aus:

  • "lisa" => "Lisa"
  • "sina" => "Sina","Sina"
  • "anna_sophie" => "Anna-Sophie", "Anna Sophie"
  • "karl" => "Karl"

2. Reduce

Die Reduce-Funktion wird verwendet, um alle Werte eines Schlüssels zu einem einzigen zusammenzufassen (dieser kann natürlich wieder ein Dokument bzw. Objekt sein).

Meine Reduce-Funktion soll zwei Aufgaben übernehmen:

  1. Mehrere identische Werte sollen zu einem zusammengefasst werden
  2. Mehrere unterschiedliche Werte sollen als Schlüssel in einem Objekt zusammengefasst werden

Auch die Reduce-Funktion ist in Javascript geschrieben, sie wird für alle Schlüssel aufgerufen, die mehr als einen Wert haben (in diesem Beispiel also "sina" und "anna_sophie" und sie gibt den neuen, einzelnen Wert für diesen Schlüssel zurück. Sie kann beispielsweise die Anzahl der Werte zählen, aufsummieren oder - wie in diesem Fall - zusammenfassen.

function(k,val) {  var shopnames = new Object; // Prepare new object  // Add all values as object properties  val.forEach(function(oneval) { shopnames[oneval] = 1; });  // return object as new value  return shopnames;}

Nachdem die Reduce-Funktion für "sina" und "anna_sophie" gelaufen ist, sieht die reduzierte Map wie folgt aus:

  • "lisa" => "Lisa"
  • "sina" => { "Sina" => 1 }
  • "anna_sophie" => { "Anna-Sophie" => 1, "Anna Sophie" => 1 }
  • "karl" => "Karl"

3. Finalize

So ganz ist das Ergebnis noch nicht das, was ich wollte: Unterschiedliche Datenformate für Schlüssel die einen oder mehrere Treffer hatten sind nicht schön, aber dafür gibt es die Finalize-Funktion. Diese ist zwar optional, aber in diesem Fall recht hilfreich.

Die Finalize-Funktion erhält jeweils einen Schlüssel mit seinem dazugehörigen Wert als Parameter und gibt den neuen Wert zurück.

function(k,v) {  // Different handling for single string values and objects  if (typeof(v) == "object") {    // Merge all keys into one array    var shopnames = new Array();    for (var key in v) { shopnames.push(key); }    return shopnames;  } else {    // Return a single-element array    return [ v ];  }}

In diesem Fall konvertiert sie sowohl Objekte als auch Einzelwerte zu einem Array, danach sieht die Map wesentlich einheitlicher aus.

  • "lisa" => [ "Lisa" ]
  • "sina" => [ "Sina" ]
  • "anna_sophie" => [ "Anna-Sophie", "Anna Sophie" ]
  • "karl" => [ "Karl" ]

Dieses Ergebnis wird in eine neue Collection geschrieben, jedes Dokument erhält den Schlüssel als "id" und der Wert wird als "value" gespeichert.

Die neue Collection lässt sich wie jede manuell erstellte verwenden. Es gibt keine Verpflichtung, eine MapReduce-Kombination immer wieder bei Änderungen auszuführen, ebenso könnte man auch einmal eine neue Collection initial mit generierten Daten befüllen und danach "von Hand", also mit eigenen Scripten weiterpflegen.

Werden die Informationen nur temporär benötigt, sollte die Collection manuell gelöscht werden, sobald sie nicht mehr gebraucht wird um den von ihr belegten Speicherplatz freizugeben, andernfalls könnte es recht schnell zu einer Speicherknappheit auf dem Datenbankserver kommen.

Die neue Collection ist zunächst auch (abgesehen vom id) nicht indiziert, evtl. gewünschte Indices müssen nach ihrer Erstellunge mit "ensureIndex" generiert werden.

Praktische Umsetzung

Ich habe den MapReduce-Aufruf in einer Funktion gekapselt um ihn nach jedem Importlauf einfach durchführen zu können:

$class->_database->users->ensure_index({name => 1});

$class->_database->run_command([ 'mapreduce' => 'users', 'map' => 'function () { emit(this.name.replace(/[^a-zA-Z0-9\.\-]+/g, "_").toLowerCase(), this.name); }', 'reduce' => 'function(k,val) { var shopnames = new Object; val.forEach(function(oneval) { shopnames[oneval] = 1; }); return shopnames; }', 'out' => 'names', 'finalize' => 'function(k,v) { if (typeof(v) == "object") { var shopnames = new Array(); for (var key in v) { shopnames.push(key); } return shopnames; } else { return [ v ]; } } ',]);

Die erste Zeile setzt einen Index auf die den Namen sofern noch keiner vorhanden ist. Dieser hat zwar mit MapReduce nicht viel zu tun, ist aber beim späten auslesen der gewünschten Datensätze hilfreich.

Der MapReduce-Aufruf selbst übermittelt alle Funktionen und den Namen der neuen Collection, in diesem Falle "names". Ist diese schon vorhanden, wird ihr Inhalt mit den neu berechneten Daten überschrieben.

Das Ergebnis lässt sich einfach über die MongoDB-Shell mit

db.names.find()

abfragen.

 

Noch keine Kommentare. Schreib was dazu

Schreib was dazu

Die folgenden HTML-Tags sind erlaubt:<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>