Aumento de 146% no throughput de uma aplicação rodando em PHP-FPM
Resumo: Implementei connection pooling com PgBouncer em um framework PHP interno e aumentei o throughput em 146%, sem trocar o modelo de execução da aplicação. Neste artigo, detalho o processo, os obstáculos durante a implementação e como os resolvi.
O problema: o limite do process-per-request
PHP no modelo process-per-request, gerenciado pelo PHP-FPM, é ainda uma das arquiteturas clássicas que sustentam muitas aplicações web. Embora tenha a vantagem de ser facilmente implementada com menos custo infraestrutural, ela pode rapidamente chegar a um gargalo real: atingir o número limite de conexões e gerar sobrecarga de recursos no banco de dados.
Essa semana lidei com esse gargalo em um projeto interno da empresa. Montei um teste de carga local da infraestrutura do projeto usando docker e um serviço com wrk configurado para benchmark.
Com 50 conexões simultâneas o resultado foi claro: a aplicação estagnou em 750rps e o banco de dados Postgres já estava chegando aos limites de CPU e consumindo muita memória, não por queries pesadas, mas pela quantidade de conexões abertas.
Connection polling com PHP-FPM
Com os dados em mãos comecei a estudar uma solução muito simples, mas com nuances técnicas que podem fazer toda a diferença: connection pooling no banco de dados. Diferente de runtimes de longa duração que conseguem implementar connection pooling a nível de código, o PHP-FPM (process-per-request) exige uma infraestrutura separada para essa solução. Isso acontece porque cada requisição é atendida por um processo limpo, sem estado anterior, o que impede de gerenciar conexões a nível de código.
Escolhendo o melhor modo do PgBouncer
A solução escolhida foi o uso do PgBouncer, que possui três modos de uso: session, transaction e statement.
O modo session não traria melhora na performance porque faria basicamente o mesmo que o banco já fazia: prende uma conexão pelo tempo da sessão ativa, ou até a requisição finalizar. Já o modo statement é o mais agressivo, devolve a conexão ao pool a cada statement enviado ao banco (praticamente a cada query), mas isso pode causar um overhead desnecessário já que com poucas queries há um trabalho intenso feito pelo PgBouncer em gerenciá-las.
Para aproveitar da melhor forma o pool de conexões, optei pelo modo transaction, que é mais equilibrado. Nesse modo a conexão é devolvida ao pool assim que uma transação termina (COMMIT/ROLLBACK), em vez de prender ela durante todo o tempo de vida da requisição. Com isso, é possível definir no código quando uma conexão será usada e devolvida ao pool usando transações.
O obstáculo: queries auto-commit
Mas aqui entra um detalhe de implementação importante: no projeto em questão, havia uma estrutura MVC simples, com models divididas cada uma com seu domínio. A orquestração das lógicas entre models (entre domínios diferentes) ficava totalmente a cargo do controller. E mais um detalhe: cada método de model usava geralmente uma query auto-commit.
Esse não é o caso mais ideal para se usar transaction mode no connection pooling, porque o resultado final de se usar queries auto-commit seria o mesmo que usar o modo statement do PgBouncer: overhead desnecessário.
A solução: uma camada de Application Service
A solução implementada foi adicionar uma camada de Application Service no código, uma refatoração razoavelmente grande mas que diminuiu bastante a complexidade dos controllers. Ela ficou responsável por lidar com a lógica macro entre models e por gerenciar o início e fim de uma transação de acordo com o escopo lógico do código.
Isso evitou que o controller recebesse a responsabilidade de gerenciar as transações que englobassem as chamadas de múltiplas models (o que criaria dependência da camada de banco de dados direto no controller), mantendo a responsabilidade de cada camada separada. Com isso, foi possível equilibrar quando segurar e quando devolver uma conexão ao pool de acordo com a necessidade da lógica da aplicação.
O resultado
Rodei novamente o mesmo benchmark com as mesmas configurações e o resultado foi o seguinte: o sistema passou de 750rps para 1850rps. Isso significa um aumento de 146% no throughput da aplicação, e dessa vez sem sobrecarga no banco de dados (ele manteve uma média de 30 conexões abertas, bem abaixo do limite). Isso significa que a mesma aplicação, com a mesma arquitetura clássica de process-per-request, agora aguenta mais que o dobro de requisições de forma consistente.
O que mais me chamou a atenção nesse processo foi perceber como soluções simples, como um connection pooling bem configurado e uma camada de Application Service, podem resolver grandes gargalos e otimizar muito a capacidade de aplicações que rodam em infraestruturas clássicas.
#PHP #Backend #PostgreSQL #Performance #Arquitetura