Bases de datos

Sharding de PostgreSQL en 2026: escalado horizontal sin Citus ni soluciones en la nube

Ruslan Ismailov Publicado 14 min de lectura
S

Introducción: cuando el escalado vertical deja de ser suficiente

PostgreSQL es uno de los motores de bases de datos relacionales más fiables y completos. Pero incluso él alcanza su límite cuando la carga crece. El escalado vertical (añadir CPU, RAM, NVMe) funciona hasta cierto punto: aproximadamente 32–64 núcleos y varios terabytes de datos. A partir de ahí empiezan los problemas: aumento de latencia en escrituras, sobrecarga del WAL, tiempos de VACUUM inaceptables y degradación de índices.

En 2026, un sistema típico de alta carga es una arquitectura de microservicios con decenas de millones de eventos al día, plataformas SaaS multiinquilino y colectores de datos IoT. El escalado horizontal mediante sharding deja de ser una opción para convertirse en una necesidad. Al mismo tiempo, Citus requiere licenciamiento y un modelo operativo específico, mientras que las soluciones gestionadas (Aurora, AlloyDB, Neon) generan dependencia del proveedor y aumentan los costes. Este artículo se centra en el sharding self-hosted de PostgreSQL con infraestructura propia.

Principales estrategias de sharding

Hash Sharding

Los datos se distribuyen entre los shards en función del hash de la clave. Por ejemplo, shard_id = hash(user_id) % N. Esto garantiza una distribución uniforme de los registros y la ausencia de shards con hotspot cuando las claves se distribuyen de forma aleatoria.

Ventajas: carga equilibrada, lógica de enrutamiento sencilla, buen escalado al añadir shards con resharding.

Desventajas: las consultas de rango sobre la clave de sharding son ineficientes — hay que recorrer todos los shards. El resharding al cambiar el número de shards requiere migración de datos.

Caso de uso: cargas de trabajo orientadas al usuario donde las consultas siempre incluyen user_id y no se necesitan consultas de rango sobre ese campo.

Range Sharding

Cada shard es responsable de un rango de valores: por ejemplo, user_id de 1 a 1 000 000 — shard 1, de 1 000 001 a 2 000 000 — shard 2, etc. O sharding por fecha: eventos de enero — shard 1, de febrero — shard 2.

Ventajas: consultas de rango eficientes, posibilidad de enrutamiento preciso, conveniente para datos de series temporales.

Desventajas: riesgo de hotspot — los nuevos registros siempre van al último shard. Requiere rebalanceo ante un crecimiento desigual.

Caso de uso: tablas analíticas de eventos con consultas por rangos temporales, archivado de datos antiguos mediante la desconexión de shards.

Directory-Based Sharding

Una tabla de metadatos independiente almacena el mapeo: qué inquilino o entidad está ubicado en qué shard. El router consulta esta tabla antes de cada petición.

Ventajas: máxima flexibilidad, posibilidad de mover inquilinos manualmente entre shards, aislamiento de datos de grandes clientes.

Desventajas: lookup adicional en cada consulta (se resuelve con caché), punto único de fallo para el shard map (se resuelve con replicación).

Caso de uso: SaaS multiinquilino donde se requiere aislamiento de datos y la posibilidad de migrar un cliente a un shard dedicado.

Implementación del sharding a nivel de aplicación

El sharding a nivel de aplicación es el enfoque más común en sistemas en producción sin Citus. La lógica de enrutamiento reside en la aplicación o en una capa proxy separada. Componentes clave:

  • Shard Map — estructura de datos (tabla hash, fichero de configuración o base de datos independiente) que mapea la clave de sharding a la cadena de conexión del nodo PostgreSQL correspondiente.
  • Router — componente que recibe la petición y devuelve el pool de conexiones adecuado.
  • Metadata Store — almacén del esquema de sharding, versiones de migraciones y estado de los shards.

Ejemplo de shard map en Go (pseudocódigo):

type ShardMap struct {\n    Shards []ShardNode\n    TotalShards int\n}\n\ntype ShardNode struct {\n    ID   int\n    DSN  string\n    Pool *pgxpool.Pool\n}\n\nfunc (sm *ShardMap) GetShard(userID int64) *ShardNode {\n    idx := int(userID) % sm.TotalShards\n    return &sm.Shards[idx]\n}\n

Para el sharding basado en directorio, el shard map se almacena en una instancia PostgreSQL independiente con réplica. La caché del mapeo se actualiza mediante Redis pub/sub cuando cambia el esquema.

Es importante versionar el esquema de sharding: si se cambia el número de shards o el algoritmo de hash, los datos existentes deben migrarse, de lo contrario el enrutamiento fallará. Utilice una tabla independiente shard_schema_version con el historial de cambios.

Foreign Data Wrappers y postgres_fdw para consultas federadas

Cuando es necesario ejecutar consultas que afectan a varios shards, se puede usar postgres_fdw — la extensión estándar de PostgreSQL para acceder a servidores PostgreSQL remotos. Esto permite crear un «coordinador» — un único nodo PostgreSQL a través del cual se ejecutan las consultas cross-shard.

-- En el coordinador: conectamos los shards como servidores foráneos
CREATE EXTENSION postgres_fdw;

CREATE SERVER shard1
    FOREIGN DATA WRAPPER postgres_fdw
    OPTIONS (host 'shard1.internal', port '5432', dbname 'events_db');

CREATE SERVER shard2
    FOREIGN DATA WRAPPER postgres_fdw
    OPTIONS (host 'shard2.internal', port '5432', dbname 'events_db');

CREATE USER MAPPING FOR app_user
    SERVER shard1
    OPTIONS (user 'app_user', password 'secret');

CREATE USER MAPPING FOR app_user
    SERVER shard2
    OPTIONS (user 'app_user', password 'secret');

-- Creamos las tablas foráneas
CREATE FOREIGN TABLE events_shard1 (
    id          BIGINT,
    user_id     BIGINT,
    event_type  TEXT,
    payload     JSONB,
    created_at  TIMESTAMPTZ
) SERVER shard1
OPTIONS (table_name 'events');

CREATE FOREIGN TABLE events_shard2 (
    id          BIGINT,
    user_id     BIGINT,
    event_type  TEXT,
    payload     JSONB,
    created_at  TIMESTAMPTZ
) SERVER shard2
OPTIONS (table_name 'events');

-- Las unimos mediante una VIEW
CREATE VIEW events_all AS
    SELECT * FROM events_shard1
    UNION ALL
    SELECT * FROM events_shard2;

Ahora la consulta SELECT * FROM events_all WHERE user_id = 42 se ejecutará en el coordinador con pushdown de predicados a ambos shards. PostgreSQL es lo suficientemente inteligente como para enviar la condición de filtro a los servidores remotos, minimizando el tráfico.

Limitaciones de postgres_fdw: las agregaciones y ordenaciones con LIMIT se ejecutan localmente en el coordinador tras recibir los datos de los shards. Con grandes volúmenes de datos esto genera carga en el coordinador. Para análisis pesados es preferible lanzar consultas en paralelo sobre cada shard desde la aplicación y realizar una merge-sort de los resultados.

Gestión de transacciones y consistencia de datos

El principal dolor del sharding son las transacciones distribuidas. PostgreSQL soporta transacciones en dos fases (2PC) mediante PREPARE TRANSACTION / COMMIT PREPARED, pero es un mecanismo operacionalmente complejo: las transacciones preparadas bloqueadas bloquean el vacuum y ocupan slots.

-- El coordinador inicia una transacción distribuida
BEGIN;
-- En el shard 1
INSERT INTO events (user_id, event_type, created_at)
    VALUES (42, 'purchase', now());
PREPARE TRANSACTION 'txn_20260101_001';

-- En el shard 2
INSERT INTO user_stats (user_id, total_purchases)
    VALUES (42, 1)
    ON CONFLICT (user_id)
    DO UPDATE SET total_purchases = user_stats.total_purchases + 1;
PREPARE TRANSACTION 'txn_20260101_001';

-- Si ambos shards respondieron OK:
COMMIT PREPARED 'txn_20260101_001'; -- en ambos shards

En la práctica, en sistemas de microservicios el 2PC se usa raramente. En su lugar se aplica el patrón Saga: una secuencia de transacciones locales, cada una de las cuales publica un evento para el siguiente paso. En caso de fallo se ejecutan transacciones compensadoras.

Ejemplo de Saga para una operación de transferencia de fondos entre shards:

  1. Shard A: descontar fondos, estado de la operación — PENDING, publicar evento debit_completed.
  2. Shard B: acreditar fondos, estado — COMPLETED, publicar evento credit_completed.
  3. Shard A: actualizar el estado de la operación a SUCCESS.
  4. En caso de fallo en el paso 2: publicar credit_failed, el Shard A compensa — devuelve los fondos, estado — ROLLED_BACK.

Para la consistencia eventual entre shards use el patrón outbox: escritura transaccional en una tabla local outbox_events y un proceso independiente de entrega de eventos. Esto garantiza la entrega at-least-once sin un gestor de transacciones coordinador.

Ejemplo práctico: sharding de la tabla de eventos por user_id

Consideremos un escenario real: tabla events con 500 millones de registros, crecimiento de 5 millones al día. Aplicamos hash sharding por user_id con 4 shards.

Esquema en cada shard:

-- Se ejecuta en cada uno de los 4 servidores PostgreSQL
CREATE TABLE events (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT       NOT NULL,
    event_type  VARCHAR(64)  NOT NULL,
    payload     JSONB        DEFAULT '{}',
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE INDEX idx_events_user_id     ON events (user_id);
CREATE INDEX idx_events_created_at  ON events (created_at DESC);
CREATE INDEX idx_events_type_user   ON events (event_type, user_id);

-- Particionamiento dentro del shard por meses (range por created_at)
CREATE TABLE events_2026_01
    PARTITION OF events
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE events_2026_02
    PARTITION OF events
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

Lógica de enrutamiento en la aplicación:

-- Determinamos el shard para user_id = 123456
-- shard_index = 123456 % 4 = 0 → shard_0

-- Consulta en el shard correspondiente:
SELECT id, event_type, payload, created_at
FROM events
WHERE user_id = 123456
  AND created_at >= now() - INTERVAL '30 days'
ORDER BY created_at DESC
LIMIT 50;

Agregación cross-shard mediante el coordinador FDW:

-- Estadísticas por tipo de evento en los últimos 7 días
-- Se ejecuta en el coordinador, predicados con pushdown a todos los shards
SELECT
    event_type,
    count(*)           AS total,
    count(DISTINCT user_id) AS unique_users
FROM events_all
WHERE created_at >= now() - INTERVAL '7 days'
GROUP BY event_type
ORDER BY total DESC;

Para obtener los datos del usuario 123456, la aplicación accede directamente al shard 0 — sin coordinador, con latencia mínima. Para informes analíticos se utiliza el coordinador con FDW, cuya carga queda aislada del tráfico OLTP.

Monitorización y observabilidad del clúster con sharding

Monitorizar un PostgreSQL con sharding es más complejo que uno monolítico: hay que agregar métricas de todos los nodos y correlacionarlas. Métricas e instrumentos clave:

  • pg_stat_statements en cada shard — las consultas más lentas, normalizadas por parámetros. Recójalas mediante Prometheus + postgres_exporter.
  • pg_stat_replication — lag de replicación en cada shard. Crítico para réplicas de lectura: con lag > 5 segundos, la réplica de lectura se excluye de la rotación.
  • Métricas de balance de shards — tamaño de tablas y número de filas en cada shard. Una desviación > 20% respecto a la media es señal de necesidad de resharding.
  • Latencia de consultas cross-shard — tiempo de ejecución de consultas a través del coordinador FDW. Trácelas mediante OpenTelemetry con anotación shard_id.
  • Utilización del pool de conexiones — PgBouncer en cada shard. Monitorización mediante SHOW POOLS y SHOW STATS.
-- Consulta útil para monitorizar el balance de shards
-- Ejecútela en el coordinador mediante FDW
SELECT
    'shard1' AS shard,
    count(*) AS row_count,
    pg_size_pretty(pg_total_relation_size('events')) AS table_size
FROM events_shard1
UNION ALL
SELECT
    'shard2',
    count(*),
    pg_size_pretty(pg_total_relation_size('events'))
FROM events_shard2;

Para el distributed tracing use application_name en la cadena de conexión indicando el shard_id y el request_id. Esto permite correlacionar los logs de PostgreSQL con los traces de la aplicación en Jaeger o Tempo.

Escollos y errores típicos

  • Elección incorrecta de la clave de sharding. La clave debe estar presente en la mayoría de las consultas. El sharding por created_at para eventos parece lógico, pero si las consultas siempre usan user_id, habrá fanout a todos los shards en cada consulta.
  • Ausencia de resharding en el diseño. El número de shards hardcodeado en el código lleva a una migración dolorosa. Planifique el resharding desde el principio: consistent hashing o shards virtuales (1024 virtuales → 4 físicos).
  • Secuencias globales. BIGSERIAL en cada shard genera secuencias independientes — pueden producirse colisiones de id en JOINs cross-shard. Use UUIDv7 o Snowflake ID con shard_id incorporado.
  • Cross-shard JOIN en el camino crítico. Un JOIN entre tablas en distintos shards implica un roundtrip de red más un merge en el coordinador. Desnormalice los datos o use co-location: los datos relacionados de un mismo usuario siempre en el mismo shard.
  • Ignorar VACUUM en los shards. Con alta carga de escritura en cada shard es necesario un autovacuum agresivo. Configure autovacuum_vacuum_cost_delay = 2ms y autovacuum_max_workers = 6 en cada nodo de forma independiente.
  • Sin circuit breaker para los shards. Si un shard se degrada, sin circuit breaker se producirá un fallo en cascada. Use el patrón circuit breaker en la capa de router con fallback a la réplica de lectura.

Conclusión: cómo elegir la estrategia

El sharding de PostgreSQL sin Citus ni servicios gestionados en la nube es viable y justificado con un diseño adecuado. La elección de la estrategia depende de los patrones de acceso a los datos:

  • Hash sharding — si todas las consultas contienen una sola clave (user_id, tenant_id) y no se necesitan consultas de rango sobre esa clave. Adecuado para la mayoría de los sistemas OLTP.
  • Range sharding — si los datos tienen naturaleza temporal y se archivan regularmente. Conveniente para series temporales y almacenamiento de logs.
  • Directory-based sharding — para SaaS multiinquilino con requisitos de aislamiento de datos y posibilidad de migrar clientes entre shards.

Comience con sharding a nivel de aplicación y un número mínimo de shards (4–8). Use postgres_fdw para consultas analíticas cross-shard, aislándolas de la carga OLTP. Incorpore el resharding en la arquitectura desde el primer día. Aplique el patrón Saga en lugar de 2PC para operaciones distribuidas. E invierta obligatoriamente en observabilidad — sin métricas de todos los shards trabajará a ciegas.

El escalado horizontal de PostgreSQL no es magia ni una bala de plata. Es un compromiso de ingeniería entre la complejidad del sistema y su escalabilidad. Tómelo de forma consciente.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →