StackPractices
advanced Por Mathias Paulenko

Patrón Geode: Particionar Datos entre Nodos Independientes

Particiona los datos para que cada nodo posea un shard no superpuesto con su propio almacén. Escalado horizontal y aislamiento de fallos sin estado compartido.

Descripción general

El patrón Geode divide los datos en shards no superpuestos y asigna cada uno a un nodo que lo posee por completo — almacén incluido. Una petición de un elemento concreto se enruta directamente al nodo que lo tiene, y ese nodo responde desde sus propios datos. Nada es compartido: ni base de datos central, ni caché común, ni coordinador en el camino crítico.

El Azure Architecture Center de Microsoft documenta este patrón justo para el problema que resuelve: cuando todos los nodos leen y escriben a través del mismo backend, ese backend se convierte en el techo. Lleva los datos dentro de los nodos y cada uno solo carga con su fracción del tráfico, así que la capacidad crece casi linealmente al añadir máquinas. El nombre encaja con la metáfora — las geodas parecen rocas corrientes, pero cada una es una cavidad independiente recubierta de cristales; cada nodo es una unidad autocontenida con sus propios datos dentro.

Lo que lo distingue del sharding de base de datos es el grado de independencia. El sharding divide una base de datos lógica entre varias instancias que siguen respondiendo al mismo motor. Una geoda va más allá: cada nodo es una unidad totalmente independiente que puede tener su propio almacén y fallar por su cuenta.

flowchart diagram: Petición<br/>clave = alice

Cuándo usarlo

Usa el patrón Geode cuando:

  • Una base de datos o caché compartida es el cuello de botella de escalabilidad
  • Los datos tienen una clave de partición natural (ID de usuario, ID de tenant, región)
  • Necesitas aislamiento de fallos: que una partición caiga no debe afectar a las demás
  • Quieres escalado horizontal sin transacciones distribuidas

Encajes típicos: SaaS multi-tenant, servidores de juegos, ingesta IoT, entrega de contenido regional.

Evítalo cuando tu carga de trabajo está dominada por consultas entre particiones o transacciones distribuidas — el coste de coordinación se come el beneficio del particionamiento. Para dividir un dataset donde los nodos pueden compartir motor, el patrón Sharding es más simple.

Solución

Cada clave se hashea a un rango numérico; cada nodo posee un rango. El clúster enruta por el hash, y el fallo de un nodo solo afecta a su propio shard.

Python

from dataclasses import dataclass, field
from typing import Dict, Any, List, Optional
import hashlib

@dataclass
class GeodeNode:
    node_id: str
    shard_range: tuple
    data: Dict[str, Any] = field(default_factory=dict)
    healthy: bool = True

    def owns_key(self, key: str) -> bool:
        h = int(hashlib.md5(key.encode()).hexdigest(), 16) % 10000
        return self.shard_range[0] <= h <= self.shard_range[1]

    def get(self, key: str) -> Optional[Any]:
        if not self.healthy:
            raise RuntimeError(f"Node {self.node_id} is down")
        return self.data.get(key)

    def put(self, key: str, value: Any) -> None:
        if not self.healthy:
            raise RuntimeError(f"Node {self.node_id} is down")
        self.data[key] = value

class GeodeCluster:
    def __init__(self, num_nodes: int = 4):
        shard_size = 10000 // num_nodes
        self.nodes: List[GeodeNode] = [
            GeodeNode(
                node_id=f"node-{i}",
                shard_range=(
                    i * shard_size,
                    i * shard_size + shard_size - 1
                    if i < num_nodes - 1 else 9999,
                ),
            )
            for i in range(num_nodes)
        ]

    def find_node(self, key: str) -> Optional[GeodeNode]:
        return next((n for n in self.nodes if n.owns_key(key)), None)

    def get(self, key: str) -> Optional[Any]:
        node = self.find_node(key)
        if node is None:
            raise KeyError(f"No node owns key: {key}")
        return node.get(key)

    def put(self, key: str, value: Any) -> None:
        node = self.find_node(key)
        if node is None:
            raise KeyError(f"No node owns key: {key}")
        node.put(key, value)

    def fail_node(self, node_id: str) -> None:
        for n in self.nodes:
            if n.node_id == node_id:
                n.healthy = False

    def stats(self) -> List[dict]:
        return [{"node_id": n.node_id, "keys": len(n.data),
                 "healthy": n.healthy, "range": n.shard_range}
                for n in self.nodes]

# Uso
cluster = GeodeCluster(num_nodes=4)
users = ["alice", "bob", "carol", "dave", "eve", "frank", "grace", "heidi"]
for u in users:
    cluster.put(u, {"email": f"{u}@example.com", "score": len(u) * 10})

for s in cluster.stats():
    print(f"  {s}")

print("\n=== Simulate node-2 failure ===")
cluster.fail_node("node-2")
for u in users:
    node = cluster.find_node(u)
    try:
        cluster.get(u)
        print(f"  {u} -> {node.node_id}: OK")
    except RuntimeError as e:
        print(f"  {u} -> {node.node_id}: FAILED ({e})")

JavaScript

const crypto = require('crypto');

class GeodeNode {
  constructor(nodeId, shardStart, shardEnd) {
    this.nodeId = nodeId;
    this.shardRange = [shardStart, shardEnd];
    this.data = new Map();
    this.healthy = true;
  }

  ownsKey(key) {
    const h = parseInt(
      crypto.createHash('md5').update(key).digest('hex'), 16
    ) % 10000;
    return h >= this.shardRange[0] && h <= this.shardRange[1];
  }

  get(key) {
    if (!this.healthy) throw new Error(`Node ${this.nodeId} is down`);
    return this.data.get(key);
  }

  put(key, value) {
    if (!this.healthy) throw new Error(`Node ${this.nodeId} is down`);
    this.data.set(key, value);
  }
}

class GeodeCluster {
  constructor(numNodes = 4) {
    this.nodes = [];
    const shardSize = Math.floor(10000 / numNodes);
    for (let i = 0; i < numNodes; i++) {
      const start = i * shardSize;
      const end = i < numNodes - 1 ? start + shardSize - 1 : 9999;
      this.nodes.push(new GeodeNode(`node-${i}`, start, end));
    }
  }

  findNode(key) {
    return this.nodes.find(n => n.ownsKey(key)) || null;
  }

  get(key) {
    const node = this.findNode(key);
    if (!node) throw new Error(`No node owns key: ${key}`);
    return node.get(key);
  }

  put(key, value) {
    const node = this.findNode(key);
    if (!node) throw new Error(`No node owns key: ${key}`);
    node.put(key, value);
  }

  failNode(nodeId) {
    const node = this.nodes.find(n => n.nodeId === nodeId);
    if (node) node.healthy = false;
  }

  stats() {
    return this.nodes.map(n => ({
      nodeId: n.nodeId, keys: n.data.size,
      healthy: n.healthy, range: n.shardRange,
    }));
  }
}

// Uso
const cluster = new GeodeCluster(4);
const users = [
  "alice", "bob", "carol", "dave", "eve", "frank", "grace", "heidi",
];
users.forEach(u =>
  cluster.put(u, { email: `${u}@example.com`, score: u.length * 10 }));

cluster.stats().forEach(s => console.log("  ", s));

console.log("\n=== Simulate node-2 failure ===");
cluster.failNode("node-2");
users.forEach(u => {
  const node = cluster.findNode(u);
  try {
    cluster.get(u);
    console.log(`  ${u} -> ${node.nodeId}: OK`);
  } catch (e) {
    console.log(`  ${u} -> ${node.nodeId}: FAILED (${e.message})`);
  }
});

Java

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;

public class GeodeCluster {

    static class GeodeNode {
        final String nodeId;
        final int shardStart, shardEnd;
        final Map<String, Object> data = new HashMap<>();
        boolean healthy = true;

        GeodeNode(String nodeId, int shardStart, int shardEnd) {
            this.nodeId = nodeId;
            this.shardStart = shardStart;
            this.shardEnd = shardEnd;
        }

        boolean ownsKey(String key) {
            int h = hashKey(key);
            return h >= shardStart && h <= shardEnd;
        }

        Object get(String key) {
            if (!healthy)
                throw new RuntimeException("Node " + nodeId + " is down");
            return data.get(key);
        }

        void put(String key, Object value) {
            if (!healthy)
                throw new RuntimeException("Node " + nodeId + " is down");
            data.put(key, value);
        }
    }

    final List<GeodeNode> nodes = new ArrayList<>();

    public GeodeCluster(int numNodes) {
        int shardSize = 10000 / numNodes;
        for (int i = 0; i < numNodes; i++) {
            int start = i * shardSize;
            int end = (i < numNodes - 1) ? start + shardSize - 1 : 9999;
            nodes.add(new GeodeNode("node-" + i, start, end));
        }
    }

    static int hashKey(String key) {
        try {
            byte[] d = MessageDigest.getInstance("MD5")
                .digest(key.getBytes(StandardCharsets.UTF_8));
            int h = 0;
            for (int i = 0; i < 4; i++) h = (h << 8) | (d[i] & 0xFF);
            return Math.abs(h) % 10000;
        } catch (Exception e) {
            return Math.abs(key.hashCode()) % 10000;
        }
    }

    GeodeNode findNode(String key) {
        return nodes.stream().filter(n -> n.ownsKey(key))
            .findFirst().orElse(null);
    }

    Object get(String key) {
        GeodeNode node = findNode(key);
        if (node == null)
            throw new RuntimeException("No node owns key: " + key);
        return node.get(key);
    }

    void put(String key, Object value) {
        GeodeNode node = findNode(key);
        if (node == null)
            throw new RuntimeException("No node owns key: " + key);
        node.put(key, value);
    }

    void failNode(String nodeId) {
        nodes.stream()
            .filter(n -> n.nodeId.equals(nodeId))
            .forEach(n -> n.healthy = false);
    }

    public static void main(String[] args) {
        var cluster = new GeodeCluster(4);
        String[] users = {"alice", "bob", "carol", "dave",
                          "eve", "frank", "grace", "heidi"};
        for (String u : users)
            cluster.put(u, Map.of(
                "email", u + "@example.com", "score", u.length() * 10));

        System.out.println("=== Simulate node-2 failure ===");
        cluster.failNode("node-2");
        for (String u : users) {
            var node = cluster.findNode(u);
            try {
                cluster.get(u);
                System.out.printf("  %s -> %s: OK%n", u, node.nodeId);
            } catch (Exception e) {
                System.out.printf("  %s -> %s: FAILED (%s)%n",
                    u, node.nodeId, e.getMessage());
            }
        }
    }
}

Explicación

La mecánica son tres pasos:

  1. Particionar por hash. Cada clave se hashea a un número dentro de un rango fijo (aquí 0-9999). Cada nodo posee un tramo contiguo y no superpuesto, así que exactamente un nodo es responsable de cada clave.
  2. Enrutar por el hash. Quien recibe la petición — un router de borde, una librería cliente, el propio clúster — recalcula el hash y envía la petición al nodo propietario. El nodo responde desde su estado local, sin un backend compartido de por medio.
  3. Fallar de forma aislada. Un nodo caído solo derriba su propio shard. El resto sigue sirviendo, y el radio de explosión son las claves de un solo nodo.

La demo de arriba simula el paso 3: mata node-2 y solo sus usuarios fallan, mientras el resto responde con normalidad.

Rebalanceo y failover

Los rangos fijos del ejemplo son fáciles de razonar pero dolorosos de cambiar: añadir un nodo implica rebanar rangos y migrar claves. Hay dos respuestas estándar:

  • Consistent hashing coloca nodos y claves sobre un anillo. Añadir o quitar un nodo solo remapea el tramo entre él y sus vecinos, así que la mayoría de claves no se mueven — por eso los sistemas estilo Dynamo lo usan.
  • Nodos virtuales dan a cada nodo físico varios rangos pequeños. La carga se reparte más uniformemente, y rebalancear mueve unas pocas rebanadas virtuales en vez de un shard gigante.

Para failover, replica cada shard en un segundo nodo (read-only suele bastar) y promuévelo ante un fallo. Sin réplica, las claves del nodo muerto quedan no disponibles hasta que vuelva — aceptable para cachés, inaceptable para la fuente de verdad.

Variantes

VarianteDescripciónCaso de uso
Consistent hashingAnillo en lugar de rangos fijos para rebalancear baratoClústers dinámicos donde los nodos entran y salen
Geodas replicadasCada shard copiado a un segundo nodo para failoverAlta disponibilidad sin estado compartido
Geodas geográficasPartición por región, cada una en su propio datacenterApps globales sensibles a latencia
Nodos virtualesUn nodo posee varios rangos no contiguosMejor balance con pocos nodos físicos

Lo que funciona

  • Particiona por una clave natural (ID de usuario, ID de tenant): los datos relacionados caen en el mismo nodo y las consultas quedan locales.
  • Prefiere consistent hashing cuando el clúster ya está en producción — los rangos fijos convierten cada cambio de topología en un proyecto de migración.
  • Mantén los shards independientes: en cuanto dos shards coordinan una escritura, has reconstruido el cuello de botella de estado compartido del que huías.
  • Vigila el balance de shards: una clave caliente o una distribución sesgada concentra carga en un nodo sin hacer ruido. Mide claves y tráfico por shard y rebalancea cuando uno derive.
  • Enruta en el borde para que los clientes no necesiten conocer la topología.
  • Replica los shards críticos, manteniendo las réplicas read-only para no necesitar un protocolo de coordinación.

Errores comunes

  • Claves de partición desiguales — un usuario famoso o un tenant enorme convierte un shard en un punto caliente mientras el resto está ocioso.
  • Transacciones distribuidas entre shards — el overhead de coordinación anula la razón de ser del particionamiento.
  • No tener plan de failover — un nodo muere y su porción de datos desaparece hasta que alguien se da cuenta.
  • Joins entre shards en el camino crítico — scatter-gather sirve para lecturas ocasionales, no para cada petición.
  • Crecimiento de shards sin monitorizar — un shard llena su disco en silencio mientras el uso agregado parece correcto.
  • Particionamiento aleatorio — sin una clave natural pierdes localidad y acabas haciendo scatter-gather hasta para búsquedas rutinarias.

Lectura adicional

Preguntas frecuentes

¿En qué se diferencia el patrón Geode del sharding de base de datos?

El sharding divide una base de datos lógica entre instancias del mismo motor. Una geoda es más independiente: cada nodo posee su shard de punta a punta, con su propio almacén y sin backend compartido. El sharding es la opción ligera cuando basta una partición a nivel de motor.

¿Qué pasa cuando un nodo falla?

Solo se ven afectadas las claves de su shard. Con réplica, el tráfico hace failover; sin ella, esas claves no están disponibles hasta que el nodo se recupera. Todo lo demás sigue sirviendo.

¿Cómo ejecuto una consulta que abarca varios shards?

Scatter-gather: manda la consulta a todos los nodos en paralelo y fusiona los resultados. Vale para lecturas ocasionales — si tu camino crítico la necesita constantemente, tu clave de partición está mal elegida.

¿Cuándo no debería usar el patrón Geode?

Cuando la mayoría de operaciones necesitan joins entre particiones o transacciones distribuidas — gastarás el beneficio del particionamiento en coordinación. También es exceso mientras una base de datos replicada única aún cubre tu carga.

¿Cómo añado o quito nodos sin mover todos los datos?

Usa consistent hashing con nodos virtuales: solo se remapean los rangos adyacentes al cambio. Con rangos fijos toca planificar una migración de claves — posible, pero es un proyecto, no una operación.