Pular para o conteúdo
upgbp

Supabase: porta 5432, 6543 ou conexão direta — qual usar

São três formas de conectar no mesmo banco, com limitações diferentes. Escolher errado gera erros que parecem de código e são de infraestrutura.

9 min de leitura

O erro tratado aqui

MaxClientsInSessionMode: max clients reached - in Session mode max clients are limited to pool_size

Ambiente testado

  • Supabase Postgres 15
  • Supavisor
  • Prisma 6.5
  • Node 22
Neste artigo (8)

Contexto: o que estava rodando

Um projeto no Supabase com três consumidores do mesmo banco: a aplicação Next.js na Vercel, um worker de processamento num contêiner de longa duração, e as migrations rodando no CI.

Todos os três usavam a mesma string de conexão. Cada um quebrou de um jeito diferente.

Os erros

Na aplicação serverless, sob carga:

vercel logs --prodexit 1
Error: prepared statement "s0" already exists

No worker, depois de algumas horas:

logs do contêinerexit 1
MaxClientsInSessionMode: max clients reached -
in Session mode max clients are limited to pool_size

No CI, ao rodar migration a partir de um runner sem IPv6:

github actionsexit 1
Error: P1001: Can't reach database server at `db.abcdefghijkl.supabase.co:5432`

connect ENETUNREACH 2600:1f1c:4a2:be00:9d21:5b7c:0000:0001:5432

Três mensagens sem nada em comum. A causa é a mesma: cada caminho de conexão tem uma limitação, e a string estava errada para dois dos três usos.

Diagnóstico

São três caminhos, não um

O Supabase expõe o mesmo Postgres de três maneiras. A diferença entre elas não é performance — é o que cada uma consegue fazer.

Comparação lado a lado dos três caminhos de conexão do Supabase: conexão direta, pooler em modo sessão e pooler em modo transação, com a topologia de cada um, porta, usuário, rede, recursos suportados e para qual tipo de cliente serve.
A diferença entre os três não é velocidade — é quanto tempo cada cliente segura a conexão.
Conexão direta Pooler · sessão Pooler · transação
Host db.REF.supabase.co aws-0-REGIAO.pooler.supabase.com aws-0-REGIAO.pooler.supabase.com
Porta 5432 5432 6543
Usuário postgres postgres.REF postgres.REF
Rede IPv6 (IPv4 é adicional pago) IPv4 e IPv6 IPv4 e IPv6
Conexão é sua Até desconectar Até desconectar Só durante a transação
Quantos clientes Poucos Poucos Muitos
Statements preparados Sim Sim Não (desligue no cliente)
LISTEN/NOTIFY, advisory lock, set de sessão Sim Sim Não
DDL e migrations Sim Sim Não recomendado

Note o detalhe fácil de passar batido: o pooler em modo sessão também usa a porta 5432. Só o host e o formato do usuário mudam. Isso faz muita gente achar que está na conexão direta quando está no pooler, e vice-versa.

Por que cada erro aconteceu

prepared statement "s0" already exists — a aplicação estava no modo transação (porta 6543), correto para serverless, mas sem desligar os statements preparados no cliente. Em modo transação a conexão é emprestada por transação, e dois clientes acabam criando s0 na mesma sessão do servidor.

MaxClientsInSessionMode — o worker estava no modo sessão. Nesse modo cada cliente segura uma conexão real enquanto estiver conectado, e o número de vagas é pequeno. O pool interno do worker abria mais conexões do que o modo sessão comporta.

ENETUNREACH — o runner do GitHub Actions não tem IPv6, e db.REF.supabase.co resolve para um endereço IPv6. Sem o adicional de IPv4, não há rota. O erro parece “banco fora do ar” e é “rede incompatível”.

A solução

  1. Aplicação serverless: modo transação, statements desligados, pool de um.

    .env.production (Vercel)
    DATABASE_URL="postgresql://postgres.REF:SENHA@aws-0-sa-east-1.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1"
    DIRECT_URL="postgresql://postgres.REF:SENHA@aws-0-sa-east-1.pooler.supabase.com:5432/postgres"

    O connection_limit=1 não é economia exagerada: cada invocação atende uma requisição por vez, e um pool maior só cria conexão ociosa que o pooler precisa administrar.

    prisma/schema.prisma
    datasource db {
      provider  = "postgresql"
      url       = env("DATABASE_URL")   // 6543 — consultas da aplicação
      directUrl = env("DIRECT_URL")     // 5432 — migrations
    }
  2. Worker de longa duração: modo sessão, com pool dimensionado.

    O erro do worker não era o modo — era o tamanho do pool. Um processo estável pode e deve manter conexões abertas; ele só não pode abrir mais do que existe vaga.

    src/worker/db.ts
    import postgres from 'postgres';
    
    export const sql = postgres(process.env.SESSION_URL!, {
      // Um número que cabe no modo sessão, e não o padrão da biblioteca.
      // Some o pool de todos os processos antes de escolher: duas réplicas
      // com max: 10 pedem 20 vagas, não 10.
      max: 5,
      idle_timeout: 30,
      connect_timeout: 10,
    });

    Antes de escolher o número, veja quantas vagas existem:

    show max_connections;
    
    select count(*) as em_uso
    from pg_stat_activity
    where datname = current_database();
  3. CI e migrations: pooler em modo sessão, não a conexão direta.

    Este é o ajuste que resolve o ENETUNREACH sem contratar adicional. O host pooler.supabase.com responde em IPv4, e o modo sessão suporta tudo que uma migration precisa.

    .github/workflows/deploy.yml
    - name: Aplicar migrations
      env:
        # Pooler em modo sessão (porta 5432): IPv4 e recursos de sessão completos.
        DIRECT_URL: ${{ secrets.SUPABASE_SESSION_URL }}
      run: npx prisma migrate deploy
  4. Deixar registrado na configuração qual caminho é qual.

    O maior risco aqui não é escolher errado uma vez — é alguém copiar a string errada daqui a seis meses. Nomes explícitos previnem isso melhor que documentação:

    .env.example
    # 6543 · modo transação · aplicação serverless
    # Muitos clientes efêmeros. Sem statements preparados, sem estado de sessão.
    DATABASE_URL=
    
    # 5432 · pooler em modo sessão · migrations, CI e workers
    # Poucos clientes estáveis. Sessão completa, IPv4 disponível.
    DIRECT_URL=
    
    # db.REF.supabase.co:5432 · conexão direta · só com IPv6 ou adicional IPv4
    # Use apenas para manutenção pontual pelo psql.
    ADMIN_URL=

Como confirmar que resolveu

Confirme por qual caminho cada serviço está entrando:

select
  inet_server_addr() as endereco_servidor,
  inet_server_port() as porta,
  current_user      as usuario;

Usuário postgres.REF indica pooler; postgres puro indica conexão direta.

Veja o total de conexões durante o pico. Com a aplicação em modo transação, o número precisa ficar estável mesmo com o tráfego subindo:

select state, count(*)
from pg_stat_activity
where datname = current_database()
group by state;

Force concorrência na aplicação e confirme que o 42P05 não volta:

npx autocannon -c 50 -d 20 https://seu-app.vercel.app/api/produtos

Rode uma migration pelo CI. É o único jeito de validar a rota de IPv4 antes de precisar dela num deploy urgente.

Armadilhas que sobram depois disso

A região do pooler faz diferença. aws-0-us-east-1 a partir de uma função na região gru1 acrescenta latência de ida e volta em toda consulta. Alinhe a região do projeto com a região onde a aplicação roda.

O painel oferece a string pronta — leia qual delas você copiou. As três ficam no mesmo lugar da interface, com rótulos parecidos. É o erro de origem mais comum.

O usuário da conexão decide o que você enxerga. O pooler conecta como postgres.REF, e a conexão direta como postgres — que é dono das tabelas e ignora as políticas de Row Level Security. Trocar de caminho pode fazer uma consulta passar a devolver linhas que antes estavam corretamente ocultas.

Modo transação não faz mágica com transação longa. Um $transaction que faz chamada HTTP no meio segura a conexão do pooler durante toda a chamada externa, anulando o benefício. Rede fora de transação, sempre.

connection_limit=1 derruba consultas paralelas na mesma requisição. Se uma rota dispara três consultas com Promise.all, elas passam a ser sequenciais. Em geral é aceitável; quando não for, suba para 2 ou 3 — nunca para o padrão.

Continue por aqui