() translation by (you can also view the original English article)
En el caso de uso correcto, los filtros Bloom parecen mágicos. Esa es una afirmación audaz, pero en este tutorial exploraremos la curiosa estructura de datos, la mejor manera de usarla y algunos ejemplos prácticos usando Redis y Node.js.
Los filtros Bloom son una estructura de datos probabilística y unidireccional. La palabra 'filtro' puede ser confusa en este contexto; filter implica que es una cosa activa, un verbo, pero podría ser más fácil pensar en él como almacenamiento, un sustantivo. Con un simple filtro Bloom puedes hacer dos cosas:
- Agrega un artículo.
- Verifique si un artículo no ha sido agregado previamente.
Estas son limitaciones importantes que debe comprender: no puede eliminar un artículo ni puede enumerarlos en un filtro Bloom. Además, no puede decir con certeza si un elemento se ha agregado al filtro en el pasado. Aquí es donde entra la naturaleza probabilística de un filtro Bloom: son posibles falsos positivos, pero los falsos negativos no lo son. Si el filtro está configurado correctamente, los falsos positivos pueden ser extremadamente raros.
Existen variantes de filtros Bloom y agregan otras habilidades, como eliminación o escala, pero también agregan complejidad y limitaciones. Es importante comprender primero los filtros Bloom simples antes de pasar a las variantes. Este artículo solo cubrirá los filtros Bloom simples.
Con estas limitaciones, tiene una serie de beneficios: tamaño fijo, encriptación basada en hash y búsquedas rápidas.
Cuando configura un filtro Bloom, le da un tamaño. Este tamaño es fijo, por lo que si tiene un elemento o mil millones de elementos en el filtro, nunca crecerá más allá del tamaño especificado. A medida que agrega más elementos a su filtro, la posibilidad de un falso positivo aumenta. Si ha especificado un filtro más pequeño, esta tasa de falsos positivos aumentará más rápidamente que si tiene un tamaño más grande.
Los filtros Bloom se basan en el concepto de hash unidireccional. Al igual que el almacenamiento correcto de contraseñas, los filtros Bloom usan un algoritmo hash para determinar un identificador único para los elementos que se pasan a él. Los hash, por naturaleza, no se pueden revertir y están representados por una cadena de caracteres aparentemente aleatoria. Por lo tanto, si alguien obtiene acceso a un filtro Bloom, no revelará directamente ninguno de los contenidos.
Finalmente, los filtros Bloom son rápidos. La operación implica muchas menos comparaciones que otros métodos, y se puede almacenar fácilmente en la memoria, evitando hits de bases de datos que roban el rendimiento.
Ahora que conoce los límites y las ventajas de los filtros Bloom, echemos un vistazo a algunas situaciones donde puede usarlos.
Preparacion
Utilizaremos Redis y Node.js para ilustrar los filtros Bloom. Redis es un medio de almacenamiento para su filtro Bloom; es rápido, en memoria y tiene algunos comandos específicos (GETBIT
, SETBIT
) que hacen que la implementación sea eficiente. Asumiré que tienes Node.js, npm y Redis instalados en tu sistema. Su servidor Redis debería ejecutarse en localhost
en el puerto predeterminado para que nuestros ejemplos funcionen.
En este tutorial, no implementaremos un filtro desde cero; en su lugar, nos centraremos en usos prácticos con un módulo preconstruido en npm: bloom-redis. bloom-redis tiene un conjunto de métodos muy concisos: add
, contains
y clear.
Como se mencionó anteriormente, los filtros Bloom necesitan un algoritmo hash para los identificadores únicos generados para un artículo. bloom-redis usa el conocido algoritmo MD5, que, aunque quizás no sea el ajuste perfecto para un filtro Bloom (un poco lento, excesivo en bits), funcionará bien.
Nombres de usuario únicos
Los nombres de usuario, especialmente aquellos que identifican a un usuario en una URL, deben ser únicos. Si construye una aplicación que permite a los usuarios cambiar el nombre de usuario, entonces probablemente querrá un nombre de usuario que nunca se haya utilizado para evitar confusiones y ataques de nombres de usuario.
Sin un filtro Bloom, necesitaría hacer referencia a una tabla que tenga todos los nombres de usuario utilizados, y a gran escala esto puede ser muy costoso. Los filtros Bloom le permiten agregar un artículo cada vez que un usuario adopta un nuevo nombre. Cuando un usuario verifica si se toma un nombre de usuario, todo lo que necesita hacer es verificar el filtro Bloom. Podrá decirle, con absoluta certeza, si el nombre de usuario solicitado ha sido agregado previamente. Es posible que el filtro devuelva falsamente que se ha utilizado un nombre de usuario cuando no lo ha hecho, pero esto es obvio por precaución y no puede causar ningún daño real (aparte de que un usuario no pueda reclamar 'k3w1d00d47') .
Para ilustrar esto, construyamos un servidor REST rápido con Express. Primero, cree su archivo package.json
y luego ejecute los siguientes comandos de terminal.
npm install bloom-redis --save
npm install express --save
npm install redis --save
Las opciones predeterminadas para bloom-redis tienen el tamaño establecido en dos megabytes. Esto se equivoca por precaución, pero es bastante grande. Configurar el tamaño del filtro Bloom es fundamental: demasiado grande y desperdiciará memoria, demasiado pequeño y su tasa de falsos positivos será demasiado alta. La matemática involucrada en determinar el tamaño es bastante complicada y está más allá del alcance de este tutorial, pero afortunadamente hay una calculadora de tamaño de filtro Bloom para hacer el trabajo sin descifrar un libro de texto.
Ahora, crea tu app.js
de la siguiente manera:
1 |
var
|
2 |
Bloom = require('bloom-redis'), |
3 |
express = require('express'), |
4 |
redis = require('redis'), |
5 |
|
6 |
app, |
7 |
client, |
8 |
filter; |
9 |
|
10 |
//setup our Express server
|
11 |
app = express(); |
12 |
|
13 |
//create the connection to Redis
|
14 |
client = redis.createClient(); |
15 |
|
16 |
|
17 |
filter = new Bloom.BloomFilter({ |
18 |
client : client, //make sure the Bloom module uses our newly created connection to Redis |
19 |
key : 'username-bloom-filter', //the Redis key |
20 |
|
21 |
//calculated size of the Bloom filter.
|
22 |
//This is where your size / probability trade-offs are made
|
23 |
//http://hur.st/bloomfilter?n=100000&p=1.0E-6
|
24 |
size : 2875518, // ~350kb |
25 |
numHashes : 20 |
26 |
});
|
27 |
|
28 |
app.get('/check', function(req,res,next) { |
29 |
//check to make sure the query string has 'username'
|
30 |
if (typeof req.query.username === 'undefined') { |
31 |
//skip this route, go to the next one - will result in a 404 / not found
|
32 |
next('route'); |
33 |
} else { |
34 |
filter.contains( |
35 |
req.query.username, // the username from the query string |
36 |
function(err, result) { |
37 |
if (err) { |
38 |
next(err); //if an error is encountered, send it to the client |
39 |
} else { |
40 |
res.send({ |
41 |
username : req.query.username, |
42 |
//if the result is false, then we know the item has *not* been used
|
43 |
//if the result is true, then we can assume that the item has been used
|
44 |
status : result ? 'used' : 'free' |
45 |
});
|
46 |
}
|
47 |
}
|
48 |
);
|
49 |
}
|
50 |
});
|
51 |
|
52 |
|
53 |
app.get('/save',function(req,res,next) { |
54 |
if (typeof req.query.username === 'undefined') { |
55 |
next('route'); |
56 |
} else { |
57 |
//first, we need to make sure that it's not yet in the filter
|
58 |
filter.contains(req.query.username, function(err, result) { |
59 |
if (err) { next(err); } else { |
60 |
if (result) { |
61 |
//true result means it already exists, so tell the user
|
62 |
res.send({ username : req.query.username, status : 'not-created' }); |
63 |
} else { |
64 |
//we'll add the username passed in the query string to the filter
|
65 |
filter.add( |
66 |
req.query.username, |
67 |
function(err) { |
68 |
//The callback arguments to `add` provides no useful information, so we'll just check to make sure that no error was passed
|
69 |
if (err) { next(err); } else { |
70 |
res.send({ |
71 |
username : req.query.username, status : 'created' |
72 |
});
|
73 |
}
|
74 |
}
|
75 |
);
|
76 |
}
|
77 |
}
|
78 |
});
|
79 |
}
|
80 |
});
|
81 |
|
82 |
app.listen(8010); |
Para ejecutar este servidor: node app.js
. Vaya a su navegador y apúntelo a: https://localhost:8010/check?username=kyle
. La respuesta debería ser: {"username":"kyle","status":"free"}
.
Ahora, guardemos ese nombre de usuario apuntando su navegador a http://localhost:8010/save?username=kyle
. La respuesta será: {"username":"kyle","status":"created"}
. Si vuelve a
la dirección http://localhost:8010/check?username=kyle
, la
respuesta será {"username":"kyle","status":"used"}
. Del
mismo modo, volver a http://localhost:8010/save?username=kyle
resultará en {"username":"kyle","status":"not-created"}
.
Desde la terminal, puede ver el tamaño del filtro: redis-cli strlen username-bloom-filter
.
En este momento, con un elemento, debe mostrar 338622
.
Ahora, continúe e intente agregar más nombres de usuario con la ruta /save
. Prueba todos los que quieras.
Si luego verifica el tamaño de nuevo, puede notar que su tamaño ha aumentado levemente, pero no para cada adición. Curioso, ¿verdad? Internamente, un filtro Bloom establece bits individuales (1's / 0) en diferentes posiciones en la cadena guardada en username-bloom. Sin embargo, estos no son contiguos, por lo que si establece un bit en el índice 0 y luego uno en el índice 10.000, todo lo que haya entre ellos será 0. Para usos prácticos, inicialmente no es importante comprender la mecánica precisa de cada operación, solo sepa que esto es normal y que su almacenamiento en Redis nunca excederá el valor que usted especificó.
Contenido fresco
El contenido fresco en un sitio web hace que un usuario regrese, entonces ¿cómo se le muestra a un usuario algo nuevo cada vez? Utilizando un enfoque de base de datos tradicional, podría agregar una nueva fila a una tabla con el identificador de usuario y el identificador de la historia, y luego consultaría esa tabla cuando decidiera mostrar una parte del contenido. Como se puede imaginar, su base de datos crecerá extremadamente rápido, especialmente con el crecimiento tanto de usuarios como de contenido.
En este caso, un falso negativo (por ejemplo, que no muestre una porción de contenido invisible) tiene muy pocas consecuencias, haciendo que los filtros Bloom sean una opción viable. A primera vista, puede pensar que necesita un filtro Bloom para cada usuario, pero usaremos una concatenación simple del identificador de usuario y el identificador de contenido, y luego insertaremos esa cadena en nuestro filtro. De esta forma, podemos usar un solo filtro para todos los usuarios.
En este ejemplo, construyamos otro servidor Express
básico que muestre contenido. Cada
vez que visita la ruta /show-content/any-username
(con cualquier
nombre de usuario como valor seguro de URL), se mostrará una nueva pieza
de contenido hasta que el sitio se quede sin contenido. En el ejemplo,
el contenido es la primera línea de los diez mejores libros del Proyecto
Gutenberg.
Tendremos que instalar un módulo npm más. Desde la terminal,
ejecuta: npm install async --save
Su nuevo archivo app.js:
1 |
var
|
2 |
async = require('async'), |
3 |
Bloom = require('bloom-redis'), |
4 |
express = require('express'), |
5 |
redis = require('redis'), |
6 |
|
7 |
app, |
8 |
client, |
9 |
filter, |
10 |
|
11 |
// From Project Gutenberg - opening lines of the top 10 public domain ebooks
|
12 |
// https://www.gutenberg.org/browse/scores/top
|
13 |
openingLines = { |
14 |
'pride-and-prejudice' : |
15 |
'It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.', |
16 |
'alices-adventures-in-wonderland' : |
17 |
'Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it, \'and what is the use of a book,\' thought Alice \'without pictures or conversations?\'', |
18 |
'a-christmas-carol' : |
19 |
'Marley was dead: to begin with.', |
20 |
'metamorphosis' : |
21 |
'One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin.', |
22 |
'frankenstein' : |
23 |
'You will rejoice to hear that no disaster has accompanied the commencement of an enterprise which you have regarded with such evil forebodings.', |
24 |
'adventures-of-huckleberry-finn' : |
25 |
'YOU don\'t know about me without you have read a book by the name of The Adventures of Tom Sawyer; but that ain\'t no matter.', |
26 |
'adventures-of-sherlock-holmes' : |
27 |
'To Sherlock Holmes she is always the woman.', |
28 |
'narrative-of-the-life-of-frederick-douglass' : |
29 |
'I was born in Tuckahoe, near Hillsborough, and about twelve miles from Easton, in Talbot county, Maryland.', |
30 |
'the-prince' : |
31 |
'All states, all powers, that have held and hold rule over men have been and are either republics or principalities.', |
32 |
'adventures-of-tom-sawyer' : |
33 |
'TOM!' |
34 |
};
|
35 |
|
36 |
|
37 |
app = express(); |
38 |
client = redis.createClient(); |
39 |
|
40 |
filter = new Bloom.BloomFilter({ |
41 |
client : client, |
42 |
key : '3content-bloom-filter', //the Redis key |
43 |
size : 2875518, // ~350kb |
44 |
//size : 1024,
|
45 |
numHashes : 20 |
46 |
});
|
47 |
|
48 |
app.get('/show-content/:user', function(req,res,next) { |
49 |
//we're going to be looping through the contentIds, checking to see if they are in the filter.
|
50 |
//Since this spends time on each contentId wouldn't be advisable to do over a high number of contentIds
|
51 |
//But, in this case the number of contentIds is small / fixed and our filter.contains function is fast, it is okay.
|
52 |
var
|
53 |
//creates an array of the keys defined in openingLines
|
54 |
contentIds = Object.keys(openingLines), |
55 |
//getting part of the path from the URI
|
56 |
user = req.params.user, |
57 |
checkingContentId, |
58 |
found = false, |
59 |
done = false; |
60 |
|
61 |
//since filter.contains is asynchronous, we're using the async library to do our looping
|
62 |
async.whilst( |
63 |
//check function, where our asynchronous loop will end
|
64 |
function () { return (!found && !done); }, |
65 |
function(cb) { |
66 |
//get the first item from the array of contentIds
|
67 |
checkingContentId = contentIds.shift(); |
68 |
|
69 |
//false means we're sure that it isn't in the filter
|
70 |
if (!checkingContentId) { |
71 |
done = true; // this will be caught by the check function above |
72 |
cb(); |
73 |
} else { |
74 |
//concatenate the user (from the URL) with the id of the content
|
75 |
filter.contains(user+checkingContentId, function(err, results) { |
76 |
if (err) { cb(err); } else { |
77 |
found = !results; |
78 |
cb(); |
79 |
}
|
80 |
});
|
81 |
}
|
82 |
},
|
83 |
function(err) { |
84 |
if (err) { next(err); } else { |
85 |
if (openingLines[checkingContentId]) { |
86 |
//before we send the fresh contentId, let's add it to the filter to prevent it from showing again
|
87 |
filter.add( |
88 |
user+checkingContentId, |
89 |
function(err) { |
90 |
if (err) { next(err); } else { |
91 |
//send the fresh quote
|
92 |
res.send(openingLines[checkingContentId]); |
93 |
}
|
94 |
}
|
95 |
);
|
96 |
} else { |
97 |
res.send('no new content!'); |
98 |
}
|
99 |
}
|
100 |
}
|
101 |
);
|
102 |
});
|
103 |
|
104 |
app.listen(8011); |
Si prestas atención cuidadosamente al tiempo de ida y vuelta en Dev Tools, notarás que cuanto más solicites una ruta única con un nombre de usuario, más tiempo tomará. Si bien el control del filtro lleva un tiempo fijo, en este ejemplo, estamos verificando la presencia de más elementos. Los filtros Bloom están limitados en cuanto a lo que le pueden decir, por lo que está probando la presencia de cada elemento. Por supuesto, en nuestro ejemplo es bastante simple, pero probar cientos de elementos sería ineficiente.
Datos obsoletos
En este ejemplo, construiremos un pequeño servidor Express que hará dos cosas: aceptar datos nuevos a través de POST y visualizar los datos actuales (con una solicitud GET). Cuando los datos nuevos se envían por POST al servidor, la aplicación verificará su presencia en el filtro. Si no está presente, lo agregaremos a un conjunto en Redis, de lo contrario, devolveremos nulo. La solicitud GET la obtendrá de Redis y la enviará al cliente.
Esto es diferente a las dos situaciones anteriores, en que los falsos positivos no estarían bien. Utilizaremos el filtro Bloom como primera línea de defensa. Dadas las propiedades de los filtros Bloom, solo sabremos con certeza que algo no está en el filtro, por lo que en este caso podemos continuar y dejar entrar los datos. Si el filtro Bloom regresa probablemente en el filtro, Haremos un control en comparación con la fuente de datos real.
Entonces, ¿qué ganamos? Ganamos la velocidad de no tener que
verificar contra la fuente real cada vez. En
situaciones en las que la fuente de datos es lenta (API externas, bases
de datos pokey, el centro de un archivo plano), el aumento de velocidad
es realmente necesario. Para demostrar la velocidad, agreguemos un retraso realista de 150 ms en nuestro ejemplo. También usaremos console.time
/ console.timeEnd
para registrar las
diferencias entre una comprobación de filtro Bloom y una comprobación de filtro no Bloom.
En este ejemplo, también usaremos un número extremadamente limitado de bits: solo 1024. Se llenará rápidamente. A medida que se llena, mostrará más y más falsos positivos: verá que el tiempo de respuesta aumenta a medida que se llena la tasa de falsos positivos.
Este servidor utiliza los mismos módulos que antes, por lo que
debe establecer el archivo app.js
en:
1 |
var
|
2 |
async = require('async'), |
3 |
Bloom = require('bloom-redis'), |
4 |
bodyParser = require('body-parser'), |
5 |
express = require('express'), |
6 |
redis = require('redis'), |
7 |
|
8 |
app, |
9 |
client, |
10 |
filter, |
11 |
|
12 |
currentDataKey = 'current-data', |
13 |
usedDataKey = 'used-data'; |
14 |
|
15 |
app = express(); |
16 |
client = redis.createClient(); |
17 |
|
18 |
filter = new Bloom.BloomFilter({ |
19 |
client : client, |
20 |
key : 'stale-bloom-filter', |
21 |
//for illustration purposes, this is a super small filter. It should fill up at around 500 items, so for a production load, you'd need something much larger!
|
22 |
size : 1024, |
23 |
numHashes : 20 |
24 |
});
|
25 |
|
26 |
app.post( |
27 |
'/', |
28 |
bodyParser.text(), |
29 |
function(req,res,next) { |
30 |
var
|
31 |
used; |
32 |
|
33 |
console.log('POST -', req.body); //log the current data being posted |
34 |
console.time('post'); //start measuring the time it takes to complete our filter and conditional verification process |
35 |
|
36 |
//async.series is used to manage multiple asynchronous function calls.
|
37 |
async.series([ |
38 |
function(cb) { |
39 |
filter.contains(req.body, function(err,filterStatus) { |
40 |
if (err) { cb(err); } else { |
41 |
used = filterStatus; |
42 |
cb(err); |
43 |
}
|
44 |
});
|
45 |
},
|
46 |
function(cb) { |
47 |
if (used === false) { |
48 |
//Bloom filters do not have false negatives, so we need no further verification
|
49 |
cb(null); |
50 |
} else { |
51 |
//it *may* be in the filter, so we need to do a follow up check
|
52 |
//for the purposes of the tutorial, we'll add a 150ms delay in here since Redis can be fast enough to make it difficult to measure and the delay will simulate a slow database or API call
|
53 |
setTimeout(function() { |
54 |
console.log('possible false positive'); |
55 |
client.sismember(usedDataKey, req.body, function(err, membership) { |
56 |
if (err) { cb(err); } else { |
57 |
//sismember returns 0 if an member is not part of the set and 1 if it is.
|
58 |
//This transforms those results into booleans for consistent logic comparison
|
59 |
used = membership === 0 ? false : true; |
60 |
cb(err); |
61 |
}
|
62 |
});
|
63 |
}, 150); |
64 |
}
|
65 |
},
|
66 |
function(cb) { |
67 |
if (used === false) { |
68 |
console.log('Adding to filter'); |
69 |
filter.add(req.body,cb); |
70 |
} else { |
71 |
console.log('Skipped filter addition, [false] positive'); |
72 |
cb(null); |
73 |
}
|
74 |
},
|
75 |
function(cb) { |
76 |
if (used === false) { |
77 |
client.multi() |
78 |
.set(currentDataKey,req.body) //unused data is set for easy access to the 'current-data' key |
79 |
.sadd(usedDataKey,req.body) //and added to a set for easy verification later |
80 |
.exec(cb); |
81 |
} else { |
82 |
cb(null); |
83 |
}
|
84 |
}
|
85 |
],
|
86 |
function(err, cb) { |
87 |
if (err) { next(err); } else { |
88 |
console.timeEnd('post'); //logs the amount of time since the console.time call above |
89 |
res.send({ saved : !used }); //returns if the item was saved, true for fresh data, false for stale data. |
90 |
}
|
91 |
}
|
92 |
);
|
93 |
});
|
94 |
|
95 |
app.get('/',function(req,res,next) { |
96 |
//just return the fresh data
|
97 |
client.get(currentDataKey, function(err,data) { |
98 |
if (err) { next(err); } else { |
99 |
res.send(data); |
100 |
}
|
101 |
});
|
102 |
});
|
103 |
|
104 |
app.listen(8012); |
Dado que POSTing a un servidor puede ser complicado con un navegador, usemos curl para probar.
curl --data “your data goes here" --header "Content-Type: text/plain" http://localhost:8012/
Se puede usar un script bash rápido para mostrar cómo se ve el llenado de todo el filtro:
1 |
#!/bin/bash
|
2 |
for i in `seq 1 500`; |
3 |
do
|
4 |
curl --data “data $i" --header "Content-Type: text/plain" http://localhost:8012/ |
5 |
done
|
Ver un relleno o un filtro completo es interesante. Como este es pequeño, puedes verlo fácilmente con redis-cli
. Al ejecutar redis-cli get stale-filter
desde el terminal entre la adición de elementos, verá que los bytes individuales aumentan. Un filtro completo será \xff
para cada byte. En este punto, el filtro siempre regresará positivo.
Conclusión
Los filtros Bloom no son una solución panacea, pero en la situación correcta, un filtro Bloom puede proporcionar un complemento rápido y eficiente a otras estructuras de datos.