Den Zauber der Blüte mit Node.js & Redis verstehen

Im richtigen Anwendungsfall wirken Bloom-Filter wie Magie. Das ist eine mutige Aussage, aber in diesem Tutorial werden wir die kuriose Datenstruktur, die beste Verwendung und einige praktische Beispiele mit Redis und Node.js untersuchen.

Bloom-Filter sind eine probabilistische, einseitige Datenstruktur. Das Wort "Filter" kann in diesem Zusammenhang verwirrend sein. filter impliziert, dass es sich um ein aktives Ding handelt, ein Verb, aber es könnte einfacher sein, es als Speicher, ein Nomen, zu betrachten. Mit einem einfachen Bloom-Filter können Sie zwei Dinge tun:

  1. Einen Artikel hinzufügen.
  2. Prüfen Sie, ob ein Artikel vorhanden ist hat nicht zuvor hinzugefügt worden.

Dies sind wichtige Einschränkungen, um zu verstehen, dass Sie ein Element nicht entfernen können und die Elemente nicht in einem Bloom-Filter auflisten können. Sie können auch nicht mit Sicherheit sagen, ob dem Filter in der Vergangenheit ein Element hinzugefügt wurde. Hier kommt der probabilistische Charakter eines Bloom-Filters zu falsch positiven Ergebnissen, falsche Negative jedoch nicht. Wenn der Filter richtig eingerichtet ist, können Fehlalarme äußerst selten sein.

Es gibt Varianten von Bloom-Filtern, die andere Fähigkeiten hinzufügen, z. B. Entfernen oder Skalieren, aber auch Komplexität und Einschränkungen. Es ist wichtig, zunächst einfache Bloom-Filter zu verstehen, bevor Sie zu den Varianten übergehen. Dieser Artikel behandelt nur die einfachen Bloom-Filter.

Mit diesen Einschränkungen haben Sie eine Reihe von Vorteilen: feste Größe, Hash-basierte Verschlüsselung und schnelle Suche.

Wenn Sie einen Bloom-Filter einrichten, geben Sie ihm eine Größe. Diese Größe ist fest vorgegeben. Wenn Sie also einen Artikel oder eine Milliarde Artikel im Filter haben, wird er niemals über die angegebene Größe hinauswachsen. Wenn Sie Ihrem Filter weitere Elemente hinzufügen, steigt die Wahrscheinlichkeit eines falsch positiven Ergebnisses. Wenn Sie einen kleineren Filter angegeben haben, steigt diese falsch positive Rate schneller als bei einer größeren Größe.

Bloom-Filter basieren auf dem Konzept des One-Way-Hashing. Ähnlich wie das korrekte Speichern von Kennwörtern verwenden Bloom-Filter einen Hash-Algorithmus, um einen eindeutigen Bezeichner für die Elemente zu bestimmen, die an sie übergeben werden. Hashes können von Natur aus nicht umgekehrt werden und werden durch eine scheinbar zufällige Zeichenfolge dargestellt. Wenn also jemand Zugriff auf einen Bloom-Filter erhält, wird der Inhalt nicht direkt angezeigt.

Schließlich sind Bloom-Filter schnell. Die Operation beinhaltet weit weniger Vergleiche als andere Methoden. Sie kann leicht im Arbeitsspeicher gespeichert werden, wodurch Datenbankeinbrüche vermieden werden, die die Performance beeinträchtigen.

Nun, da Sie die Grenzen und Vorteile von Bloom-Filtern kennen, wollen wir uns einige Situationen ansehen, in denen Sie sie einsetzen können.

Konfiguration

Wir verwenden Redis und Node.js, um Bloom-Filter zu veranschaulichen. Redis ist ein Speichermedium für Ihren Bloom-Filter. es ist schnell, in-memory und hat einige spezifische Befehle (GETBIT, SETBIT), die die Implementierung effizienter machen. Ich gehe davon aus, dass Sie Node.js, npm und Redis auf Ihrem System installiert haben. Ihr Redis-Server sollte laufen localhost am Standardport, damit unsere Beispiele funktionieren.

In diesem Tutorial implementieren wir keinen Filter von Grund auf. Stattdessen konzentrieren wir uns auf praktische Anwendungen mit einem vorgefertigten Modul in npm: bloom-redis. bloom-redis hat eine sehr kurze Methodik: hinzufügen, enthält und klar.

Wie bereits erwähnt, benötigen Bloom-Filter einen Hash-Algorithmus, um eindeutige Bezeichner für ein Element zu generieren. bloom-redis verwendet den bekannten MD5-Algorithmus, der zwar nicht perfekt für einen Bloom-Filter (etwas langsam, Overkill auf Bits) ist, aber gut funktionieren wird.

Einzigartige Benutzernamen

Benutzernamen, insbesondere diejenigen, die einen Benutzer in einer URL identifizieren, müssen eindeutig sein. Wenn Sie eine Anwendung erstellen, mit der Benutzer den Benutzernamen ändern können, möchten Sie wahrscheinlich einen Benutzernamen mit noch nie wurde verwendet, um Verwirrung und das Ausreißen von Benutzernamen zu vermeiden.

Ohne einen Bloom-Filter müssen Sie auf eine Tabelle verweisen, die jeden Benutzernamen enthält, der je verwendet wurde. Bei einer Größenordnung kann dies sehr teuer sein. Mit Bloom-Filtern können Sie jedes Mal ein Element hinzufügen, wenn ein Benutzer einen neuen Namen annimmt. Wenn ein Benutzer überprüft, ob ein Benutzername verwendet wird, müssen Sie nur den Bloom-Filter überprüfen. Es kann Ihnen mit absoluter Sicherheit sagen, ob der angeforderte Benutzername zuvor hinzugefügt wurde. Es ist möglich, dass der Filter fälschlicherweise zurückgibt, dass ein Benutzername verwendet wurde, wenn dies nicht der Fall ist. Dies ist jedoch vorsichtshalber und kann keinen echten Schaden verursachen (abgesehen von einem Benutzer, der möglicherweise nicht in der Lage ist, "k3w1d00d47" zu beanspruchen).

Lassen Sie uns zur Veranschaulichung einen schnellen REST-Server mit Express erstellen. Zuerst erstellen Sie Ihre package.json Datei und führen Sie die folgenden Terminalbefehle aus.

npm install bloom-redis --save

npm install express --save

npm install redis --save

Die Standardoptionen für Bloom-Redis haben eine Größe von zwei Megabytes. Dies ist auf der Seite der Vorsicht, aber es ist ziemlich groß. Das Einstellen der Größe des Bloom-Filters ist kritisch: zu groß und Sie verschwenden Speicher, zu klein und Ihre False-Positive-Rate ist zu hoch. Die Berechnung der Größe ist ziemlich kompliziert und würde den Rahmen dieses Tutorials sprengen. Glücklicherweise gibt es einen Bloom-Filtergrößen-Rechner, der die Arbeit erledigen kann, ohne ein Lehrbuch zu knacken.

Nun erstellen Sie Ihre app.js wie folgt:

"Javascript var Bloom = erfordern ('bloom-redis'), express = required ('express'), redis = required ('redis'),

App, Client, Filter;

// Richten Sie unseren Express-Server ein. app = express ();

// die Verbindung zu Redis herstellen client = redis.createClient ();

filter = new Bloom.BloomFilter (client: client, // Vergewissern Sie sich, dass das Bloom-Modul unsere neu erstellte Verbindung zur Redis-Taste verwendet: 'username-bloom-filter'), // die Redis-Taste

// berechnete Größe des Bloom-Filters. // Hier werden Ihre Größen- / Wahrscheinlichkeitsabwägungen vorgenommen //http://hur.st/bloomfilter?n=100000&p=1.0E-6 size: 2875518, // ~ 350kb numHashes: 20);

app.get ('/ check', Funktion (req, res, next) // Überprüfen Sie, ob der Abfrage-String 'username' enthält if (typeof req.query.username === 'undefined') // überspringen diese Route, gehe zur nächsten - führt dazu, dass 404 / nicht gefunden wird ("route"); else filter.contains (req.query.username // der Benutzername aus der Abfragezeichenfolge-Funktion (err, result.) ) if (err) next (err); // Wenn ein Fehler aufgetreten ist, senden Sie ihn an den Client else res.send (Benutzername: req.query.username, //, wenn das Ergebnis "false" ist, dann Wir wissen, dass der Artikel hat nicht verwendet wurde // Wenn das Ergebnis wahr ist, können wir davon ausgehen, dass das Element verwendet wurde. status: result? 'used': 'free'); ); );

app.get ('/ save', Funktion (req, res, next) if (typeof req.query.username === 'undefined') next ('route'); else // Zuerst brauchen wir um sicherzustellen, dass es noch nicht im filter ist filter.contains (req.query.username, function (err, result) if (err) next (err); else if (result) // wahres Ergebnis bedeutet Es ist bereits vorhanden, also sagen Sie dem Benutzer res.send (Benutzername: req.query.username, Status: 'nicht erstellt'); else // wir fügen den in der Abfragezeichenfolge übergebenen Benutzernamen zum Filter hinzu filter.add (req.query.username, function (err) // Die Rückrufargumente für hinzufügen liefert keine nützlichen Informationen. Wir prüfen nur, ob kein Fehler übergeben wurde. if (err) next (err); else res.send (Benutzername: req.query.username, Status: 'erstellt'); ); ); );

app.listen (8010); "

So führen Sie diesen Server aus: Knoten app.js. Gehen Sie zu Ihrem Browser und zeigen Sie auf: https: // localhost: 8010 / check? username = kyle. Die Antwort sollte sein: "username": "kyle", "status": "free".

Nun speichern wir diesen Benutzernamen, indem Sie Ihren Browser auf zeigen http: // localhost: 8010 / save? username = kyle. Die Antwort wird sein: "username": "kyle", "status": "erstellt". Wenn Sie zur Adresse zurückkehren http: // localhost: 8010 / check? username = kyle, Die Antwort wird sein "username": "kyle", "status": "used". In ähnlicher Weise zurück zu http: // localhost: 8010 / save? username = kyle wird darin enden, dass "username": "kyle", "status": "nicht erstellt".

Vom Terminal aus können Sie die Größe des Filters sehen: redis-cli strlen Benutzername-Bloom-Filter.

Im Moment sollte es mit einem Element angezeigt werden 338622.

Versuchen Sie jetzt, weitere Benutzernamen mit der /sparen Route. Versuchen Sie so viele, wie Sie möchten.

Wenn Sie dann die Größe erneut überprüfen, stellen Sie möglicherweise fest, dass Ihre Größe leicht gestiegen ist, jedoch nicht bei jeder Zugabe. Neugierig, richtig? Intern setzt ein Bloom-Filter einzelne Bits (1/0) an verschiedenen Positionen in der Zeichenfolge, die bei Benutzername-Bloom gespeichert werden. Diese sind jedoch nicht zusammenhängend. Wenn Sie also an Index 0 ein Bit und dann an Index 10.000 ein Bit setzen, ist alles dazwischen 0. Für den praktischen Gebrauch ist es zunächst nicht wichtig, die genaue Mechanik jeder Operation zu verstehen - wissen Sie einfach, dass dies der Fall ist ist normal und Ihr Speicher in Redis überschreitet niemals den von Ihnen angegebenen Wert.

Neuer Inhalt

Neue Inhalte auf einer Website sorgen dafür, dass ein Benutzer immer wieder zurückkommt. Wie können Sie dem Benutzer also jedes Mal etwas Neues zeigen? Bei einem herkömmlichen Datenbankansatz könnten Sie einer Tabelle eine neue Zeile mit der Benutzer-ID und der ID der Story hinzufügen. Anschließend würden Sie diese Tabelle abfragen, wenn Sie sich für die Anzeige eines Inhalts entscheiden. Wie Sie sich vorstellen können, wird Ihre Datenbank extrem schnell wachsen, insbesondere mit dem Wachstum von Benutzern und Inhalten.

In diesem Fall hat ein falsches Negativ (z. B. das Anzeigen eines unsichtbaren Inhaltsstücks) nur eine sehr geringe Auswirkung, wodurch Bloom-Filter eine praktikable Option sind. Auf den ersten Blick sind Sie vielleicht der Meinung, dass Sie für jeden Benutzer einen Bloom-Filter benötigen. Wir verwenden jedoch eine einfache Verkettung der Benutzer-ID und der Inhalts-ID. Anschließend fügen Sie diese Zeichenfolge in unseren Filter ein. Auf diese Weise können wir einen einzigen Filter für alle Benutzer verwenden.

Lassen Sie uns in diesem Beispiel einen weiteren Basis-Express-Server erstellen, der Inhalt anzeigt. Jedes Mal, wenn Sie die Route besuchen / show-content / any-Benutzername (mit beliebiger Benutzername (ein beliebiger URL-sicherer Wert), wird ein neuer Inhalt angezeigt, bis die Site keinen Inhalt mehr hat. In diesem Beispiel handelt es sich bei dem Inhalt um die erste Zeile der zehn besten Bücher des Projekts Gutenberg.

Wir müssen ein weiteres npm-Modul installieren. Führen Sie vom Terminal aus Folgendes aus: npm install async --save

Ihre neue app.js-Datei:

"javascript var async = required ('async'), Bloom = required ('bloom-redis'), express = requir ('express'), redis = required ('redis'),

App, Client, Filter,

// Aus dem Projekt Gutenberg - Eröffnungszeilen der Top 10 der Public Domain-E-Books // https://www.gutenberg.org/browse/scores/top openingLines = 'Stolz und Vorurteil': 'Es ist eine allgemein anerkannte Wahrheit , dass ein einzelner Mann, der ein glückliches Vermögen besitzt, einer Frau fehlen muss. ',' alices-adventures-in-wonderland ':' Alice wurde langsam müde, bei ihrer Schwester auf der Bank zu sitzen, und nichts zu tun haben: ein- oder zweimal hatte sie in das Buch geschaut, das ihre Schwester las, aber es hatte keine Bilder oder Unterhaltungen darin, und was nützt ein Buch, dachte Alice, ohne Bilder oder Unterhaltungen? , 'a-christmas-carol': 'Marley war tot: Zunächst einmal.', 'Metamorphose': 'Eines Morgens, als Gregor Samsa aus unruhigen Träumen aufwachte, befand er sich in seinem Bett in ein schreckliches Ungeziefer verwandelt.' 'frankenstein': 'Sie werden sich freuen, zu hören, dass keine Katastrophe den Beginn eines Unternehmens begleitet hat, das Sie mit solchen bösen Vorahnungen betrachtet haben.' ' es-of-huckleberry-finn ':' SIE wissen nichts von mir, ohne dass Sie ein Buch mit dem Namen The Adventures of Tom Sawyer gelesen haben; Aber das ist egal. "," Abenteuer von Sherlock-Holmes ":" Für Sherlock Holmes ist sie immer die Frau. "," Erzählung des Lebens von Frederick-Douglass ":" Ich wurde in Tuckahoe, in der Nähe von Hillsborough, und etwa zwölf Meilen von Easton im Kreis Talbot in Maryland geboren. Der Prinz: Alle Staaten, alle Mächte, die die Herrschaft über die Männer ausgeübt haben, waren und sind entweder Republiken oder Fürstentümer. "," Abenteuer von Tom-Sawyer ":" TOM! " ;

app = express (); client = redis.createClient ();

filter = new Bloom.BloomFilter (client: client, Schlüssel: '3content-bloom-filter'), // die Größe der Redis-Schlüssel: 2875518, // ~ 350kb // Größe: 1024, numHashes: 20);

app.get ('/ show-content /: user', Funktion (req, res, next) // Wir durchlaufen die Inhalts-IDs und überprüfen, ob sie im Filter enthalten sind. // Seitdem Es wird keine Zeit für jede contentId aufgewendet, über eine große Anzahl von contentIds zu arbeiten // Aber in diesem Fall ist die Anzahl der contentIds klein / fest und unsere filter.contains-Funktion ist schnell, es ist in Ordnung. var // erstellt ein Array der Schlüssel, die in openingLines definiert sind contentIds = Object.keys (openingLines), // einen Teil des Pfads vom URI beziehen user = req.params.user, checkingContentId, found = false, done = false;

// Da filter.contains asynchron ist, verwenden wir die async-Bibliothek, um unsere Schleife async.whilst auszuführen (// check-Funktion, bei der unsere asynchrone Schleife endet. function () return (! found &&! done);, function (cb) // Abrufen des ersten Elements aus dem Array von contentIds checkingContentId = contentIds.shift ();

 // false bedeutet, dass wir sicher sind, dass es nicht im Filter ist if (! checkingContentId) done = true; // Dies wird von der Check-Funktion über cb ();  else // Verketten Sie den Benutzer (von der URL) mit der ID des Inhaltsfilters.contains (user + checkingContentId, function (err, results) if (err) cb (err); else found =! Ergebnisse; cb (););  function (err) if (err) next (err);  else if (openingLines [checkingContentId]) // Bevor wir die frische contentId senden, fügen wir sie dem Filter hinzu, um zu verhindern, dass filter.add (user + checkingContentId, function (err) if (err) erneut angezeigt wird). next (err); else // send the fresh quote res.send (openingLines [checkingContentId]););  else res.send ('kein neuer Inhalt!'); ); ); 

app.listen (8011); "

Wenn Sie die Entwicklungszeit in Dev Tools sorgfältig beachten, werden Sie feststellen, dass je länger Sie einen einzelnen Pfad mit einem Benutzernamen anfordern, desto länger dauert es. Während die Überprüfung des Filters eine feste Zeit in Anspruch nimmt, prüfen wir in diesem Beispiel, ob weitere Elemente vorhanden sind. Bloom-Filter haben nur eine begrenzte Anzahl an Informationen. Sie prüfen also, ob die einzelnen Elemente vorhanden sind. In unserem Beispiel ist das natürlich ziemlich einfach, aber das Testen auf Hunderte von Artikeln wäre ineffizient.

Veraltete Daten

In diesem Beispiel erstellen wir einen kleinen Express-Server, der zwei Aufgaben übernimmt: Neue Daten über POST annehmen und die aktuellen Daten (mit einer GET-Anforderung) anzeigen. Wenn die neuen Daten auf dem Server POST sind, prüft die Anwendung, ob sie im Filter vorhanden ist. Wenn es nicht vorhanden ist, fügen wir es einem Satz in Redis hinzu, andernfalls geben wir null zurück. Die GET-Anfrage holt sie von Redis ab und sendet sie an den Client.

Dies unterscheidet sich von den beiden vorherigen Situationen, da falsche Positive nicht in Ordnung wären. Wir werden den Bloom-Filter als erste Verteidigungslinie verwenden. In Anbetracht der Eigenschaften von Bloom-Filtern wissen wir nur mit Sicherheit, dass etwas nicht im Filter enthalten ist. In diesem Fall können wir die Daten eingeben. Wenn der Bloom-Filter zurückkehrt, befindet sich das wahrscheinlich im Filter Ich werde die tatsächliche Datenquelle überprüfen.

Was gewinnen wir also? Wir gewinnen die Geschwindigkeit, dass wir nicht jedes Mal die tatsächliche Quelle überprüfen müssen. In Situationen, in denen die Datenquelle langsam ist (externe APIs, Datenbankdateien, die Mitte einer flachen Datei), ist die Geschwindigkeitserhöhung wirklich erforderlich. Um die Geschwindigkeit zu demonstrieren, fügen wir in unserem Beispiel eine realistische Verzögerung von 150 ms hinzu. Wir werden auch die verwenden Konsole.Zeit / console.timeEnd um die Unterschiede zwischen einer Bloom-Filterprüfung und einer Nicht-Bloom-Filterprüfung zu protokollieren.

In diesem Beispiel werden wir auch eine extrem begrenzte Anzahl von Bits verwenden: nur 1024. Das wird sich schnell füllen. Während des Auffüllens werden immer mehr Fehlalarme angezeigt - Sie werden sehen, dass sich die Antwortzeit erhöht, wenn sich die Falsch-Positiv-Rate auffüllt.

Dieser Server verwendet die gleichen Module wie zuvor app.js Datei an:

"javascript var async = required ('async'), Bloom = required ('bloom-redis'), bodyParser = required ('body-parser'), express = required ('express'), redis = required ('redis') ),

App, Client, Filter,

currentDataKey = 'aktuelle Daten', usedDataKey = 'verwendete Daten';

app = express (); client = redis.createClient ();

filter = new Bloom.BloomFilter (client: client, Schlüssel: 'stale-bloom-filter'), // Dies ist ein sehr kleiner Filter. Er sollte etwa 500 Elemente enthalten, also für eine Produktionslast. Sie würden etwas viel Größeres brauchen! size: 1024, numHashes: 20);

app.post ('/', bodyParser.text (), function (req, res, next) verwendete Variable;

console.log ('POST -', req.body); // Protokolliere die aktuellen Daten, die gepostet werden console.time ('post'); // Beginnen Sie mit der Messung der Zeit, die der Abschluss des Filters und der bedingten Überprüfung benötigt. //async.series wird zum Verwalten mehrerer asynchroner Funktionsaufrufe verwendet. async.series ([function (cb) filter.contains (erforderliche Körper, function (err, filterStatus) if (err) cb (err); else used = filterStatus; cb (err); ;, Funktion (cb) if (verwendet === false) // Bloom-Filter haben keine falschen Negative, daher brauchen wir keine weitere Überprüfung. cb (null); else // es kann * im * sein filtern, also müssen wir eine Nachuntersuchung durchführen // für die Zwecke des Tutorials. Wir fügen hier eine Verzögerung von 150 ms hinzu, da Redis schnell genug sein kann, um die Messung zu erschweren, und die Verzögerung simuliert eine langsame Datenbank oder API-Aufruf setTimeout (function () console.log ('false false')); client.sismember (usedDataKey, req.body, function (err, member) if (err) cb (err); / sismember gibt 0 zurück, wenn ein Member nicht Teil der Gruppe ist, und 1, wenn dies der Fall ist. // Dies wandelt die Ergebnisse in Booleans um, damit konsistente Logikvergleiche verwendet werden. Zugehörigkeit === 0? false: true; cb (err); );, 150); Funktion (cb) if (verwendet === false) console.log ('Adding to filter'); filter.a dd (req.body, cb);  else console.log ('Filterzusatz übersprungen, [false] positiv'); cb (null); , Funktion (cb) if (verwendet === false) client.multi () .set (currentDataKey, req.body) // Die nicht verwendeten Daten werden für den einfachen Zugriff auf den Schlüssel "current-data" .sadd festgelegt (usedDataKey, req.body) // und zu einer Gruppe hinzugefügt, um sie später leichter überprüfen zu können .exec (cb);  else cb (null); ], function (err, cb) if (err) next (err);  else console.timeEnd ('post'); // protokolliert die Zeit seit dem Aufruf von console.time über res.send (gespeichert:! used); // gibt zurück, wenn das Element gespeichert wurde, true für frische Daten, false für veraltete Daten. ); ); 

app.get ('/', function (req, res, next) // gibt einfach die frischen Daten client.get (currentDataKey, function (err, data) if (err) next (err); else zurück res.send (data);););

app.listen (8012); "

Da das POST-Verfahren an einen Server mit einem Browser schwierig sein kann, verwenden wir zum Testen curl.

curl --data "Ihre Daten werden hier" --header "Content-Type: text / plain" http: // localhost: 8012 /

Ein schnelles Bash-Skript kann verwendet werden, um zu zeigen, wie der gesamte Filter ausgefüllt wird:

bash #! / bin / bash für i in 'seq 1 500'; curl --data "data $ i" --header "Content-Type: text / plain" http: // localhost: 8012 / done

Der Blick auf einen Füll- oder Vollfilter ist interessant. Da dieser klein ist, können Sie ihn leicht mit anzeigen Redis-Cli. Laufen redis-cli holt den Filter Vom Terminal aus zwischen dem Hinzufügen von Elementen sehen Sie, dass die einzelnen Bytes zunehmen. Ein voller Filter wird sein \ xff für jedes Byte Zu diesem Zeitpunkt ist der Filter immer positiv.

Fazit

Bloom-Filter sind keine Allheilmittel, aber in der richtigen Situation können Bloom-Filter andere Datenstrukturen schnell und effizient ergänzen.