Skip to content

035. Conexões ilimitadas

Filipe Deschamps edited this page Feb 24, 2022 · 2 revisions

23/02/2022

Hoje foi um dia massa e vou repassar aqui o conteúdo desse PR, olha que massa:

Fazer o Database ser mais robusto contra falhas ao pegar novos Clients de conexão

Contexto: quem viu o último vídeo do TabNews, acompanhou que uma hora no meio do tutorial o banco engasgou, do nada, e isso "aparentemente" só estava acontecendo localmente e sem padrão algum (porque nunca aconteceu no CI). Pensei que era minha máquina e fiquei analisando o banco, número de conexões abertas e não encontrava nada... a aplicação do Next.js local simplesmente engasgava, só que em paralelo eu conseguia conectar normalmente no banco usando um Client externo. No final das contas, era eu quem estava fechando por completo o Pool de conexões a todo momento, achando que eu estava apenas fechando a conexão de um Client específico e que foi aberta manualmente.

Então como funciona o pg nesse sentido (e pensando aqui toda estratégia de Pool com Postgres): sempre você está com um Client em mãos para trabalhar... mas que pode ser adquirido de duas formas:

  1. Manualmente abrindo a conexão e com isso tendo em mãos um Client
  2. Ou pedindo para o Pool por um Client disponível que esteja lá na fila parado e aguardando ser utilizado. Destaco isso, porque eu pensei que você se conectava contra um Pool (como um PgBouncer da vida), mas não, o Pool do pg só administra Clients que ele mesmo está abrindo e fechando.

E quando você precisa de um Client manual? Quando você faz uma transaction (que no Postgres deve sempre manter na mesma conexão) ou bibliotecas que pedem um Client conectado, como a node-pg-migrate que usamos, que por dentro usa uma transaction e passa pra você a responsabilidade de encerrar ela ao final.

Então antes eu pedia um Client e encerrava o Pool (porque ele estava vindo de lá). Agora não, você pode usar o getNewClient() para pegar um client e o database.query() vai continuar usando de forma transparente o Pool (🔥 SÓ QUE GUARDA ESSA INFORMAÇÃO, PORQUE VAI TER REVIRAVOLTA NO FINAL 🔥)

Mas o que trouxe "robustez" foi outra coisa

Parar de fechar o Pool quando não queria foi um bug que eu introduzi por me confundir com a api do pg, mas deixar o database mais robusto quando você tem poucas conexões disponíveis (no atual serviço tem apenas 5 conexões, o que é muito pouco) foi feito através de uma estratégia de retry.

Tanto para pegar um Client manualmente ou do Pool, o código fica repetidamente pedindo por um novo Client, tentando, tentando, tentando (com máximo de 50 tentativas) e quando conseguir pegar um com sucesso, o código continua. E por continuar pode ser simplesmente retornar o Client, ou rodar uma query. Importante destacar que "rodar a query" fica fora das tentativas... a única coisa que se tenta é pegar um Client saudável.

// Primeira versão do código antes da reviravolta

import retry from 'async-retry';

...

async function tryToGetNewClientFromPool() {
  const clientFromPool = await retry(newClientFromPool, {
    retries: 50,
    minTimeout: 0,
    factor: 1.3,
  });

  return clientFromPool;

  async function newClientFromPool() {
    return await pool.connect();
  }
}

E isso pode ser usado no método de quey:

async function query(query, params) {
  let clientFromPool;

  try {
    clientFromPool = await tryToGetNewClientFromPool(); // isso só irá continuar quando pegar um Client saudável
    return await clientFromPool.query(query, params);
  } catch (error) {
    ...
  } finally {
    if (clientFromPool) {
      clientFromPool.release();
    }
  }
}

Testes de carga em localhost

Usei o ab pra tentar arrebentar aqui o serviço local tanto com 5 conexões disponíveis no Postgres, quanto 1 única conexão e a aplicação se comportou muito melhor do que eu esperava. A única forma que consegui estourar foi chegando no limite dos sockets do sistema operacional.

Fiquei muito feliz porque isso nos habilita a usar serviços mais baratos de Postgres (e que vem com uma quantidade pequena de conexões como comentei), com o agravante de estarmos num ambiente serverless e que pode abrir infinitas conexões.

Em paralelo, confere essa issue #196 onde o @andrefd17 deu duas dicas sensacionais de ferramentas pra teste de carga.

Nem tudo foram flores

Fazendo testes contra o ambiente de Preview da Vercel (usando 100 conexões em paralelo), tanto o Pool quanto o Retry estão se comportando como esperado, porém algo não esperado obviamente aconteceu: por algum motivo, algumas queries ficam presas lá no serviço do banco de dados, olha só:

image

E quando isso acontece, fica ocupando as conexões e não tem retentativa que aguente, pois a Lambda é terminada depois de 60 segundos:

image

Solução

Fiz agora pouco de um call com 3 pessoas fantásticas do Pagar.me: @gustavolivrare @lucianopf @grvcoelho

Esse call aconteceu da forma mais maluca e espontânea possível 😂 e a gente conseguiu melhorar o PR que comentei para lidar com muito mais conexões do que estava antes e o segredo foi, por incrível que pareça, parar de usar o Pool e gerenciar manualmente o Client de conexão.

Num cenário serverless e distribuído como o da Vercel, junto com o nosso banco que dá no máximo 5 conexões simultâneas, a pior escolha é usar o Pool porque ele vai segurar as conexões para poder reaproveitar elas (bloqueando conexões que poderia ser abertas por outras instâncias), e com o agravante de que a lamba em que tudo isto está sendo gerenciado é encerrada dentro de 60 segundos, aparentemente impedindo com que o Pool feche os clients abertos, deixando eles pendurados lá, como a gente viu no print abaixo. Então se o encerramento da lambda impede o Pool de fazer seu trabalho até ao final (que é desconectar os clients), você vai lotar suas conexões.

image

Então nesse cenário (e que não serve para quando você trabalha com instâncias menos efêmeras), acabou sendo muito melhor tentar abrir uma conexão, fazer o que for preciso, e fechar ela o mais rápido possível. Isso penaliza a performance, pois a todo momento precisamos abrir uma nova conexão, mas para esse cenário de conexões limitadas, ficou excelente. E como esse é o "worst-case scenario", daqui pra frente só melhora 👍

Número máximo de conexões

Agora o endpoint /api/v1/status retorna o número máximo de conexões do banco no campo max_connections. Vai ficar mais rápido e fácil saber o que de fato nosso provider de banco de dados está nos entregando:

{
  "updated_at": "2022-02-23T10:20:17-08:00",
  "dependencies": {
    "database": {
      "status": "healthy",
      "max_connections": 4,
      "opened_connections": 1
    }
  }
}
Clone this wiki locally