Estratégias de escalabilidade horizontal de aplicações

1. Fundamentos da Escalabilidade Horizontal

A escalabilidade horizontal (scale-out) consiste em adicionar mais instâncias de uma aplicação para distribuir a carga de trabalho, diferentemente da escalabilidade vertical (scale-up), que aumenta os recursos de uma única máquina. Enquanto o scale-up encontra limites físicos e financeiros, o scale-out permite crescimento quase ilimitado, desde que a arquitetura seja projetada para isso.

Os pré-requisitos fundamentais são statelessness (cada requisição deve conter todas as informações necessárias) e idempotência (múltiplas execuções da mesma operação produzem o mesmo resultado). Sem esses princípios, adicionar instâncias pode gerar inconsistências.

Trade-offs: a complexidade operacional aumenta significativamente — gerenciamento de configurações distribuídas, coordenação entre instâncias e depuração de problemas em múltiplos nós. Por outro lado, os ganhos de capacidade são proporcionais ao número de instâncias adicionadas.

# Exemplo: Aplicação stateless em Node.js
const express = require('express');
const app = express();

// Sessão armazenada em cookie (stateless)
app.use(require('cookie-session')({
  secret: 'chave-secreta',
  maxAge: 24 * 60 * 60 * 1000
}));

app.post('/api/transfer', (req, res) => {
  // Operação idempotente com id único
  const { from, to, amount, idempotencyKey } = req.body;
  processTransfer(from, to, amount, idempotencyKey);
  res.status(200).json({ status: 'processed' });
});

2. Estratégias de Balanceamento de Carga

O balanceamento de carga distribui requisições entre instâncias disponíveis. Os principais algoritmos incluem:

  • Round-robin: distribuição sequencial, simples mas ignora carga real
  • Least connections: direciona para instância com menos conexões ativas
  • IP hash: mantém afinidade baseada no IP do cliente
  • Consistent hashing: minimiza redistribuição quando instâncias são adicionadas/removidas

Balanceadores de camada 4 (TCP/UDP) são mais rápidos, mas não entendem o conteúdo HTTP. Balanceadores de camada 7 (HTTP/HTTPS) permitem roteamento inteligente baseado em cabeçalhos, cookies ou paths.

Health checks periódicos e circuit breakers previnem que requisições sejam enviadas a instâncias falhas.

# Configuração de balanceador Nginx (camada 7)
upstream backend {
    least_conn;
    server app1:3000 max_fails=3 fail_timeout=30s;
    server app2:3000 max_fails=3 fail_timeout=30s;
    server app3:3000 backup;
}

server {
    location /api/ {
        proxy_pass http://backend;
        proxy_next_upstream error timeout http_500;
    }
}

3. Gerenciamento de Estado Distribuído

Em arquiteturas horizontais, o estado não pode ficar apenas na memória local. As soluções incluem:

Sessões centralizadas com Redis ou Memcached: todas as instâncias consultam um cache compartilhado. Isso elimina a necessidade de sticky sessions, que amarram um cliente a uma instância específica.

Padrões de cache distribuído:
- Cache-aside: aplicação verifica cache antes do banco
- Read-through: cache carrega automaticamente dados ausentes
- Write-through: cache é atualizado simultaneamente ao banco

A consistência eventual requer estratégias de invalidação cuidadosas, como TTLs (time-to-live) ou notificações por mensageria.

# Exemplo: Sessão centralizada com Redis (Python/Flask)
import redis
from flask import Flask, session
from flask_session import Session

app = Flask(__name__)
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_REDIS'] = redis.from_url('redis://redis-service:6379')
Session(app)

@app.route('/cart/add')
def add_to_cart():
    cart = session.get('cart', [])
    cart.append(request.args.get('item'))
    session['cart'] = cart  # Armazenado no Redis
    return 'Item adicionado'

4. Estratégias de Particionamento de Dados

O sharding horizontal divide dados entre múltiplos bancos. A escolha da chave de shard é crítica:

  • Hash range: distribui por hash da chave (ex: user_id % 4)
  • Consistent hashing: minimiza migrações quando shards são adicionados
  • Baseado em intervalo: divide por faixas de valores (ex: clientes A-M, N-Z)

O padrão Database per service (cada microsserviço tem seu banco) contrasta com shared database (todos acessam o mesmo banco com réplicas de leitura). O primeiro oferece maior isolamento, o segundo simplifica consultas que cruzam domínios.

Migrações sem downtime exigem técnicas como dual-writes (escrever em ambos os shards antigo e novo) ou shadow reads (ler do novo shard e comparar com o antigo).

# Estratégia de sharding por consistent hashing (Python)
import hashlib

class ConsistentHashRing:
    def __init__(self, nodes, replicas=3):
        self.replicas = replicas
        self.ring = {}
        self.sorted_keys = []
        for node in nodes:
            self.add_node(node)

    def add_node(self, node):
        for i in range(self.replicas):
            key = hashlib.md5(f"{node}:{i}".encode()).hexdigest()
            self.ring[key] = node
            self.sorted_keys.append(key)
        self.sorted_keys.sort()

    def get_node(self, key):
        if not self.ring:
            return None
        hash_key = hashlib.md5(key.encode()).hexdigest()
        for ring_key in self.sorted_keys:
            if hash_key <= ring_key:
                return self.ring[ring_key]
        return self.ring[self.sorted_keys[0]]

5. Comunicação entre Instâncias

O acoplamento direto entre instâncias via HTTP/REST pode criar dependências frágeis. Mensageria assíncrona (filas como RabbitMQ, tópicos como Kafka) desacopla produtores e consumidores, permitindo que instâncias processem em seu próprio ritmo.

Event-driven architecture publica eventos de domínio (ex: "pedido_criado") que múltiplos serviços consomem. Event sourcing armazena o estado como sequência de eventos, permitindo reconstrução histórica.

Service mesh (como Istio ou Linkerd) adiciona sidecar proxies a cada instância, gerenciando roteamento, retry, circuit breaking e métricas sem modificar o código da aplicação.

# Publicação de evento em Kafka (Java)
Properties props = new Properties();
props.put("bootstrap.servers", "kafka-cluster:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

Producer<String, String> producer = new KafkaProducer<>(props);
producer.send(new ProducerRecord<>("pedidos", 
    "pedido-123", 
    "{\"cliente\": \"João\", \"total\": 150.00}"));

6. Autoscaling e Orquestração

O Horizontal Pod Autoscaler (HPA) do Kubernetes ajusta automaticamente o número de réplicas baseado em métricas como CPU, memória ou métricas customizadas (latência, requisições por segundo).

Métricas eficazes para auto-scaling:
- CPU/memória: indicadores de saturação de recursos
- Latência: aumento indica necessidade de mais instâncias
- Throughput: requisições por segundo vs. capacidade atual

Estratégias de warm-up: instâncias novas precisam de tempo para aquecer caches e conexões. Técnicas como readiness probes com período de graça ou pre-warming com tráfego simulado mitigam cold starts.

# Manifesto Kubernetes HPA
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: app-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: minha-app
  minReplicas: 3
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Pods
    pods:
      metric:
        name: requests_per_second
      target:
        type: AverageValue
        averageValue: 1000

7. Observabilidade em Ambientes Escalados

Com dezenas ou centenas de instâncias, a observabilidade é essencial. Logging centralizado (ELK Stack, Loki) com correlation IDs (trace IDs) permite rastrear uma requisição através de múltiplos serviços.

Métricas agregadas (Prometheus + Grafana) monitoram:
- Taxa de erro (error rate)
- Latência (p50, p95, p99)
- Saturação (utilização de recursos)

Alertas baseados em SLOs (Service Level Objectives) e SLIs (Service Level Indicators) permitem escalabilidade preditiva — por exemplo, escalar antes que a latência do p99 ultrapasse 500ms.

# Middleware para trace ID (Go)
func TraceMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := r.Header.Get("X-Trace-ID")
        if traceID == "" {
            traceID = uuid.New().String()
        }
        ctx := context.WithValue(r.Context(), "traceID", traceID)
        w.Header().Set("X-Trace-ID", traceID)
        log.Printf("[%s] %s %s", traceID, r.Method, r.URL.Path)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

8. Desafios e Anti-padrões

Dependências de estado local: armazenar dados em disco local ou memória da instância impede que qualquer outra instância atenda o mesmo cliente. Solução: usar armazenamento compartilhado (Redis, banco de dados).

Race conditions em operações concorrentes exigem locks distribuídos (Redis Redlock, ZooKeeper) ou otimismo com versionamento.

Custos de rede: cada requisição entre instâncias adiciona latência. O padrão bulkhead (separar pools de conexão por serviço) e o cache local reduzem chamadas desnecessárias.

Anti-padrão comum: sticky sessions sem fallback. Se a instância específica falha, o cliente perde a sessão. Prefira sempre sessões centralizadas.

# Lock distribuído com Redis (Python)
import redis
import time

r = redis.Redis(host='redis-service', port=6379)

def acquire_lock(lock_name, acquire_timeout=10):
    identifier = str(uuid.uuid4())
    end = time.time() + acquire_timeout
    while time.time() < end:
        if r.setnx(f"lock:{lock_name}", identifier):
            r.expire(f"lock:{lock_name}", 10)
            return identifier
        time.sleep(0.001)
    return None

def release_lock(lock_name, identifier):
    pipe = r.pipeline(True)
    while True:
        try:
            pipe.watch(f"lock:{lock_name}")
            if pipe.get(f"lock:{lock_name}") == identifier:
                pipe.multi()
                pipe.delete(f"lock:{lock_name}")
                pipe.execute()
                return True
            pipe.unwatch()
            break
        except redis.exceptions.WatchError:
            pass
    return False

A escalabilidade horizontal não é uma solução mágica — exige planejamento cuidadoso de estado, comunicação e observabilidade. Quando bem implementada, porém, permite que aplicações cresçam de forma elástica, resiliente e economicamente viável.

Referências