Cómo hicimos la página Business Members ~100x más rápida

La página Business Members tardaba alrededor de 1,8 segundos de tiempo de servidor en cada carga. Lo rastreamos hasta dos fallos de diseño que se reforzaban entre sí, corregimos ambos, y la misma página ahora renderiza desde caché en milisegundos de un solo dígito.

Mejora de rendimiento

  • Antes: ~1,8 segundos
  • Después: <10 milisegundos
  • Mejora: ~100x+

Medido con PayCal Lens.

Para administradores de empresas, la página Members ahora se siente prácticamente instantánea, incluso en organizaciones más grandes.

Resumen ejecutivo

Página afectada Business Members — la grilla que lista cada miembro de una empresa con columnas financieras calculadas
Antes ~1,8 segundos de tiempo de servidor por carga de página
Después Milisegundos de un solo dígito en cache hits (~100x+ de mejora); los cache misses también son más rápidos que el camino anterior
Causas raíz Un patrón de consultas N+1 contra Redis y recomputación completa de un año de cálculos de payroll en cada vista
Corrección Búsquedas Redis agrupadas con pipeline, más una caché materializada de la grilla final con invalidación explícita
Frescura de datos La caché se invalida inmediatamente ante cualquier cambio relacionado con miembros; una expiración de 5 minutos limita la antigüedad como red de seguridad

Por qué publicamos esto

El rendimiento es parte de la transparencia. Cuando una página es lenta, los usuarios merecen saber si la lentitud es inherente al trabajo que se realiza o resultado de un fallo de diseño evitable. En este caso fue lo segundo, dos veces, y creemos que vale la pena compartir los detalles porque ambos fallos están entre los errores de rendimiento más comunes en software web.

Nada en este artículo implica un problema de seguridad ni exposición de datos de usuarios. Es únicamente una historia de ingeniería sobre hacer rápida una página lenta.

Qué hace la página

La página Business Members lista cada miembro de una empresa. Junto al nombre y rol de cada miembro, la grilla muestra cinco columnas financieras calculadas:

Ninguno de estos valores se guarda como número final. Se calculan desde las entradas de trabajo crudas de cada miembro, los registros individuales de turnos almacenados en Redis. Producir una fila implica cargar el perfil del miembro, cargar su año completo de entradas, separar horas regulares y extra, y sumar el pago bruto. Multiplicado por cada miembro, ese es el trabajo que hace la página.

  • Bruto acumulado del año — ganancias totales en lo que va del año
  • Horas totales — todas las horas trabajadas este año
  • Horas regulares — horas a la tarifa estándar
  • Horas extra — horas por encima del umbral regular
  • Trailing baseline — cifra de referencia móvil usada para comparación

El problema, parte 1 — El patrón de consulta N+1

La implementación original hacía una consulta para obtener la lista de miembros y luego, para cada uno de unos 100 miembros, hacía round-trips separados y secuenciales a Redis: uno para el perfil y más para su año completo de entradas de trabajo.

Las empresas pequeñas con pocos miembros casi no se veían afectadas; la latencia acumulada solo se vuelve visible cuando las organizaciones crecen a decenas o cientos de miembros.

Este es el patrón clásico “N+1”: una consulta para obtener la lista y luego N consultas más, una por una, para sus elementos. Cada lookup de Redis es rápido, pero cada round-trip paga una latencia fija de red. Emitidos secuencialmente, esos costos no se solapan; se acumulan linealmente.

La base de datos nunca fue el cuello de botella. La conversación con la base de datos lo era.

El problema, parte 2 — Recalcular todo en cada vista

El segundo fallo amplificaba el primero. Toda la matemática de payroll, separar un año de entradas en horas regulares y extra, calcular bruto anual y derivar el trailing baseline para cada miembro, se rehacía desde cero en cada vista.

Las entradas de trabajo no cambian tan seguido. Entre dos visitas consecutivas a Members, los datos subyacentes casi siempre son idénticos. Aun así, cada visita pagaba el costo completo de recalcular resultados recién calculados y descartados.

Combinados, los dos fallos producían cerca de 1,8 segundos de tiempo de servidor por carga, medidos con PayCal Lens.

La corrección, parte 1 — Redis pipelining

Redis soporta pipelining: enviar un lote de comandos en un solo round-trip y recibir todas las respuestas juntas. En vez de preguntar, esperar, preguntar de nuevo y volver a esperar, el servidor ahora hace todas sus preguntas a la vez.

Agregamos el método de búsqueda por lotes Database::pipelineHgetall() y convertimos la grilla de miembros para usarlo. Todos los perfiles se reúnen en un round-trip y todas las entradas de trabajo en otro, no uno por miembro y tipo de dato.

// 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));

Ese cambio por sí solo redujo cientos de pagos de latencia secuencial a unos pocos.

La corrección, parte 2 — Una caché materializada

El pipelining abarata alimentar el cálculo; la segunda corrección evita repetir el cálculo. Introdujimos BusinessMembersCache, una caché del lado del servidor que guarda en Redis los datos finales calculados de la grilla.

Dos reglas gobiernan la frescura de la caché:

  • Expiración de 5 minutos. Cada grilla cacheada expira automáticamente después de 5 minutos, limitando cuán vieja puede ser la información.
  • Invalidación explícita ante cambios. Cualquier cambio relacionado con miembros, como un rol actualizado, miembro agregado o removido, elimina la grilla cacheada inmediatamente.

La identidad del miembro y los permisos deliberadamente no se cachean. Cada solicitud sigue ejecutando todo el camino de control de acceso; solo se reutiliza la aritmética financiera costosa.

El impacto

  • Los cache hits sirven la grilla en milisegundos de un solo dígito — una mejora de ~100x+ frente a los ~1,8 segundos anteriores.
  • Los cache misses siguen siendo más rápidos que la página antigua, porque la recomputación ahora usa lookups agrupados con pipeline.
  • La corrección está probada. Tests contractuales y unitarios cubren la invalidación de caché para cambios de rol, cambios de membresía y entradas expiradas o incompatibles.
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.

Seguiremos publicando informes de ingeniería como este en el Centro de Transparencia.

Datos de ingeniería

  • 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