1. Introducción
Cuando escribes un CRUD normal, un solo INSERT o un solo UPDATE rara vez asusta a nadie. Pero en cuanto surge un escenario del tipo «importar 10 000 productos», Hibernate se convierte de repente en un amigo muy honesto: hace exactamente lo que le pides… simplemente lo hace 10 000 veces. Y cada una de esas operaciones es una comunicación separada con la base de datos, con todos los gastos generales que conlleva.
Hasta ahora, lo que más nos interesaba era que Hibernate se comportara correctamente: dónde se desarrolla la transacción, cuándo ocurre flush, por qué un objeto está managed o detached. En el bloque de rendimiento, la cuestión cambia no de forma radical, sino muy concreta: hay que conseguir que el mismo bucle de escritura orientado a entidades no acceda a la base de datos una petición cada vez.
En palabras sencillas, el problema de la escritura masiva casi nunca es que PostgreSQL «no sepa insertar rápido», sino que la aplicación da demasiados pasos individuales: prepara una sentencia, la envía por la red, espera la respuesta, repite. Incluso si cada paso lleva milisegundos, 10 000 pasos se convierten en un tiempo notable. El batching JDBC es un intento de reducir el número de esas «idas y venidas» individuales, agrupando operaciones homogéneas en lotes.
Un punto importante: el batching no va de lógica de negocio ni de «anotaciones correctas». Es una optimización técnica a nivel de cómo exactamente Hibernate envía SQL a JDBC.
2. Qué es el batching JDBC
JDBC batching suena casi como «activar el modo turbo». En la práctica, es mucho más prosaico: Hibernate intenta usar el mecanismo estándar de JDBC donde la misma sentencia SQL se ejecuta muchas veces con diferentes parámetros, pero se envía al driver por lotes. Básicamente, se trata de «reunir varias operaciones idénticas y enviarlas juntas» para reducir la sobrecarga de comunicación.
Es útil eliminar de inmediato algunas expectativas falsas. JDBC batching no convierte tus 10 000 inserciones en una gran inserción súper «atómica». Tampoco convierte a Hibernate en una herramienta de procesamiento por lotes del tipo «un comando actualizó un millón de filas»; eso ya es otra clase de tarea. Y lo más importante: el batching no surge de la nada. Requiere que Hibernate pueda generar muchas operaciones SQL idénticas que se puedan agrupar.
Batching no es un único INSERT grande
Cuando un desarrollador oye «batch insert», a menudo se imagina: «Ajá, entonces habrá un solo SQL como INSERT INTO ... VALUES (...), (...), (...)». A veces los drivers realmente pueden reescribir las inserciones por lotes en un INSERT multilínea (por ejemplo, el driver de PostgreSQL puede hacerlo con ciertas configuraciones), pero eso ya es una optimización secundaria del lado del driver. Hibernate piensa ante todo así: «tengo una plantilla SQL y muchos conjuntos de parámetros, vamos a enviarlos en un lote mediante addBatch() / executeBatch()».
Es decir, el batching sigue siendo una serie de inserciones/actualizaciones individuales, simplemente agrupadas a nivel de JDBC. La base de datos aún tiene que procesar cada fila, verificar restricciones, índices, claves foráneas. Por lo tanto, el batching reduce la sobrecarga de comunicación, no anula las leyes de la física.
JDBC batching y @BatchSize son cosas diferentes
En nuestro curso ya hubo un módulo sobre fetching, donde aparecía @BatchSize y la idea de «cargar relaciones perezosas por lotes». Eso también se llama batching, pero es batching de lectura (SELECT), mientras que el tema de hoy es el batching de escritura (INSERT/UPDATE/DELETE) a nivel JDBC.
Para no confundirse, mantenga un límite mental simple. @BatchSize intenta reducir el número de SELECT en la carga perezosa de asociaciones. JDBC batching intenta reducir el número de accesos individuales a la base de datos en la escritura masiva. Estos mecanismos viven en diferentes lugares, resuelven diferentes problemas y se configuran de manera diferente. Si los mezclas, obtendrás una combinación maravillosa: «nada se aceleró, pero se volvió más confuso».
3. Dónde está el batching en el ciclo de vida de Hibernate
La razón más común por la que el batching «no funciona» es que el desarrollador lo busca en el lugar equivocado. Mira el código, ve un bucle con persist() y piensa: «Bueno, esto es escritura masiva». Y Hibernate en ese momento sonríe en silencio, porque en el bucle estás haciendo… cambios en memoria, no enviando SQL.
Ahora vamos a ensamblar la cadena donde aparece exactamente el batching. Y sí, sonará un poco más «mecánica» que las explicaciones habituales de CRUD, pero eso es precisamente lo que hace que una inmersión profunda sea una inmersión profunda.
flowchart TD %% Esto no es «cómo está escrito tu bucle for», sino «dónde ocurre exactamente el envío de SQL». %% El batching se manifiesta en el momento del flush, cuando Hibernate llega a las operaciones JDBC. A["Código Java: em.persist(entidad)"] --> B["La entidad se vuelve managed"] B --> C["Hibernate coloca la operación en una cola (ActionQueue)"] C --> D["Flush (antes del commit o antes)"] D --> E["Generación de SQL y preparación del PreparedStatement"] E --> F["statement.addBatch() para operaciones homogéneas"] F --> G["statement.executeBatch()"] G --> H["PostgreSQL ejecuta las inserciones/actualizaciones"]
persist() no es SQL todavía
Cuando haces em.persist(producto), Hibernate, en términos generales, anota: «Vale, habrá que insertar esta entidad». Pero hasta que no se produce la fase de flush, es posible que no llegue ni un solo INSERT a la base de datos.
En el proyecto de aprendizaje Commerce Persistence Lab, esto es especialmente fácil de observar en un escenario de importación: puedes tener una lista de Product y hacer «persist» en un bucle. Desde fuera parece que estás «insertando», pero en realidad solo estás acumulando la intención de insertar.
Mini ejemplo al estilo de nuestro proyecto (código corto y directo):
package com.example.commerce.catalog.service;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class ProductImportService {
@Transactional
public void importProducts(List<Product> products, EntityManager em) {
// Importante: persist() NO envía SQL inmediatamente.
// Aquí solo registramos nuevas entidades en el contexto de persistencia.
for (Product product : products) {
em.persist(product);
}
// Los INSERT reales irán a la BD en el flush/commit (si no hubo flush antes).
}
}
Este código crea el contexto adecuado para el batching (muchas operaciones homogéneas en una misma transacción), pero aún no demuestra que el batching vaya a ocurrir realmente. ¿Por qué? Porque el batching solo comienza donde Hibernate envía realmente el SQL.
Flush: el «momento de la verdad» para el batching
El batching siempre está relacionado con cómo Hibernate ejecuta SQL durante el flush. Podemos recordar una fórmula simple: el batching vive dentro de un solo ciclo de flush. Si ocurre el flush, Hibernate comienza a ejecutar los INSERT/UPDATE acumulados, y es ahí donde intenta agrupar las operaciones homogéneas en un lote.
Por lo tanto, si tu transacción es muy corta y el flush ocurre con frecuencia (o tú mismo llamas a flush() por cualquier motivo), es posible que el lote no tenga tiempo de acumularse. Si el flush ocurre una sola vez al final de una transacción grande, Hibernate tendrá la oportunidad de reunir un lote considerable.
De momento, basta con fijar la idea: el batching es una optimización de la fase de envío de SQL, no de la fase de «cambio de objetos».
Nivel JDBC: addBatch() y executeBatch()
En el nivel más bajo, Hibernate se comunica con la base de datos a través de JDBC. Para inserciones y actualizaciones, normalmente usa PreparedStatement, porque es más seguro y rápido que construir la cadena SQL constantemente. Y el batching solo funciona cuando Hibernate puede ejecutar repetidamente el mismo PreparedStatement, cambiando solo los parámetros.
Si Hibernate obtiene diferentes plantillas SQL (diferentes tablas, diferentes conjuntos de columnas, diferente orden de acciones), físicamente no podrá «reunirlas en un solo lote». Por lo tanto, el batching no ama el caos. Ama la repetitividad. Como todos nosotros el lunes por la mañana: danos tareas idénticas y seremos más rápidos.
4. Condiciones para el batching
Ahora viene la parte más práctica: qué debe coincidir para que el batching tenga una oportunidad. Es importante percibir esto no como una «lista de verificación de casillas mágicas», sino como un modelo de causa y efecto. Hibernate no está obligado a hacer batching, solo puede intentarlo si tiene el material adecuado.
A continuación, una tabla compacta que ayuda a no confundirse. Después, repasaremos los puntos clave en texto.
| Condición | Qué significa en lenguaje humano | Por qué sin esto no ocurrirá el batching |
|---|---|---|
| hibernate.jdbc.batch_size > 0 | el batching está activado y hay un límite de lote | de lo contrario, Hibernate no usará executeBatch() en absoluto |
| Muchas operaciones homogéneas en una misma Session/transaction | hay «materia prima» para el lote | no hay con qué formar el lote |
| Forma SQL idéntica | una misma plantilla SQL repetida | no se pueden agrupar sentencias diferentes |
| El flush ocurre «por lotes», no después de cada operación | el lote tiene tiempo de acumularse | el lote se desmorona en migajas |
| La generación de id no obliga a hacer el INSERT inmediatamente | Hibernate puede posponer las inserciones | si se necesita el insert ahora mismo, el batching se rompe |
| El driver y el dialecto soportan bien el batching | JDBC ejecutará realmente el lote | a veces el driver «sabe» hacerlo solo sobre el papel |
Precisamente de esta lista se desprende por qué no basta con hibernate.jdbc.batch_size. Más adelante en la cadena nos frenarán tres cosas: el id que obliga a insertar de inmediato; el contexto de persistencia inflado en un bucle largo; y un flujo SQL irregular, donde las sentencias idénticas no tienen tiempo de agruparse en un lote normal.
Operaciones SQL idénticas y «flujo uniforme»
Imagina que solo importas Product. Hibernate genera una única plantilla SQL insert into product (...) values (...) y la repite muchas veces. Esta es una situación ideal para el batching.
Ahora imagina que en un mismo bucle alternas Product y Category. Desde el punto de vista de Java, todo es bonito: un bucle, dos operaciones. Pero desde el punto de vista del batching, obtienes un flujo del tipo: INSERT product, INSERT category, INSERT product, INSERT category… y Hibernate simplemente no puede reunir 20 INSERT product seguidos porque entre ellos se intercalan INSERT category.
Aquí hay un ejemplo que externamente parece «escritura masiva», pero al que el batching le cuesta más:
import jakarta.persistence.EntityManager;
import org.hibernate.Session;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional
public void saveCatalog(List<Product> products, List<Category> categories, EntityManager em) {
// Establecemos localmente el tamaño del lote (útil para experimentos en el laboratorio).
em.unwrap(Session.class).setJdbcBatchSize(20);
for (int i = 0; i < products.size(); i++) {
// El flujo de acciones está «mezclado»: INSERT Product, luego INSERT Category.
// Esto empeora la posibilidad de reunir una serie larga de sentencias idénticas.
em.persist(products.get(i));
em.persist(categories.get(i));
}
// Los INSERT ocurrirán igualmente en el flush/commit, pero el batching puede volverse «irregular».
}
Sí, aquí hay setJdbcBatchSize(20). Sí, aquí hay una sola transacción. Pero el flujo de acciones está «mezclado» y el batching se vuelve menos predecible. Puede ocurrir parcialmente, en lotes pequeños, o casi no dar efecto, según cómo Hibernate ordene las acciones en la fase de flush en una situación concreta.
Transacción y límites del flush
El batching casi siempre implica que tienes una sola «unidad de trabajo» dentro de la cual haces muchas inserciones o actualizaciones. Es la transacción la que da a Hibernate espacio para maniobrar: puede acumular cambios y enviarlos al final (o por lotes).
Si no hay transacción, o está dividida en múltiples microtransacciones, el batching será mínimo. Es como intentar cargar un camión con cajas, pero cada 30 segundos te dicen: «Sal inmediatamente, aunque solo haya una caja». El camión se va, el batching no nace.
Para nuestros laboratorios, esto significa una disciplina simple: los escenarios masivos deben diseñarse como un único método de servicio transaccional. No por la belleza de la anotación, sino porque el batching sin esto es como café sin cafeína: se parece, pero ¿para qué?
El driver también participa
En nuestra pila de aprendizaje trabajamos con PostgreSQL, y el driver JDBC de PostgreSQL en general soporta el batching. Pero en la vida real, a veces resulta que «la configuración está activada» y casi no hay efecto, porque el driver hace los lotes de forma ineficiente o el servidor no recibe la optimización esperada.
Aquí es útil mantener una posición técnica tranquila. El batching de Hibernate es una estrategia para «reducir la sobrecarga». Si activaste el batching y no viste diferencia, no es motivo para discutir con la realidad. Es motivo para medir, mirar los registros, asegurarse de que la agrupación realmente ocurre, y solo entonces sacar conclusiones.
5. Activación del batching en Spring Boot + Hibernate
En la práctica, el batching se activa mediante hibernate.jdbc.batch_size. Se puede establecer globalmente en las propiedades de Hibernate o localmente en la Session a través de setJdbcBatchSize(...), si quieres probar una importación concreta o sobrescribir temporalmente el comportamiento para un flujo de escritura específico.
Aquí es importante fijar solo dos ideas. batch_size es el límite superior del lote, no una promesa de «exactamente 20». Y el mero hecho de configurarlo aún no demuestra nada: si IDENTITY, un flush() frecuente o un flujo SQL irregular rompen la agrupación, Hibernate no está obligado a mostrar un lote bonito solo porque escribiste un número en la configuración.
6. Escenarios en Commerce Persistence Lab
Para que el tema de hoy no quede en el aire, lo vincularemos a nuestro dominio. Tenemos la entidad Product, y la importación de productos es un escenario natural con muchas inserciones. Es un «campo de pruebas ideal» para el batching, porque las inserciones son homogéneas y se repiten muchas veces.
Empecemos con la variante más «pura»: importación solo de productos. Es buena porque incluso un registro SQL débil muestra rápidamente la repetitividad.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional
public void importOnlyProducts(List<Product> products, EntityManager em) {
// Flujo máximamente «uniforme»: solo Product -> INSERT repetible.
for (Product p : products) {
em.persist(p);
}
// Este es el mejor candidato para un batching eficiente (con la configuración activada).
}
Ahora, un ejemplo de contraste: importación de productos junto con algo más (categorías, relaciones de asignación, etc.). Aunque el código parezca «aún masivo», el flujo SQL se vuelve irregular y el batching puede agrupar peor.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional
public void importProductsAndCategories(List<Product> products,
List<Category> categories,
EntityManager em) {
for (int i = 0; i < products.size(); i++) {
// Mezclar diferentes tipos de entidades generalmente significa diferentes INSERT.
em.persist(products.get(i));
em.persist(categories.get(i));
}
// En el flush, a Hibernate le costará más reunir grandes lotes de operaciones homogéneas.
}
El segundo ejemplo no es «malo» en sí mismo. Simplemente ilustra la idea principal de la lección: el batching no depende de cuántos bucles for tengas. Depende de qué tan homogéneo sea el flujo SQL que Hibernate pueda reunir en la fase de flush.
7. Verificación del batching mediante registros
El batching debe confirmarse mediante registros, no por la sensación de «parece que fue más rápido». El registro SQL (org.hibernate.SQL) muestra la forma de las consultas, pero no siempre el hecho mismo del envío por lotes: puedes ver los mismos insert into product ... aunque el driver los haya ejecutado en lotes.
El indicador directo es org.hibernate.orm.jdbc.batch. Ahí es donde se ve si Hibernate llegó a addBatch() / executeBatch() y comenzó realmente a ejecutar lotes. El tiempo de ejecución es una métrica secundaria útil, pero no una prueba en sí misma.
8. Errores típicos con JDBC batching
Error n.º 1: pensar que el batching se activa por el mero hecho de que exista un bucle.
Un bucle es solo una forma de código Java. A Hibernate no le interesa el bucle, sino qué operaciones SQL podrá reunir en la fase de flush. Un mismo for puede generar un flujo «limpio» de INSERT homogéneos, o puede generar un conjunto mezclado de operaciones que apenas se agrupa en lotes.
Error n.º 2: confundir el batching de escritura con el batching de lectura (@BatchSize).
Esta es una confusión muy popular, porque la palabra es la misma pero el significado es diferente. @BatchSize ayuda con los SELECT perezosos y reduce el número de consultas al cargar asociaciones. El batching JDBC ayuda a enviar INSERT/UPDATE/DELETE en lotes. Si activaste uno y esperas el efecto del otro, solo obtendrás decepción y un ligero odio hacia las abstracciones.
Error n.º 3: establecer un hibernate.jdbc.batch_size enorme sin entender que es un límite, no una garantía.
Si pones 500 o 1000 «por si acaso», no estás obligado a obtener lotes de exactamente 1000. Pero sí puedes obtener un consumo de memoria inesperado y un comportamiento extraño en transacciones largas. Para empezar, es mucho más razonable usar 20–50 y observar los registros.
Error n.º 4: «demostrar» el batching solo con el tiempo de ejecución.
El tiempo es un indicador útil, pero demasiado ruidoso. Que una ejecución sea más rápida que otra no siempre se debe al batching. A veces es JIT, a veces la caché del disco, a veces el calentamiento de la base de datos, a veces simplemente un día de suerte. Si no has confirmado el batching con los registros org.hibernate.orm.jdbc.batch, todavía estás creyendo, no sabiendo.
Error n.º 5: no separar la fase «acumulo cambios» de la fase «envío SQL».
Si esperas el batching «en el momento de persist()», buscarás el efecto en el lugar equivocado. persist() registra la entidad, el batching se manifiesta en la fase de flush. Hasta que esto no se convierta en tu modelo mental, cualquier experimento con escritura masiva se sentirá como chamanismo: «a veces funciona, a veces no».
GO TO FULL VERSION