Como tornamos a página Business Members ~100x mais rápida

A página Business Members levava cerca de 1,8 segundo de tempo de servidor a cada carregamento. Rastreamos isso até dois defeitos de desenho que se somavam, corrigimos ambos, e a mesma página agora renderiza a partir de cache em milissegundos de um dígito.

Melhoria de desempenho

  • Antes: ~1,8 segundo
  • Depois: <10 milissegundos
  • Melhoria: ~100x+

Medido com PayCal Lens.

Para administradores de empresas, a página Members agora parece praticamente instantânea, mesmo em organizações maiores.

Resumo executivo

Página afetada Business Members — a grade que lista cada membro de uma empresa com colunas financeiras calculadas
Antes ~1,8 segundo de tempo de servidor por carregamento
Depois Milissegundos de um dígito em cache hits (~100x+); cache misses também são mais rápidos que o caminho antigo
Causas raiz Um padrão de consultas N+1 contra Redis e recomputação completa de um ano de cálculos de payroll em cada visualização
Correção Lookups Redis em batch/pipeline, mais cache materializado da grade final com invalidação explícita
Frescor dos dados A cache é invalidada imediatamente em qualquer mudança relacionada a membros; expiração de 5 minutos limita staleness como rede de segurança

Por que estamos publicando isto

Desempenho faz parte da transparência. Quando uma página é lenta, usuários merecem saber se a lentidão é inerente ao trabalho ou resultado de uma falha de desenho evitável. Neste caso era a segunda opção, duas vezes, e os detalhes valem ser compartilhados porque ambos os defeitos são erros comuns em software web.

Nada neste artigo envolve problema de segurança ou exposição de dados de usuários. É apenas uma história de engenharia sobre tornar rápida uma página lenta.

O que a página faz

A página Business Members lista cada membro de uma empresa. Ao lado de nome e função, a grade mostra cinco colunas financeiras calculadas:

Nenhum desses valores é armazenado como número final. Eles são calculados a partir das work entries brutas de cada membro, os registros de turno armazenados no Redis. Produzir uma linha significa carregar perfil, entradas do ano inteiro, separar horas regulares e extras, e somar pagamento bruto.

  • Bruto acumulado no ano — ganhos totais até agora no ano
  • Total de horas — todas as horas trabalhadas este ano
  • Horas regulares — horas na taxa padrão
  • Horas extras — horas acima do limite regular
  • Trailing baseline — referência móvel usada para comparação

O problema, parte 1 — O padrão de consulta N+1

A implementação original fazia uma consulta para buscar a lista de membros e depois, para cerca de 100 membros, fazia round-trips Redis separados e sequenciais: um para perfil e outros para o ano completo de work entries.

Empresas pequenas quase não eram afetadas; a latência empilhada aparece quando organizações crescem para dezenas ou centenas de membros.

Esse é o padrão clássico “N+1”: uma consulta para a lista, depois N consultas uma por vez. Cada lookup Redis é rápido, mas cada round-trip paga latência fixa de rede; em sequência, esses custos se acumulam.

O banco nunca foi o gargalo. A conversa com o banco era.

O problema, parte 2 — Recalcular tudo em cada visualização

O segundo defeito ampliava o primeiro. Toda a matemática de payroll, separar entradas do ano em horas regulares e extras, calcular bruto acumulado e baseline, para cada membro, era refeita do zero em cada visualização.

Work entries não mudam com frequência. Entre duas visitas consecutivas, os dados quase sempre são idênticos. Mesmo assim, cada visita pagava o custo completo de recalcular resultados recém-calculados e descartados.

Combinados, os dois defeitos produziam cerca de 1,8 segundo de tempo de servidor por carregamento, medidos com PayCal Lens.

A correção, parte 1 — Redis pipelining

Redis oferece pipelining: enviar um lote de comandos em um único round-trip e receber todas as respostas juntas. O servidor agora faz todas as perguntas de uma vez.

Adicionamos Database::pipelineHgetall() e convertemos a grade de membros. Perfis são carregados em um round-trip e work entries em outro.

// Before - one round-trip per member, latency stacks linearly
foreach ($memberIds as $id) {
    $profiles[$id] = Database::hgetall($profileKey($id));
}

// After - one round-trip for the whole batch
$profiles = Database::pipelineHgetall(array_map($profileKey, $memberIds));

Essa mudança sozinha reduziu centenas de pagamentos sequenciais de latência para poucos.

A correção, parte 2 — Cache materializado

Pipelining torna mais barato alimentar o cálculo; a segunda correção evita repetir o cálculo. Introduzimos BusinessMembersCache, cache server-side que armazena no Redis a grade final calculada.

Duas regras governam o frescor da cache:

  • Expiração de 5 minutos. Cada grade em cache expira automaticamente após 5 minutos.
  • Invalidação explícita em mudança. Qualquer mudança relacionada a membros remove a grade em cache imediatamente.

Identidade de membro e verificações de permissão deliberadamente não são cacheadas. Cada request ainda executa todo o caminho de access control.

Impacto

  • Cache hits servem a grade em milissegundos de um dígito — melhoria ~100x+ sobre os ~1,8 segundo anteriores.
  • Cache misses continuam mais rápidos que a página antiga, porque a recomputação agora usa lookups em batch com pipeline.
  • A correção é testada. Testes contract e unit cobrem o comportamento de invalidação da cache.
Performance improvement summary showing ~1.8 seconds before, under 10 milliseconds after, and roughly 100x improvement measured with PayCal Lens
PayCal Lens measured server time before and after the fix.
PayCal Lens performance summary before optimization showing 1812 milliseconds total duration with financial summaries, profile hydration, and sequential work entry lookups as the slowest paths
Before: PayCal Lens ranked the per-member financial recomputation and sequential Redis round trips as the dominant costs (~1812 ms total).
PayCal Lens performance summary after optimization showing 7 milliseconds total duration with BusinessMembersCache get as the top path and cache hit status
After: the same page on a cache hit completes in single-digit milliseconds (~7 ms), with the materialized grid read as the primary work.
Business members grid table showing member names, roles, year-to-date gross, and total hours columns populated
The members grid loads immediately on repeat visits while access control still runs on every request.

What We Took Away From This

  • Measure before optimizing. PayCal Lens told us exactly where the 1.8 seconds went. Without per-request timing instrumentation, both flaws would have been guesses.
  • Latency stacks; batch it. Many small fast queries issued sequentially are slower than one large batched query. Round-trips, not data volume, dominated this page.
  • Cache finished work, invalidate eagerly. A cache is only trustworthy if every write path that affects it also clears it. The expiry is a safety net, not the mechanism.

Continuaremos publicando relatos de engenharia como este no Centro de Transparência.

Fatos de engenharia

  • Commit(s): 3db2229b, 2b3eafb8, f63773ea
  • Files Changed: 14 (under html/)
  • Tests Added: 12 (BusinessMembersCacheTest — 9 cases; LensRenderTest — 3 cases)
  • Tests Passing: 1901 (full PHPUnit suite, June 2026)
  • Performance Impact: ~1.8 s → ~7 ms (<10 ms on cache hits)
  • Production Status: Deployed