Buscar en la documentación

Rendimiento del listado y del dashboard

Cómo se validaron los índices del listado y del dashboard con EXPLAIN sobre 100.000 incidentes, y los resultados.

Módulo incidents11 min de lecturaVer en GitHub

El listado de incidentes es la consulta más usada y la que más crece. Sus índices se validaron con EXPLAIN sobre 100.000 incidentes generados por el seeder de volumen, en la misma base MySQL 8.4 del proyecto.

Cómo se mide

  1. Cargar la base de pruebas con volumen (alrededor de un minuto, porque cada incidente trae su historial: unas 520.000 filas en incident_activities):
    docker compose -p trazo exec -e DB_DATABASE=trazo_test -e SEED_INCIDENTS=100000 app php artisan migrate:fresh --seed --force
  2. Ejecutar el script de medición, que corre el listado real y pide el EXPLAIN del conteo y de la página:
    docker compose -p trazo exec -e DB_DATABASE=trazo_test app php artisan tinker --execute "require base_path('database/benchmarks/explain-incident-list.php');"
  3. Para el dashboard, el script mide cada fuente de datos sin caché, para quien ve todo, un agente y un solicitante:
    docker compose -p trazo exec -e DB_DATABASE=trazo_test app php artisan tinker --execute "require base_path('database/benchmarks/explain-dashboard.php');"

Cada escenario se ejecuta tres veces y se reporta el mejor tiempo: en Docker sobre Windows una sola medición tiene mucho ruido (la misma consulta, con el mismo plan, puede tardar el triple de una vez a otra).

La suite de pruebas vuelve a crear esa base desde cero en su siguiente ejecución, así que no queda nada que limpiar.

Resultados (100.000 incidentes, caché caliente)

Cada listado hace dos consultas importantes: el conteo (para el total y las páginas) y la página (20 filas). Las demás son de catálogos y relaciones, y no crecen con el volumen.

EscenarioResultadosTiempo totalConteo usaPágina usa
Todo, orden por creación100.00012 msdue_date, deleted_at (solo índice)created_at (recorrido inverso, 20 filas)
Agente: propios, asignados y sin asignar40.37729 msTabla completa (el filtro abarca el 40 %)created_at (20 filas)
Solicitante: propios12.46118 mscreated_bycreated_at (90 filas)
Estado + prioridad, orden por prioridad17.33872 msstatus_id, priority_idstatus_id, priority_id + ordenamiento en memoria
Vencidos, orden por vencimiento22.94266 msstatus_id, due_date, deleted_at (solo índice)due_date, deleted_at
Asignado a una persona17.02361 msassigned_to, status_idcreated_at (58 filas)
Rango de vencimiento55.01019 msstatus_id, due_date, deleted_at (salto de índice)due_date, deleted_at
Búsqueda de texto6.23340 msFULLTEXT title, descriptionFULLTEXT + ordenamiento

Lista agrupada (ADR-0080). En lugar del conteo general hay un conteo por grupo, y la página sale de una consulta corta por cada grupo que la toca:

EscenarioResultadosTiempo totalConteo por grupo usaPágina del grupo usa
Todo, agrupado por vencimiento100.00042 msstatus_id, due_date, deleted_at (solo índice)created_at (550 filas)
Agente, agrupado por vencimiento30.22740 msdashboard_assignee (solo índice)created_at (550 filas)
Todo, agrupado por estado100.00023 msstatus_id, due_date, deleted_at (solo índice)created_at (540 filas)
Todo, agrupado por prioridad100.00027 msdashboard_counts (solo índice)created_at (200 filas)
Todo, agrupado por asignado100.00024 msdashboard_assignee (solo índice)created_at (900 filas)
Pendientes por vencimiento, orden por vencimiento8.97922 msstatus_id, due_date, deleted_at (rango)due_date, deleted_at

La primera versión ordenaba todo lo filtrado por grupo (ORDER BY CASE …) y tardaba de 130 a 300 ms sin filtros: el orden por una expresión obliga a MySQL a ordenar todas las filas. El conteo por prioridad se agrupa por estado y prioridad, y se suma en PHP: así lo cubre el índice dashboard_counts; agrupado solo por prioridad, MySQL elegía el índice de priority_id, que no tiene deleted_at, y leía cada fila (142 ms).

Todos los escenarios responden en menos de 100 ms con 100.000 incidentes.

Ajuste que salió de la medición

Los conteos también filtran por deleted_at (borrado lógico), y los índices de vencimiento no incluían esa columna. Por eso MySQL tenía que leer cada fila para comprobarla. La migración 2026_10_22_000014 reemplazó:

AntesDespués
(status_id, due_date)(status_id, due_date, deleted_at)
(due_date)(due_date, deleted_at)

Los índices nuevos empiezan igual que los anteriores, así que los reemplazan sin perder ningún uso. Los resultados:

  • El conteo de vencidos bajó de 27 ms a 3 ms.
  • El del rango de vencimiento bajó de 18 ms a 10 ms.
  • El conteo general ahora se resuelve solo con el índice.

Lo que no se cambió, y por qué

  • El conteo del agente recorría la tabla. Su filtro (propios, asignados o sin asignar) abarca el 40 % de las filas. Desde los índices del dashboard (abajo), se resuelve solo con el índice.
  • El orden por prioridad u estado ordena en memoria. Ordena por el nivel del catálogo (priorities.level), que vive en otra tabla. Copiar el nivel a incidents repetiría un dato, y la base de datos está normalizada. Con 17.000 filas filtradas, tarda 72 ms.
  • Paginación por número de página. Pedir la página 4.000 de 5.000 es más lento que pedir la primera. Es lo que pide la prueba (page y per_page), y nadie recorre miles de páginas a mano: los filtros y la búsqueda acotan antes.

Las mejoras para un volumen mucho mayor (réplica de lectura, tabla resumen para los conteos y motor de búsqueda externo) están documentadas en ADR-0014.

Dashboard (100.000 incidentes, sin caché)

En uso real, cada fuente queda en caché hasta que cambia un incidente o pasan 60 segundos (ver Módulo Dashboard). Esta medición es el peor caso: la primera lectura después de un cambio.

FuenteQuien ve todoAgenteSolicitanteÍndice
Por estado y prioridad (total, pendientes y los dos gráficos)42 ms57 ms44 msdashboard_counts (solo índice)
Vencidos5 ms21 ms25 msstatus_id, due_date, deleted_at (solo índice)
Sin asignar3 ms3 ms4 msdashboard_assignee (solo índice)
Creados por hora (90 días)172 ms133 ms60 mscreated_at, deleted_at
Resueltos por hora (90 días)140 ms156 ms53 msresolved_at, deleted_at
Próximos a vencer (conteo y 5 filas)7 ms28 ms19 msstatus_id, due_date, deleted_at y due_date, deleted_at
Asignados a mí (conteo y 5 filas)17 ms15 ms3 msdashboard_assignee
Carga por agente52 ms62 ms35 msdashboard_assignee (solo índice)
Cumplimiento del plazo (30 días)55 ms52 ms—resolved_at, deleted_at (lee cada resuelto para comparar con su vencimiento)
Mapa de riesgo17 ms16 ms—status_id, due_date, deleted_at (solo pendientes)
Salidas sin resolver por hora (90 días)34 ms31 ms—resolved_at, deleted_at (los que no tienen fecha de resolución)

Las tres últimas son del diseño centrado en el riesgo (ADR-0081), medidas en una corrida posterior con el mismo volumen; el pulso, el flujo y el balance reutilizan además el conteo por estado y prioridad y la tendencia.

Los datos de demostración reparten los incidentes en los últimos 60 días, así que con 100.000 hay unos 1.600 por día. Es mucho más que una mesa de ayuda normal, y sirve como peor caso:

  • Todas las fuentes responden en menos de 100 ms, salvo la tendencia de 90 días (de 130 a 170 ms). Esa ventana abarca los 100.000 incidentes, y la consulta cuenta cada uno por hora. El tiempo depende de cuántos incidentes caen en la ventana, no del tamaño de la tabla.
  • La tendencia queda en caché 60 segundos (o hasta el siguiente cambio), así que solo la paga la primera lectura.
  • Si el volumen diario real se acercara a esa cifra, el paso siguiente es la tabla resumen por día ya prevista en ADR-0014: los días pasados no cambian y se contarían una sola vez.
  • Para el solicitante, la base usa created_by y lee sus filas: son pocas.

Índices que salieron de la medición

La primera medición mostró tres consultas lentas:

  • La carga por agente recorría la tabla entera (560 ms).
  • El agente recorría la tabla en el conteo por estado y prioridad (260 ms): su visibilidad usa created_by y assigned_to, que no estaban en el índice.
  • “Asignados a mí” (130 ms): MySQL recorría el índice de vencimientos completo, incluidos los cerrados, para no ordenar.

La migración 2026_10_23_000017 las resolvió:

AntesDespués
(status_id, priority_id)(status_id, priority_id, deleted_at, created_by, assigned_to) (dashboard_counts)
(assigned_to, status_id)(assigned_to, status_id, due_date, deleted_at, created_by) (dashboard_assignee)
(created_at)(created_at, deleted_at)
—(resolved_at, deleted_at)

Además, “asignados a mí” le indica a MySQL el índice dashboard_assignee (useIndex): ordenar los pendientes de una persona es mucho más barato que recorrer todos los vencimientos.

Efecto en el listado

Los índices nuevos empiezan igual que los anteriores, así que el listado no pierde ningún uso. Para compararlo se midió el listado con los índices anteriores y con los nuevos, seguidos y en las mismas condiciones (con la distribución de datos anterior, en la que el 60 % de los incidentes seguía pendiente):

EscenarioÍndices anterioresÍndices nuevos
Todo, orden por creación22 ms20 ms
Agente54 ms30 ms
Solicitante49 ms35 ms
Estado + prioridad, orden por prioridad170 ms117 ms
Vencidos, orden por vencimiento152 ms156 ms
Asignado a una persona54 ms16 ms
Rango de vencimiento28 ms26 ms
Búsqueda de texto82 ms74 ms

Ese día la máquina estaba más cargada que en la primera medición del listado (arriba): el escenario más simple tardó 22 ms en lugar de 12 ms, sin cambios de código. Con esa carga, dos escenarios del listado superan los 100 ms con los índices anteriores y con los nuevos:

  • El orden por prioridad, porque ordena en memoria (ver arriba).
  • Los vencidos ordenados por vencimiento, porque MySQL recorre el índice de vencimientos desde los más antiguos, que en su mayoría están cerrados.

Quedan anotados como puntos a vigilar.

Revisión general (25 de octubre de 2026)

En la pasada de calidad (ADR-0060) se revisó todo lo construido después de estas mediciones.

Slow query log (más de 1 s), después de las corridas E2E y del escaneo activo de ZAP: ninguna consulta de la app. Las 32 anotadas son de las pruebas: borrar y truncar las tablas de trazo_test al empezar cada suite, y la inserción de 100.000 incidentes para medir la exportación.

Lista de eventos del sistema con 50.000 eventos (mejor de 3 intentos):

ConsultaAntesDespués
Pendientes (por defecto)97 ms38 ms
Todos105 ms34 ms
Críticos pendientes25 ms30 ms
Por tipo1 ms1 ms

El EXPLAIN mostraba que MySQL empezaba por la tabla de tipos y ordenaba todo en una tabla temporal. Se cambió a LEFT JOIN (la clave foránea garantiza el tipo) y los filtros del tipo a una subconsulta: ahora empieza por los eventos. Sigue ordenando en memoria, pero con un volumen real mucho menor, porque los eventos se agrupan por huella y 50.000 serían 50.000 fallas distintas.

Exportación: 50.000 filas en 2,1 s con la memoria estable (Incidents).

Sin N+1 en ningún listado: incidentes, auditoría, dashboard, personas, roles y eventos del sistema tienen su prueba que compara las consultas con pocos y con muchos registros.

Front, sobre el build de producción:

PantallaCelular lento (CPU ×4, red 4G): listo / LCPPortátil: listo / LCPCLS
Ingreso1,7 s / 1,6 s1,1 s / 1,1 s0
Inicio (dashboard)2,2 s / 2,1 s1,1 s / 1,1 s0
Incidentes3,3 s / 3,0 s1,5 s / 1,2 s0
  • Carga inicial: unos 175 kB comprimidos.
    • JavaScript principal: 50 kB. Vue y Quasar: 27 kB. Estructura de la app: 38 kB. Mensajes y cliente HTTP: 22 kB.
    • CSS: 38 kB, casi todo de Quasar.
  • Cada pantalla se descarga al entrar (entre 5 y 25 kB); el editor del dashboard (44 kB), solo al personalizar.
  • Los tiempos del listado en celular incluyen la latencia del Docker local en Windows, de alrededor de 1 s por petición.

Dónde está en el código