Introducción al Hashing Consistente
Con el auge de las arquitecturas distribuidas, el hashing consistente se ha convertido en la corriente principal. Pero, ¿qué es exactamente y en qué se diferencia de un algoritmo hash estándar? ¿Cuáles son las motivaciones exactas detrás de él?
Primero, describiremos los conceptos principales. A continuación, profundizaremos en los algoritmos existentes para entender los retos asociados al hashing consistente.
Hashing
El hashing es el proceso de asignar datos de tamaño arbitrario a valores de tamaño fijo. Cada algoritmo existente tiene su propia especificación:
- MD5 produce valores hash de 128 bits.
- SHA-1 produce valores hash de 160 bits.
- etc.
Hashing tiene muchas aplicaciones en informática. Por ejemplo, una de estas aplicaciones se llama suma de comprobación. Para verificar la integridad de un conjunto de datos es posible utilizar un algoritmo de hash. Un servidor realiza el hash de un conjunto de datos e indica el valor del hash a un cliente. A continuación, el cliente realiza el hash de su versión del conjunto de datos y compara los valores hash. Si son iguales, la integridad debería ser verificada.
El «debería» aquí es importante. El peor escenario es cuando se produce una colisión. Una colisión es cuando dos piezas distintas de datos tienen el mismo valor hash. Tomemos un ejemplo de la vida real definiendo la siguiente función hash: dada una persona, devuelve la fecha de su cumpleaños (día & mes de nacimiento). La paradoja del cumpleaños nos dice que si sólo hay 23 personas en una sala, la probabilidad de que dos personas tengan el mismo cumpleaños (y por tanto una colisión) es superior al 50%. Por lo tanto, la función de cumpleaños probablemente no es una buena función de hashing.
Como introducción rápida al hashing es importante entender que la idea principal es repartir valores en un dominio. Por ejemplo:
- MD5 distribuye valores a través de un dominio de espacio de 128 bits
- Una tabla hash (o hashmap) respaldada por un array de 32 elementos tiene una función hash interna que distribuye valores a cualquier índice (de 0 a 31).
Distribución de la carga
La distribución de la carga puede definirse como el proceso de distribución de la carga a través de los nodos. El término nodo aquí es intercambiable con servidor o instancia. Es una unidad de computación.
El equilibrio de carga es un ejemplo de distribución de carga. Se trata de distribuir un conjunto de tareas sobre un conjunto de recursos. Por ejemplo, usamos el balanceo de carga para distribuir las peticiones de la API entre las instancias del servidor web.
Cuando se trata de datos, usamos más bien el término sharding. Un shard de base de datos es una partición horizontal de datos en una base de datos. Un ejemplo típico es una base de datos dividida en tres fragmentos donde cada fragmento tiene un subconjunto de los datos totales.
El equilibrio de carga y la fragmentación comparten algunos desafíos comunes. Repartir los datos uniformemente, por ejemplo, para garantizar que un nodo no esté sobrecargado en comparación con los demás. En algún contexto, el balanceo de carga y el sharding también necesitan seguir asociando tareas o datos al mismo nodo:
- Si necesitamos serializar, manejar una a una, las operaciones para un determinado consumidor, debemos enrutar la petición al mismo nodo.
- Si necesitamos distribuir datos, debemos saber qué shard es el propietario para una clave concreta.
¿Te suena familiar? En estos dos ejemplos, repartimos valores en un dominio. Ya sea una tarea repartida en los nodos del servidor o los datos repartidos en el fragmento de la base de datos, volvemos a encontrar la idea asociada al hashing. Esta es la razón por la que el hashing se puede utilizar junto con la distribución de la carga. Veamos cómo.
Mod-n Hashing
El principio de mod-n hashing es el siguiente. Cada clave se convierte en hash utilizando una función hash para transformar una entrada en un número entero. Luego, realizamos un módulo basado en el número de nodos.
Veamos un ejemplo concreto con 3 nodos. Aquí, necesitamos distribuir la carga entre estos nodos en base a un identificador de clave. Cada clave es hash y luego realizamos una operación de módulo:
La ventaja de este enfoque es su ausencia de estado. No tenemos que mantener ningún estado que nos recuerde que foo
fue enrutado al nodo 2. Sin embargo, necesitamos saber cuántos nodos están configurados para aplicar la operación de módulo.
Entonces, ¿cómo funciona el mecanismo en caso de escalar hacia fuera o hacia dentro (añadiendo o quitando nodos)? Si añadimos otro nodo, la operación de módulo se basa ahora en 4 en lugar de 3:
Como podemos ver, las claves foo
y baz
ya no están asociadas al mismo nodo. Con el hashing mod-n, no hay garantía de mantener ninguna consistencia en la asociación clave/nodo. ¿Es esto un problema? Puede ser.
¿Qué pasa si implementamos un datastore usando sharding y basado en la estrategia mod-n para distribuir los datos? Si escalamos el número de nodos, necesitamos realizar una operación de rebalanceo. En el ejemplo anterior, rebalancear significa:
- Mover
foo
del nodo 2 al nodo 0. - Mover
baz
del nodo 2 al nodo 3.
Ahora, ¿qué pasa si tuviéramos almacenados millones o incluso miles de millones de datos y que necesitáramos rebalancear casi todo? Como podemos imaginar, esto sería un proceso pesado. Por lo tanto, debemos cambiar nuestra técnica de distribución de la carga para asegurarnos de que al reequilibrar:
- La distribución siga siendo lo más uniforme posible en función del nuevo número de nodos.
- El número de claves que tenemos que migrar debe ser limitado. Idealmente, sería sólo 1/n por ciento de las claves donde n es el número de nodos.
Este es exactamente el propósito de los algoritmos hashing consistentes.
El término consistente podría ser algo confuso sin embargo. Conocí a ingenieros que daban por sentado que tales algoritmos seguían asociando una clave determinada al mismo nodo incluso ante la escalabilidad. Este no es el caso. Tiene que ser consistente hasta cierto punto para mantener la distribución uniforme.
Ahora, es el momento de profundizar en algunas soluciones.
Rendezvous
Rendezvous fue el primer algoritmo propuesto para resolver nuestro problema. Aunque el estudio original, publicado en 1996, no menciona el término hashing consistente, proporciona una solución a los retos que hemos descrito. Veamos una posible implementación en Go:
¿Cómo funciona? Recorremos cada nodo y calculamos su valor hash. El valor hash es devuelto por una función hash
que produce un entero basado en una clave (nuestra entrada) y un identificador de nodo (el enfoque más fácil es hacer un hash de la concatenación de las dos cadenas). A continuación, devolvemos el nodo con el valor hash más alto. Esta es la razón por la que el algoritmo también se conoce como hashing de peso aleatorio más alto.
El principal inconveniente de rendezvous es su complejidad de tiempo O(n) donde n es el número de nodos. Es muy eficiente si necesitamos tener un número limitado de nodos. Sin embargo, si empezamos a mantener miles de nodos, podría empezar a causar problemas de rendimiento.
Ring Consistent Hash
El siguiente algoritmo fue publicado en 1997 por Karger et al. en este documento. En este estudio se menciona por primera vez el término hashing consistente.
Se basa en un anillo (una matriz conectada de extremo a extremo). Aunque es el algoritmo de hashing consistente más popular (o al menos el más conocido), el principio no siempre se entiende bien. Vamos a sumergirnos en él.
La primera operación es crear el anillo. Un anillo tiene una longitud fija. En nuestro ejemplo, lo dividimos en 12 partes:
Leave a Reply