Saltar a contenido

Scaling Strategy Postgres

Roadmap de 6 fases para evolucionar el MVP single-node PostgreSQL hacia una plataforma con scalability y replication comparable a Camunda 8, manteniendo PostgreSQL como foundation. Cada fase tiene triggers cuantitativos (TPS, instances, latency), patterns aplicables, y trade-offs explícitos. La estrategia evita el costoso path de Camunda (fork de Atomix Raft) heredando 35+ años de tooling maduro de PostgreSQL.

Filosofía: PostgreSQL all the way down

La decisión arquitectónica más importante del MVP es NO forkear protocolos de consensus. Camunda invirtió ~4 años de R&D en su fork de Atomix (ver concepts/atomix-fork-lessons) — costo que un MVP no puede absorber.

PostgreSQL provee gratis lo que Camunda construyó manualmente:

Feature Camunda Equivalente Postgres
RocksDB durability WAL (write-ahead log)
Raft consensus Streaming replication + Patroni
Snapshot transfer chunk-based pg_basebackup + WAL streaming
Atomix SWIM membership Patroni + etcd/Consul
Partitioning (8192 max) Citus shards o pg_partman
Backup to S3/GCS pgBackRest, WAL-G, pg_dump
Multi-region Logical replication, BDR, pglogical

El MVP no necesita reinventar nada de esto. Solo necesita componer estas piezas en las fases correctas.

Triggers de escalamiento

Antes de cada fase, validar con métricas reales:

Métrica Single-node OK Necesita escalar
Sustained TPS (creation) < 100 > 100
Active process instances < 100K > 1M
TP99 latency < 1s > 2s consistente
DB connections < 100 > 200
Storage growth < 50 GB/month > 500 GB/month
Required SLA uptime 99% > 99.9%
Geographic users 1 región Multi-región

Fase 0 — MVP single-node (start here)

Arquitectura

flowchart TD
    LB[Load balancer optional] --> Engine[Engine instance single<br/>REST API, BPMN processor, Job worker dispatcher]
    Engine --> PG[(PostgreSQL primary single<br/>command_log, event_log, state tables, job_queue)]
    PG -.->|pg_basebackup nightly| S3[(S3 backup)]

Capacidades

  • Throughput: 100-200 TPS
  • Latency: TP99 < 500ms
  • Availability: ~99% (failover manual)
  • Storage: hasta ~100 GB workload

Trabajo de implementación

  • Single Postgres con WAL habilitado
  • Backups automáticos a S3 (cron + pg_basebackup)
  • Monitoring: pg_stat_statements, cache hit ratio (ver concepts/postgres-monitoring)
  • Health endpoint en el engine

Inversión estimada

~0 — esto ES el MVP.

Cuándo dejar Fase 0

Trigger primary: el negocio requiere SLA > 99% (>3.6 horas downtime/mes inaceptable) → ir a Fase 1.

Fase 1 — High Availability con read replicas

Arquitectura

flowchart TD
    LB[Load balancer] --> Engine[Engine instance single, active]
    Engine -->|writes| PGPrimary[(PG Primary writes)]
    Engine -->|reads| PGReplica1[(PG Replica 1 reads)]
    PGPrimary -->|streaming repl| PGReplica1
    PGPrimary -->|streaming repl| PGReplica2[(PG Replica 2 reads/HA)]
    PGReplica1 -.-> PGReplica2

    note["Patroni + etcd para automatic failover<br/>PgBouncer para connection pooling"]

Cambios desde Fase 0

  1. Postgres en HA: primary + 2 hot standbys con streaming replication
  2. Patroni: leader election + automatic failover
  3. etcd o Consul: state store de Patroni
  4. PgBouncer: connection pooling (importante para evitar agotar conexiones durante failover)
  5. Engine routing: writes → primary, reads opcionales → replicas (con lag tolerance)
  6. Backups: WAL streaming a S3 vía WAL-G o pgBackRest (PITR habilitado)

Capacidades

  • Throughput: 200-500 TPS (mismo engine, mejor DB performance)
  • Latency: TP99 < 1s
  • Availability: 99.9% (failover automatic en <30s)
  • Storage: hasta ~500 GB
  • RPO: ~5s (WAL streaming lag)
  • RTO: ~30s (Patroni promotion)

Implementación

# patroni.yml ejemplo simplificado
scope: workflow-engine
namespace: /service/
name: pg-primary

restapi:
  listen: 0.0.0.0:8008

etcd:
  hosts: etcd-1:2379,etcd-2:2379,etcd-3:2379

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576
    postgresql:
      use_pg_rewind: true
      parameters:
        max_connections: 200
        max_wal_size: 4GB
        wal_level: replica
        hot_standby: "on"
        max_wal_senders: 10
        max_replication_slots: 10

Engine read routing

-- Write path: siempre al primary
INSERT INTO command_log (...) VALUES (...);

-- Read path para queries no-críticas (Operate-equivalent):
-- Usar conexión a replica con lag tolerance
SELECT * FROM process_instance_view WHERE state = 'ACTIVE';
-- ↑ Acepta lag de unos segundos

No leer del replica para command processing — sería violación de consistency (could miss recent events).

Inversión estimada

  • 2 nodos Postgres adicionales (cost ~3x storage/compute)
  • Patroni + etcd setup (~1 semana ingeniero)
  • Pipeline de monitoring para replication lag
  • Runbook de failover testing

Total: ~2-3 semanas + ~3x infrastructure cost.

Trade-offs

  • Pro: 99.9% availability automático
  • Pro: read scaling para queries de monitoring
  • Con: write throughput sigue limitado a un nodo
  • Con: complexity operacional (failover testing crítico)

Cuándo dejar Fase 1

Triggers: - TPS sostenido > 500 → primary se satura - DB connections > 200 incluso con PgBouncer - Necesitas zero-downtime deploys del engine

Fase 2 — Active-active engines con leader election

Arquitectura

flowchart TD
    LB[Load balancer] --> E1[Engine 1 LEADER]
    LB --> E2[Engine 2 FOLLOWER]
    LB --> E3[Engine 3 FOLLOWER]
    E1 -->|writes| PG[(PostgreSQL HA cluster Phase 1)]
    E2 -.->|reads| PG
    E3 -.->|reads| PG

    note["Leader election via PG advisory lock o Consul session"]

Cambios desde Fase 1

  1. Múltiples engines detrás del load balancer
  2. Leader election: solo el leader procesa commands del log
  3. Followers: sirven REST API (reads), validan commands antes de forward al leader, health checks
  4. Zero-downtime deploys: rolling restart sin perder availability

Leader election: dos opciones

Opción A — PostgreSQL Advisory Lock

-- Engine intenta tomar leadership cada N segundos
SELECT pg_try_advisory_lock(42); -- 42 = arbitrary leader key

-- Si returns true: este nodo es leader
-- Si returns false: otro nodo es leader
-- Lock se libera automáticamente si la conexión muere

Pros: zero dependencias adicionales, lock atomic en Postgres Cons: lock muere si conexión Postgres se cae, no expiration timer

Opción B — Consul/etcd session

# Engine crea Consul session con TTL
session = consul.session.create(ttl="15s", behavior="delete")

# Intenta acquire leader key
acquired = consul.kv.put("workflow-engine/leader", node_id, acquire=session)

# Renew session periodicamente
while running:
    consul.session.renew(session)
    sleep(5)

Pros: TTL configurable, observable, integra con health checks Cons: dependencia adicional (Consul/etcd)

Recomendación: Patroni ya usa etcd. Reusar el mismo cluster.

Patrón de processing

async def main_loop():
    while running:
        if i_am_leader():
            await process_next_command_from_log()
        else:
            await serve_only_reads_and_health()
            sleep(1)

Capacidades

  • Throughput writes: ~1000 TPS (limited por single Postgres primary)
  • Throughput reads: 10x writes (read replicas + multiple engine instances serving reads)
  • Availability: 99.95% (engine failover instant via leader election)
  • Zero-downtime deploys: rolling restart funciona

Inversión estimada

  • Leader election logic (~3-5 días ingeniero)
  • Tests de split-brain scenarios
  • Deploy automation con rolling updates
  • Documentación de operations

Total: ~1-2 semanas.

Trade-offs vs Camunda

Camunda usa Raft para distribuir el log entre brokers (multi-leader perez partition). Esta fase usa single-leader simple sobre Postgres primary.

Aspecto Camunda (Raft) Esta fase
Latencia commit ~10ms (quorum) ~5ms (single primary)
Failover engine <1s <5s (lock renovation)
Failover DB N/A (RocksDB) <30s (Patroni)
Implementation complexity Alta (Raft) Baja (lock + Patroni)
Throughput Higher (multi-leader) Lower (single-leader)

Trade-off explícito: menor throughput pico, mucha menos complejidad.

Cuándo dejar Fase 2

Triggers: - Write TPS > 1000 saturando single Postgres - Storage > 1 TB (escala vertical de Postgres se vuelve caro) - Necesidad de aislamiento por tenant (compliance)

Fase 3 — Tenant sharding (horizontal por tenant)

Arquitectura

flowchart TD
    Router[Smart Router / Tenant-aware proxy<br/>routes commands by tenant_id]
    Router --> S1[Shard 1 Tenants A, B]
    Router --> S2[Shard 2 Tenants C, D]
    Router --> S3[Shard 3 Tenants E, F]
    S1 --> PG1[(PG 1 HA)]
    S2 --> PG2[(PG 2 HA)]
    S3 --> PG3[(PG 3 HA)]

Cambios desde Fase 2

  1. Múltiples shards de Postgres: cada uno con su propio HA cluster
  2. Tenant → shard mapping: tabla central (en metadata DB) o algoritmo determinístico
  3. Smart router: cada engine routea commands al shard correcto basado en tenantId
  4. Tenant migration: tooling para mover tenants entre shards (online idealmente)
  5. Cross-shard queries: agregación a nivel de application (rare, evitar)

Sharding strategy

Sharding determinístico por hash

def get_shard(tenant_id: str, shard_count: int) -> int:
    # Consistent hashing para minimizar re-sharding
    return hash(tenant_id) % shard_count

Pros: zero state, automatic distribution Cons: re-shard costoso al crecer

Sharding por lookup table

CREATE TABLE tenant_shard_mapping (
    tenant_id TEXT PRIMARY KEY,
    shard_id INT NOT NULL,
    migration_status TEXT,  -- ACTIVE | MIGRATING_OUT | MIGRATING_IN
    created_at TIMESTAMPTZ
);

Pros: control fino, migration friendly Cons: necesita cachear (consultar siempre es costoso)

Recomendación: lookup table con cache en cada engine. Cache hit ~99%.

Schema por shard

Cada shard tiene el mismo schema que el MVP completo: - command_log - event_log - process_instances - jobs, etc.

Solo cambia: cada shard solo contiene datos de sus tenants.

Capacidades

  • Throughput: 5,000-10,000 TPS (linear con shards)
  • Storage: hasta ~10 TB total (1-2 TB por shard)
  • Tenants: 1000s
  • Isolation: noisy neighbor problema mitigado

Inversión estimada

  • Smart router (~2-3 semanas)
  • Tenant migration tooling (~2 semanas)
  • Cross-shard query handling
  • Operational runbooks (10x más procesos a manejar)

Total: ~5-7 semanas.

Trade-offs

  • Pro: linear horizontal scaling
  • Pro: tenant isolation
  • Pro: migration de tenants posible
  • Con: cross-tenant queries son costosas
  • Con: 10x más Postgres clusters a operar
  • Con: necesita observability más sofisticada (per-shard metrics)

Cuándo dejar Fase 3

Triggers: - Algunos shards crecen mucho más que otros (skew) - Single tenant excede capacidad de un shard - Queries cross-shard se vuelven frecuentes

Fase 4 — Citus para horizontal scaling dentro de un tenant

Arquitectura

flowchart TD
    Engines[Engine instances]
    Engines --> Coord[Citus Coordinator<br/>Query planner, routes to worker nodes]
    Coord --> W1[(Worker 1 shards)]
    Coord --> W2[(Worker 2 shards)]
    Coord --> W3[(Worker 3 shards)]

¿Qué es Citus?

Extension de PostgreSQL (ahora open-source, Microsoft owned) que distribuye tablas across múltiples nodos:

  • Distributed tables: sharded por columna (e.g., tenant_id o process_instance_key)
  • Reference tables: replicadas en todos los nodos (e.g., process_definitions)
  • Local tables: solo en coordinator
  • Query distribution: SQL queries automáticamente distribuidos

Schema para Citus

-- Distribuir command_log por tenant_id
SELECT create_distributed_table('command_log', 'tenant_id');

-- Distribuir process_instances por tenant_id (co-located con command_log)
SELECT create_distributed_table('process_instances', 'tenant_id');

-- Process definitions son small y se acceden mucho → reference table
SELECT create_reference_table('process_definitions');

Capacidades

  • Throughput: 10K-100K TPS (linear con workers)
  • Storage: 100s TB
  • Queries: SQL transparent (no smart router en app)
  • Single logical DB: queries cross-shard manejadas por Citus

Inversión vs Fase 3

Aspecto Fase 3 (sharding app-level) Fase 4 (Citus)
Implementation complexity Higher (smart router en app) Lower (transparent SQL)
Operational complexity High (N clusters) Medium (Citus cluster)
Cross-shard queries Difícil Automatic
Re-sharding Manual tooling Native operation
Cost N clusters HA each One Citus cluster HA

Citus es generalmente mejor que sharding app-level, excepto si: - Necesitas full PostgreSQL features (algunas no soportadas en Citus) - Quieres ZERO dependencies adicionales (Citus es extension) - Requieres aislamiento físico estricto entre tenants (compliance)

Cuándo dejar Fase 4

Triggers: - Necesidad de multi-region writes - Latencia geografica a usuarios < 100ms requirement - Disaster recovery cross-region requerido

Fase 5 — Geo-distribución

Arquitectura

flowchart TD
    US[Region US-East<br/>Engines + Citus cluster<br/>primary para tenants US]
    EU[Region EU-West<br/>Engines + Citus cluster<br/>primary para tenants EU]
    APAC[Region APAC<br/>Engines + Citus cluster<br/>primary para tenants APAC]

    US -.->|logical replication async| EU
    EU -.->|logical replication async| APAC
    US -.->|logical replication async| APAC

    note["Cross-region: async logical replication para DR, no para consistency strong"]

Estrategia: geo-sharding

NO active-active globalmente — too complex y conflict resolution es nightmare.

En su lugar: cada tenant es asignado a una región primary. Datos del tenant viven en esa región. Otras regiones son DR replicas (read-only, async).

Cross-region replication options

Tool Use case Complexity
Streaming replication Same Postgres major version Low
Logical replication (PG10+) Cross-version, selective tables Medium
pglogical Más control, conflict handling Medium
BDR (2ndQuadrant) Active-active commercial High
Postgres in Yugabyte Globally distributed Postgres-compat High

Recomendación: logical replication nativa para DR cross-region. Geo-active-active es nicho extremo, no para 99% de casos.

Capacidades

  • Geographic latency: < 100ms a usuarios en cada región
  • DR RPO: 5-30s (logical replication lag)
  • DR RTO: 5-15 minutos (manual promotion)
  • Compliance: data residency por tenant

Inversión estimada

  • Multi-region infra (3x clusters Citus HA)
  • Logical replication setup + monitoring
  • DR drill regular
  • Cross-region observability

Total: ~2-3 meses + ~3-5x infrastructure cost.

Trade-offs

  • Pro: geographic distribution
  • Pro: DR fuerte
  • Pro: compliance residency
  • Con: massive operational complexity
  • Con: very expensive
  • Con: writes cross-region son costosas (tenant migration cross-region es project)

Fase 6 — Multi-master con resolución de conflictos (opcional, extremo)

Solo si: necesitas writes locales en múltiples regiones simultáneamente.

Opciones: - CockroachDB (no Postgres pero compatible): globally distributed con strong consistency - YugabyteDB: similar, Postgres-compatible - Postgres BDR: commercial, complex

Casi nadie llega aquí. Si llegas, probablemente quieres replantear el problema (sharding por región es casi siempre mejor).

Tabla resumen de fases

Fase TPS target Storage Cost mult Availability Setup time
0 — Single-node 100-200 100 GB 1x 99% Days
1 — HA replicas 200-500 500 GB 3x 99.9% 2-3 weeks
2 — Active-active engines 500-1,000 500 GB 3.5x 99.95% 1-2 weeks
3 — Tenant sharding 5,000-10,000 10 TB 8-15x 99.95% 5-7 weeks
4 — Citus 10K-100K 100 TB 6-12x 99.95% 6-8 weeks
5 — Geo-distribuido Per region Per region 15-25x 99.99% 2-3 months
6 — Multi-master Global Global 25-50x 99.99%+ 6+ months

Cost multipliers son muy aproximados y dependen del cloud provider.

Comparación con Camunda 8

Capability Camunda 8 Esta estrategia
HA Raft consensus (built-in) Patroni + streaming repl (Fase 1)
Horizontal scaling Partitions (8192 max) Tenant sharding + Citus (Fases 3-4)
Multi-region NO native Logical replication (Fase 5)
Failover time < 1s (Raft) < 30s (Patroni)
Operational complexity Self-contained pero complex Postgres ecosystem mature
Total LOC ~860K ~80-100K (MVP) + plumbing

Trade-off principal: failover más lento (Patroni 30s vs Raft 1s), pero mucho menos código a mantener. Para 99% de casos, 30s de failover es aceptable.

Lo que NO se necesita replicar de Camunda

Estos features de Camunda son innecesarios con la estrategia Postgres:

  1. Snapshot transfer chunk-based: pg_basebackup + WAL streaming es equivalente y mejor
  2. Custom Raft fork: Patroni + streaming repl cubre HA
  3. Atomix SWIM: Patroni + etcd cubre membership
  4. 142+ RocksDB column families: Postgres tablas son suficientes
  5. SBE serialization: JSON en JSONB cubre el caso (debuggable bonus)
  6. Custom backup tooling: pgBackRest, WAL-G, Barman son mejores

Esto es valor incremental gratis de elegir Postgres como foundation.

Patterns transversales

Patterns que se mantienen en TODAS las fases

  1. Command sourcing: append-only command_log siempre
  2. Event sourcing: events derivados son re-derivables
  3. Replay determinism: testeable continuamente (ver concepts/replay-determinism)
  4. Idempotency en workers: at-least-once delivery semantics
  5. Object pooling en hot paths: reduce GC pressure (ver concepts/microbenchmark-methodology)
  6. Listeners pattern para integration: desacoplar consensus de application

Lo que cambia entre fases

  • Operational tooling (más complejo a cada paso)
  • Schema (sharding key cuando aplica)
  • Routing logic (smart router en app o transparent en Citus)
  • Monitoring (más métricas, más dashboards)

Decision framework

"¿En qué fase debería estar mi sistema?"

flowchart TD
    Q1{TPS sostenido < 100?} -->|Sí| F0[Fase 0 single-node]
    Q1 -->|No| Q2{Availability > 99%?}
    Q2 -->|Sí| F1[Fase 1 HA replicas]
    Q2 -->|No| Q3{Zero-downtime deploys?}
    Q3 -->|Sí| F2[Fase 2 active-active engines]
    Q3 -->|No| Q4{TPS > 1000?}
    Q4 -->|Sí| F34[Fase 3 o 4 depende cross-tenant queries]
    Q4 -->|No| Q5{Multi-region users?}
    Q5 -->|Sí| F5[Fase 5 geo-distribuido]
    Q5 -->|No| Q6{Active-active multi-region writes?}
    Q6 -->|Sí| F6[Fase 6 multi-master o reconsider]

"¿Cuándo NO escalar más?"

Razones para mantener single-node más tiempo: - Cost > benefit: 99.9% vs 99% no justifica 3x cost - Single-tenant SaaS: cada tenant es un deploy single-node - Compliance que requiere isolated DBs: forzosamente sharded desde inicio

Conclusión

La estrategia: empezar simple, escalar reactivamente basado en métricas reales. No prematuramente arquitecturar para Phase 5 si estás en Phase 0.

El valor de PostgreSQL: cada fase reusa tooling maduro existente. NO se construye custom replication, custom snapshot transfer, custom consensus. Esto es la diferencia entre invertir 4 años (Camunda) vs ~6-9 meses para llegar a Phase 4.

La clave del MVP: arquitectar el código con las abstracciones correctas desde Phase 0 (ver concepts/journal-and-stream-platform) para que cada fase sea un cambio incremental de infraestructura, no de código.

Si los patterns RecordProcessor / ProcessingResultBuilder / post-commit tasks están desde el día 1, escalar a Phase 4 es agregar Citus, no reescribir el engine.

Referencias