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¶
- Postgres en HA: primary + 2 hot standbys con streaming replication
- Patroni: leader election + automatic failover
- etcd o Consul: state store de Patroni
- PgBouncer: connection pooling (importante para evitar agotar conexiones durante failover)
- Engine routing: writes → primary, reads opcionales → replicas (con lag tolerance)
- 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¶
- Múltiples engines detrás del load balancer
- Leader election: solo el leader procesa commands del log
- Followers: sirven REST API (reads), validan commands antes de forward al leader, health checks
- 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¶
- Múltiples shards de Postgres: cada uno con su propio HA cluster
- Tenant → shard mapping: tabla central (en metadata DB) o algoritmo determinístico
- Smart router: cada engine routea commands al shard correcto basado en tenantId
- Tenant migration: tooling para mover tenants entre shards (online idealmente)
- 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_idoprocess_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:
- Snapshot transfer chunk-based: pg_basebackup + WAL streaming es equivalente y mejor
- Custom Raft fork: Patroni + streaming repl cubre HA
- Atomix SWIM: Patroni + etcd cubre membership
- 142+ RocksDB column families: Postgres tablas son suficientes
- SBE serialization: JSON en JSONB cubre el caso (debuggable bonus)
- 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¶
- Command sourcing: append-only command_log siempre
- Event sourcing: events derivados son re-derivables
- Replay determinism: testeable continuamente (ver concepts/replay-determinism)
- Idempotency en workers: at-least-once delivery semantics
- Object pooling en hot paths: reduce GC pressure (ver concepts/microbenchmark-methodology)
- 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¶
- analysis/blueprint-plataforma-simplificada — Spec del MVP Fase 0
- concepts/postgres-monitoring — Queries operacionales para todas las fases
- concepts/atomix-fork-lessons — Por qué NO forkear consensus
- concepts/journal-and-stream-platform — Patterns que persisten entre fases
- analysis/intuit-production-benchmarks — Targets reales validados
- Patroni: https://github.com/patroni/patroni
- Citus: https://github.com/citusdata/citus
- pgBackRest: https://pgbackrest.org/
- WAL-G: https://github.com/wal-g/wal-g