Guía del hashing consistente

En los últimos años, con la llegada de conceptos como la computación en la nube y el big data, los sistemas distribuidos han ganado popularidad y relevancia.

Uno de estos tipos de sistemas, las cachés distribuidas que alimentan muchos sitios web dinámicos y aplicaciones web de alto tráfico, suelen consistir en un caso particular de hashing distribuido. Estos aprovechan un algoritmo conocido como hashing consistente.

¿Qué es el hashing consistente? ¿Cuál es la motivación detrás de él, y por qué debería importarte?

En este artículo, primero repasaré el concepto general de hashing y su propósito, seguido de una descripción del hashing distribuido y los problemas que conlleva. A su vez, eso nos llevará a nuestro tema del título.

¿Qué es el hashing?

¿De qué trata el «hashing»? Merriam-Webster define el sustantivo hash como «carne picada mezclada con patatas y dorada», y el verbo como «picar (como carne y patatas) en trozos pequeños». Así que, dejando a un lado los detalles culinarios, hash significa, a grandes rasgos, «picar y mezclar», y es precisamente de ahí de donde procede el término técnico.

Una función hash es una función que mapea una pieza de datos -que suele describir algún tipo de objeto, a menudo de tamaño arbitrario- a otra pieza de datos, normalmente un número entero, conocida como código hash, o simplemente hash.

Por ejemplo, una función de hash diseñada para hashear cadenas, con un rango de salida de 0 .. 100, puede asignar la cadena Hello a, digamos, el número 57, Hasta la vista, baby al número 33, y cualquier otra cadena posible a algún número dentro de ese rango. Dado que hay muchas más entradas que salidas posibles, cualquier número tendrá muchas cadenas diferentes asignadas a él, un fenómeno conocido como colisión. Las buenas funciones hash deberían de alguna manera «cortar y mezclar» (de ahí el término) los datos de entrada de tal manera que las salidas para los diferentes valores de entrada se distribuyan lo más uniformemente posible en el rango de salida.

Las funciones hash tienen muchos usos y para cada uno de ellos, se pueden desear diferentes propiedades. Hay un tipo de funciones hash conocidas como funciones hash criptográficas, que deben cumplir con un conjunto restrictivo de propiedades y se utilizan para fines de seguridad-incluyendo aplicaciones como la protección de contraseñas, la comprobación de la integridad y la huella digital de los mensajes, y la detección de la corrupción de datos, entre otros, pero esos están fuera del alcance de este artículo.

Las funciones hash no criptográficas también tienen varios usos, siendo el más común su uso en tablas hash, que es el que nos concierne y que exploraremos con más detalle.

Introducción a las Tablas Hash (Mapas Hash)

Imagina que necesitamos mantener una lista de todos los miembros de algún club a la vez que podemos buscar a cualquier miembro específico. Podríamos manejarlo manteniendo la lista en un array (o lista enlazada) y, para realizar una búsqueda, iterar los elementos hasta encontrar el deseado (podríamos estar buscando en base a su nombre, por ejemplo). En el peor de los casos, eso significaría comprobar todos los miembros (si el que buscamos es el último, o no está presente), o la mitad de ellos en promedio. En términos de teoría de la complejidad, la búsqueda tendría entonces una complejidad O(n), y sería razonablemente rápida para una lista pequeña, pero se volvería cada vez más lenta en proporción directa al número de miembros.

¿Cómo se podría mejorar eso? Supongamos que todos estos socios del club tuvieran un miembro ID, que resulta ser un número secuencial que refleja el orden en el que se unieron al club.

Suponiendo que la búsqueda por ID fuera aceptable, podríamos colocar todos los socios en una matriz, con sus índices coincidiendo con sus ID (por ejemplo, un socio con ID=10 estaría en el índice 10 de la matriz). Esto nos permitiría acceder a cada miembro directamente, sin necesidad de buscar. Eso sería muy eficiente, de hecho, tan eficiente como puede ser, lo que corresponde a la menor complejidad posible, O(1), también conocido como tiempo constante.

Pero, hay que admitir que nuestro escenario del miembro del club ID es algo artificioso. ¿Qué pasaría si los ID fueran números grandes, no secuenciales o aleatorios? ¿O si la búsqueda por ID no fuera aceptable y tuviéramos que buscar por nombre (o algún otro campo)? Sin duda, sería útil mantener nuestro acceso directo rápido (o algo parecido) y, al mismo tiempo, poder manejar conjuntos de datos arbitrarios y criterios de búsqueda menos restrictivos.

Aquí es donde las funciones hash vienen al rescate. Una función hash adecuada puede utilizarse para asignar un dato arbitrario a un entero, que desempeñará un papel similar al de nuestro miembro del club ID, aunque con algunas diferencias importantes.

En primer lugar, una buena función hash suele tener un amplio rango de salida (normalmente, todo el rango de un entero de 32 o 64 bits), por lo que construir una matriz para todos los índices posibles sería poco práctico o sencillamente imposible, y un derroche colosal de memoria. Para superar esto, podemos tener una matriz de tamaño razonable (digamos, sólo el doble del número de elementos que esperamos almacenar) y realizar una operación de módulo en el hash para obtener el índice de la matriz. Así, el índice sería index = hash(object) mod N, donde N es el tamaño del array.

En segundo lugar, los hashes de los objetos no serán únicos (a menos que trabajemos con un conjunto de datos fijo y una función hash perfecta hecha a medida, pero no hablaremos de eso aquí). Habrá colisiones (incrementadas por la operación modulo), y por lo tanto un simple acceso directo al índice no funcionará. Hay varias maneras de manejar esto, pero una típica es adjuntar una lista, comúnmente conocida como cubo, a cada índice del array para mantener todos los objetos que comparten un índice dado.

Así, tenemos un array de tamaño N, con cada entrada apuntando a un cubo de objetos. Para añadir un nuevo objeto, tenemos que calcular su hash modulo N, y comprobar el cubo en el índice resultante, añadiendo el objeto si no está ya allí. Para buscar un objeto, hacemos lo mismo, simplemente buscando en el cubo para comprobar si el objeto está allí. Una estructura de este tipo se denomina tabla hash, y aunque las búsquedas dentro de los cubos son lineales, una tabla hash de tamaño adecuado debería tener un número razonablemente pequeño de objetos por cubo, lo que resulta en un tiempo de acceso casi constante (una complejidad media de O(N/k), donde k es el número de cubos).

Con los objetos complejos, la función hash normalmente no se aplica a todo el objeto, sino a una clave en su lugar. En nuestro ejemplo del socio del club, cada objeto podría contener varios campos (como nombre, edad, dirección, correo electrónico, teléfono), pero podríamos elegir, por ejemplo, el correo electrónico para que actúe como clave, de modo que la función hash se aplicaría sólo al correo electrónico. De hecho, no es necesario que la clave forme parte del objeto; es habitual almacenar pares clave/valor, en los que la clave suele ser una cadena relativamente corta y el valor puede ser un dato arbitrario. En estos casos, la tabla de hash o el mapa de hash se utiliza como un diccionario, y esa es la forma en que algunos lenguajes de alto nivel implementan los objetos o las matrices asociativas.

Escalado: Hashing distribuido

Ahora que hemos hablado del hashing, estamos listos para ver el hashing distribuido.

En algunas situaciones, puede ser necesario o deseable dividir una tabla hash en varias partes, alojadas en diferentes servidores. Una de las principales motivaciones para esto es eludir las limitaciones de memoria del uso de un solo ordenador, permitiendo la construcción de tablas hash arbitrariamente grandes (dados suficientes servidores).

En tal escenario, los objetos (y sus claves) se distribuyen entre varios servidores, de ahí el nombre.

Un caso de uso típico para esto es la implementación de cachés en memoria, como Memcached.

Estas configuraciones consisten en un grupo de servidores de caché que alojan muchos pares clave/valor y se utilizan para proporcionar un acceso rápido a los datos originalmente almacenados (o calculados) en otro lugar. Por ejemplo, para reducir la carga de un servidor de base de datos y, al mismo tiempo, mejorar el rendimiento, se puede diseñar una aplicación para que, en primer lugar, obtenga los datos de los servidores de caché y, sólo si no están presentes allí -situación conocida como cache miss-, recurra a la base de datos, ejecutando la consulta pertinente y almacenando los resultados en la caché con una clave adecuada, de modo que se pueda encontrar la próxima vez que se necesite.

Ahora bien, ¿cómo se realiza la distribución? ¿Qué criterios se utilizan para determinar qué claves se alojan en qué servidores?

La forma más sencilla es tomar el módulo del hash del número de servidores. Es decir, server = hash(key) mod N, donde N es el tamaño del pool. Para almacenar o recuperar una clave, el cliente primero calcula el hash, aplica una operación modulo N y utiliza el índice resultante para contactar con el servidor apropiado (probablemente utilizando una tabla de búsqueda de direcciones IP). Tenga en cuenta que la función hash utilizada para la distribución de la clave debe ser la misma en todos los clientes, pero no tiene por qué ser la misma utilizada internamente por los servidores de caché.

Veamos un ejemplo. Digamos que tenemos tres servidores, A, B y C, y tenemos unas claves de cadena con sus hashes:

KEY HASH HASH mod 3
«john» 1633428562 2
«bill» 7594634739 0
«jane» 5000799124 1
«steve» 9787173343 0
«kate» 3421657995 2

Un cliente quiere recuperar el valor de la clave john. Su hash modulo 3 es 2, por lo que debe contactar con el servidor C. La clave no se encuentra allí, por lo que el cliente obtiene los datos de la fuente y los añade. El pool queda así:

A B C
«john»

A continuación otro cliente (o el mismo) quiere recuperar el valor de la clave bill. Su hash modulo 3 es 0, por lo que debe contactar con el servidor A. La clave no se encuentra allí, así que el cliente recupera los datos de la fuente y los añade. El pool se ve así ahora:

A B C
«bill» «john»

Después de añadir el resto de claves, el pool se ve así:

A B C
«bill» «jane» «john»
«steve» «kate»

El Problema del Rehacer

Este esquema de distribución es simple, intuitivo, y funciona bien. Eso es, hasta que el número de servidores cambia. ¿Qué ocurre si uno de los servidores se bloquea o deja de estar disponible? Hay que redistribuir las claves para tener en cuenta el servidor que falta, por supuesto. Lo mismo ocurre si se añaden uno o varios servidores nuevos al pool; hay que redistribuir las claves para incluir los nuevos servidores. Esto es cierto para cualquier esquema de distribución, pero el problema con nuestra simple distribución en módulo es que cuando el número de servidores cambia, la mayoría de los hashes modulo N cambiarán, por lo que la mayoría de las claves tendrán que ser trasladadas a un servidor diferente. Así que, aunque se elimine o se añada un solo servidor, es probable que todas las claves tengan que volver a ser trasladadas a un servidor diferente.

De nuestro ejemplo anterior, si eliminamos el servidor C, tendríamos que rehacer todas las claves usando hash modulo 2 en lugar de hash modulo 3, y las nuevas ubicaciones para las claves serían:

KEY HASH HASH mod 2
«john» 1633428562 0
«bill» 7594634739 1
«jane» 5000799124 0
«steve» 9787173343 1
«kate» 3421657995 1
A B
«john» «bill»
«jane» «steve»
«kate»

Nota que todas las ubicaciones de las teclas cambiaron, no sólo las del servidor C.

En el típico caso de uso que mencionamos antes (almacenamiento en caché), esto significaría que, de repente, las claves no se encontrarán porque todavía no estarán presentes en su nueva ubicación.

Así, la mayoría de las consultas resultarán en fallos, y los datos originales probablemente necesitarán ser recuperados de nuevo desde la fuente para ser rehechos, poniendo así una pesada carga en el servidor(es) de origen (normalmente una base de datos). Esto puede muy bien degradar el rendimiento severamente y posiblemente colapsar los servidores de origen.

La Solución: Hashing consistente

Entonces, ¿cómo se puede resolver este problema? Necesitamos un esquema de distribución que no dependa directamente del número de servidores, de forma que, al añadir o eliminar servidores, se minimice el número de claves que hay que reubicar. Uno de estos esquemas, inteligente pero sorprendentemente sencillo, se llama hashing consistente, y fue descrito por primera vez por Karger et al. en el MIT en un artículo académico de 1997 (según Wikipedia).

El hashing consistente es un esquema de hashing distribuido que funciona independientemente del número de servidores u objetos en una tabla de hash distribuida, asignándoles una posición en un círculo abstracto, o anillo de hash. Esto permite a los servidores y objetos escalar sin afectar al sistema en general.

Imagina que mapeamos el rango de salida del hash en el borde de un círculo. Eso significa que el valor mínimo posible de hash, cero, correspondería a un ángulo de cero, el valor máximo posible (algún gran entero que llamaremos INT_MAX) correspondería a un ángulo de 2𝝅 radianes (o 360 grados), y todos los demás valores de hash encajarían linealmente en algún punto intermedio. Así, podríamos tomar una clave, calcular su hash y averiguar dónde se encuentra en el borde del círculo. Asumiendo un INT_MAX de 1010 (por ejemplo), las claves de nuestro ejemplo anterior se verían así:

Ejemplo de Hashing Consistente: Claves

CLAVE HASH ANGULO (DEG)
«juan» 1633428562 58.8
«bill» 7594634739 273.4
«jane» 5000799124 180
«steve» 9787173343 352.3
«kate» 3421657995 123.2

Ahora imagina que también colocamos los servidores en el borde del círculo, asignándoles ángulos de forma pseudo-aleatoria también. Esto debe hacerse de forma repetible (o al menos de forma que todos los clientes estén de acuerdo con los ángulos de los servidores). Una manera conveniente de hacer esto es haciendo un hash del nombre del servidor (o dirección IP, o algún ID) -como haríamos con cualquier otra clave- para obtener su ángulo.

En nuestro ejemplo, las cosas podrían verse así:

Ejemplo de Hashing Consistente: Servidores

KEY HASH ANGLE (DEG)
«john» 1633428562 58.8
«bill» 7594634739 273.4
«jane» 5000799124 180
«steve» 9787173343 352.3
«kate» 3421657995 123.2
«A» 5572014558 200.6
«B» 8077113362 290,8
«C» 2269549488 81.7

Como tenemos las claves de los objetos y de los servidores en el mismo círculo, podemos definir una regla sencilla para asociar los primeros con los segundos: Cada clave de objeto pertenecerá al servidor cuya clave esté más cerca, en sentido contrario a las agujas del reloj (o a las agujas del reloj, según las convenciones utilizadas). Es decir, para saber a qué servidor pedir una clave determinada, debemos localizar la clave en el círculo y movernos en la dirección del ángulo ascendente hasta encontrar un servidor.

En nuestro ejemplo:

Ejemplo de Hashing Consistente: Objetos

KEY HASH ANGLE (DEG)
«john» 1633428562 58.7
«C» 2269549488 81.7
«kate» 3421657995 123.1
«jane» 5000799124 180
«A» 5572014557 200.5
«factura» 7594634739 273.4
«B» 8077113361 290,7
«steve» 787173343 352.3
KEY HASH ANGLE (DEG) LABEL SERVER
«john» 1632929716 58.7 «C» C
«kate» 3421831276 123.1 «A» A
«jane» 5000648311 180 «A» A
«bill» 7594873884 273.4 «B» B
«steve» 9786437450 352.3 «C» C

Desde el punto de vista de la programación, lo que haríamos es mantener una lista ordenada de los valores de los servidores (que podrían ser ángulos o números en cualquier intervalo real), y recorrer esta lista (o utilizar una búsqueda binaria) para encontrar el primer servidor con un valor mayor o igual al de la clave deseada. Si no se encuentra tal valor, tenemos que dar la vuelta, tomando el primero de la lista.

Para asegurar que las claves de los objetos se distribuyen uniformemente entre los servidores, tenemos que aplicar un sencillo truco: Asignar no una, sino muchas etiquetas (ángulos) a cada servidor. Así, en lugar de tener las etiquetas A, B y C, podríamos tener, por ejemplo, A0 .. A9, B0 .. B9 y C0 .. C9, todas intercaladas a lo largo del círculo. El factor por el que aumentar el número de etiquetas (claves del servidor), conocido como peso, depende de la situación (e incluso puede ser diferente para cada servidor) para ajustar la probabilidad de que las claves acaben en cada uno. Por ejemplo, si el servidor B fuera el doble de potente que el resto, se le podría asignar el doble de etiquetas y, como resultado, acabaría teniendo el doble de objetos (de media).

Para nuestro ejemplo supondremos que los tres servidores tienen un peso igual de 10 (esto funciona bien para tres servidores, para 10 a 50 servidores, un peso en el rango de 100 a 500 funcionaría mejor, y los pools más grandes pueden necesitar pesos aún mayores):

Ejemplo de Hashing de contenido 5

KEY HASH ANGLE (DEG)
«C6» 408965526 14.7
«A1» 473914830 17
«A2» 548798874 19.7
«A3» 1466730567 52.8
«C4» 1493080938 53,7
«john» 1633428562 58.7
«B2» 1808009038 65
«C0» 1982701318 71.3
«B3» 2058758486 74,1
«A7» 2162578920 77.8
«B4» 2660265921 95,7
«C9» 3359725419 120.9
«kate» 3421657995 123.1
«A5» 34972143 123.6
«C1» 3672205973 132.1
«C8» 3750588567 135
«B0» 4049028775 145.7
«B8» 4755525684 171.1
«A9» 4769549830 171.7
«jane» 5000799124 180
«C7» 5014097839 180.5
«B1» 5444659173 196
«A6» 6210502707 223.5
«A0» 6511384141 234.4
«B9» 7292819872 262.5
«C3» 7330467663 263.8
«C5» 7502566333 270
«bill» 7594634739 273.4
«A4» 8047401090 289.7
«C2» 8605012288 309.7
«A8» 8997397092 323.9
«B7» 9038880553 325.3
«B5» 93682254 337.2
«B6» 9379713761 337.6
«steve» 9787173343 352.3
KEY HASH ANGLE (DEG) LABEL SERVER
«john» 1632929716 58.7 «B2» B
«kate» 3421831276 123.1 «A5» A
«jane» 5000648311 180 «C7» C
«bill» 7594873884 273.4 «A4» A
«steve» 9786437450 352.3 «C6» C

Entonces, ¿cuál es el beneficio de toda esta aproximación al círculo? Imagina que el servidor C es eliminado. Para tener en cuenta esto, debemos eliminar las etiquetas C0 .. C9 del círculo. Esto hace que las claves de objeto antes adyacentes a las etiquetas eliminadas se etiqueten ahora aleatoriamente como Ax y Bx, reasignándolas a los servidores A y B.

¿Pero qué pasa con las otras claves de objeto, las que originalmente pertenecían a A y B? Nada. Esa es la gracia del asunto: La ausencia de etiquetas Cx no afecta a esas claves de ninguna manera. Así que, al eliminar un servidor, sus claves de objeto se reasignan aleatoriamente al resto de los servidores, dejando todas las demás claves intactas:

Ejemplo de Hashing Consistente 6

KEY HASH ANGLE (DEG)
«A1» 473914830 17
«A2» 548798874 19.7
«A3» 1466730567 52,8
«john» 1633428562 58.7
«B2» 1808009038 65
«B3» 2058758486 74.1
«A7» 2162578920 77,8
«B4» 2660265921 95.7
«kate» 3421657995 123.1
«A5» 34972143 123.6
«B0» 4049028775 145.7
«B8» 4755525684 171.1
«A9» 4769549830 171.7
«jane» 5000799124 180
«B1» 5444659173 196
«A6» 6210502707 223.5
«A0» 6511384141 234.4
«B9» 7292819872 262.5
«bill» 7594634739 273.4
«A4» 8047401090 289.7
«A8» 8997397092 323.9
«B7» 9038880553 325.3
«B5» 9368225254 337.2
«B6» 9379713761 337.6
«steve» 9787173343 352.3

KEY HASH ANGLE (DEG) LABEL SERVER
«john» 1632929716 58.7 «B2» B
«kate» 3421831276 123.1 «A5» A
«jane» 5000648311 180 «B1» B
«bill» 7594873884 273.4 «A4» A
«steve» 9786437450 352.3 «A1» A

Algo similar ocurre si, en lugar de eliminar un servidor, añadimos uno. Si quisiéramos añadir el servidor D a nuestro ejemplo (digamos, como sustituto de C), tendríamos que añadir las etiquetas D0 .. D9. El resultado sería que aproximadamente un tercio de las claves existentes (todas pertenecientes a A o B) se reasignarían a D, y, de nuevo, el resto se mantendría igual:

Ejemplo de Hashing Consistente 7

CLAVE HASH ANGULO (DEG)
«D2» 439890723 15.8
«A1» 473914830 17
«A2» 548798874 19.7
«D8» 796709216 28,6
«D1» 1008580939 36.3
«A3» 1466730567 52,8
«D5» 1587548309 57.1
«john» 1633428562 58.7
«B2» 1808009038 65
«B3» 2058758486 74.1
«A7» 2162578920 77,8
«B4» 2660265921 95.7
«D4» 2909395217 104.7
«kate» 3421657995 123.1
«A5» 3434972143 123.6
«D7» 3567129743 128.4
«B0» 4049028775 145.7
«B8» 4755525684 171.1
«A9» 4769549830 171.7
«jane» 5000799124 180
«B1» 5444659173 196
«D6» 5703092354 205.3
«A6» 6210502707 223,5
«A0» 6511384141 234.4
«B9» 7292819872 262.5
«bill» 7594634739 273.4
«A4» 8047401090 289.7
«D0» 8272587142 297.8
«A8» 8997397092 323.9
«B7» 9038880553 325.3
«D3» 9048608874 325.7
«D9» 9314459653 335.3
«B5» 9368225254 337.2
«B6» 9379713761 337.6
«steve» 9787173343 352.3
KEY HASH ANGLE (DEG) LABEL SERVER
«john» 1632929716 58.7 «B2» B
«kate» 3421831276 123.1 «A5» A
«jane» 5000648311 180 «B1» B
«bill» 7594873884 273.4 «A4» A
«steve» 9786437450 352.3 «D2» D

Así es como el hashing consistente resuelve el problema del refrito.

En general, sólo k/N claves necesitan ser remapeadas cuando k es el número de claves y N es el número de servidores (más específicamente, el máximo del número inicial y final de servidores).

Observamos que cuando se utiliza la caché distribuida para optimizar el rendimiento, puede ocurrir que el número de servidores de caché cambie (las razones de esto pueden ser la caída de un servidor, o la necesidad de añadir o eliminar un servidor para aumentar o disminuir la capacidad global). Al utilizar el hashing consistente para distribuir las claves entre los servidores, podemos estar seguros de que si eso sucede, el número de claves que se rehacen -y por lo tanto, el impacto en los servidores de origen- se reducirá al mínimo, evitando posibles tiempos de inactividad o problemas de rendimiento.

Hay clientes para varios sistemas, como Memcached y Redis, que incluyen soporte para el hashing consistente fuera de la caja.

Alternativamente, puede implementar el algoritmo usted mismo, en su lenguaje de elección, y que debe ser relativamente fácil una vez que se entiende el concepto.

Si la ciencia de datos le interesa, Toptal tiene algunos de los mejores artículos sobre el tema en el blog

Deja un comentario