Como a OpenAI escalou o PostgreSQL para suportar 800 milhões de usuários do ChatGPT com milhões de consultas por segundo

Published on: 2026-01-23
Post image
pt postgresql postgresql-scalability postgresql-performance scaling-postgresql postgresql-high-availability postgresql-read-replicas postgresql-architecture postgresql-optimization postgresql-millions-of-qps chatgpt-infrastructure openai

Escalar um banco de dados relacional para sustentar tráfego global intenso costuma expor limites que, em situações comuns, permanecem invisíveis. No caso de um ambiente com centenas de milhões de usuários, pequenas ineficiências se tornam gargalos, e picos de carga podem se transformar rapidamente em indisponibilidade ampla.

O tema central é a experiência de Bohan Zhang, Member of the Technical Staff, ao descrever como o PostgreSQL foi levado a um patamar de escala pouco associado ao seu uso tradicional. O relato aborda como uma arquitetura com primário único e dezenas de réplicas de leitura foi refinada para suportar milhões de consultas por segundo, com foco em carga majoritariamente de leitura, rigor operacional e otimizações em várias camadas.

Papel do PostgreSQL e a escalada abrupta de demanda

O PostgreSQL permaneceu por anos como um sistema de dados “nos bastidores” que sustenta serviços centrais. Com o crescimento acelerado da base de usuários, o volume de carga no banco aumentou de forma exponencial, com crescimento superior a 10x em aproximadamente um ano. Essa mudança pressionou não apenas CPU e I/O, mas também conexões, replicação e previsibilidade de latência. A experiência mostrou que, com engenharia cuidadosa, o PostgreSQL suporta cargas de leitura muito maiores do que frequentemente se supõe.

A arquitetura descrita manteve um único primário para escrita em um Azure PostgreSQL Flexible Server e adicionou quase 50 réplicas de leitura distribuídas globalmente. Essa combinação permitiu servir tráfego massivo com latência baixa por região, mantendo a simplicidade do modelo de consistência de escrita centralizada. Ao mesmo tempo, esse desenho exigiu disciplina para reduzir pressão no primário e para impedir ciclos de degradação. A meta não foi apenas “aguentar carga”, mas sustentar confiabilidade em produção sob picos imprevisíveis.

Fissuras do desenho inicial e o padrão dos incidentes

Após o lançamento do ChatGPT, o tráfego cresceu em velocidade incomum e forçou otimizações rápidas em aplicação e banco. O sistema foi escalado “para cima” com instâncias maiores e “para fora” com mais réplicas de leitura. Mesmo assim, diversos incidentes de severidade alta ocorreram por sobrecarga do PostgreSQL. Esses incidentes frequentemente seguiram um mesmo roteiro de causa e efeito.

Um problema a montante podia disparar uma subida súbita de carga no banco, como falhas na camada de cache gerando cache miss em massa. Em outros casos, consultas caras, com múltiplos joins, saturavam CPU, ou um lançamento de funcionalidade disparava uma tempestade de escrita. Com recursos próximos do limite, a latência subia, requisições expiravam e surgiam novas tentativas automáticas. As tentativas aumentavam ainda mais a carga, criando um ciclo vicioso capaz de degradar serviços inteiros.

Limitações de escrita no PostgreSQL e o impacto do MVCC

Apesar do bom desempenho em leitura, os períodos de escrita intensa continuaram desafiadores. O PostgreSQL usa MVCC (controle de concorrência multiversão), técnica que permite leituras consistentes sem bloquear escrita. Na prática, ao atualizar um registro, cria-se uma nova versão da linha, e a versão antiga torna-se obsoleta. Esse comportamento traz custos quando há muitas escritas.

Em cargas de escrita elevadas, o MVCC pode causar amplificação de escrita, pois a linha inteira pode ser copiada ao mudar um único campo. Também ocorre amplificação de leitura, já que consultas precisam atravessar versões antigas, gerando dead tuples (tuplas mortas) até chegar à versão visível. Isso aumenta bloat (inchaço) de tabelas e índices, piora a manutenção de índices e torna o ajuste do autovacuum mais complexo. O resultado é que o mesmo desenho que escala bem para leitura pode ficar mais instável quando a escrita aumenta abruptamente.

Migração de cargas “shardáveis” e a decisão de manter PostgreSQL sem sharding

Para reduzir a pressão de escrita, a estratégia adotada migrou cargas de escrita intensas e shardáveis para sistemas particionados horizontalmente. “Shardável” descreve um tipo de dado que pode ser dividido em partes independentes, cada uma atendida por um nó, reduzindo contenção central. Essa migração foi direcionada a bancos distribuídos, como Azure Cosmos DB, e acompanhada de mudanças de lógica na aplicação para evitar escritas desnecessárias. Também foi estabelecida uma regra operacional de não permitir novas tabelas no PostgreSQL atual.

Mesmo com essa evolução, o PostgreSQL permaneceu com instância primária única atendendo todas as escritas. A justificativa foi o custo de “shardar” aplicações existentes, que exigiria mudanças extensas em centenas de endpoints e poderia levar meses ou anos. Como o perfil era majoritariamente de leitura e as otimizações davam folga, a arquitetura continuou viável. A possibilidade de sharding do PostgreSQL permaneceu aberta para o futuro, mas não como prioridade imediata diante do “runway” disponível.

Redução de carga no primário como princípio de sobrevivência

Em um modelo de single-primary, a escrita não escala horizontalmente, e picos podem derrubar o primário. A resposta foi reduzir ao máximo a carga no primário, tanto de leitura quanto de escrita, preservando capacidade para absorver tempestades de escrita. Leituras foram descarregadas para réplicas sempre que possível, mas algumas leituras precisaram permanecer no primário por estarem dentro de transações de escrita. Nessas leituras “presas” ao primário, o foco foi garantir eficiência e remover consultas lentas.

Do lado da escrita, a migração de workloads shardáveis para sistemas distribuídos tirou pressão constante do primário. Para workloads difíceis de particionar, a migração foi tratada como um processo gradual e contínuo. Houve também otimizações agressivas para reduzir escrita, como correção de bugs que geravam gravações redundantes e adoção de lazy writes (escritas postergadas) quando apropriado para suavizar picos. Em atividades de backfill (preenchimento retroativo de campos), limites rígidos de taxa foram usados para evitar saturação por atualização em massa.

Otimização de consultas e prevenção de antipadrões de OLTP

Consultas caras foram identificadas como gatilhos diretos de incidentes, especialmente quando seu volume aumentava de repente. Em sistemas OLTP (processamento de transações online), consultas devem ser curtas, previsíveis e indexáveis, evitando operações que explodem em custo com o crescimento. Um pequeno conjunto de consultas dispendiosas pode degradar ou até derrubar um serviço inteiro. Por isso, a otimização constante de SQL tornou-se uma disciplina recorrente.

Um exemplo citado foi uma consulta extremamente custosa com join de 12 tabelas, associada a incidentes graves quando havia picos. A diretriz foi evitar joins complexos de múltiplas tabelas sempre que possível e, quando inevitável, quebrar a consulta e deslocar parte da lógica para a camada de aplicação. Também foi observado que muitos desses SQLs são gerados por ORM (mapeador objeto-relacional), e a revisão do SQL produzido passou a ser essencial. Outro problema comum foi a presença de consultas ociosas em transações, e o ajuste de timeouts como idle_in_transaction_session_timeout foi tratado como crítico para evitar bloqueio do autovacuum.

Exemplo prático: como um join “explosivo” pode ser quebrado

Um padrão típico de degradação em OLTP aparece quando um SQL tenta resolver tudo em um único comando, unindo muitas tabelas e aplicando filtros tardios. A seguir está um exemplo simplificado do tipo de consulta que tende a ficar cara, especialmente sob volume e cardinalidade altos. Em seguida, aparece uma abordagem alternativa que divide o problema em duas etapas, reduzindo custo e tornando mais previsível o plano de execução. Os exemplos abaixo ilustram o tipo de transformação descrita, sem depender de um esquema específico real.

-- Exemplo de consulta potencialmente cara: muitos joins e filtro tardio
SELECT
  u.id AS usuario_id,
  u.email,
  o.id AS organizacao_id,
  p.id AS projeto_id,
  r.nome AS papel,
  a.ultimo_acesso_em
FROM usuarios u
JOIN membros_organizacao mo ON mo.usuario_id = u.id
JOIN organizacoes o ON o.id = mo.organizacao_id
JOIN projetos p ON p.organizacao_id = o.id
JOIN papeis r ON r.id = mo.papel_id
LEFT JOIN acessos a ON a.usuario_id = u.id
WHERE u.status = 'ATIVO'
  AND p.status = 'ATIVO'
  AND o.regiao = 'us-east'
ORDER BY a.ultimo_acesso_em DESC
LIMIT 100;
-- Alternativa: reduzir escopo primeiro e depois buscar detalhes
-- Etapa 1: selecionar um conjunto pequeno e bem indexável de usuários
WITH usuarios_base AS (
  SELECT u.id
  FROM usuarios u
  WHERE u.status = 'ATIVO'
  ORDER BY u.id
  LIMIT 5000
),
usuarios_filtrados AS (
  SELECT DISTINCT ub.id
  FROM usuarios_base ub
  JOIN membros_organizacao mo ON mo.usuario_id = ub.id
  JOIN organizacoes o ON o.id = mo.organizacao_id
  WHERE o.regiao = 'us-east'
)
SELECT
  u.id AS usuario_id,
  u.email,
  o.id AS organizacao_id,
  p.id AS projeto_id,
  r.nome AS papel
FROM usuarios_filtrados uf
JOIN usuarios u ON u.id = uf.id
JOIN membros_organizacao mo ON mo.usuario_id = u.id
JOIN organizacoes o ON o.id = mo.organizacao_id
JOIN projetos p ON p.organizacao_id = o.id
JOIN papeis r ON r.id = mo.papel_id
WHERE p.status = 'ATIVO'
LIMIT 100;

Mitigação de ponto único de falha no primário

Réplicas de leitura permitem redirecionar tráfego quando uma réplica falha, mas o escritor único continua sendo um ponto único de falha. A mitigação começou reduzindo leituras no primário para que requisições críticas, majoritariamente de leitura, continuassem mesmo durante falhas do escritor. Com isso, a queda do primário passou a afetar principalmente escrita, reduzindo o impacto global. A disponibilidade de leitura passou a ser um mecanismo de “degradação controlada” em incidentes.

Além disso, o primário operou em modo de alta disponibilidade (HA) com hot standby, isto é, uma réplica sincronizada pronta para assumir. Em falhas ou manutenção, a promoção do standby reduz o tempo de indisponibilidade. Houve trabalho específico para tornar failover seguro sob carga alta, evitando corrupção, split brain e comportamentos inconsistentes. Em paralelo, múltiplas réplicas por região com folga de capacidade reduziram o risco de falhas isoladas virarem indisponibilidade regional.

Isolamento de workloads e o problema do “noisy neighbor”

Em ambientes compartilhados, um conjunto de requisições pode consumir recursos desproporcionalmente e prejudicar o restante. Esse fenômeno é conhecido como noisy neighbor, quando um “vizinho barulhento” degrada todos ao redor. Um lançamento de funcionalidade pode introduzir consultas ineficientes e elevar CPU e I/O, afetando operações críticas sem relação com a novidade. O resultado típico é aumento de latência, filas, timeouts e escalada de tentativas.

A solução foi isolar workloads em instâncias dedicadas e separar por prioridade. Requisições foram divididas em camadas de baixa e alta prioridade e roteadas para instâncias diferentes. Assim, uma carga de baixa prioridade que se torne cara não derruba o desempenho do tráfego crítico. A mesma lógica foi aplicada entre produtos e serviços para impedir que picos de um componente contaminem a confiabilidade de outro. O isolamento virou uma ferramenta de contenção e previsibilidade, não apenas uma otimização de performance.

Pool de conexões e o papel do PgBouncer

Bancos gerenciados impõem limites de conexões, e em Azure PostgreSQL o teto citado foi de 5.000 conexões por instância. Sem controle, surgem connection storms (tempestades de conexão), com milhares de clientes abrindo conexões ao mesmo tempo, esgotando o limite e degradando tudo. Além disso, muitas conexões podem ficar ociosas, consumindo memória e slots sem produzir trabalho. Em sistemas distribuídos, a latência de estabelecer conexão também se torna custo relevante.

A abordagem adotada foi usar PgBouncer como camada de proxy para connection pooling (reuso de conexões). Em modo de pooling por transação ou por statement, poucas conexões reais ao PostgreSQL conseguem servir muitos clientes. Isso também reduz o tempo médio de conexão, medido em benchmarks como queda de 50 ms para 5 ms. Para reduzir custo de rede, proxy, clientes e réplicas foram colocados na mesma região sempre que possível. A configuração exigiu cuidado especial em timeouts de inatividade para evitar acúmulo e exaustão.

Exemplo prático: configuração essencial do PgBouncer

A configuração a seguir exemplifica parâmetros comuns e relevantes para reduzir tempestades de conexão e manter previsibilidade. Ela mostra como limitar clientes, reaproveitar conexões e aplicar timeouts para evitar o acúmulo de sessões ociosas. Os valores são ilustrativos e servem para demonstrar o tipo de decisão descrita. O foco está em manter o banco protegido de explosões no número de conexões simultâneas.

; Configuração ilustrativa do PgBouncer (pgbouncer.ini)

[databases]
; Mapeia o nome lógico para o host do PostgreSQL
app_db = host=postgres-primario port=5432 dbname=app_db

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432

auth_type = md5
auth_file = /etc/pgbouncer/usuarios.txt

; Pooling por transação reduz o número de conexões reais no servidor
pool_mode = transaction

; Limites para absorver picos sem esgotar o PostgreSQL
max_client_conn = 20000
default_pool_size = 200
reserve_pool_size = 50
reserve_pool_timeout = 3

; Timeouts para evitar conexões ociosas e bloqueios longos
server_idle_timeout = 60
client_idle_timeout = 120
query_timeout = 30
idle_transaction_timeout = 30

; Observabilidade básica
log_connections = 1
log_disconnections = 1
stats_period = 60

Cache, cache miss storms e bloqueio por chave

Cache reduz pressão de leitura no banco ao servir dados frequentes com baixa latência. Porém, quando a taxa de acerto cai de forma inesperada, ocorre uma tempestade de cache misses, e muitas requisições passam a bater no PostgreSQL ao mesmo tempo. Esse efeito pode saturar CPU e I/O rapidamente e degradar toda a plataforma. O risco aumenta quando muitos clientes pedem a mesma chave simultaneamente.

A proteção usada foi um mecanismo de cache locking e leasing. Nesse padrão, quando uma chave falha no cache, apenas uma requisição adquire um “cadeado” e consulta o banco para repopular o cache. As demais requisições aguardam o preenchimento, em vez de multiplicar leituras redundantes no banco. Isso reduz explosões de carga, melhora estabilidade e evita que uma falha de cache vire falha sistêmica. O cache deixa de ser apenas um acelerador e passa a ser também um amortecedor de picos.

Exemplo prático: cache com lock e lease para evitar leituras redundantes

O exemplo abaixo demonstra uma implementação típica em Python com Redis para cache e lock por chave, incluindo um tempo de lease para evitar deadlocks. A ideia é garantir que somente uma execução busque no banco quando o cache falhar. As demais esperam por um curto período até o cache ser preenchido. Esse padrão reduz carga no PostgreSQL durante picos e falhas temporárias no cache.

import time
import json
import redis

redis_client = redis.Redis(host="redis", port=6379, decode_responses=True)

def buscar_no_postgresql(chave: str) -> dict:
    # Representa uma consulta real ao PostgreSQL
    # Em produção, seria uma query parametrizada com timeout e pool adequado
    return {"chave": chave, "valor": "dados_do_banco", "atualizado_em": time.time()}

def obter_com_cache_lock(chave: str, ttl_cache_seg: int = 60, ttl_lock_seg: int = 5) -> dict:
    chave_cache = f"cache:{chave}"
    chave_lock = f"lock:{chave}"

    valor = redis_client.get(chave_cache)
    if valor is not None:
        return json.loads(valor)

    # Tenta adquirir lock com expiração (lease) para evitar lock preso
    lock_adquirido = redis_client.set(chave_lock, "1", nx=True, ex=ttl_lock_seg)

    if lock_adquirido:
        try:
            # Double-check: outro worker pode ter preenchido entre o get e o lock
            valor = redis_client.get(chave_cache)
            if valor is not None:
                return json.loads(valor)

            dados = buscar_no_postgresql(chave)
            redis_client.setex(chave_cache, ttl_cache_seg, json.dumps(dados))
            return dados
        finally:
            redis_client.delete(chave_lock)

    # Se não adquiriu lock, espera o preenchimento do cache por pouco tempo
    limite_espera = time.time() + ttl_lock_seg
    while time.time() < limite_espera:
        valor = redis_client.get(chave_cache)
        if valor is not None:
            return json.loads(valor)
        time.sleep(0.05)

    # Fallback: em último caso, consulta o banco para não bloquear indefinidamente
    dados = buscar_no_postgresql(chave)
    redis_client.setex(chave_cache, ttl_cache_seg, json.dumps(dados))
    return dados

Escala de réplicas e o custo de replicação via WAL

Adicionar réplicas reduz latência de leitura e distribui tráfego, mas traz um custo oculto: o primário precisa enviar dados de replicação para cada réplica. No PostgreSQL, isso ocorre via WAL (Write Ahead Log), um registro sequencial das mudanças usado para recuperação e replicação. Quanto mais réplicas, maior o esforço de transmissão do WAL, aumentando consumo de rede e CPU no primário. Com isso, cresce o risco de replica lag (atraso de replicação) instável.

Com quase 50 réplicas globais, o sistema funcionou usando instâncias muito grandes e alta capacidade de rede, mas não permitiria crescimento infinito. Para avançar, a proposta foi adotar cascading replication (replicação em cascata), onde réplicas intermediárias retransmitem WAL para réplicas “filhas”. Assim, o primário envia WAL para menos destinos diretos, e o fan-out ocorre em camadas. O custo é maior complexidade operacional, principalmente em failover e na promoção correta de nós para evitar inconsistência. O recurso foi tratado como algo ainda em validação antes de produção.

Rate limiting e bloqueio seletivo para conter picos e tempestades de retry

Picos súbitos em endpoints específicos, explosões de consultas caras ou tempestades de tentativas podem esgotar CPU, I/O e conexões. Em sistemas distribuídos, timeouts parciais costumam gerar retry storms, onde muitos clientes repetem a requisição quase ao mesmo tempo, multiplicando carga. Esse padrão piora exatamente quando o sistema já está fragilizado, acelerando a degradação. Uma estratégia efetiva precisa limitar entrada de trabalho e impedir repetição agressiva.

Foi implementado rate limiting em múltiplas camadas: aplicação, pooler, proxy e consulta. O objetivo foi amortecer picos antes que se tornassem incidentes. Também se evitou intervalos de retry curtos demais, reduzindo a chance de sincronização de tentativas. A camada de ORM foi aprimorada para suportar rate limiting e, quando necessário, bloquear totalmente determinados query digests (assinaturas de consultas) identificados como caros. Esse load shedding (descartar carga) seletivo ajudou a recuperar o serviço com rapidez em surtos de consultas específicas.

Exemplo prático: rate limiting por digest de consulta na camada de aplicação

O trecho abaixo exemplifica como uma aplicação pode limitar consultas por um identificador estável, chamado aqui de digest. Esse digest pode ser um hash do SQL normalizado, permitindo identificar “a mesma consulta” mesmo com parâmetros diferentes. A política apresentada mostra como limitar taxa e também bloquear completamente uma assinatura em incidentes. A implementação é didática e representa a lógica descrita, não um padrão único obrigatório.

import time
import hashlib
from collections import deque, defaultdict

class RateLimiter:
    def __init__(self):
        self.janelas = defaultdict(deque)
        self.bloqueados = set()

    def bloquear_digest(self, digest: str) -> None:
        self.bloqueados.add(digest)

    def permitir(self, chave: str, max_por_janela: int, janela_seg: int) -> bool:
        agora = time.time()
        fila = self.janelas[chave]

        while fila and (agora - fila[0]) > janela_seg:
            fila.popleft()

        if len(fila) >= max_por_janela:
            return False

        fila.append(agora)
        return True

def digest_sql(sql_normalizado: str) -> str:
    return hashlib.sha256(sql_normalizado.encode("utf-8")).hexdigest()[:16]

rate_limiter = RateLimiter()

def executar_consulta(sql_normalizado: str, parametros: dict) -> str:
    digest = digest_sql(sql_normalizado)

    if digest in rate_limiter.bloqueados:
        raise RuntimeError("Consulta bloqueada por política de proteção")

    # Limita a taxa por tipo de consulta (digest), reduzindo picos de consultas caras
    if not rate_limiter.permitir(f"digest:{digest}", max_por_janela=200, janela_seg=1):
        raise RuntimeError("Limite de taxa excedido para esta consulta")

    # Aqui entraria a execução real no PostgreSQL via pool/proxy
    return "resultado_ok"

Gestão de schema e mudanças com risco de reescrita de tabela

Em ambientes grandes, uma alteração aparentemente simples pode ser perigosa. No PostgreSQL, certas mudanças de schema podem disparar uma table rewrite, que reescreve toda a tabela, gerando grande volume de I/O e escrita e potencialmente afetando produção. Por isso, mudanças estruturais foram tratadas como eventos de alto risco e submetidas a regras restritivas. O objetivo foi preservar estabilidade e evitar degradação por operações administrativas.

Somente mudanças leves foram permitidas, como adicionar ou remover certas colunas que não provocassem reescrita completa. Mudanças de schema ganharam timeout rígido de 5 segundos para impedir operações longas em produção. Criação e remoção de índices de forma concorrente foram permitidas para reduzir bloqueios. Mudanças ficaram restritas a tabelas existentes, e novas necessidades de tabelas passaram a ser direcionadas aos sistemas shardados. Em backfills, limites rígidos de taxa foram aplicados, aceitando janelas longas de execução para manter o ambiente estável.

Resultados operacionais e métricas alcançadas

O conjunto de otimizações demonstrou que, com desenho e disciplina corretos, Azure PostgreSQL pode atender cargas de produção gigantescas. O ambiente passou a lidar com milhões de QPS (consultas por segundo) em workloads de leitura, sustentando produtos críticos como ChatGPT e a plataforma de API. Quase 50 réplicas foram adicionadas com lag de replicação próximo de zero, preservando leituras de baixa latência em múltiplas regiões. A capacidade foi mantida com folga, permitindo absorver crescimento adicional.

Além de desempenho, o sistema sustentou confiabilidade com disponibilidade de “cinco noves” e latência p99 em poucos dezenas de milissegundos do lado do cliente. Em 12 meses, houve apenas um incidente de severidade máxima relacionado ao PostgreSQL, associado a um evento viral que elevou a escrita mais de 10x e adicionou mais de 100 milhões de novos usuários em uma semana. O aprendizado prático reforçou a importância de conter escrita no primário e de evitar que falhas em cache ou retries se transformem em avalanche. O foco permaneceu em previsibilidade, isolamentos e proteções em camadas.

Encerramento: o que ficou estabelecido como direção técnica

A história descrita consolidou uma visão pragmática: manter PostgreSQL sem sharding pode ser viável por muito tempo quando a carga é majoritariamente de leitura e quando o primário é protegido com rigor. A estratégia dominante foi retirar escrita do primário sempre que possível, migrando workloads shardáveis para sistemas distribuídos e reduzindo escrita redundante na aplicação. Em paralelo, a confiabilidade foi fortalecida com HA e failover, isolamento de workloads, pooling de conexões, cache com lock e rate limiting multinível. Essas camadas reduziram a probabilidade de ciclos de degradação e melhoraram a capacidade de recuperação sob picos.

No horizonte, a continuidade do trabalho incluiu migrar os workloads de escrita restantes que são difíceis de shardar e avançar com replicação em cascata para ampliar o número de réplicas sem sobrecarregar o primário. Também permaneceu em aberto explorar abordagens adicionais, como PostgreSQL shardado ou sistemas distribuídos alternativos, conforme a demanda cresça. O ponto final é que a escala alcançada não veio de uma única mudança, mas de uma sequência de otimizações coerentes e de decisões que priorizaram estabilidade e controle de explosões de carga. A combinação de arquitetura simples no núcleo e complexidade controlada nas bordas sustentou o crescimento com latência e disponibilidade consistentes.