La lección anterior cerró con una promesa: CicloUrbana está en producción y llega sola, y a partir de ahora vamos a verla funcionar y a hacer que vaya rápido. Este es el primer paso, y conviene empezar deshaciendo un malentendido: ajustar el rendimiento no consiste en aplicar trucos. No es añadir una caché, subir el pool de conexiones a 100 ni cambiar el recolector de basura porque en un artículo decían que era más rápido. Todo eso son palancas, y sin un método detrás son tres formas distintas de mover un número al azar.
El método es corto y no se salta ningún paso: medir, encontrar el cuello de botella, corregir una sola cosa, volver a medir. Esta lección lo recorre entero sobre la red de Ribalta. Veremos por qué la media de latencia miente y qué mirar en su lugar, cómo establecer una línea base con una prueba de carga que reproduzca la hora punta, dos leyes que dicen dónde merece la pena esforzarse, y después el recorrido priorizado por los lugares donde de verdad se va el tiempo: la base de datos primero —el N+1 y los índices—, luego el pool de conexiones, la JVM, los hilos y la red. Terminaremos con un caso completo: un endpoint que tarda 2,4 segundos en p95 y acaba tardando 90 milisegundos.
Contenido
- El método: nunca optimizar sin medir
- Objetivos: SLI, SLO y por qué la media miente
- La línea base: la prueba de carga
- Un script de k6 para la hora punta de Ribalta
- Leer los resultados de la prueba
- Dos leyes prácticas: Little y Amdahl
- Dónde se va el tiempo en una aplicación Spring Boot
- La base de datos (I): detectar el N+1
- La base de datos (II): índices y
EXPLAIN ANALYZE - La base de datos (III): paginación, proyecciones y lotes
- HikariCP: dimensionar el pool con criterio
- La JVM: memoria, recolector y pausas
- Hilos: Tomcat, el pool y los hilos virtuales
- La red: compresión y tamaño de la respuesta
- Perfilado: async-profiler, JFR y volcados
- Caso práctico: el endpoint de estaciones
- Errores Comunes y Consejos
- Ejercicios
- El método: nunca optimizar sin medir
Hay una frase de Donald Knuth que todo el mundo cita a medias. La completa dice: «los programadores gastan una cantidad enorme de tiempo preocupándose por la velocidad de partes no críticas, y esos intentos tienen un impacto muy negativo cuando se consideran la depuración y el mantenimiento. Deberíamos olvidarnos de las pequeñas eficiencias, digamos el 97 % del tiempo: la optimización prematura es la raíz de todos los males. Aun así, no deberíamos dejar pasar nuestras oportunidades en ese 3 % crítico.»
Lo importante es el final: existe un 3 % crítico y hay que encontrarlo. El error no es optimizar, es optimizar a ciegas. Y la intuición sobre dónde está ese 3 % es notoriamente mala: casi todos los desarrolladores apuestan por su propio código Java, cuando en una aplicación como CicloUrbana el tiempo está casi siempre esperando a la base de datos o a un servicio remoto.
El ciclo de trabajo es este:
flowchart LR
A[Definir el objetivo<br/>SLO medible] --> B[Medir la linea base<br/>prueba de carga]
B --> C[Localizar el cuello de botella<br/>perfilado y trazas]
C --> D[Corregir UNA cosa]
D --> E[Volver a medir]
E --> F{Se cumple<br/>el objetivo?}
F -->|No| C
F -->|Si| G[Parar y documentar]
Tres reglas gobiernan ese ciclo. Un cambio cada vez: si tocas el pool, la caché y el índice a la vez y mejora, no sabes cuál sirvió ni cuál está empeorando otra cosa —y en CicloUrbana esa disciplina es barata, porque cada cambio es un despliegue automático (08-05)—. Parar cuando se cumple el objetivo: sin criterio de parada el ajuste no termina nunca y acaba añadiendo complejidad que nadie necesita; «rápido» no es un objetivo, «p95 por debajo de 300 ms con 200 usuarios concurrentes» sí lo es. Y medir donde importa: un System.currentTimeMillis() alrededor de un método en tu portátil, con la base de datos vacía y sin concurrencia, no dice nada sobre Ribalta a las ocho de la mañana.
- Objetivos: SLI, SLO y por qué la media miente
Antes de medir hay que decidir qué se mide y qué valor es aceptable:
| Término | Qué es | Ejemplo en CicloUrbana |
|---|---|---|
| SLI (indicator) | La métrica concreta que se observa | Latencia de POST /api/v1/alquileres |
| SLO (objective) | El objetivo interno sobre ese SLI | p95 < 300 ms y p99 < 800 ms durante el 99 % del mes |
| SLA (agreement) | El compromiso contractual, con penalización | El ayuntamiento exige 99,5 % de disponibilidad mensual |
La elección del estadístico no es un detalle: es la decisión más importante de todo el capítulo. Supongamos 1.000 peticiones al endpoint de inicio de alquiler en una hora punta de Ribalta:
| Grupo | Peticiones | Latencia |
|---|---|---|
| Estación en caché, todo bien | 900 | 40 ms |
| Estación con muchas bicicletas | 80 | 300 ms |
| Coincide con el bloqueo de un lote nocturno | 20 | 3.000 ms |
La media es (900×40 + 80×300 + 20×3000) / 1000 = 120 ms. Un número excelente que se puede llevar a una reunión sin sonrojarse. Ahora los percentiles: ordenadas las 1.000 latencias, la posición 950 (p95) cae en el grupo de 300 ms, y la posición 990 (p99) cae en el grupo de 3.000 ms.
p50 = 40 ms · media = 120 ms · p95 = 300 ms · p99 = 3.000 ms
Los mismos datos cuentan dos historias opuestas. Y la que importa es la segunda: 20 de cada 1.000 ciudadanos esperan tres segundos mirando la pantalla del móvil delante de una bicicleta. En una hora punta con 12.000 alquileres, son 240 personas cada mañana. La media los ha escondido porque promedia sobre una distribución con cola larga, que es exactamente la forma que tienen todas las distribuciones de latencia reales.
Cuatro reglas prácticas. La media nunca es el SLO: sirve, como mucho, para detectar una regresión grosera. p95 describe la experiencia típica mala y p99 o p99,9 la peor, y cuanto más crítico es el servicio más a la derecha hay que mirar. Los percentiles no se promedian: la media de los p95 de tres instancias no es el p95 del conjunto, algo con consecuencias técnicas muy concretas que veremos en 09-03 con los histogramas. Y latencia y disponibilidad se miran juntas, porque un endpoint que devuelve 500 en 5 ms tiene un p99 magnífico.
- La línea base: la prueba de carga
Sin línea base no hay mejora, solo sensación de mejora. Una prueba de carga somete a la aplicación a un tráfico controlado y reproducible, para poder comparar el antes y el después con el mismo patrón.
| k6 | JMeter | |
|---|---|---|
| Escenarios | JavaScript, en el repositorio | XML, normalmente con interfaz gráfica |
| Curva de aprendizaje | Baja para quien programa | Media; muchos conceptos propios |
| Recursos del generador | Muy bajos (Go, corrutinas) | Altos (un hilo por usuario virtual) |
| Control de versiones | Natural: es código | Difícil: el .jmx no se revisa bien |
| Integración en CI | Excelente, imagen oficial | Posible, más pesada |
| Protocolos | HTTP, gRPC, WebSocket | Muchísimos (JDBC, JMS, LDAP...) |
| Informes | Consola, JSON, salida a Prometheus | Interfaz gráfica e informes HTML |
| Cuándo elegirlo | Por defecto en CicloUrbana | Protocolos exóticos o equipos ya formados |
Elegimos k6: el escenario vive junto al código, se ejecuta en la canalización de 08-05 y no necesita una máquina potente para generar 500 usuarios virtuales. Tres advertencias antes del script: no pruebes contra producción sin acordarlo —se prueba contra pre (07-02) y con datos de volumen realista, porque una base con 12 estaciones y 30 alquileres no revela nada—; el generador no puede ser el cuello de botella, así que si k6 corre en el mismo portátil que la aplicación estás midiendo el portátil; y calienta la JVM, porque los primeros segundos son código interpretado y pool frío, y se descartan.
- Un script de k6 para la hora punta de Ribalta
El escenario reproduce lo que ocurre entre las 7:45 y las 9:15 en Ribalta: mucha gente consulta estaciones cercanas, una parte inicia un alquiler y, unos minutos después, lo finaliza.
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Trend, Rate } from 'k6/metrics';
const latenciaInicio = new Trend('alquiler_inicio_ms');
const alquileresFallidos = new Rate('alquileres_fallidos');
const BASE = __ENV.BASE_URL || 'https://pre.ciclourbana.ribalta.es';
const ESTACIONES = [1, 2, 3, 4];
export const options = {
stages: [
{ duration: '1m', target: 50 }, // rampa: calentar la JVM y el pool
{ duration: '3m', target: 200 }, // subida hasta la hora punta
{ duration: '5m', target: 200 }, // meseta: aqui se mide de verdad
{ duration: '1m', target: 0 }, // bajada ordenada
],
thresholds: {
'http_req_failed': ['rate<0.01'], // menos del 1 % de errores
'http_req_duration': ['p(95)<300', 'p(99)<800'], // el SLO del apartado 2
'alquiler_inicio_ms': ['p(95)<400'],
},
};
export function setup() { // una sola vez, token compartido
const res = http.post(`${BASE}/api/v1/auth/login`,
JSON.stringify({ correo: '[email protected]', clave: __ENV.CLAVE }),
{ headers: { 'Content-Type': 'application/json' } });
return { token: res.json('token') };
}
export default function (datos) {
const cabeceras = { headers: { 'Authorization': `Bearer ${datos.token}`,
'Content-Type': 'application/json' } };
const estacion = ESTACIONES[Math.floor(Math.random() * ESTACIONES.length)];
group('consultar estaciones', () => {
const res = http.get(`${BASE}/api/v1/estaciones?page=0&size=20`, cabeceras);
check(res, { 'estaciones 200': (r) => r.status === 200 });
sleep(Math.random() * 2 + 1); // el ciudadano mira el mapa
});
group('iniciar y finalizar alquiler', () => {
const inicio = http.post(`${BASE}/api/v1/alquileres`,
JSON.stringify({ estacionOrigenId: estacion, tipoTarifa: 'ESTANDAR' }), cabeceras);
latenciaInicio.add(inicio.timings.duration);
const creado = check(inicio, { 'alquiler 201': (r) => r.status === 201 });
alquileresFallidos.add(!creado);
if (!creado) return;
sleep(3); // trayecto simulado
const fin = http.patch(`${BASE}/api/v1/alquileres/${inicio.json('id')}/finalizar`,
JSON.stringify({ estacionDestinoId: estacion }), cabeceras);
check(fin, { 'finalizar 200': (r) => r.status === 200 });
});
}Qué hace cada pieza y por qué. stages define la forma de la carga, y la rampa inicial no es cortesía: sin ella se mide la JVM interpretando bytecode y HikariCP abriendo conexiones, no el estado estacionario —la meseta es la única parte cuyos números valen—. thresholds convierte el SLO en una condición de fallo: si no se cumple, k6 sale con código distinto de cero y la canalización de 08-05 se pone en rojo, de modo que el rendimiento deja de ser una opinión y pasa a ser una prueba. setup() se ejecuta una vez y comparte el token con todos los usuarios virtuales, para no medir el coste del login en cada iteración. sleep() es imprescindible: sin pausas no se simulan 200 ciudadanos, se simula un ataque; el tiempo de reflexión es lo que hace que la concurrencia del escenario se parezca a la real. Y las métricas propias (Trend, Rate) separan el SLO de negocio —iniciar un alquiler— del agregado de todas las peticiones.
Se ejecuta así:
k6 run --env BASE_URL=https://pre.ciclourbana.ribalta.es \
--env CLAVE="$CLAVE_CARGA" \
--out json=resultados-base.json carga-hora-punta.js
- Leer los resultados de la prueba
http_req_duration..............: avg=214ms min=11ms med=98ms max=4.8s p(90)=402ms p(95)=1.19s
{ expected_response:true }...: avg=201ms min=11ms med=95ms max=4.8s p(90)=380ms p(95)=1.11s
http_req_failed................: 0.71% 432 out of 60731
http_reqs......................: 60731 112.4/s
iterations.....................: 15182 28.1/s
vus............................: 200 min=1 max=200
alquiler_inicio_ms.............: avg=486ms med=210ms p(95)=2.41s
✓ estaciones 200
✗ alquiler 201 99.29% ✓ 15074 ✗ 108
✗ http_req_duration: p(95)<300 -> p(95)=1.19sCómo se interpreta, en orden. ¿Falló algún umbral? Sí: el p95 global es 1,19 s frente a los 300 ms del SLO, así que la prueba ha hecho su trabajo. ¿Hay errores? Un 0,71 %, por debajo del umbral del 1 %, pero no es ruido y hay que mirar de qué tipo son: un 500 es un fallo, mientras que un 409 puede ser el índice único parcial de 04-08 rechazando un segundo alquiler del mismo usuario, que es correcto. ¿Media contra percentiles? avg=214ms frente a p(95)=1.19s y max=4.8s: la cola larga del apartado 2, exactamente. ¿Qué operación concreta? alquiler_inicio_ms tiene p95 de 2,41 s con mediana de 210 ms, luego el problema no está repartido: está en un punto y solo bajo carga —una operación con mediana buena y p95 diez veces peor casi siempre espera por un recurso compartido: una conexión del pool, un bloqueo de fila o una pausa de GC—. ¿Escala? 112 peticiones por segundo con 200 usuarios virtuales; si al doblar los usuarios el rendimiento no sube y la latencia sí, se ha alcanzado la saturación y hay un recurso limitado detrás.
Esa es la línea base. Se guarda en el repositorio con la fecha y la versión desplegada, porque la única forma honesta de afirmar «esto va más rápido» es comparar contra ella.
- Dos leyes prácticas: Little y Amdahl
La ley de Little relaciona tres magnitudes en cualquier sistema estable:
Con los números de la prueba: λ = 112 pet/s, W = 0,214 s de media, luego L = 24 peticiones dentro del sistema a la vez. Ese número es oro:
- Si hay 24 peticiones concurrentes y el pool de HikariCP tiene 10 conexiones, hay peticiones esperando conexión. Ahí está el candidato.
- Si Tomcat tiene 200 hilos y solo 24 se usan, subir los hilos no arregla nada. Es el error más frecuente del ajuste por intuición.
- Al revés: si el objetivo es 300 peticiones por segundo con 200 ms de latencia, hará falta soportar
L = 60peticiones simultáneas. La ley te dice el requisito antes de probar.
La ley de Amdahl dice cuánto puede mejorar el conjunto al acelerar una parte:
donde P es la fracción del tiempo que ocupa la parte optimizada y S cuánto se acelera. Dos casos de CicloUrbana:
| Qué se optimiza | P |
S |
Aceleración total |
|---|---|---|---|
| Las consultas SQL, que son el 70 % del tiempo | 0,70 | 10× | 2,7× |
| La serialización JSON, que es el 5 % | 0,05 | 10× | 1,05× |
| Las consultas, eliminándolas del todo con caché | 0,70 | ∞ | 3,3× (el techo) |
La lectura es demoledora: hacer diez veces más rápida la serialización mejora el conjunto un 5 %. Media semana de trabajo para una ganancia que no se ve. Por eso el primer paso siempre es averiguar el reparto del tiempo, y por eso la tabla del apartado siguiente está ordenada.
- Dónde se va el tiempo en una aplicación Spring Boot
Reparto típico en una aplicación de gestión como CicloUrbana, con el orden en que conviene buscar:
| Orden | Fuente | Peso habitual | Síntoma característico | Herramienta |
|---|---|---|---|---|
| 1 | Base de datos: N+1, falta de índice, consulta pesada | 40-80 % | La latencia crece con el volumen de datos | Log SQL, contador de consultas, EXPLAIN ANALYZE |
| 2 | Espera de recursos: pool de conexiones, bloqueos | 0-40 % bajo carga | Mediana buena, p99 malísimo | hikaricp.connections.* (09-03), pg_locks |
| 3 | Red externa: pasarela de pagos (07-06) | 0-30 % | Latencia constante e insensible al volumen | Trazas (09-06), métricas del cliente |
| 4 | Serialización y tamaño de respuesta | 5-15 % | CPU alta, respuestas de megabytes | Perfilado de CPU |
| 5 | GC y memoria | 1-10 % | Picos periódicos, pausas simultáneas en todo | -Xlog:gc, jvm.gc.pause |
| 6 | Tu propio código Java | 1-10 % | Un método concreto arde en el gráfico de llama | async-profiler, JFR |
La conclusión práctica: empieza siempre por la base de datos y baja por la lista. Y no pases al nivel siguiente hasta haber agotado el anterior, porque el orden refleja la ley de Amdahl aplicada al caso medio.
- La base de datos (I): detectar el N+1
Recuperamos el problema de 04-04 con la formulación operativa: el número de consultas de un endpoint debe ser constante, no proporcional al número de resultados. Primero, verlo. En dev basta con abrir el SQL:
spring:
jpa.properties.hibernate:
format_sql: true
generate_statistics: true # resumen por sesion al terminar
logging.level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE # los parametros, en Hibernate 6El resumen de generate_statistics es el dato clave:
Session Metrics { 201 JDBC statements executed;
1876453200 nanoseconds spent executing 201 JDBC statements; }201 sentencias para listar 200 estaciones. No hace falta más diagnóstico.
Ver el SQL a mano no escala: en cuanto el endpoint es complejo, nadie cuenta líneas. La solución es un contador de consultas por petición, con datasource-proxy:
<dependency>
<groupId>net.ttddyy</groupId>
<artifactId>datasource-proxy</artifactId>
<version>1.10</version>
</dependency>Se envuelve el DataSource en un BeanPostProcessor anotado con @Profile({"dev","test"}) —nunca en producción, porque intercepta cada consulta— que devuelve ProxyDataSourceBuilder.create(ds).name("ciclourbana").countQuery().logSlowQueryBySlf4j(200, MILLISECONDS).build(): countQuery() acumula el recuento por hilo y logSlowQueryBySlf4j avisa de las consultas que pasan de 200 ms. Encima, un filtro escribe al final de cada petición cuántas consultas se ejecutaron —y como el FiltroTraza de 03-06 ya ha puesto el trazaId en el MDC, la línea es directamente correlacionable con el resto del log:
@Component
@Profile({"dev", "test"})
public class FiltroContadorConsultas extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain cadena) throws ServletException, IOException {
QueryCountHolder.clear();
long t0 = System.nanoTime();
try {
cadena.doFilter(req, res);
} finally {
QueryCount contador = QueryCountHolder.get("ciclourbana");
long ms = (System.nanoTime() - t0) / 1_000_000;
if (contador != null && contador.getTotal() > 10) {
log.warn("{} {} -> {} consultas en {} ms",
req.getMethod(), req.getRequestURI(), contador.getTotal(), ms);
}
QueryCountHolder.clear(); // igual que MDC.remove(): el hilo se reutiliza
}
}
}Con esto, un endpoint que se degrada avisa solo en cuanto alguien introduce un getBicicletas() dentro de un bucle. Y la técnica llega hasta las pruebas: en un @DataJpaTest (06-04) se puede afirmar assertThat(contador.getTotal()).isEqualTo(1), convirtiendo «no hay N+1» en una prueba de regresión de verdad.
La corrección ya la conocemos de 04-04 y 04-06: @EntityGraph(attributePaths = "bicicletas") o JOIN FETCH cuando se necesitan las entidades, @BatchSize cuando la colección se pagina, y —a menudo la mejor— una proyección que haga contar a la base de datos y no traiga entidades en absoluto.
- La base de datos (II): índices y
EXPLAIN ANALYZE
EXPLAIN ANALYZEUn índice es una estructura auxiliar que evita leer la tabla entera. La pregunta no es «¿pongo un índice?» sino «¿qué columnas filtran y ordenan mis consultas reales?». Y la respuesta la da PostgreSQL, no la intuición.
La consulta del historial del ciudadano:
EXPLAIN (ANALYZE, BUFFERS)
SELECT a.id, a.inicio, a.fin, a.importe
FROM alquileres a
WHERE a.usuario_id = 4711 AND a.estado = 'FINALIZADO'
ORDER BY a.inicio DESC
LIMIT 20;Antes de tocar nada, con 3,2 millones de filas:
Limit (cost=98234.11..98234.16 rows=20) (actual time=812.443..812.451 rows=20 loops=1)
-> Sort (cost=98234.11..98301.44 rows=26932) (actual time=812.441..812.445 rows=20)
Sort Key: inicio DESC
Sort Method: top-N heapsort Memory: 27kB
-> Seq Scan on alquileres a (cost=0.00..97517.00) (actual time=0.031..798.220)
Filter: ((usuario_id = 4711) AND ((estado)::text = 'FINALIZADO'::text))
Rows Removed by Filter: 3172896
Buffers: shared hit=1204 read=61313
Execution Time: 812.478 msCómo se lee, de dentro hacia fuera:
| Línea | Qué significa |
|---|---|
Seq Scan |
Lee la tabla completa. El delito principal. |
Rows Removed by Filter: 3172896 |
Ha leído 3,2 millones de filas para descartarlas. La señal más clara de índice ausente. |
cost= vs actual time= |
Estimación del planificador vs realidad. Si divergen mucho, faltan estadísticas: ANALYZE alquileres. |
Buffers: read=61313 |
Bloques leídos de disco (8 KB cada uno): ~480 MB movidos para devolver 20 filas. |
Sort Method: top-N heapsort |
Ordena en memoria; si dijera external merge Disk, estaría ordenando en disco. |
Execution Time |
El número que importa. |
Existía ya idx_alquileres_usuario de 04-08, pero el planificador lo descarta: con 27.000 alquileres de ese usuario, saltar del índice a la tabla 27.000 veces le sale más caro que barrerla. La solución es un índice compuesto que cubra el filtro y el orden:
Limit (cost=0.56..12.83 rows=20) (actual time=0.041..0.061 rows=20 loops=1)
-> Index Scan using idx_alquileres_historial on alquileres a
Index Cond: ((usuario_id = 4711) AND ((estado)::text = 'FINALIZADO'::text))
Buffers: shared hit=24
Execution Time: 0.084 msDe 812 ms a 0,08 ms: casi diez mil veces. No hay ninguna optimización de código Java capaz de acercarse a eso, y ese es el argumento de toda la lección.
El orden de las columnas del índice compuesto no es arbitrario. La regla, en este orden: primero las columnas de igualdad (usuario_id, estado), después las de rango u orden (inicio DESC). Un índice (usuario_id, estado, inicio) sirve para consultar por usuario_id, por usuario_id + estado y por los tres; no sirve para consultar solo por estado. Es el principio del prefijo izquierdo, y explica por qué un índice bien pensado suele hacer innecesarios dos o tres.
Índices parciales. Cuando solo interesa un subconjunto:
-- Solo los alquileres abiertos: unas decenas frente a millones
CREATE INDEX idx_alquileres_en_curso ON alquileres (estacion_origen_id)
WHERE fin IS NULL;Ocupa kilobytes en lugar de cientos de megabytes y es el mismo mecanismo del uk_alquileres_usuario_en_curso de 04-08.
El coste de indexar de más. Un índice no es gratis: ocupa espacio, cada INSERT, UPDATE y DELETE debe actualizarlo, y empeora el trabajo del planificador. Una tabla con doce índices puede tardar el triple en escribir. Para saber cuáles sobran:
SELECT relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC;idx_scan = 0 tras semanas en producción significa que ese índice solo cuesta. Y para encontrar las consultas caras, la extensión pg_stat_statements ordenada por total_exec_time responde a la pregunta «qué consulta consume el 40 % del tiempo de base de datos».
- La base de datos (III): paginación, proyecciones y lotes
El OFFSET grande. Pageable genera LIMIT 20 OFFSET 200000, y PostgreSQL debe producir y descartar 200.000 filas antes de devolver 20. La página 1 vuela; la página 10.000 tarda segundos. Para listados profundos —el histórico completo de alquileres, una exportación— se usa paginación por keyset:
// En lugar de: findAll(PageRequest.of(pagina, 20))
@Query("""
select a from Alquiler a
where a.usuario.id = :usuarioId
and (a.inicio < :ultimoInicio or (a.inicio = :ultimoInicio and a.id < :ultimoId))
order by a.inicio desc, a.id desc
""")
List<Alquiler> siguientePagina(Long usuarioId, Instant ultimoInicio, Long ultimoId,
Limit limite);El cliente envía la última fila vista en lugar de un número de página. La consulta usa el índice para posicionarse en lugar de contar, y el coste es constante en cualquier profundidad. Sus límites: no permite saltar a la página 500 ni mostrar el total —y ese count(*) de cada Page es, muchas veces, más caro que la consulta de datos: por eso existe Slice, que lo evita.
Proyecciones frente a entidades (04-06). Cargar la entidad completa trae todas las columnas, crea objetos gestionados en el contexto de persistencia y obliga a Hibernate a hacer dirty checking sobre cada uno al confirmar:
| Entidad | Proyección | |
|---|---|---|
| Columnas leídas | Todas | Solo las declaradas |
| Objetos gestionados | Sí, con coste de memoria y de comprobación | No |
Riesgo de LazyInitializationException |
Sí | No |
| Cuándo usarla | Cuando vas a modificar | Cuando solo vas a mostrar |
La regla de CicloUrbana: los endpoints de solo lectura devuelven proyecciones. Es simultáneamente más rápido y más seguro.
readOnly y lotes. @Transactional(readOnly = true) (04-07) hace que Hibernate use FlushMode.MANUAL y no guarde el estado original para comparar: menos memoria y menos CPU en cada lectura. Y para las escrituras masivas del importador nocturno:
spring:
jpa:
properties:
hibernate:
jdbc.batch_size: 50 # agrupa 50 sentencias por viaje
order_inserts: true # las agrupa por tabla para poder lotearlas
order_updates: true
batch_versioned_data: true # necesario con @Version (04-03)Insertar 10.000 filas pasa de 10.000 viajes de red a 200. Requisito: el generador de identificadores no puede ser IDENTITY, porque obliga a ejecutar cada INSERT por separado para conocer la clave; por eso CicloUrbana usa secuencias con allocationSize = 50 desde 04-03.
- HikariCP: dimensionar el pool con criterio
La intuición dice «más conexiones, más rendimiento». Es falsa, y el motivo es que una conexión ocupada es un núcleo de CPU y un disco de la base de datos trabajando. Cuando hay más conexiones activas que capacidad real, el servidor de base de datos dedica tiempo a cambiar de contexto y a competir por bloqueos: el rendimiento baja y la latencia sube.
La fórmula que documenta el propio HikariCP:
La instancia de PostgreSQL de CicloUrbana tiene 4 núcleos y almacenamiento SSD en red (1 «disco» efectivo): 4 × 2 + 1 = 9, redondeado a 10. Sí, diez conexiones sirven 112 peticiones por segundo: por la ley de Little, si una consulta dura 5 ms, cada conexión puede atender 200 consultas por segundo.
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 10 # fijo: evita la latencia de abrir bajo carga
connection-timeout: 3000 # fallar rapido, no encolar indefinidamente
max-lifetime: 1800000 # por debajo del timeout del lado servidor
leak-detection-threshold: 20000
pool-name: ciclourbana-poolCuatro detalles con consecuencias. minimum-idle igual a maximum-pool-size: un pool elástico ahorra unos recursos irrelevantes y añade la latencia de un handshake TCP más autenticación justo cuando llega el pico. connection-timeout: 3000: si no hay conexión en 3 segundos se lanza excepción, mientras que un valor de 30 s convierte una degradación en una cola que acaba consumiendo los 200 hilos de Tomcat —así es como un problema de base de datos derriba la aplicación entera—. leak-detection-threshold avisa de una conexión retenida más de 20 s, síntoma clásico de una llamada HTTP dentro de una transacción. Y max-lifetime debe ser menor que el tiempo de cierre del lado servidor, o el pool entregará conexiones muertas.
Detectar la espera es lo que decide si el pool está mal dimensionado. La métrica hikaricp.connections.pending (09-03) mide cuántos hilos esperan y hikaricp.connections.acquire cuánto tardan. Si pending es persistentemente mayor que cero hay dos causas posibles y solo una se arregla subiendo el pool: si las consultas son lentas o las transacciones larguísimas, arregla eso y no el pool; si la base de datos va sobrada y la concurrencia es real, sube el pool con moderación y vuelve a medir.
- La JVM: memoria, recolector y pausas
Memoria en contenedor (07-04). La JVM moderna detecta los límites del cgroup, pero por defecto reserva solo el 25 % de la memoria disponible para el heap. En un contenedor de 1 GB eso son 256 MB, y el resto se desperdicia:
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:InitialRAMPercentage=50 \
-XX:+ExitOnOutOfMemoryError -Xlog:gc*:file=/tmp/gc.log:time,uptime:filecount=5,filesize=10M"El 75 % —no el 100 %— deja sitio para lo que no es heap: metaespacio, pilas de hilos, búferes directos y la propia JVM. Si el heap ocupa todo el límite del contenedor, el proceso muere con OOMKilled (código 137) sin dejar ni una excepción, y el restartPolicy de Kubernetes lo reinicia en bucle. +ExitOnOutOfMemoryError es preferible a arrastrarse: una instancia con la memoria agotada responde mal y sigue recibiendo tráfico; muerta, la sonda de 07-01 la saca del balanceador.
Elegir recolector:
| G1 (por defecto) | ZGC (-XX:+UseZGC) |
Parallel | |
|---|---|---|---|
| Objetivo | Equilibrio | Latencia mínima | Rendimiento máximo |
| Pausas típicas | 10-200 ms | < 1 ms, con heaps enormes | Cientos de ms |
| Heap recomendado | 2-32 GB | 8 GB - varios TB | < 4 GB |
| Coste en CPU y memoria | Moderado | ~10-15 % más de ambos | Bajo |
| Cuándo usarlo | CicloUrbana hoy | Si el p99 lo marcan las pausas | Lotes nocturnos, no APIs |
El criterio real: no cambies de recolector hasta haber comprobado, en los logs de GC, que las pausas explican tu p99. Con 2 GB de heap y G1, las pausas de CicloUrbana son de 15-40 ms; el p95 de 1,19 s no viene de ahí.
Leer los logs de GC:
[12.481s][info][gc] GC(38) Pause Young (Normal) (G1 Evacuation Pause) 1204M->318M(2048M) 22.418ms [41.902s][info][gc] GC(72) Pause Full (System.gc()) 1902M->1841M(2048M) 1842.771ms
Qué mirar. La notación 1204M->318M(2048M) es antes → después (total): recuperó 886 MB, luego está sano. En cambio 1902M->1841M recuperó 61 MB de 1,9 GB, es decir, la memoria ya no baja —fuga o heap insuficiente—, que es exactamente el síntoma que anticipaba el cierre de 08-05. La Pause Full de 1,8 s detiene todos los hilos y una sola basta para explicar un p99 desastroso; si se repiten, se acerca un OutOfMemoryError. Y la frecuencia importa tanto como la duración: pausas jóvenes cortas cada pocos segundos son lo normal, pero cada 200 ms significa que se genera basura a manos llenas, casi siempre por cargar entidades que no se necesitan —el apartado 10 otra vez—.
- Hilos: Tomcat, el pool y los hilos virtuales
server:
tomcat:
threads:
max: 200 # hilos que atienden peticiones
min-spare: 20
accept-count: 100 # cola del sistema operativo cuando los 200 estan ocupados
max-connections: 8192 # conexiones aceptadas simultaneamente
connection-timeout: 20sLa relación entre ambos pools es la fuente de un malentendido caro. Con 200 hilos de Tomcat y 10 conexiones de HikariCP, hasta 190 hilos pueden estar bloqueados esperando una conexión. Eso no es un fallo de diseño: es un embudo deliberado que protege a la base de datos. Lo que sí es un fallo es no acotar la espera —de ahí el connection-timeout: 3000 del apartado 11—.
Y subir threads.max a 800 casi nunca ayuda: si el cuello está en las 10 conexiones, los 600 hilos nuevos solo añaden 600 pilas de 1 MB y más cambios de contexto. La ley de Little dice cuántos hilos hacen falta de verdad: hilos ≈ λ × W; con 112 pet/s y 214 ms, unos 24.
Hilos virtuales (07-03), en Java 21 y Spring Boot 3.2+:
Cada petición se atiende en un hilo virtual, que al bloquearse en E/S libera el hilo de plataforma en lugar de ocuparlo. Para una aplicación como CicloUrbana —bloqueante y con mucha espera de base de datos— es la forma más barata de sostener alta concurrencia sin pila reactiva. Con dos advertencias que ya vimos: synchronized alrededor de una operación bloqueante fija el hilo virtual y anula la ventaja, y los ThreadLocal —el MDC del FiltroTraza, el SecurityContext— siguen funcionando, pero ya no hay un pool acotado que limite la concurrencia: el límite pasa a ser el pool de conexiones, así que su connection-timeout se vuelve todavía más importante.
- La red: compresión y tamaño de la respuesta
El tiempo que el ciudadano percibe incluye la transferencia. Un listado de 200 estaciones en JSON son unos 180 KB; sobre una red móvil de Ribalta, medio segundo por sí solo.
server.compression:
enabled: true
mime-types: application/json,application/problem+json,text/html,text/plain
min-response-size: 1KB # comprimir 200 bytes cuesta mas de lo que ahorraCon JSON, gzip reduce entre un 70 % y un 90 % porque el formato repite las claves en cada elemento: 180 KB pasan a unos 20 KB. El coste es CPU del servidor; si hay un proxy o un ALB delante (08-03), a menudo es mejor que comprima él.
Pero la mejor compresión es no enviar el dato. Aquí es donde los DTOs de 03-05 dejan de ser una cuestión de diseño y se convierten en rendimiento: EstacionResponse publica seis campos en lugar de volcar la entidad con sus auditorías, su version y su colección de bicicletas. Menos bytes serializados, menos CPU, menos red y menos memoria. Tres reglas: pagina siempre los listados (nunca un findAll() sin Pageable), no anides colecciones completas en la respuesta —enlaza a un subrecurso—, y usa ETag (03-03) para que el cliente que ya tiene el dato reciba un 304 Not Modified de doscientos bytes.
- Perfilado: async-profiler, JFR y volcados
Cuando la base de datos ya está limpia y el tiempo sigue yéndose a algún sitio, toca perfilar.
| Herramienta | Qué da | Coste | Cuándo |
|---|---|---|---|
| async-profiler | CPU, asignaciones, bloqueos, con gráfico de llama | ~1 % | Investigación puntual, incluso en prod |
| JFR | Grabación continua de eventos de la JVM | ~1 % | Dejarlo siempre activo en prod |
/actuator/threaddump |
Foto de todos los hilos y su estado | Nulo | Aplicación colgada o lenta ahora |
/actuator/heapdump |
Volcado completo del heap | Alto: pausa la JVM | Sospecha de fuga; con cuidado |
# async-profiler: 30 segundos de CPU en un grafico de llama
./profiler.sh -d 30 -e cpu -f /tmp/perfil.html $(pgrep -f ciclourbana.jar)
# JFR permanente, con volcado bajo demanda
java -XX:StartFlightRecording=disk=true,maxsize=200M,settings=profile -jar app.jar
jcmd $(pgrep -f ciclourbana.jar) JFR.dump name=1 filename=/tmp/ciclourbana.jfrCómo se lee un gráfico de llama. El eje horizontal no es tiempo: es la proporción de muestras. Cada barra es un método y encima están los métodos a los que llama. Se lee de abajo arriba buscando mesetas anchas: una barra ancha significa que ese método estaba en la pila en muchas muestras. Si la meseta está arriba del todo, ese método consume CPU por sí mismo; si es ancha pero con torres encima, el coste está en lo que llama. En CicloUrbana, un perfilado típico muestra una meseta ancha bajo PgConnection.execSQLQuery —esperando a la base de datos, que es lo esperado— y, si algo va mal en el código, una torre inesperada bajo ObjectMapper.writeValue o bajo un equals de entidades.
Y el uso más rentable de /actuator/threaddump: con la aplicación lenta, se descarga y se cuentan los hilos por estado. Cincuenta hilos http-nio-8080-exec-* en WAITING sobre HikariPool.getConnection es un diagnóstico cerrado, sin necesidad de perfilar nada.
- Caso práctico: el endpoint de estaciones
Síntoma. GET /api/v1/estaciones?page=0&size=20 tiene un p95 de 2,4 s en la prueba de carga. La mediana es 210 ms.
Paso 1 — medir. El FiltroContadorConsultas del apartado 8 escribe en el log de pre:
43 consultas para 20 estaciones: 1 + 20 + 20 + 2. El patrón es inequívoco.
Paso 2 — localizar. El código:
@Transactional(readOnly = true)
public PaginaResponse<EstacionResponse> listar(Pageable pageable) {
return PaginaResponse.de(estacionRepositorio.findAll(pageable)
.map(e -> new EstacionResponse(
e.getId(), e.getNombre(), e.getDireccion(),
e.getBicicletas().size(), // consulta perezosa (20)
contarDisponibles(e.getId())))); // otra consulta (20)
}Dos N+1 encadenados. Y contarDisponibles ejecuta select count(*) from bicicletas where estacion_id = ? and estado = 'DISPONIBLE', cuyo EXPLAIN ANALYZE revela un Seq Scan: idx_bicicletas_estado existe, pero por sí solo no filtra bien.
Paso 3 — corregir. Dos cambios, uno por causa. Una proyección que hace contar a la base de datos en una sola consulta:
public interface EstacionRepositorio extends JpaRepository<Estacion, Long> {
@Query("""
select e.id as id, e.nombre as nombre, e.direccion as direccion,
size(e.bicicletas) as total,
(select count(b) from Bicicleta b
where b.estacion = e and b.estado = 'DISPONIBLE') as disponibles
from Estacion e where e.activa = true
""")
Page<EstacionResumen> resumenPaginado(Pageable pageable);
}Y el índice compuesto que sostiene esa subconsulta:
-- V13__indice_bicicletas_estacion_estado.sql
CREATE INDEX CONCURRENTLY idx_bicicletas_estacion_estado
ON bicicletas (estacion_id, estado);Igualdad primero, y con las dos columnas del filtro el índice cubre la consulta: PostgreSQL cuenta sin tocar la tabla.
Paso 4 — volver a medir. Misma prueba de k6, misma máquina, mismos datos:
| Métrica | Antes | Después | Factor |
|---|---|---|---|
| Consultas por petición | 43 | 1 | 43× |
| p50 | 210 ms | 28 ms | 7,5× |
| p95 | 2.410 ms | 91 ms | 26× |
| p99 | 3.980 ms | 148 ms | 27× |
| Rendimiento (pet/s) | 112 | 604 | 5,4× |
| Conexiones en espera (pico) | 37 | 0 | — |
| Bytes por respuesta | 184 KB | 21 KB | 8,7× (proyección + gzip) |
Cuatro observaciones valen más que los números. No se ha añadido ni una caché ni una máquina: se ha quitado trabajo innecesario. pending bajó a cero solo, lo que demuestra que el pool no estaba mal dimensionado sino retenido por consultas de más —subirlo a 50 habría escondido el problema y castigado a la base de datos—. El p95 mejoró más que la mediana (26× frente a 7,5×), que es lo típico cuando la causa era espera por un recurso compartido. Y el SLO del apartado 2 —p95 < 300 ms— se cumple, así que se para.
Este era el caso fácil: había trabajo desperdiciado. Cuando ya no lo hay y la consulta es mínima y está indexada, pero se repite mil veces por minuto con el mismo resultado, la siguiente palanca es distinta: no ejecutarla. Eso es la caché, y es la lección siguiente.
Errores Comunes y Consejos
Optimizar sin línea base y usar la media como objetivo. Sin un número anterior, «va más rápido» es una impresión: guarda el resultado de k6 con la fecha y la versión en el repositorio. Y la media esconde exactamente a los usuarios peor tratados, así que el SLO se escribe en p95 y p99.
Cambiar varias cosas a la vez. Si mejoras el índice, subes el pool y activas la compresión en el mismo despliegue, no sabrás cuál valió ni podrás revertir el que sobra.
Subir el pool de conexiones como primera medida. Casi siempre empeora: traslada la congestión de tu aplicación a la base de datos, donde es más difícil de ver y afecta a todo el mundo.
Medir en el portátil con datos de juguete. Un Seq Scan sobre 40 filas es instantáneo. Los problemas de rendimiento son problemas de volumen y de concurrencia, y se reproducen en pre con datos realistas.
Dejar org.hibernate.SQL: DEBUG o datasource-proxy en producción. Ambos añaden coste por consulta y llenan el disco: van bajo @Profile({"dev","test"}), y si hace falta ver SQL en producción se sube el nivel en caliente con /actuator/loggers (07-01) y se baja después.
Confundir un Full GC con «hay que subir la memoria». A veces sí; muchas veces es que se cargan entidades que no se necesitan. Mira primero el apartado 10.
Consejo: pon un límite de consultas por petición en las pruebas, con un assertThat(contador.getTotal()).isLessThanOrEqualTo(3) en un @DataJpaTest: es la única forma de que un N+1 no vuelva dentro de tres meses. Ejecuta además la prueba de carga en la canalización nocturna, porque con thresholds una regresión se detecta el día que se introduce y no el día que llama el ayuntamiento. Y EXPLAIN ANALYZE antes de crear un índice, con una revisión de pg_stat_user_indexes cada trimestre para borrar los que nadie usa.
Ejercicios
Ejercicio 1: diagnosticar a partir de las señales
Tras un despliegue, la operación de CicloUrbana observa: p50 de POST /api/v1/alquileres = 45 ms (sin cambios), p99 = 4,2 s (antes 300 ms), CPU de la aplicación al 20 %, CPU de la base de datos al 25 %, hikaricp.connections.pending con picos de 60, ningún Full GC, y el log del contador de consultas dice «3 consultas en 4.100 ms». Formula la hipótesis más probable, di qué comprobarías para confirmarla y qué no harías.
Ejercicio 2: índice y EXPLAIN
El cuadro de mando del ayuntamiento ejecuta esta consulta cada minuto y tarda 1,8 s:
SELECT estacion_origen_id, count(*) FROM alquileres
WHERE inicio >= now() - interval '1 hour' AND estado = 'EN_CURSO'
GROUP BY estacion_origen_id;EXPLAIN ANALYZE muestra Seq Scan on alquileres con Rows Removed by Filter: 3198412. Propón el índice, justifica el orden de las columnas, decide si debe ser parcial y estima el efecto.
Ejercicio 3: dimensionar con la ley de Little
CicloUrbana debe soportar 400 peticiones por segundo con p95 < 250 ms. La latencia media medida es 180 ms, de los cuales 120 ms son base de datos (dos consultas de 60 ms). El servidor de PostgreSQL tiene 8 núcleos y SSD. Calcula la concurrencia necesaria, dimensiona el pool de HikariCP y los hilos de Tomcat, y di si el sistema puede cumplir el objetivo o dónde se romperá primero.
Soluciones
Solución 1.
Hipótesis: espera por conexión del pool, no lentitud de la aplicación. Las señales encajan una a una: la mediana intacta indica que el camino rápido no ha cambiado; el p99 disparado con CPU baja en ambos lados descarta saturación de cálculo; pending con picos de 60 dice literalmente que 60 hilos esperan conexión; «3 consultas en 4.100 ms» es la firma exacta —pocas consultas, mucho tiempo—, porque el tiempo no se va ejecutando SQL sino esperando para poder ejecutarlo; y la ausencia de Full GC descarta pausas de memoria.
Causa más probable: algo retiene conexiones más tiempo del debido. Los dos sospechosos habituales: una llamada HTTP dentro de una transacción —muy probable aquí, porque el despliegue tocó el cobro y la pasarela de 07-06 puede tardar segundos— o una transacción alargada por trabajo que no necesita la base de datos.
Cómo confirmarlo: con /actuator/threaddump durante un pico, buscando hilos http-nio-* en WAITING sobre HikariPool.getConnection y —sobre todo— hilos que ya tienen conexión y están bloqueados en un socket de la pasarela; activando leak-detection-threshold: 20000, cuya traza de pila señala el método culpable; mirando las métricas de Resilience4j (07-06), porque si la latencia de la pasarela subió a la vez el diagnóstico está cerrado; y observando hikaricp.connections.usage, que mide cuánto se retiene cada conexión.
Qué NO haría: subir maximum-pool-size. Con la CPU de la base de datos al 25 % el problema no es falta de capacidad, y más conexiones retenidas por llamadas remotas solo alargan la agonía. Tampoco subir los hilos de Tomcat: crearía más hilos para esperar lo mismo. La corrección es sacar la llamada a la pasarela fuera de la transacción —confirmar primero y cobrar después, en el AFTER_COMMIT de 07-03— y verificar que el connection-timeout es de segundos, no de treinta.
Solución 2.
CREATE INDEX CONCURRENTLY idx_alquileres_inicio_estado
ON alquileres (estado, inicio, estacion_origen_id);Orden de las columnas: estado primero porque es el predicado de igualdad; inicio después porque es de rango —una columna de rango en el índice «consume» el resto para futuras igualdades, así que va detrás de todas las igualdades—; y estacion_origen_id al final para que el índice cubra la consulta: PostgreSQL puede agrupar leyendo solo el índice (Index Only Scan) sin visitar la tabla.
¿Parcial? Sí, y es la mejor decisión del ejercicio:
CREATE INDEX CONCURRENTLY idx_alquileres_en_curso_inicio
ON alquileres (inicio, estacion_origen_id) WHERE estado = 'EN_CURSO';Los alquileres EN_CURSO son unos cientos entre 3,2 millones. El índice parcial ocupa kilobytes en lugar de ~150 MB, se mantiene casi gratis en cada escritura y, como todas las filas indexadas cumplen ya el filtro de estado, el planificador solo tiene que recorrer el rango de la última hora.
Efecto estimado: de Seq Scan sobre 3,2 millones de filas (~1,8 s) a recorrer unos cientos de entradas contiguas del índice: por debajo de 5 ms, dos o tres órdenes de magnitud. Verificación obligatoria: repetir EXPLAIN (ANALYZE, BUFFERS) y comprobar que aparece Index Scan/Index Only Scan, que Rows Removed by Filter desaparece y que Buffers: read cae a unas decenas. Y CONCURRENTLY porque la tabla está en producción: sin él, el CREATE INDEX bloquea las escrituras durante toda la construcción (04-08).
Solución 3.
Concurrencia necesaria (Little): L = λ × W = 400 × 0,180 = 72 peticiones simultáneas dentro del sistema.
Pool de conexiones: la fórmula da 8 × 2 + 1 = 17, redondeamos a 20. ¿Aguanta? Cada petición ocupa una conexión 120 ms; una conexión sirve 1 / 0,120 = 8,3 peticiones por segundo; 20 conexiones dan 20 × 8,3 = 166 pet/s. No llega a 400: aquí se rompe primero.
Qué hacer, en orden. Primero, reducir el tiempo de base de datos por petición, que es la palanca correcta: dos consultas de 60 ms para iniciar un alquiler es muchísimo y con los índices adecuados bajan a 5 ms, de modo que con W_bd = 10 ms una conexión sirve 100 pet/s y 20 conexiones dan 2.000 pet/s, cinco veces el objetivo. Solo si tras optimizar sigue faltando capacidad, escalar horizontalmente (08-04): tres instancias con 20 conexiones cada una son 60 conexiones contra la base de datos, cerca del límite razonable para 8 núcleos, y el paso siguiente serían réplicas de lectura. Lo que sería un error es subir el pool a 80: 80 conexiones activas sobre 8 núcleos multiplican los cambios de contexto y la competencia por bloqueos, el rendimiento agregado baja y el p95 sube.
Hilos de Tomcat: con L = 72, los 200 por defecto sobran holgadamente; threads.max: 200 y accept-count: 100 son correctos. Con hilos virtuales activados, el límite efectivo pasa a ser el pool de conexiones, lo que refuerza la conclusión: el objetivo se cumple optimizando las consultas, no ampliando pools.
Latencia resultante: con las consultas a 10 ms, W ≈ 70 ms y L = 400 × 0,07 = 28. Muy por debajo del SLO de 250 ms en p95, con margen para la variabilidad.
Conclusión
CicloUrbana ya no se ajusta por intuición. Tienes un método que no se salta pasos —definir el objetivo, medir la línea base, localizar el cuello, corregir una cosa, volver a medir— y sabes por qué la media miente y por qué el SLO se escribe en p95 y p99, con los 20 ciudadanos de cada 1.000 que la media escondía. Sabes montar una prueba de carga con k6 que reproduce la hora punta de Ribalta, con rampa, meseta y thresholds que ponen en rojo la canalización de 08-05, y leer su salida en el orden correcto: umbrales, errores, percentiles frente a media, operación concreta y saturación. La ley de Little te dice cuánta concurrencia hay realmente dentro del sistema y, con ella, si un pool está bien dimensionado; la de Amdahl te dice que optimizar la serialización un 900 % mejora el total un 5 %, y por eso el recorrido está ordenado.
Ese recorrido lo has hecho entero. La base de datos primero: detectar el N+1 de 04-04 con generate_statistics y con un contador de consultas por petición que avisa solo, y corregirlo con @EntityGraph, JOIN FETCH o —mejor— una proyección; leer un EXPLAIN ANALYZE distinguiendo Seq Scan de Index Scan y sabiendo qué significan Rows Removed by Filter y Buffers; diseñar índices compuestos con las igualdades delante y el orden detrás, índices parciales que ocupan kilobytes, y borrar los que nadie usa. Después la paginación por keyset frente al OFFSET grande, las proyecciones frente a las entidades, readOnly y los lotes de Hibernate. Luego HikariCP, con la fórmula y la explicación de por qué un pool grande empeora las cosas, y pending como la señal que distingue «falta pool» de «sobran consultas». Y por último la JVM —MaxRAMPercentage, G1 frente a ZGC, leer -Xlog:gc y reconocer la memoria que sube y no baja—, los hilos de Tomcat y su relación con el pool, los hilos virtuales de 07-03 como alternativa, la compresión y el perfilado con async-profiler, JFR y los volcados de 07-01.
El caso práctico resume la lección entera: un endpoint con p95 de 2,4 s, 43 consultas por petición y un Seq Scan, que tras quitar trabajo —una proyección y un índice— responde en 91 ms y multiplica por cinco el rendimiento, sin una máquina más y sin una caché. Ese orden importa: primero se elimina el trabajo innecesario. Pero cuando el trabajo ya es mínimo y aun así se repite mil veces por minuto para devolver siempre lo mismo —el catálogo de estaciones de Ribalta, que cambia una vez al mes y se consulta cien veces por segundo—, queda una palanca distinta: no ejecutarlo. La siguiente lección, Caché con Spring Cache, la desarrolla entera: qué datos merecen caché y cuáles no, @Cacheable y sus trampas, Caffeine y Redis, y la parte de verdad difícil, que es invalidar.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
