Módulo 5: Escalar la base de datos

5. Sharding y la clave de shard

Descripción

Al terminar esta lección vas a entender la segunda herramienta de escalado horizontal y la que ataca el eje que las réplicas no pueden: el sharding, partir los datos en trozos (shards) y guardar cada trozo en una máquina distinta. Vas a ver por qué se shardea —cuando ni los ~6 TB ni las escrituras de Enlace caben en una sola máquina—, qué es la clave de shard (el campo que decide en qué shard vive cada registro), por qué en Enlace esa clave es el short_code, y la distinción central entre particionar por rango (range) y por hash. Y vas a ver, ejecutando código, el fenómeno que hace o deshace un plan de sharding: los hotspots, cuando una mala clave concentra casi toda la carga en un solo shard mientras los demás quedan ociosos.

Esto importa porque el sharding es la decisión más difícil de revertir de todo el escalado. Agregar una réplica es reversible y barato; elegir mal la clave de shard te condena a un sistema donde un nodo arde mientras los otros duermen, y rebalancearlo después —con los datos ya repartidos y el tráfico en vivo— es una de las operaciones más dolorosas que existen. La clave de shard es una de esas decisiones que, tomada bien al principio, no vuelves a pensar en años, y tomada mal, te persigue todo ese tiempo. Esta lección te enseña a tomarla con criterio, y prepara el terreno para las dos que la rematan: cómo repartir por hash sin la trampa de mod-N (lección 6) y cómo consistent hashing la desactiva (lección 7).

Conexión con el módulo: esta lección cruza del eje de las lecturas (réplicas, lecciones 3-4) al eje de los datos y las escrituras (sharding). Es el que resuelve los límites 1 y 3 de la lección 2 —almacenamiento y throughput de escritura—, los que las réplicas dejaban intactos. Y es la primera mitad de un arco de tres lecciones: aquí estableces qué es shardear y por qué la clave importa; la lección 6 muestra por qué la forma obvia de repartir por hash (hash(key) % N) es una trampa al crecer; y la 7 la resuelve con consistent hashing. Piensa en estas tres lecciones como una sola historia contada en tres actos, y este es el planteamiento.

La biblioteca que ya no cabe en un edificio

Piénsalo así. Una biblioteca crece hasta que sus libros ya no caben en un solo edificio. La solución no es un edificio más grande sin fin —eso es escalado vertical, y tiene techo—: es repartir la colección en varias sedes. Cada sede guarda una parte de los libros; entre todas, la colección completa. Ahora la biblioteca puede crecer agregando sedes, y cada sede atiende a sus propios visitantes en paralelo.

Pero surge de inmediato la pregunta que decide todo: ¿según qué criterio repartes los libros entre sedes? Es la clave de shard, y no es un detalle. Mira dos criterios:

Criterio malo — por fecha de adquisición. La sede 1 guarda los libros más antiguos, la sede 4 los más nuevos. Suena ordenado, pero tiene un defecto fatal: todos los libros nuevos que llegan cada día van a la misma sede —la de "lo más reciente"—. Esa sede vive desbordada de trabajo mientras las otras tres, con los libros viejos que casi nadie toca, están ociosas. Concentraste el trabajo en un punto. Eso es un hotspot.

Criterio bueno — por un código que reparte parejo. Asignas cada libro a una sede con una regla que no correlaciona con cuándo llegó ni con qué tan popular es —por ejemplo, un hash de su código de catálogo—. Los libros nuevos de hoy se reparten entre las cuatro sedes por igual, y también los viejos y los populares. Ninguna sede se desborda; el trabajo se distribuye. No hay hotspot.

La lección de la biblioteca es la lección del sharding: repartir es fácil; repartir parejo es el arte. El criterio de reparto —la clave de shard— decide si tus máquinas comparten la carga o si una arde mientras las demás duermen. En Enlace, la pregunta es idéntica: ¿según qué campo del registro Link decides en qué shard vive cada enlace?

Qué es la clave de shard, y por qué en Enlace es el short_code

La clave de shard (shard key, o partition key) es el campo del registro cuyo valor decide en qué shard vive ese registro. Es una función: shard = f(shard_key). Toda operación que conozca la clave puede ir directo al shard correcto sin preguntarle a los demás; toda operación que no la conozca tiene que preguntarle a todos —una consulta que "cruza shards", lenta y a evitar—.

En Enlace, la operación que domina es resolve(short_code) -> long_url: llega un short_code y hay que encontrar su long_url. Si eliges el short_code como clave de shard, entonces resolve sabe exactamente a qué shard ir —calcula f(short_code) y consulta solo ese shard—. Es una elección casi perfecta para Enlace por tres razones:

  1. La lectura dominante la conoce. resolve siempre trae el short_code en la mano; nunca tiene que consultar todos los shards. Cada resolución toca exactamente un shard.
  2. Es de alta cardinalidad y única. Hay 62⁷ ≈ 3.5 billones de short_code posibles, todos distintos; una clave con muchísimos valores distintos se reparte fina, sin grumos.
  3. No correlaciona con la carga. Un short_code no dice si el enlace será popular ni cuándo se creó (si lo hasheas), así que repartir por él no concentra la carga —a diferencia de repartir por fecha—.

¿Y la escritura? shorten crea un Link con un short_code nuevo; también conoce la clave, así que sabe a qué shard escribir. Los dos caminos —lectura y escritura— conocen el short_code, y por eso es la clave natural de Enlace.

Por rango contra por hash

Elegida la clave, falta decidir cómo la clave se traduce a un shard. Hay dos familias, con tradeoffs opuestos.

Particionar por rango (range). Asignas rangos contiguos de la clave a cada shard: shard 0 guarda los short_code de 0000000 a Fffffff, shard 1 de G a V, y así. Ventaja: las consultas de rango son eficientes ("dame todos los códigos entre X e Y" tocan pocos shards contiguos). Desventaja fatal para claves secuenciales: si los short_code se generan con un contador creciente (como vimos en el módulo 3), todos los códigos nuevos caen en el rango más alto, o sea en el mismo shard —un hotspot de escritura—. Los códigos frescos son los que reciben las escrituras y las primeras lecturas; concentrarlos en un shard mata el propósito de shardear.

Particionar por hash. Aplicas una función hash a la clave y el resultado decide el shard: shard = hash(short_code) % N (o consistent hashing, que es a dónde vamos). Ventaja: el hash destruye el orden y la correlación; códigos consecutivos, o creados el mismo día, se dispersan por igual entre todos los shards. No hay hotspot por secuencia. Desventaja: pierdes las consultas de rango eficientes (un rango de códigos queda esparcido por todos los shards). Para Enlace no es pérdida —nadie pide "todos los códigos entre X e Y"; la operación es resolve de un código puntual—, así que por hash es la elección de Enlace.

Ejemplo trabajado: el hotspot, medido

Veamos el hotspot con números, no con palabras. Tomamos un lote de 100 short_code nuevos, generados por un contador consecutivo (como en el módulo 3), y los repartimos entre 4 shards de las dos maneras: por rango del código y por hash del código.

import hashlib
from collections import Counter

N = 4  # 4 shards

def make_sequential_codes(k, start):
    """short_codes de un contador -> base62. Consecutivos, como en el modulo 3."""
    alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    out = []
    for i in range(k):
        num, s = start + i, ""
        while num:
            s, num = alphabet[num % 62] + s, num // 62
        out.append(s.rjust(7, "0"))
    return out

new_batch = make_sequential_codes(100, start=500_000_000)  # 100 codigos recien creados

def shard_by_range(code):        # rango por el primer caracter del codigo
    first = code[0]
    if first <= 'F': return 0
    if first <= 'V': return 1
    if first <= 'l': return 2
    return 3

def shard_by_hash(code):         # hash del codigo completo
    return int(hashlib.md5(code.encode()).hexdigest(), 16) % N

range_dist = Counter(shard_by_range(c) for c in new_batch)
hash_dist = Counter(shard_by_hash(c) for c in new_batch)

print("A) por RANGO del codigo:", [range_dist.get(s, 0) for s in range(N)])
print("B) por HASH del codigo: ", [hash_dist.get(s, 0) for s in range(N)])

Qué esperar. Al correrlo:

A) por RANGO del codigo: [100, 0, 0, 0]
B) por HASH del codigo:  [27, 27, 28, 18]

Ahí está el hotspot, medido y sin ambigüedad. Por rango, las 100 escrituras nuevas caen en el shard 0 y los otros tres reciben cero —porque los códigos consecutivos comparten el mismo prefijo y caen en el mismo rango—. Ese shard 0 recibiría todo el tráfico de creación mientras los demás duermen: escalaste a 4 máquinas y una hace el 100% del trabajo. Por hash, las mismas 100 escrituras se reparten 27/27/28/18 —casi parejo—, porque el hash rompe la correlación entre "código nuevo" y "mismo shard". Cuatro máquinas compartiendo la carga, que es el punto entero de shardear.

Esta es la razón por la que Enlace shardea el short_code por hash, no por rango. Y esa decisión abre justo la pregunta de las dos lecciones siguientes: hash(short_code) % N reparte precioso... hasta que cambias N.

El problema que asoma: ¿y cuando agregas un shard?

El reparto por hash tiene un talón de Aquiles que aún no vimos y que domina el resto del módulo. La fórmula shard = hash(short_code) % N depende de N, el número de shards. Reparte perfecto mientras N no cambie. Pero shardear es, por definición, algo que haces para poder crecer: tarde o temprano vas a agregar un shard (de 4 a 5, de 8 a 9) porque los datos siguen creciendo. Y en ese instante, N cambia, y con él cambia hash(short_code) % N para casi cada clave —o sea, casi todos los enlaces tendrían que mudarse de shard de golpe—.

Esa mudanza masiva —mover terabytes entre máquinas mientras el sistema está en vivo, con las cachés quedando frías de golpe— es una tormenta que puede tumbar el sistema. La lección 6 la cuantifica con el experimento que mide exactamente cuántas claves se remapean, y la 7 muestra la técnica —consistent hashing— que la reduce de "casi todas" a "solo las justas". Por ahora, quédate con el planteamiento: elegiste short_code por hash como clave de shard, es la elección correcta, y acabas de destapar el problema que hace difícil el sharding de verdad.

Errores comunes

Elegir una clave de shard que la lectura dominante no conoce (de diseño). Qué pasa: se shardea por un campo —digamos el user_id que creó el enlace— pero la operación dominante, resolve(short_code), no trae ese campo, así que cada resolución tiene que preguntarle a todos los shards cuál tiene el código. Por qué pasa: se elige la clave pensando en cómo se agrupan los datos, no en cómo se consultan. Cómo detectarlo: pregúntate "¿la operación más frecuente conoce la clave de shard?". Si no, cada una de esas operaciones es una consulta que cruza todos los shards. Cómo corregirlo: elige la clave que la lectura dominante siempre trae en la mano; para Enlace, es el short_code.

Shardear una clave secuencial por rango (de hotspot). Qué pasa: se particiona por rango un campo que crece con el tiempo —un ID autoincremental, una fecha, un contador base62—, y todas las escrituras nuevas se apilan en el último shard. Por qué pasa: el rango parece natural y ordenado, y el hotspot no se ve hasta que el tráfico llega. Cómo detectarlo: si un shard tiene el 90% de las escrituras y los demás casi nada, y ese shard es el del rango "más reciente", es el hotspot clásico. Lo mediste: [100, 0, 0, 0]. Cómo corregirlo: para claves secuenciales, reparte por hash, no por rango; el hash rompe la correlación entre "nuevo" y "mismo shard".

Shardear cuando bastaba replicar (de secuencia, otra vez). Qué pasa: se shardea para resolver un problema de lecturas, cargando con toda la complejidad del sharding cuando una réplica lo habría resuelto más simple. Por qué pasa: "más máquinas" se siente como la respuesta a cualquier saturación. Cómo detectarlo: si tu límite es throughput de lectura y las escrituras y los datos caben en una máquina, no necesitas sharding. Cómo corregirlo: recuerda los dos ejes —sharding es para datos y escrituras (límites 1 y 3); lecturas son réplicas (límite 2)—. Enlace shardea por los 6 TB, no por las lecturas.

Ejercicios

Ejercicio 1 — Elige la clave. Para cada campo candidato del registro Link, di si sería una buena o mala clave de shard para Enlace y por qué. (a) short_code. (b) created_at (la fecha de creación). (c) long_url. (d) El user_id de quien creó el enlace.

Ver solución
  • (a) short_code — buena, la mejor. La lectura dominante (resolve) siempre la conoce, es de altísima cardinalidad (62⁷ valores) y, hasheada, no correlaciona con carga. Cada resolución toca un solo shard. Es la elección de Enlace.
  • (b) created_at — mala. Es secuencial: todas las escrituras nuevas comparten fecha reciente y, por rango, caen en el mismo shard (hotspot [100,0,0,0]). Además, resolve no trae la fecha, así que cada resolución cruzaría todos los shards. Doble falla.
  • (c) long_url — mala. resolve no la conoce (justamente busca encontrar la long_url a partir del short_code), así que no sirve para enrutar la lectura dominante. Cada resolución cruzaría shards.
  • (d) user_id — mala para Enlace. resolve no trae el user_id; encontrar un código exigiría preguntar a todos los shards. Podría tener sentido en un sistema donde la operación dominante sea "dame todos los enlaces de este usuario", pero esa no es la operación de Enlace.

Ejercicio 2 — Predice el reparto. Tienes 8 shards y un lote de 1,000 short_code recién creados por un contador consecutivo. (a) Si shardeas por rango del código, ¿cómo se reparten aproximadamente las 1,000 escrituras? (b) Si shardeas por hash, ¿cómo se reparten? (c) ¿Qué shard "arde" en cada caso?

Ver solución
  • (a) Por rango: las ~1,000 escrituras caen casi todas en un solo shard, el del rango más alto (el de los códigos más recientes), porque los códigos consecutivos comparten prefijo. Aproximadamente [1000, 0, 0, 0, 0, 0, 0, 0].
  • (b) Por hash: las ~1,000 se reparten parejo entre los 8 shards, ~125 cada uno (con pequeñas variaciones), porque el hash rompe la correlación con la secuencia.
  • (c) Qué arde: por rango, arde ese único shard del rango reciente mientras los otros siete duermen —un hotspot que anula el sharding—. Por hash, ninguno arde: la carga se distribuye, que es el objetivo. Es la misma historia del experimento [100,0,0,0] contra [27,27,28,18], escalada a 8 shards y 1,000 escrituras.

Ejercicio 3 — El tradeoff de perder los rangos. Particionar por hash reparte parejo pero pierde las consultas de rango eficientes. (a) Da un ejemplo de una consulta de rango que sería lenta con hash. (b) Explica por qué a Enlace no le importa esa pérdida. (c) Describe un sistema hipotético donde sí importaría y por rango sería la elección correcta pese al riesgo de hotspot.

Ver solución
  • (a) Una consulta de rango lenta con hash: "dame todos los short_code que empiezan con aX" o "todos los creados en marzo". Con hash, esos registros están esparcidos por todos los shards, así que la consulta tiene que preguntarle a todos y juntar los resultados —lento—.
  • (b) A Enlace no le importa porque su operación dominante no es de rango: es resolve(short_code), un lookup de un código puntual y exacto. Enlace nunca pide "todos los códigos entre X e Y"; pide "el long_url de este código". Un lookup puntual con hash es óptimo (un shard), y las consultas de rango que perdemos no existen en la carga real.
  • (c) Un sistema donde el rango sí importaría: una base de series de tiempo o de logs donde la consulta dominante es "dame todos los eventos entre las 10:00 y las 11:00 de hoy". Ahí, particionar por rango de tiempo hace esa consulta eficiente (toca pocos shards contiguos), y el hotspot de escritura en el shard "actual" se acepta o se mitiga de otras formas (por ejemplo, agregando un prefijo que reparta dentro del rango). El criterio general: elige rango cuando la carga es de consultas de rango; elige hash cuando es de lookups puntuales, como Enlace.

Resumen y siguiente paso

En esta lección cruzaste al eje que las réplicas no escalan: el sharding, partir los datos entre varias máquinas para que quepan los 6 TB y se repartan las escrituras. Con la biblioteca de varias sedes viste que repartir es fácil pero repartir parejo es el arte, y que el criterio de reparto —la clave de shard— lo decide todo. Estableciste por qué la clave de Enlace es el short_code: la lectura dominante siempre la conoce, es de altísima cardinalidad, y hasheada no correlaciona con la carga. Contrastaste particionar por rango (bueno para consultas de rango, fatal para claves secuenciales) contra por hash (reparte parejo, pierde los rangos), y mediste el hotspot: por rango, [100,0,0,0]; por hash, [27,27,28,18]. Y destapaste el problema que domina el resto del módulo: hash(short_code) % N reparte perfecto hasta que cambias N.

Antes de avanzar deberías poder: definir la clave de shard y justificar la de Enlace; explicar por qué una clave secuencial por rango crea un hotspot; decidir entre rango y hash según la carga; y anticipar por qué agregar un shard con mod-N es un problema.

Lo que sigue es cuantificar ese problema. En la lección 6 vas a ejecutar el experimento que mide, con un millón de claves, cuántas se remapean cuando pasas de N a N+1 shards usando hash(key) % N. El número —lo adelanto— es demoledor: casi todas. Vas a entender por qué cambiar el módulo remapea el mundo entero, y por qué eso convierte "agregar un shard" en una tormenta de migración. Es el acto dos de la historia; el tres, consistent hashing, la resuelve.

Recursos