Multi-datasource y HikariCP tuning

Avanzado
Spring Boot
Spring Boot
Actualizado: 07/05/2026

Diagrama: tutorial-spring-boot-multi-datasource

Cuándo necesitas más de un DataSource

Spring Boot ofrece autoconfiguración de un único DataSource por defecto, lo que cubre el 90% de los proyectos. Para el 10% restante hay tres escenarios habituales:

  • 1. Read replica: separar lecturas de escrituras a réplicas dedicadas para escalar lecturas. Las consultas de reporting van a réplica; las escrituras al primario.
  • 2. Multi-tenant: cada tenant tiene su BD propia. La aplicación selecciona dinámicamente el DataSource según el contexto del usuario.
  • 3. BD heredadas en convivencia: la aplicación nueva usa PostgreSQL, pero también consulta una BD legacy en MySQL u Oracle. Cada una con sus entidades.

En estos casos hay que renunciar a la autoconfiguración por defecto y declarar manualmente los DataSource.

Configuración de dos DataSource

spring:
  datasource:
    primary:
      url: jdbc:postgresql://primary:5432/empleados
      username: ${DB_PRIMARY_USER}
      password: ${DB_PRIMARY_PASSWORD}
      hikari:
        maximum-pool-size: 20
        minimum-idle: 5
        pool-name: HikariPrimary
    legacy:
      url: jdbc:mysql://legacy:3306/empleados_legacy
      username: ${DB_LEGACY_USER}
      password: ${DB_LEGACY_PASSWORD}
      hikari:
        maximum-pool-size: 10
        minimum-idle: 2
        pool-name: HikariLegacy
@Configuration
@EnableJpaRepositories(
    basePackages = "com.empresa.empleados.repository.primary",
    entityManagerFactoryRef = "primaryEntityManagerFactory",
    transactionManagerRef = "primaryTransactionManager"
)
public class PrimaryDataSourceConfig {

    @Primary
    @Bean(name = "primaryDataSource")
    @ConfigurationProperties("spring.datasource.primary.hikari")
    public DataSource primaryDataSource(
            @Qualifier("primaryProperties") DataSourceProperties props) {
        return props.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }

    @Primary
    @Bean(name = "primaryProperties")
    @ConfigurationProperties("spring.datasource.primary")
    public DataSourceProperties primaryProperties() {
        return new DataSourceProperties();
    }

    @Primary
    @Bean(name = "primaryEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory(
            EntityManagerFactoryBuilder builder,
            @Qualifier("primaryDataSource") DataSource ds) {
        return builder
            .dataSource(ds)
            .packages("com.empresa.empleados.entity.primary")
            .persistenceUnit("primary")
            .build();
    }

    @Primary
    @Bean(name = "primaryTransactionManager")
    public PlatformTransactionManager primaryTransactionManager(
            @Qualifier("primaryEntityManagerFactory") EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }
}
@Configuration
@EnableJpaRepositories(
    basePackages = "com.empresa.empleados.repository.legacy",
    entityManagerFactoryRef = "legacyEntityManagerFactory",
    transactionManagerRef = "legacyTransactionManager"
)
public class LegacyDataSourceConfig {

    @Bean(name = "legacyDataSource")
    public DataSource legacyDataSource(
            @Qualifier("legacyProperties") DataSourceProperties props) {
        return props.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }

    @Bean(name = "legacyProperties")
    @ConfigurationProperties("spring.datasource.legacy")
    public DataSourceProperties legacyProperties() {
        return new DataSourceProperties();
    }

    @Bean(name = "legacyEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean legacyEntityManagerFactory(
            EntityManagerFactoryBuilder builder,
            @Qualifier("legacyDataSource") DataSource ds) {
        return builder
            .dataSource(ds)
            .packages("com.empresa.empleados.entity.legacy")
            .persistenceUnit("legacy")
            .build();
    }

    @Bean(name = "legacyTransactionManager")
    public PlatformTransactionManager legacyTransactionManager(
            @Qualifier("legacyEntityManagerFactory") EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }
}

Las entidades se separan en paquetes distintos:

com.empresa.empleados.entity.primary
  - Empleado.java       <- mapeada a primary
  - Departamento.java
com.empresa.empleados.entity.legacy
  - EmpleadoLegacy.java  <- mapeada a legacy

Y los repositorios respectivamente:

com.empresa.empleados.repository.primary
  - EmpleadoRepository.java
com.empresa.empleados.repository.legacy
  - EmpleadoLegacyRepository.java

Cada repository se inyecta normalmente; Spring sabe a qué DataSource pertenece por el @EnableJpaRepositories.basePackages.

@Primary y @Transactional

@Primary marca el DataSource por defecto. Si haces @Autowired DataSource ds, te entregan el primario. Si haces @Transactional sin especificar, abre transacción contra el primario.

Para usar el legacy explícitamente:

@Service
@RequiredArgsConstructor
public class MigrationService {

    private final EmpleadoLegacyRepository legacyRepo;
    private final EmpleadoRepository primaryRepo;

    @Transactional("legacyTransactionManager")
    public List<EmpleadoLegacyDto> leerLegacy() {
        return legacyRepo.findAll();
    }

    @Transactional  // implicit "primaryTransactionManager"
    public void migrar(EmpleadoLegacyDto legacy) {
        primaryRepo.save(toEmpleado(legacy));
    }
}

Importante: @Transactional no es transaccional a través de DataSources. Una transacción solo abarca un DataSource. Si necesitas atomicidad cross-datasource, hace falta JTA (XA transactions), que añade complejidad significativa. Lo idiomático es evitar este caso con eventos eventually-consistent o sagas.

Read/write split con AbstractRoutingDataSource

Patrón clásico: enviar SELECT a réplica, INSERT/UPDATE/DELETE al primario. Spring trae AbstractRoutingDataSource que selecciona el DataSource en cada llamada según un contexto:

public class RoutingDataSource extends AbstractRoutingDataSource {

    public enum DataSourceType { PRIMARY, REPLICA }

    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContext.get();
    }
}

public class DataSourceContext {
    private static final ThreadLocal<DataSourceType> CONTEXT = new ThreadLocal<>();

    public static void set(DataSourceType type) { CONTEXT.set(type); }
    public static DataSourceType get() { return CONTEXT.get(); }
    public static void clear() { CONTEXT.remove(); }
}

Configuración:

@Bean
public DataSource routingDataSource(
        @Qualifier("primaryDataSource") DataSource primary,
        @Qualifier("replicaDataSource") DataSource replica) {
    var routing = new RoutingDataSource();
    routing.setTargetDataSources(Map.of(
        DataSourceType.PRIMARY, primary,
        DataSourceType.REPLICA, replica
    ));
    routing.setDefaultTargetDataSource(primary);
    return routing;
}

Aspecto que selecciona automáticamente:

@Aspect
@Component
public class ReadOnlyRoutingAspect {

    @Around("@annotation(org.springframework.transaction.annotation.Transactional)")
    public Object route(ProceedingJoinPoint pjp) throws Throwable {
        var method = ((MethodSignature) pjp.getSignature()).getMethod();
        var tx = method.getAnnotation(Transactional.class);

        if (tx != null && tx.readOnly()) {
            DataSourceContext.set(DataSourceType.REPLICA);
        } else {
            DataSourceContext.set(DataSourceType.PRIMARY);
        }
        try {
            return pjp.proceed();
        } finally {
            DataSourceContext.clear();
        }
    }
}

Con esto, los métodos anotados con @Transactional(readOnly = true) van a la réplica; los demás al primario:

@Service
public class EmpleadoQueryService {

    @Transactional(readOnly = true)
    public List<EmpleadoDto> listar() {
        // Va a réplica
    }
}

@Service
public class EmpleadoCommandService {

    @Transactional
    public EmpleadoDto crear(EmpleadoCrearDto dto) {
        // Va a primario
    }
}

Hay que configurar la réplica con transaction-isolation: TRANSACTION_REPEATABLE_READ o READ_COMMITTED y permitir solo SELECT (a nivel de usuario de BD) para evitar escrituras accidentales.

Multi-tenant: un DataSource por tenant

Para multi-tenancy con BD por tenant, el patrón es similar pero el contexto se establece desde la petición (header, JWT claim):

@Component
public class TenantFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws ServletException, IOException {
        var tenantId = extractTenant(request);  // de header X-Tenant-Id o JWT
        TenantContext.set(tenantId);
        try {
            chain.doFilter(request, response);
        } finally {
            TenantContext.clear();
        }
    }
}
public class TenantRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.get();
    }
}

Spring Boot 4 + Hibernate también soporta multi-tenancy nativamente con hibernate.multiTenancy = SCHEMA o DATABASE, pero requiere configuración más compleja del MultiTenantConnectionProvider y CurrentTenantIdentifierResolver.

HikariCP tuning para producción

HikariCP es el connection pool por defecto en Spring Boot. Las propiedades que importan:

maximum-pool-size

Tamaño máximo del pool. Cálculo recomendado:

pool_size = ((núcleos_BD * 2) + número_spindles)

Para PostgreSQL en SSD con 8 núcleos: 82 + 1 = 17. Para MySQL en cluster con 16 núcleos: 162 + 1 = 33.

No más alto: pools grandes degradan el rendimiento por contención de locks en la BD. Es un mito que "más conexiones = más rendimiento". A partir de cierto punto, más conexiones causan menor throughput.

spring:
  datasource:
    hikari:
      maximum-pool-size: 20

minimum-idle

Conexiones mínimas siempre abiertas. Por defecto = maximum-pool-size (recomendado en producción para evitar coste de creación).

spring:
  datasource:
    hikari:
      minimum-idle: 20  # mismo que maximum

En desarrollo se puede bajar a 1-2 para no consumir recursos.

connection-timeout

Tiempo máximo que una petición espera a obtener conexión del pool antes de fallar. Por defecto 30 segundos.

spring:
  datasource:
    hikari:
      connection-timeout: 5000  # 5 segundos

5 segundos es razonable: si no hay conexión en 5s, mejor fallar y devolver 503 que dejar al cliente colgado.

idle-timeout y max-lifetime

  • idle-timeout: tiempo tras el cual una conexión idle se cierra. Default 10 minutos.
  • max-lifetime: tiempo máximo de vida de una conexión. Default 30 minutos.

max-lifetime debe ser menor que el timeout del firewall y del servidor de BD para evitar conexiones zombi:

spring:
  datasource:
    hikari:
      idle-timeout: 600000      # 10 min
      max-lifetime: 1800000     # 30 min
      keepalive-time: 300000    # 5 min, ping al servidor

leak-detection-threshold

Detecta conexiones abiertas que no se cierran. Si una conexión está fuera del pool más tiempo que este threshold, HikariCP escribe un stacktrace en logs:

spring:
  datasource:
    hikari:
      leak-detection-threshold: 60000  # 60 segundos

Útil en desarrollo y staging. En producción se desactiva (o pone alto, 5 minutos) para evitar overhead.

Métricas con Micrometer

Spring Boot expone métricas de HikariCP automáticamente:

hikaricp.connections{pool="HikariPrimary"}              # total
hikaricp.connections.active{pool="HikariPrimary"}        # en uso
hikaricp.connections.idle{pool="HikariPrimary"}          # idle
hikaricp.connections.pending{pool="HikariPrimary"}       # esperando
hikaricp.connections.acquire{pool="HikariPrimary"}       # tiempo medio adquirir
hikaricp.connections.usage{pool="HikariPrimary"}         # tiempo medio uso
hikaricp.connections.timeout{pool="HikariPrimary"}       # timeouts contador

Métricas críticas en producción:

  • pending constantemente > 0: pool saturado, aumentar maximum-pool-size o investigar consultas lentas.
  • timeout > 0: conexiones rechazadas. Cliente recibe 503. Crítico.
  • acquire p99 alto: contención. Mismo diagnóstico que pending.
  • active siempre cerca del máximo: pool al límite.

Dashboard de Grafana típico muestra estas cuatro métricas a la vez.

Anti-patrones

Pool grande "por si acaso"

maximum-pool-size: 200 en una BD con 8 núcleos colapsa la BD. La BD pasa más tiempo cambiando contextos que ejecutando queries.

Conexiones que no se cierran

Código que usa DataSource.getConnection() directamente y olvida cerrar genera leaks que el pool no detecta inmediatamente. Usar siempre try-with-resources:

try (var conn = dataSource.getConnection();
     var stmt = conn.prepareStatement(sql);
     var rs = stmt.executeQuery()) {
    // ...
}

Con Spring JdbcTemplate o JPA esto se gestiona automáticamente.

TX larga sosteniendo la conexión

Una transacción que dura 10 segundos mantiene una conexión ocupada todo ese tiempo. Si el pool tiene 20 conexiones y hay 30 peticiones simultáneas, las 10 últimas fallan con timeout.

Optimizar transacciones cortas:

  • Mover lógica que no necesita BD fuera del @Transactional.
  • Usar @Transactional(readOnly = true) cuando aplique.
  • Dividir flujos largos en transacciones pequeñas.

Misma maximum-pool-size en réplica que en primario

La réplica suele recibir más SELECT (informes, dashboards). Puede necesitar pool mayor que el primario:

spring:
  datasource:
    primary:
      hikari:
        maximum-pool-size: 20
    replica:
      hikari:
        maximum-pool-size: 40

Buenas prácticas

  • Multi-datasource solo cuando es necesario: read replica con tráfico significativo, multi-tenant real, BD heredada que conviene mantener separada. No por "preparación futura".
  • maximum-pool-size calculado, no fijado por costumbre. La fórmula es real.
  • max-lifetime < firewall timeout. Evita reconexiones forzadas que generan errores transitorios.
  • leak-detection-threshold activo en staging. Detecta leaks antes de producción.
  • Métricas Micrometer + alertas sobre pending y timeout. Sin estas alertas, una saturación de pool aparece como "lentitud" sin causa aparente.
  • Documentación clara del routing: si hay read/write split o multi-tenant, todo el equipo debe saber qué métodos van a qué DataSource.
  • Nunca confiar en routing automático sin tests: añadir tests que verifican que @Transactional(readOnly = true) realmente va a réplica.

Multi-datasource es una herramienta potente que añade complejidad significativa. La regla es: resolver el problema de hoy con un solo DataSource hasta que no se pueda. Cuando se cruza el umbral, hacerlo con cuidado, métricas y tests.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Spring Boot

Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Configurar múltiples DataSource con sus propios EntityManagerFactory y TransactionManager. Distinguir el datasource primario del secundario con @Primary. Implementar routing dinámico (read/write split, multi-tenant) con AbstractRoutingDataSource. Tunear HikariCP con criterio (pool size, timeouts, leak detection). Monitorizar el pool con Micrometer.