Read replicas: estratégias de consistência eventual

1. Fundamentos de Read Replicas e Consistência Eventual

Read replicas são cópias assíncronas de um banco de dados primário, criadas para distribuir a carga de leitura e melhorar a escalabilidade horizontal. Na arquitetura típica, o primário processa todas as escritas e replica os dados para uma ou mais réplicas de forma assíncrona. Isso significa que as réplicas podem estar ligeiramente desatualizadas em relação ao primário — daí o termo "consistência eventual".

A consistência eventual garante que, se nenhuma nova escrita ocorrer, todas as réplicas convergirão para o mesmo estado após um período. Em contraste, a consistência forte exige que qualquer leitura retorne o valor mais recente, o que geralmente implica em maior latência e menor disponibilidade (Teorema CAP).

Casos de uso típicos incluem:
- Dashboards analíticos que toleram alguns segundos de atraso
- Feeds de notícias e redes sociais
- Catálogos de produtos em e-commerce
- Relatórios operacionais noturnos

2. Estratégias de Roteamento de Leituras

Roteamento baseado em carga (Round-Robin)

Distribui as consultas igualmente entre todas as réplicas disponíveis:

# Configuração de balanceamento round-robin para read replicas
read_replicas:
  - host: replica1.internal
    port: 3306
  - host: replica2.internal
    port: 3306
  - host: replica3.internal
    port: 3306

# Algoritmo de seleção (pseudo-código)
current_replica = (current_replica + 1) % total_replicas
query(current_replica)

Roteamento com afinidade de sessão (Sticky Sessions)

Garante que o mesmo usuário sempre consulte a mesma réplica durante uma sessão:

# Middleware de sticky session para read replicas
def get_session_replica(session_id):
    replica_index = hash(session_id) % len(replicas)
    return replicas[replica_index]

# Exemplo de uso
replica = get_session_replica("user_session_12345")
result = replica.query("SELECT * FROM products WHERE id = 42")

Roteamento geográfico

Direciona leituras para a réplica mais próxima geograficamente, reduzindo latência:

# Tabela de roteamento geográfico
routing_table:
  us-east-1: replica-us-east.internal
  us-west-2: replica-us-west.internal
  eu-west-1: replica-eu-west.internal

# Função de seleção geográfica
def get_geographic_replica(user_ip):
    region = geoip_lookup(user_ip)
    return routing_table[region]

3. Garantias de Consistência Eventual na Prática

Monitoramento da janela de replicação

A janela de replicação é o intervalo entre uma escrita no primário e sua propagação para a réplica. É crucial medir o lag:

# MySQL: verificar lag de replicação
SHOW SLAVE STATUS\G

# Saída relevante:
Seconds_Behind_Master: 2
Relay_Log_Space: 1048576
Slave_IO_Running: Yes
Slave_SQL_Running: Yes

Leitura após escrita (Read-After-Write)

Para operações críticas, redirecione a leitura para o primário imediatamente após uma escrita:

# Estratégia de read-after-write
def save_and_read(user_id, data):
    # Escreve no primário
    primary.execute("INSERT INTO users (id, data) VALUES (?, ?)", user_id, data)

    # Marca sessão para leitura no primário
    session.set("force_primary", True, ttl=5)  # 5 segundos

    # Próximas leituras deste usuário vão para o primário
    if session.get("force_primary"):
        return primary.query("SELECT * FROM users WHERE id = ?", user_id)
    else:
        return replica.query("SELECT * FROM users WHERE id = ?", user_id)

Timeouts e fallbacks

Quando a réplica está muito atrasada, consulte o primário como fallback:

# Fallback para primário quando réplica está atrasada
def read_with_fallback(replica, primary, max_lag=5):
    lag = replica.get_replication_lag()  # em segundos

    if lag > max_lag:
        # Réplica muito atrasada, usar primário
        return primary.query("SELECT * FROM products")
    else:
        return replica.query("SELECT * FROM products")

4. Tratamento de Inconsistências e Stale Reads

Versionamento de registros

Use timestamps ou números de versão para detectar dados obsoletos:

# Tabela com versionamento
CREATE TABLE products (
    id INT PRIMARY KEY,
    name VARCHAR(255),
    price DECIMAL(10,2),
    version INT DEFAULT 1,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

# Verificação de versão durante leitura
def read_product(product_id):
    replica_data = replica.query(
        "SELECT id, name, price, version FROM products WHERE id = ?",
        product_id
    )

    # Se versão parecer antiga, validar no primário
    if replica_data.version < expected_version:
        primary_data = primary.query(
            "SELECT id, name, price, version FROM products WHERE id = ?",
            product_id
        )
        return primary_data

    return replica_data

Estratégias de reconciliação

Após ler da réplica, valide no primário para operações críticas:

# Validação pós-leitura
def validate_order_consistency(order_id):
    # Leitura inicial da réplica (rápida)
    order = replica.query("SELECT * FROM orders WHERE id = ?", order_id)

    # Validação no primário (garantia de consistência)
    primary_order = primary.query("SELECT * FROM orders WHERE id = ?", order_id)

    if order.status != primary_order.status:
        # Dados inconsistentes, usar dados do primário
        return primary_order

    return order

Definição de SLAs de consistência

Estabeleça limites claros para o lag aceitável:

# SLA de consistência
consistency_sla:
  max_lag_seconds: 5
  max_lag_percentage: 0.1  # 10% das consultas podem estar desatualizadas
  critical_operations: ["payment", "inventory_reservation"]
  non_critical_operations: ["product_search", "recommendations"]

5. Monitoramento e Alertas de Lag de Replicação

Métricas essenciais

# Prometheus: métricas de replicação
# HELP mysql_replication_lag_seconds Replication lag in seconds
# TYPE mysql_replication_lag_seconds gauge
mysql_replication_lag_seconds{instance="replica1"} 2.5

# HELP mysql_seconds_behind_master Seconds behind master
# TYPE mysql_seconds_behind_master gauge
mysql_seconds_behind_master{instance="replica1"} 3.0

# HELP mysql_replica_io_running Replica IO thread status
# TYPE mysql_replica_io_running gauge
mysql_replica_io_running{instance="replica1"} 1

Dashboard Grafana

# Exemplo de consulta para dashboard Grafana
avg(
  mysql_replication_lag_seconds{instance=~"replica.*"}
) by (instance)

# Alerta quando lag > 10 segundos por mais de 2 minutos
- alert: HighReplicationLag
  expr: mysql_replication_lag_seconds > 10
  for: 2m
  labels:
    severity: critical
  annotations:
    summary: "Replication lag high on {{ $labels.instance }}"

Alertas proativos

# Configuração de alertas no Prometheus
groups:
  - name: replication_alerts
    rules:
      - alert: ReplicationLagCritical
        expr: mysql_replication_lag_seconds > 30
        for: 1m
        labels:
          severity: critical
        annotations:
          description: "Replication lag on {{ $labels.instance }} is {{ $value }}s"

      - alert: ReplicaDown
        expr: mysql_replica_io_running == 0
        for: 30s
        labels:
          severity: critical
        annotations:
          summary: "Replica IO thread stopped on {{ $labels.instance }}"

6. Padrões de Design para Alta Disponibilidade com Réplicas

Failover automático

# Script de failover simplificado
def auto_failover(primary, replicas):
    if not primary.is_alive():
        # Promover réplica mais atualizada
        best_replica = max(replicas, key=lambda r: r.get_replication_lag())
        best_replica.promote_to_primary()

        # Redirecionar escritas
        update_connection_pool(best_replica)

        # Reconfigurar demais réplicas
        for replica in replicas:
            if replica != best_replica:
                replica.change_master(best_replica)

Multi-AZ e multi-região

# Configuração multi-região
regions:
  us-east-1:
    primary: db-primary-us-east.internal
    replicas:
      - db-replica-us-east-1.internal
      - db-replica-us-east-2.internal
  eu-west-1:
    primary: db-primary-eu-west.internal
    replicas:
      - db-replica-eu-west-1.internal
      - db-replica-eu-west-2.internal

# Roteamento com fallback entre regiões
def get_replica(user_region):
    if user_region in regions:
        return random.choice(regions[user_region]["replicas"])
    else:
        # Fallback para região mais próxima
        return get_nearest_region_replica(user_region)

Cache de leitura local

# Combinação de réplicas com Redis
def get_product_with_cache(product_id):
    # Tentar cache local primeiro
    cached = redis.get(f"product:{product_id}")
    if cached:
        return cached

    # Cache miss: consultar réplica
    product = replica.query("SELECT * FROM products WHERE id = ?", product_id)

    # Armazenar em cache com TTL curto
    redis.setex(f"product:{product_id}", 60, product)  # 60 segundos

    return product

7. Casos Práticos e Anti-padrões

Exemplo real: Sistema de e-commerce

# Arquitetura de catálogo de produtos
ecommerce_system:
  primary_db: 
    role: "escritas (pedidos, inventário)"
  replicas:
    - role: "catálogo de produtos (leituras pesadas)"
    - role: "histórico de preços (relatórios)"
    - role: "recomendações (consultas complexas)"

# Fluxo de consulta de produto
def search_products(query):
    # 95% das consultas vão para réplicas
    if random.random() < 0.95:
        return replica.query("SELECT * FROM products WHERE name LIKE ?", f"%{query}%")
    else:
        # 5% para primário (garantir dados recentes)
        return primary.query("SELECT * FROM products WHERE name LIKE ?", f"%{query}%")

Anti-padrão: Dependência de consistência forte em réplicas

# ERRADO: Esperar consistência forte de réplicas
def update_inventory(product_id, quantity):
    # Escrita no primário
    primary.execute("UPDATE products SET stock = stock - ? WHERE id = ?", quantity, product_id)

    # Leitura imediata na réplica (pode retornar stock desatualizado)
    stock = replica.query("SELECT stock FROM products WHERE id = ?", product_id)  # INCORRETO

    if stock < 0:
        raise Exception("Estoque negativo")  # Falso positivo frequente

# CORRETO: Usar primário para leituras críticas
def update_inventory(product_id, quantity):
    primary.execute("UPDATE products SET stock = stock - ? WHERE id = ?", quantity, product_id)

    # Leitura no primário para garantir consistência
    stock = primary.query("SELECT stock FROM products WHERE id = ?", product_id)  # CORRETO

    if stock < 0:
        raise Exception("Estoque negativo")

Checklist de decisão

Checklist: Read Replicas vs Cache vs Sharding

| Critério                    | Read Replicas | Cache (Redis) | Sharding |
|-----------------------------|---------------|---------------|----------|
| Volume de leituras          | Alto          | Muito alto    | Alto     |
| Tolerância a dados antigos  | Alta          | Alta          | Baixa    |
| Complexidade operacional    | Média         | Baixa         | Alta     |
| Custo                       | Médio         | Baixo         | Alto     |
| Consistência                | Eventual      | Eventual      | Forte    |
| Escalabilidade              | Leitura       | Leitura       | Escrita  |

Use read replicas quando:
- Leituras >> escritas (pelo menos 10:1)
- Tolerância a alguns segundos de lag
- Necessidade de dados relacionais completos

Use cache quando:
- Dados acessados com frequência e raramente modificados
- Baixa tolerância a latência (< 10ms)
- Dados podem ser regenerados facilmente

Use sharding quando:
- Volume de escritas excede capacidade de um nó
- Necessidade de consistência forte
- Dados podem ser particionados naturalmente

Referências