La lección anterior terminó con un mensaje de error muy concreto: Failed to configure a DataSource. Spring Boot detectó el starter de JPA, intentó construir la unidad de persistencia y se quedó sin lo más básico, una conexión a una base de datos. Esta lección resuelve exactamente eso y va bastante más allá de pegar cuatro propiedades: un DataSource mal dimensionado es la causa número uno de caídas de aplicaciones Spring Boot en producción, muy por delante de cualquier error de lógica.

Vamos a montar dos entornos para CicloUrbana: H2 en memoria para desarrollar rápido, y PostgreSQL 16 en Docker como base de datos real. Entenderemos qué es un pool de conexiones y afinaremos HikariCP parámetro a parámetro con criterios de dimensionado que se pueden defender ante un compañero. Fijaremos las propiedades de Hibernate que gobiernan el comportamiento del ORM, incluida la decisión —importante y poco discutida— de desactivar open-in-view. Y dejaremos el log preparado para ver el SQL que realmente viaja hacia Ribalta.

Contenido

  1. Qué es un DataSource y por qué se agrupa en un pool
  2. H2 en memoria para desarrollo
  3. La consola de H2 y su riesgo de seguridad
  4. PostgreSQL 16 con Docker Compose
  5. Las propiedades esenciales de spring.datasource
  6. HikariCP en profundidad
  7. Dimensionar el pool con criterio
  8. Las propiedades de JPA e Hibernate
  9. open-in-view: por qué se desactiva
  10. Ver el SQL generado de forma legible
  11. Múltiples fuentes de datos
  12. Credenciales fuera del repositorio
  13. Comprobar la conexión al arrancar
  14. Errores Comunes y Consejos
  15. Ejercicios

  1. Qué es un DataSource y por qué se agrupa en un pool

javax.sql.DataSource es una interfaz de Java con un método esencial: getConnection(). Es la fábrica estándar de conexiones a base de datos, y es lo que Hibernate pide cuando necesita hablar con PostgreSQL.

La pregunta interesante es qué hay detrás de ese método. La implementación ingenua abriría una conexión TCP nueva cada vez. Y abrir una conexión a una base de datos es caro: negociación TCP, autenticación, negociación TLS, creación de un proceso o hilo servidor, reserva de memoria de sesión. En PostgreSQL, entre 20 y 100 milisegundos. Si GET /api/v1/estaciones tarda 5 ms en su consulta y 40 ms en abrir la conexión, el 89 % del tiempo se va en fontanería.

Un pool de conexiones resuelve esto manteniendo un conjunto de conexiones ya abiertas y prestándolas:

sequenceDiagram
    participant S as EstacionService
    participant P as Pool HikariCP
    participant BD as PostgreSQL

    Note over P,BD: Al arrancar: se abren N conexiones
    S->>P: getConnection()
    P-->>S: conexión #3 (ya abierta, ~0,1 ms)
    S->>BD: SELECT * FROM estaciones
    BD-->>S: filas
    S->>P: close()
    Note over P: NO se cierra: vuelve al pool
    P-->>P: conexión #3 disponible

El detalle que descoloca la primera vez: cuando tu código llama a connection.close(), la conexión no se cierra. El pool devuelve un envoltorio cuyo close() significa «devuélvela al pool». Por eso los try-with-resources sobre conexiones siguen siendo correctos y necesarios.

Consecuencias que hay que tener presentes todo el módulo:

  • El número de conexiones simultáneas está acotado por el tamaño del pool, no por el número de peticiones.
  • Si todas están prestadas, la siguiente petición espera. Si espera demasiado, falla con un timeout.
  • Una conexión que se presta y no se devuelve es una fuga que acaba agotando el pool y tumbando la aplicación.

Spring Boot incluye HikariCP a través de spring-boot-starter-jdbc, que arrastra el starter de JPA. No hay que añadir nada.

  1. H2 en memoria para desarrollo

H2 es una base de datos relacional escrita en Java que puede vivir dentro del propio proceso. Para desarrollar CicloUrbana es ideal: arranca en milisegundos, no requiere instalación y se reinicia limpia en cada ejecución.

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

El scope runtime es deliberado: el driver hace falta al ejecutar, nunca al compilar. Tu código no debe importar ni una clase de H2. Si alguna vez necesitas compile, es señal de que algo se ha acoplado al motor.

La configuración en application.yml:

spring:
  datasource:
    url: jdbc:h2:mem:ciclourbana;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
    username: sa
    password:
    driver-class-name: org.h2.Driver
  h2:
    console:
      enabled: true
      path: /h2-console
  jpa:
    hibernate:
      ddl-auto: update
    open-in-view: false
    show-sql: true
    properties:
      hibernate:
        format_sql: true

Desmenucemos la URL, que es donde está lo interesante:

Fragmento Significado
jdbc:h2:mem: Base de datos en memoria: desaparece al terminar el proceso
ciclourbana Nombre de la base de datos; distintos nombres son bases distintas
DB_CLOSE_DELAY=-1 No destruir la BD cuando se cierre la última conexión
MODE=PostgreSQL Emula la sintaxis y los tipos de PostgreSQL

DB_CLOSE_DELAY=-1 no es opcional. Sin él, en cuanto el pool cierra su última conexión activa H2 borra la base de datos entera, y los datos que cargó el CargadorEstacionesDemo desaparecen a mitad de ejecución.

MODE=PostgreSQL es una decisión estratégica del curso: hace que H2 se comporte como PostgreSQL en tipos, funciones y sintaxis, de modo que lo que funciona en desarrollo tiene muchas más probabilidades de funcionar en producción. No es equivalencia total —por eso en 06-05 usaremos Testcontainers con PostgreSQL de verdad para las pruebas—, pero reduce mucho la brecha.

Si prefieres que los datos sobrevivan entre arranques, H2 también puede escribir en fichero con jdbc:h2:file:./datos/ciclourbana;MODE=PostgreSQL; recuerda entonces añadir datos/ al .gitignore.

  1. La consola de H2 y su riesgo de seguridad

Con spring.h2.console.enabled: true, al arrancar dispones de un cliente SQL web en http://localhost:8080/h2-console. Los datos de conexión que hay que introducir son los mismos del YAML:

JDBC URL:  jdbc:h2:mem:ciclourbana
User Name: sa
Password:  (vacío)

Es utilísimo para ver qué tablas ha creado Hibernate a partir de tus entidades (04-03) y comprobar que los datos están donde crees.

Y es, a la vez, el agujero de seguridad más grande que puedes dejar abierto. La consola de H2 permite ejecutar SQL arbitrario sin autenticación real. Peor aún: H2 permite ejecutar código Java desde SQL mediante alias, lo que convierte una consola expuesta en ejecución remota de código. Ha habido CVE graves justamente por esto.

Las reglas son innegociables:

  • Nunca habilitar la consola en un entorno accesible desde fuera.
  • Activarla solo en el perfil de desarrollo (los perfiles se ven en 07-02):
# application-dev.yml
spring:
  h2:
    console:
      enabled: true
      settings:
        web-allow-others: false   # solo localhost
  • En application.yml (base), dejarla desactivada: enabled: false.
  • Cuando añadamos Spring Security en el módulo 5, la consola necesitará una regla explícita y, aun así, seguirá restringida a desarrollo.

web-allow-others: false es el valor por defecto y limita el acceso a localhost. No lo cambies.

  1. PostgreSQL 16 con Docker Compose

H2 sirve para desarrollar, pero CicloUrbana funcionará en producción sobre PostgreSQL 16. Levantarlo con Docker evita instalar nada en la máquina y garantiza que todo el equipo usa la misma versión. Crea docker-compose.yml en la raíz del proyecto:

services:
  postgres:
    image: postgres:16-alpine
    container_name: ciclourbana-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ciclourbana
      POSTGRES_USER: ciclourbana
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ciclourbana_dev}
      TZ: Europe/Madrid
    ports:
      - "5432:5432"
    volumes:
      - postgres-datos:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ciclourbana -d ciclourbana"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postgres-datos:

Punto por punto:

Elemento Por qué está
postgres:16-alpine Versión fijada; alpine reduce la imagen a ~80 MB
restart: unless-stopped Vuelve a levantarse tras reiniciar el equipo
POSTGRES_DB/USER/PASSWORD En el primer arranque crean base de datos y usuario
${POSTGRES_PASSWORD:-...} Toma la variable de entorno; si no existe, usa el valor de desarrollo
ports: 5432:5432 Expone el puerto al host para conectarse desde el IDE
volumes: postgres-datos Volumen con nombre: los datos sobreviven a docker compose down
healthcheck Permite saber cuándo está realmente listo, no solo arrancado

Comandos habituales:

docker compose up -d                 # levantar en segundo plano
docker compose ps                    # ver estado y salud
docker compose logs -f postgres      # seguir el log
docker compose exec postgres psql -U ciclourbana -d ciclourbana
docker compose down                  # parar (los datos permanecen)
docker compose down -v               # parar Y BORRAR el volumen

Cuidado con down -v: borra el volumen y con él todos los datos. En 07-04 retomaremos este fichero para añadir el contenedor de la propia aplicación.

El driver de PostgreSQL:

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

Y la configuración correspondiente:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/ciclourbana
    username: ciclourbana
    password: ${POSTGRES_PASSWORD}
  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false

Puedes tener ambos drivers en el pom.xml simultáneamente: el que se use lo decide la URL. En 07-02 veremos cómo separar limpiamente ambas configuraciones en perfiles.

  1. Las propiedades esenciales de spring.datasource

Propiedad Qué es Ejemplo ¿Obligatoria?
url Cadena JDBC completa jdbc:postgresql://localhost:5432/ciclourbana Sí (salvo BD embebida)
username Usuario de base de datos ciclourbana Casi siempre
password Contraseña ${POSTGRES_PASSWORD} Casi siempre
driver-class-name Clase del driver JDBC org.postgresql.Driver No, se deduce
name Nombre del DataSource ciclourbana-ds No

Por qué no hace falta el driver-class-name. Spring Boot usa DatabaseDriver, un enumerado que asocia prefijos de URL con clases de driver: jdbc:postgresql: → org.postgresql.Driver, jdbc:h2: → org.h2.Driver, jdbc:mysql: → com.mysql.cj.jdbc.Driver. Con la URL y el driver en el classpath, la deducción es automática. Solo hay que declararlo cuando usas un driver alternativo o una URL con un prefijo no reconocido.

Y si no configuras nada teniendo H2 en el classpath, Spring Boot crea un DataSource embebido con una URL aleatoria del estilo jdbc:h2:mem:2a5f.... Es cómodo para un arranque rápido, pero como el nombre cambia en cada ejecución no puedes conectarte con la consola. Para CicloUrbana lo declaramos siempre explícitamente.

  1. HikariCP en profundidad

HikariCP es el pool por defecto de Spring Boot desde la versión 2.0, y con razón: es el más rápido del ecosistema Java y el que menos configuración necesita para estar bien. Su filosofía es tener pocas opciones y valores por defecto sensatos.

Todos sus parámetros van bajo spring.datasource.hikari:

spring:
  datasource:
    hikari:
      pool-name: CicloUrbanaPool
      maximum-pool-size: 10
      minimum-idle: 10
      connection-timeout: 30000       # 30 s
      idle-timeout: 600000            # 10 min
      max-lifetime: 1800000           # 30 min
      leak-detection-threshold: 60000 # 60 s
      auto-commit: false
Parámetro Qué controla Por defecto Si te quedas corto Si te pasas
maximum-pool-size Máximo de conexiones simultáneas 10 Peticiones esperando y timeouts Saturas la base de datos y empeora el rendimiento
minimum-idle Conexiones ociosas que se mantienen = máximo Latencia al crear conexiones bajo pico Conexiones ociosas consumiendo memoria en la BD
connection-timeout Espera máxima por una conexión 30 000 ms Fallos espurios bajo carga Los hilos se acumulan esperando y la app se congela
idle-timeout Tiempo antes de cerrar una ociosa 600 000 ms Se abren y cierran conexiones sin parar Conexiones muertas ocupando sitio
max-lifetime Vida máxima de una conexión 1 800 000 ms Reciclado excesivo Conexiones caducadas por el cortafuegos o la BD
leak-detection-threshold Aviso si una conexión no se devuelve 0 (desactivado) Las fugas pasan inadvertidas Falsos positivos con procesos largos legítimos

Detalles que importan de verdad:

max-lifetime debe ser menor que el tiempo de vida que impongan la base de datos o el cortafuegos. Es la causa más común de errores intermitentes Connection is closed en producción: un cortafuegos corta conexiones ociosas a los 30 minutos y el pool sigue creyéndolas válidas. La recomendación oficial es fijarlo varios segundos por debajo de ese límite; 30 minutos es un valor prudente casi siempre.

minimum-idle igual a maximum-pool-size es la recomendación de los autores de HikariCP para cargas estables: un pool de tamaño fijo evita el coste de abrir conexiones justo en el peor momento, el del pico de tráfico.

leak-detection-threshold merece estar activado en desarrollo y preproducción. Cuando una conexión lleva prestada más del umbral, HikariCP escribe la traza de pila de quien la pidió. Es la forma más directa de encontrar una fuga:

Connection leak detection triggered for org.postgresql.jdbc.PgConnection@3f2a1b,
stack trace follows
  java.lang.Exception: Apparent connection leak detected
    at com.ciclourbana.alquileres.AlquilerService.iniciar(AlquilerService.java:64)

auto-commit: false deja la gestión de transacciones a Spring, que es lo que queremos con @Transactional (04-07). Spring Boot ya lo ajusta correctamente al usar JPA.

  1. Dimensionar el pool con criterio

La intuición dice «más conexiones, más rendimiento». Es falsa, y entenderlo diferencia a quien configura de quien copia.

Una base de datos ejecuta consultas en un número limitado de núcleos y discos. Con más conexiones activas que recursos, el servidor dedica tiempo a cambiar de contexto y a competir por bloqueos en lugar de trabajar. La documentación de HikariCP muestra un caso clásico: un servidor con 10 000 usuarios rindiendo mejor con un pool de 10 que con uno de 100.

La fórmula de referencia de PostgreSQL:

conexiones = ((núcleos_cpu * 2) + husos_de_disco_efectivos)

Para un PostgreSQL en un contenedor con 4 vCPU y almacenamiento SSD (donde el término de discos ronda 1-2):

conexiones ≈ (4 * 2) + 2 = 10

Diez. Ese es el valor por defecto de HikariCP y rara vez hay que subirlo. Reglas prácticas para CicloUrbana:

  • Empieza en 10. Solo súbelo con métricas que lo justifiquen (Actuator y Micrometer exponen hikaricp.connections.*; lo veremos en 09-03).
  • Suma todas las instancias. Si despliegas 4 réplicas con pool de 10, la base de datos ve 40 conexiones. El max_connections de PostgreSQL (100 por defecto) es un techo global que se agota antes de lo que la gente cree.
  • Si hay esperas, casi nunca la solución es más conexiones: suele ser una consulta lenta, un índice ausente o una transacción demasiado larga.
  • Nunca hagas trabajo lento dentro de una transacción. Llamar a la pasarela de pago con una conexión prestada retiene un recurso escaso durante segundos. En CicloUrbana, el cobro del alquiler debe ocurrir fuera de la transacción; en 04-07 lo resolveremos con @TransactionalEventListener.

  1. Las propiedades de JPA e Hibernate

Bajo spring.jpa se configura el comportamiento del ORM.

Propiedad Qué hace Valor para CicloUrbana
spring.jpa.hibernate.ddl-auto Gestión automática del esquema update en dev, validate en prod
spring.jpa.show-sql Imprime el SQL por System.out false (mejor usar el log)
spring.jpa.properties.hibernate.format_sql Formatea el SQL en varias líneas true en desarrollo
spring.jpa.database-platform Dialecto SQL Se deduce, no tocar
spring.jpa.open-in-view Mantiene el contexto abierto en la vista false
spring.jpa.properties.hibernate.jdbc.batch_size Agrupa sentencias por lotes 20 (útil en cargas)
spring.jpa.defer-datasource-initialization Retrasa data.sql tras crear el esquema true si usas data.sql

Los cinco valores de ddl-auto, que es la propiedad más peligrosa del framework:

Valor Qué hace Cuándo usarlo
none Nada. Hibernate no toca el esquema Producción con Flyway (04-08)
validate Comprueba que el esquema coincide con las entidades y falla si no Producción. La mejor red de seguridad
update Añade tablas y columnas que falten. No borra ni modifica Desarrollo temprano, nunca producción
create Borra el esquema y lo crea de cero al arrancar Pruebas manuales desechables
create-drop Como create, y además borra al parar Pruebas automáticas (módulo 6)

update no es seguro en producción, y el motivo suele malinterpretarse. No es solo que «pueda borrar datos» —de hecho no borra columnas—: es que no puede modificar lo existente. Si cambias un varchar(80) a varchar(40), o añades una columna NOT NULL a una tabla con filas, update lo ignora en silencio o falla a medias, dejando el esquema en un estado que nadie ha revisado ni puede reproducir. Además no hay registro de qué se aplicó ni forma de deshacerlo.

El plan del módulo es explícito: usamos update mientras diseñamos las entidades en 04-03 y 04-04, y en 04-08 lo sustituimos por Flyway con validate. Cuando llegues a esa lección, update desaparece del proyecto para siempre.

El dialecto no se configura. Hibernate 6 lo detecta interrogando al driver y ajusta la generación de SQL a la versión concreta del motor. Fijarlo a mano solo sirve para quedarse en una versión antigua sin darse cuenta.

  1. open-in-view: por qué se desactiva

Spring Boot activa por defecto spring.jpa.open-in-view=true, y avisa de ello con un mensaje en el log:

spring.jpa.open-in-view is enabled by default. Therefore, database queries may be
performed during view rendering. Explicitly configure spring.jpa.open-in-view to
disable this warning

Qué hace: un filtro (OpenEntityManagerInViewInterceptor) mantiene abierto el contexto de persistencia durante toda la petición HTTP, no solo durante la transacción del servicio.

Suena cómodo, y esa es la trampa. Los tres problemas que causa:

  1. Oculta el N+1 hasta producción. Si el controlador serializa una entidad con una colección perezosa, con open-in-view activo la colección se carga sin protestar, disparando consultas durante la serialización. Con él desactivado, salta LazyInitializationException en desarrollo, que es donde quieres enterarte.
  2. Retiene la conexión más tiempo del necesario. La conexión JDBC puede quedar asociada a la petición completa, incluida la serialización JSON. Con un pool de 10, esto reduce drásticamente el número de peticiones concurrentes.
  3. Difumina las fronteras. La capa de presentación acaba ejecutando consultas, justo lo contrario de la separación de capas que construimos en 03-05 con los DTOs.

Para CicloUrbana la decisión está tomada desde esta lección:

spring:
  jpa:
    open-in-view: false

La contrapartida es que el servicio debe devolver DTOs completamente mapeados, con todo lo necesario ya cargado. Es exactamente lo que ya hacemos desde 03-05 con EstacionMapper y EstacionDetalleResponse, así que el coste es cero. Volveremos sobre ello en 04-04 y 04-07.

  1. Ver el SQL generado de forma legible

Trabajar con un ORM sin ver el SQL es programar a ciegas. Hay dos formas de verlo y una es claramente mejor.

Forma rápida (evítala en serio): spring.jpa.show-sql: true. Escribe por System.out, sin formato, sin nivel, sin marca de tiempo y sin pasar por el sistema de logs. Sirve para un vistazo y poco más.

Forma correcta: el log de Hibernate.

logging:
  level:
    org.hibernate.SQL: DEBUG                  # las sentencias
    org.hibernate.orm.jdbc.bind: TRACE        # los valores de los parámetros
    org.hibernate.stat: DEBUG                 # estadísticas por sesión

spring:
  jpa:
    show-sql: false
    properties:
      hibernate:
        format_sql: true
        highlight_sql: true
        generate_statistics: true

Con org.hibernate.SQL en DEBUG verás cada sentencia; con org.hibernate.orm.jdbc.bind en TRACE, los valores que sustituyen a las ?. Ojo con el nombre: en Hibernate 5 era org.hibernate.type.descriptor.sql; en Hibernate 6, el que trae Spring Boot 3, es org.hibernate.orm.jdbc.bind. Copiar la configuración antigua es un motivo frecuente de «no me salen los parámetros».

El resultado en consola:

Hibernate:
    select
        e1_0.id,
        e1_0.capacidad,
        e1_0.direccion,
        e1_0.nombre
    from
        estaciones e1_0
    where
        e1_0.capacidad>=?
binding parameter [1] as [INTEGER] - [24]

generate_statistics: true añade, al final de cada sesión, un resumen valiosísimo para detectar el N+1:

Session Metrics {
    2 JDBC statements, 1 collections fetched, 47 entities loaded
}

Cuarenta y siete entidades con dos sentencias está bien. Cuarenta y siete entidades con cuarenta y ocho sentencias es un N+1 de manual (04-04).

Advertencia de seguridad: el nivel TRACE de los parámetros imprime en el log todos los valores enviados, incluidos correos, contraseñas cifradas o datos personales de los ciudadanos de Ribalta. Es una configuración de desarrollo. En producción, nunca.

  1. Múltiples fuentes de datos

Ocasionalmente una aplicación necesita hablar con dos bases de datos: CicloUrbana podría tener su base operativa y una réplica de solo lectura para informes. Al declarar dos DataSource, la autoconfiguración se desactiva y hay que construirlos a mano.

package com.ciclourbana.comun.config;

@Configuration
public class ConfiguracionFuentesDatos {

    @Bean
    @Primary
    @ConfigurationProperties("ciclourbana.datasource.principal")
    public DataSourceProperties propiedadesPrincipal() {
        return new DataSourceProperties();
    }

    @Bean
    @Primary
    public DataSource dataSourcePrincipal() {
        return propiedadesPrincipal()
                .initializeDataSourceBuilder()
                .type(HikariDataSource.class)
                .build();
    }

    // El par de beans para "informes" es idéntico, sin @Primary y
    // apuntando a ciclourbana.datasource.informes.
}
ciclourbana:
  datasource:
    principal:
      url: jdbc:postgresql://localhost:5432/ciclourbana
      username: ciclourbana
      password: ${POSTGRES_PASSWORD}
    informes:
      url: jdbc:postgresql://replica:5432/ciclourbana
      username: informes
      password: ${INFORMES_PASSWORD}

@Primary (que ya conoces de 02-02) resuelve la ambigüedad: sin él, cualquier inyección de DataSource fallaría con NoUniqueBeanDefinitionException. Además, con dos fuentes hay que declarar manualmente EntityManagerFactory y TransactionManager para cada una, y separar los repositorios por paquetes con @EnableJpaRepositories(basePackages = ...).

Es bastante trabajo, y por eso la recomendación es clara: no lo hagas salvo que sea imprescindible. Muchas veces lo que se busca (aislar informes) se resuelve mejor con vistas materializadas, caché (09-02) o separando el servicio (07-05).

  1. Credenciales fuera del repositorio

La contraseña de PostgreSQL no puede estar en application.yml, porque application.yml está en Git y Git tiene memoria eterna: borrarla en un commit posterior no la elimina del historial.

Ya vimos la precedencia de configuración en 02-04; aquí la aplicamos. La forma más portable es la variable de entorno con marcador de posición:

spring:
  datasource:
    url: ${CICLOURBANA_DB_URL:jdbc:postgresql://localhost:5432/ciclourbana}
    username: ${CICLOURBANA_DB_USER:ciclourbana}
    password: ${CICLOURBANA_DB_PASSWORD}

Los dos primeros llevan valor por defecto tras :; el tercero no, deliberadamente: si la variable no está definida, la aplicación falla al arrancar con un mensaje claro en lugar de intentar conectarse con una contraseña vacía.

export CICLOURBANA_DB_PASSWORD='una-contrasena-larga-y-unica'
./mvnw spring-boot:run

Recuerda además la traducción automática de nombres: Spring Boot convierte spring.datasource.password en SPRING_DATASOURCE_PASSWORD, así que definir esa variable de entorno funciona sin escribir nada en el YAML.

Opción Dónde encaja Nivel
Variables de entorno Cualquier despliegue Bueno
Fichero .env fuera de Git Desarrollo local Aceptable
Secretos de Kubernetes Producción en K8s (08-04) Muy bueno
Vault / AWS Secrets Manager Producción con rotación El mejor
Contraseña en application.yml Ninguno Inaceptable

Y en .gitignore, como mínimo: .env, *.env.local y datos/.

  1. Comprobar la conexión al arrancar

Con todo configurado, conviene una verificación explícita. Un CommandLineRunner como los de 01-05, restringido a desarrollo:

package com.ciclourbana.comun;

// imports: javax.sql.DataSource, java.sql.Connection, java.sql.DatabaseMetaData,
// org.slf4j.*, org.springframework.boot.CommandLineRunner, org.springframework.stereotype.Component

@Component
public class VerificadorConexion implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(VerificadorConexion.class);

    private final DataSource dataSource;

    public VerificadorConexion(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public void run(String... args) throws Exception {
        try (Connection conexion = dataSource.getConnection()) {
            DatabaseMetaData metadatos = conexion.getMetaData();
            log.info("Conexión establecida con {} {}",
                    metadatos.getDatabaseProductName(),
                    metadatos.getDatabaseProductVersion());
            log.info("Driver: {} {}",
                    metadatos.getDriverName(), metadatos.getDriverVersion());
            log.info("URL: {}", metadatos.getURL());
        }
    }
}

El try-with-resources es importante: sin él, la conexión no vuelve al pool y acabas de crear una fuga en la primera línea de código que toca el DataSource.

Salida esperada con PostgreSQL:

INFO  c.c.comun.VerificadorConexion : Conexión establecida con PostgreSQL 16.2
INFO  c.c.comun.VerificadorConexion : Driver: PostgreSQL JDBC Driver 42.7.2
INFO  c.c.comun.VerificadorConexion : URL: jdbc:postgresql://localhost:5432/ciclourbana

En el módulo 7 veremos que Actuator ofrece esto mismo de forma permanente en /actuator/health, con un indicador db que ejecuta una consulta de validación.

Errores Comunes y Consejos

Dejar ddl-auto: update al desplegar. El error más caro del módulo. Funciona en desarrollo, parece funcionar en producción y un día deja el esquema en un estado que nadie sabe reconstruir. La lección 04-08 existe para eliminarlo.

Subir maximum-pool-size para arreglar lentitud. Casi siempre empeora: la base de datos se satura y todas las consultas se ralentizan. Antes de tocar el pool, mira el SQL, los índices y la duración de las transacciones.

Dejar open-in-view en su valor por defecto. El aviso del log se ignora sistemáticamente. Ponlo a false en la primera línea de configuración JPA del proyecto; hacerlo más tarde saca a la luz decenas de LazyInitializationException de golpe.

Olvidar DB_CLOSE_DELAY=-1 en H2. Produce el desconcertante «mis datos desaparecen a mitad de ejecución» sin ningún error.

Usar org.hibernate.type.descriptor.sql para ver los parámetros. Es el nombre de Hibernate 5. En Spring Boot 3 hay que usar org.hibernate.orm.jdbc.bind.

Dejar la consola de H2 accesible. Ejecución de SQL arbitrario sin autenticación. Solo en desarrollo, solo en localhost.

Consejo: fija pool-name. Con CicloUrbanaPool los mensajes del log y las métricas son identificables de un vistazo, sobre todo si algún día hay dos pools.

Consejo: activa leak-detection-threshold en desarrollo. Sesenta segundos es un buen umbral. Encontrar una fuga por su traza de pila cuesta minutos; encontrarla en producción por agotamiento del pool cuesta una tarde.

Consejo: fija la versión de la imagen de Docker. postgres:16-alpine, nunca postgres:latest. Que el equipo entero use el mismo motor evita la clase de fallo más difícil de reproducir.

Ejercicios

Ejercicio 1: dimensionar el pool de CicloUrbana

El ayuntamiento despliega CicloUrbana en 3 réplicas. El PostgreSQL tiene 8 vCPU, SSD y max_connections = 100. Hay además un proceso nocturno de informes que abre hasta 5 conexiones y un panel de administración con 5 más.

  1. Calcula el maximum-pool-size por réplica con la fórmula de PostgreSQL.
  2. Comprueba que el total no agota max_connections.
  3. Escribe el bloque spring.datasource.hikari completo, justificando cada valor.

Ejercicio 2: separar desarrollo y producción sin duplicar configuración

Escribe la configuración de CicloUrbana repartida en application.yml (común), application-dev.yml (H2 + consola + logs de SQL) y application-prod.yml (PostgreSQL + credenciales por variable de entorno + validate). Indica qué propiedad nunca debe aparecer en el fichero de producción y por qué.

Ejercicio 3: diagnosticar un agotamiento del pool

En producción aparece este error de forma intermitente en horas punta:

HikariPool-1 - Connection is not available, request timed out after 30001ms.

Enumera cuatro causas posibles ordenadas de más a menos probable, y para cada una indica cómo confirmarla y cómo corregirla. Explica por qué subir maximum-pool-size no es la primera respuesta.

Soluciones

Solución 1.

  1. Fórmula: (núcleos * 2) + husos_efectivos = (8 * 2) + 2 = 18 conexiones para toda la base de datos, no por réplica. Repartidas entre 3 réplicas: 18 / 3 = 6 por réplica. Un valor de 6 es defendible; 8 también, dejando margen. Lo que no es defendible es 20 por réplica.
  2. Total: 3 réplicas × 6 = 18, más 5 del proceso nocturno y 5 del panel = 28 conexiones. Frente a max_connections = 100 queda muchísimo margen, incluido el que PostgreSQL reserva para superusuario y mantenimiento. Con 20 por réplica serían 70, ya incómodo y muy por encima de lo que 8 vCPU pueden atender en paralelo.
  3. Configuración:
spring:
  datasource:
    hikari:
      pool-name: CicloUrbanaPool
      maximum-pool-size: 6         # (8*2+2)/3 réplicas
      minimum-idle: 6              # pool fijo: sin coste de apertura en el pico
      connection-timeout: 3000     # fallar rápido (3 s) en vez de encolar hilos
      idle-timeout: 600000         # irrelevante con minimum-idle = maximum
      max-lifetime: 1800000        # 30 min, por debajo del corte del cortafuegos
      leak-detection-threshold: 0  # desactivado en producción

El valor más discutible es connection-timeout: 3000. Bajarlo de los 30 s por defecto es deliberado: si el pool está agotado, esperar 30 segundos solo consigue acumular hilos y agravar el problema. Fallar en 3 segundos devuelve un 503 rápido, mantiene la aplicación viva y deja el síntoma visible en las métricas.

Solución 2.

# application.yml — común a todos los entornos
spring:
  application:
    name: ciclourbana
  jpa:
    open-in-view: false
    properties:
      hibernate:
        jdbc:
          batch_size: 20
  h2:
    console:
      enabled: false
# application-dev.yml
spring:
  datasource:
    url: jdbc:h2:mem:ciclourbana;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
    username: sa
    password:
    hikari: { pool-name: CicloUrbanaPool, maximum-pool-size: 5, leak-detection-threshold: 60000 }
  h2:
    console: { enabled: true, path: /h2-console }
  jpa:
    hibernate: { ddl-auto: update }
    properties: { hibernate: { format_sql: true } }
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE
# application-prod.yml
spring:
  datasource:
    url: ${CICLOURBANA_DB_URL}
    username: ${CICLOURBANA_DB_USER}
    password: ${CICLOURBANA_DB_PASSWORD}
    hikari:
      pool-name: CicloUrbanaPool
      maximum-pool-size: 6
      minimum-idle: 6
      connection-timeout: 3000
      max-lifetime: 1800000
  jpa:
    hibernate: { ddl-auto: validate }
logging:
  level: { org.hibernate.SQL: WARN }

Lo que nunca debe aparecer en producción, por orden de gravedad:

  • org.hibernate.orm.jdbc.bind: TRACE: volcaría al log todos los datos personales de los ciudadanos de Ribalta que pasen por una consulta. Es una brecha de privacidad, además de un coste de rendimiento notable.
  • ddl-auto: update o create: modificaciones de esquema no revisadas, o pérdida total de datos.
  • spring.h2.console.enabled: true: ejecución de SQL arbitrario sin autenticación.
  • La contraseña literal: siempre por variable de entorno, y sin valor por defecto para que el fallo sea ruidoso.

Los perfiles se activan con --spring.profiles.active=prod o SPRING_PROFILES_ACTIVE=prod, y se estudian a fondo en 07-02.

Solución 3. Causas ordenadas por probabilidad real:

  1. Transacciones demasiado largas (la más probable). Un método @Transactional que llama a un servicio externo —la pasarela de pago de CicloUrbana— retiene la conexión durante toda la llamada de red. Con 6 conexiones y llamadas de 2 segundos, el pool se agota con muy poco tráfico. Confirmar: activar leak-detection-threshold en preproducción y revisar los métodos @Transactional en busca de E/S. Corregir: sacar la llamada externa fuera de la transacción, con @TransactionalEventListener(AFTER_COMMIT) (04-07).
  2. Fuga de conexiones. Algún código obtiene una conexión del DataSource sin try-with-resources. Es raro con Spring Data, pero aparece en utilidades escritas a mano. Confirmar: leak-detection-threshold: 60000 y buscar las trazas «Apparent connection leak detected». Corregir: cerrar siempre con try-with-resources.
  3. Consultas lentas por falta de índice. Una consulta que tarda 5 segundos ocupa su conexión 5 segundos. Confirmar: pg_stat_statements en PostgreSQL o log_min_duration_statement = 1000. Corregir: índices y reescritura de la consulta (09-01).
  4. Problema N+1. Un endpoint que dispara cientos de consultas por petición multiplica el tiempo de retención. Confirmar: generate_statistics: true y contar sentencias por sesión. Corregir: JOIN FETCH o @EntityGraph (04-04 y 04-06).

Por qué subir maximum-pool-size no es la primera respuesta: el agotamiento es un síntoma, no la enfermedad. Si la causa es una transacción de 2 segundos, duplicar el pool duplica la carga sobre la base de datos sin arreglar nada; el problema reaparece con el doble de tráfico, ahora con el servidor más saturado. Más aún, más conexiones activas competiendo por CPU y bloqueos ralentizan todas las consultas, incluidas las que iban bien. Subir el pool es la última medida, después de haber medido y descartado las cuatro causas anteriores.

Conclusión

CicloUrbana ya tiene fontanería. Sabes qué es un DataSource y por qué abrir conexiones es caro, lo que justifica que exista un pool y que close() no cierre nada. Tienes H2 en memoria configurado para desarrollo con DB_CLOSE_DELAY=-1 y MODE=PostgreSQL, su consola web disponible solo en localhost y la advertencia de seguridad grabada. Tienes un docker-compose.yml con PostgreSQL 16, volumen con nombre, healthcheck y contraseña por variable de entorno, listo para retomarse en 07-04. Conoces las cuatro propiedades esenciales de spring.datasource y por qué el driver se deduce solo. Has recorrido HikariCP parámetro a parámetro, sabes que max-lifetime debe quedar por debajo del corte del cortafuegos, que minimum-idle igual al máximo evita el peor momento para abrir conexiones y que leak-detection-threshold es la forma más rápida de encontrar una fuga. Y, sobre todo, tienes un criterio defendible para dimensionar el pool: (núcleos × 2) + discos, repartido entre réplicas, con la certeza de que más conexiones no significan más rendimiento.

Del lado de JPA has fijado las decisiones que gobiernan el resto del módulo: ddl-auto en update solo mientras diseñamos las entidades, con el compromiso explícito de sustituirlo por Flyway y validate en 04-08; open-in-view: false desde ya, aceptando que el servicio devuelva DTOs completos a cambio de que el N+1 y las cargas perezosas den la cara en desarrollo; y el log de Hibernate configurado con org.hibernate.SQL y org.hibernate.orm.jdbc.bind para poder leer el SQL real con sus parámetros. También sabes cómo se declaran varias fuentes de datos con @Primary y por qué conviene evitarlo, cómo mantener las credenciales fuera de Git y cómo verificar la conexión al arrancar sin dejarte una fuga por el camino.

Pero la base de datos está vacía: no hay ni una tabla, porque no hay ni una entidad. Estacion sigue siendo un record inmutable en memoria. La lección 04-03, Creación de Entidades JPA, lo cambia: veremos por qué un record no puede ser una entidad, convertiremos Estacion, Bicicleta y Alquiler en entidades con @Entity, @Table e índices, elegiremos la estrategia de generación de identificadores adecuada para PostgreSQL, mapearemos cada tipo de dato con cuidado —incluyendo por qué los importes son BigDecimal y nunca double—, incrustaremos la Ubicacion con @Embeddable, añadiremos auditoría automática y estrenaremos @Version, el bloqueo optimista que jubila definitivamente al ShallowEtagHeaderFilter de 03-03.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados