Pool de conexões com Spring e Postgres
Como funciona e por que é necessário?
Com o aumento do uso de frameworks perdemos o contato com os detalhes mais técnicos para ganhar velocidade ao resolver problemas de negócio. Apesar de ser essencial para a maioria das necessidades, às vezes encontramos dificuldades de entender certos comportamentos quando precisamos aumentar a capacidade de processamento ou até mesmo identificar por que uma solicitação não está com o desempenho esperado.
Cada sistema de banco de dados relacional pode usar diferentes estratégias para garantir as propriedades ACID e manter alta performance com mais foco em determinadas operações, por isso nessa série vamos falar especificamente sobre o funcionamento do Postgres e a configuração do serviço na AWS (RDS Postgres), para podermos entrar em mais detalhes.
O objetivo desse post é falar sobre o funcionamento do pool de conexões, usado por padrão no Spring a partir da versão 2.0, e por que ele é necessário.
Arquitetura padrão do banco de dados Postgres
Diferente dos serviços de backend, onde costumamos usar múltiplas instâncias de serviço com balanceamento de carga, por padrão o processo do postgres funciona em apenas uma máquina para garantir de forma simples e eficiente o controle de concorrência, mantendo a integridade dos dados com semáforos e memória compartilhada.
Por esse motivo a escalabilidade é limitada e precisamos economizar os recursos de processamento.
Conexão do lado do cliente
Para executarmos uma query no banco de dados precisamos de uma conexão ativa com o servidor e a abertura dessa conexão envolve as etapas no Postgres:
- Conexão com o banco de dados
- Interpretação da query
- Aplicação das regras de transformação (Ex: Transformação das queries sobre views em queries sobre as tabelas reais)
- Planejamento e otimizações para execução
- Busca da informação
Nesse post focaremos na etapa de conexão.
Conexão do lado do servidor
A cada conexão aberta com o banco de dados do Postgres o servidor executa um fork do processo para manter a independência dos processamentos e evitar que uma falha durante uma execução interfira no serviço como um todo.
O gerenciamento de conexões do banco de dados também consome recursos de processamento e memória para manter os diferentes contextos e permitir o processamento de diversas queries.
Inclusive, para não comprometer a performance do banco de dados, o Postgres limita as conexões conforme o parâmetro max_connections. No caso do RDS Postgres esse valor é configurável em “Parameter groups”, sendo que por padrão é calculado em relação à quantidade de memória RAM, limitado a 5000.
Connection pool
Se precisarmos executar novamente todas as etapas de conexão a cada consulta no banco de dados, toda query será lenta para o cliente e custosa para o servidor. Para evitar isso foi criado o conceito de connection pool. No Spring a lib padrão que gerencia o Connection Pool é a HikariCP.
O HikariCP provê algumas funcionalidades que otimizam bastante esse processo:
- Reaproveitamento de conexões
- Limitação da quantidade de conexões simultâneas
- Identificação de alguns estados de falha da conexão e abertura automática de nova conexão
Detalhes e exemplos práticos das funcionalidades do connection pool
Reaproveitamento de conexões
Sempre que uma aplicação solicitar uma conexão para o HikariCP, uma das conexões do pool será disponibilizada. Quando a query for finalizada e a aplicação solicitar o encerramento da conexão o HikariCP a retornará para o pool, para ser reaproveitada ao invés de encerrá-la de fato.
Observando o reuso de conexões na prática
O exemplo abaixo abre 10 conexões simultâneas com o banco e as fecha, abrindo uma nova conexão no final para observarmos o comportamento de conexões estabelecidas através do PgAdmin.
Script ConnectionPoolConnectionReuseExample.kt
Resultado da execução:
[main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
[main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
[2022-04-22T20:39:59.316Z] Conexão 1 aberta
[2022-04-22T20:39:59.813Z] Conexão 2 aberta
[2022-04-22T20:40:00.314Z] Conexão 3 aberta
[2022-04-22T20:40:00.813Z] Conexão 4 aberta
[2022-04-22T20:40:01.314Z] Conexão 5 aberta
[2022-04-22T20:40:01.814Z] Conexão 6 aberta
[2022-04-22T20:40:02.314Z] Conexão 7 aberta
[2022-04-22T20:40:02.814Z] Conexão 8 aberta
[2022-04-22T20:40:03.314Z] Conexão 9 aberta
[2022-04-22T20:40:03.814Z] Conexão 10 aberta
[2022-04-22T20:40:04.322Z] Conexão 1 fechada
[2022-04-22T20:40:04.814Z] Conexão 2 fechada
[2022-04-22T20:40:05.313Z] Conexão 3 fechada
[2022-04-22T20:40:05.813Z] Conexão 4 fechada
[2022-04-22T20:40:06.313Z] Conexão 5 fechada
[2022-04-22T20:40:06.814Z] Conexão 6 fechada
[2022-04-22T20:40:07.313Z] Conexão 7 fechada
[2022-04-22T20:40:07.813Z] Conexão 8 fechada
[2022-04-22T20:40:08.313Z] Conexão 9 fechada
[2022-04-22T20:40:08.814Z] Conexão 10 fechada
Process finished with exit code 0
O número de conexões permanece estável em 10 (+1 do próprio PGAdmin) do início ao fim do processamento. Ao iniciar o HikariCP ele abre 10 conexões automaticamente e as mantém ativas após o fechamento, mesmo sem nenhum pedido de conexão.
Limitação da quantidade de conexões simultâneas
O benefício em limitar a quantidade de conexões simultâneas é evitar a sobrecarga do banco de dados ou até atingir o limite de conexões do servidor. O processamento do método para obter conexão é interrompido até uma conexão ser liberada ou o limite de tempo de espera é atingido (Nesse caso é lançada uma SQLException).
Observando o limite de conexões e timeout na prática
O exemplo abaixo abre 11 conexões simultâneas com o banco para observarmos o funcionamento do limite.
Resultado:
[main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
[main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
[2022-04-22T17:50:38.682Z] Aguardando conexão
[2022-04-22T17:51:08.690Z] Fim da execução
Exception in thread "main" java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.
at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:197)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:162)
at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:100)
at br.com.studylibrary.ConnectionPoolLimitTimeoutExampleKt.main(ConnectionPoolLimitTimeoutExample.kt:25)
at br.com.studylibrary.ConnectionPoolLimitTimeoutExampleKt.main(ConnectionPoolLimitTimeoutExample.kt)
Process finished with exit code 1
O 11º pedido de conexão é colocado em espera, bloqueando a execução do código até que uma conexão seja liberada. Após 30s de espera é lançada uma Exception.
O exemplo abaixo abre 11 conexões e fecha 1 após 2s para observarmos a liberação de conexão.
Resultado:
[main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
[main] INFO com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
[2022-04-22T17:57:20.959Z] Aguardando conexão
[2022-04-22T17:57:22.975Z] Conexão liberada após 2s
[2022-04-22T17:57:22.976Z] Fim da execução
Process finished with exit code 0
Como no exemplo anterior, a 11ª abertura entra em espera até que seja liberada uma execução, mas neste exemplo liberamos uma após 2s, o que faz com que a aplicação continue a executar.
Identificação de estado de falha
Quando a aplicação solicita uma conexão o HikariCP verifica o estado da conexão antes de retornar para a aplicação. Se a conexão estiver inválida ele abre uma nova conexão com o banco de dados antes de retornar.
Resumo e recomendações
A execução de queries nos bancos de dados relacionais consome recursos importantes e não conseguimos escalar de forma horizontal por padrão, como a maioria dos serviços de backend. Para otimizarmos o uso desses recursos utilizamos o conceito de connection pool, que reaproveita as conexões que seriam fechadas, além de limitar a abertura de conexões de forma descontrolada por uma aplicação.
O connection pool também nos traz mecanismos de segurança para a aplicação não ficar “congelada”, aguardando uma conexão que nunca é liberada.
Sempre que conectamos em um banco de dados relacional usando um Connection Pool a melhor prática é sempre buscarmos reduzir o tempo de uso de uma conexão, abrindo o mais tarde possível e fechando o mais cedo possível, para retorná-la para o pool.
Todos os exemplos estão disponíveis com os detalhes de configuração no repositório: https://github.com/Marcelo-Rodrigues/connection-pool-study