1. Introducción
El primer impulso de la mayoría de los desarrolladores Java es muy honesto: «Necesito actualizar muchos productos — pues voy a leer muchos Product, los recorro en un bucle y llamo a setPrice()». Esto parece lógico porque Hibernate realmente sabe vivir bien en el modelo de unit of work: modificas el objeto y luego él mismo «de algún modo» se actualiza en la base de datos. El problema es que «de algún modo» puede resultar demasiado caro — y la mayoría de las veces pagas por algo que ni siquiera necesitas.
El batching ya mostró algo útil: incluso cuando N UPDATE idénticos son inevitables, se pueden enviar a la BD de forma notablemente más barata. Pero esto aún no responde a otra pregunta: ¿son necesarias esas N operaciones en absoluto? Si la regla es la misma para miles de filas, el siguiente paso honesto es dejar de optimizar el bucle por costumbre y preguntarse por qué cargamos esas entidades en memoria. De ahí surge toda la discusión posterior: primero analizaremos el coste de ese loop, luego veremos cuándo es más honesto pasar a bulk, por qué después de esto cambian las reglas del juego para el persistence context y dónde puede ser útil un modo línea por línea sin un contexto managed completo.
Imaginemos un flujo managed familiar en nuestro proyecto Commerce Persistence Lab. Hay una operación administrativa hipotética «poner el mismo precio a todos los productos activos» (sí, suena como una oferta al estilo «todo a 89.90», pero para el laboratorio es ideal).
import java.math.BigDecimal;
import java.util.List;
// Cargamos las entidades en el persistence context (se convierten en managed)
List<Product> products = entityManager.createQuery("""
select p
from Product p
where p.status = :status
""", Product.class)
// Parámetro de la consulta para no hardcodear el valor en JPQL
.setParameter("status", ProductStatus.ACTIVE)
.getResultList();
// Modificamos el campo de cada entidad managed: luego Hibernate hará dirty checking
for (Product product : products) {
product.setPrice(new Money(new BigDecimal("89.90"), "USD"));
}
Desde el punto de vista de «cómo se siente», todo es bonito: obtuvimos una lista de objetos y modificamos un campo. Desde el punto de vista de Hibernate — hemos cargado N entidades en el persistence context, creado un snapshot para cada una, y luego, al hacer flush(), comparará el estado actual con el snapshot y generará un lote de UPDATE. Y aquí está la pregunta importante de la lección: ¿realmente necesitábamos arrastrar N objetos a la memoria solo para un cambio idéntico?
2. El coste del managed-loop: por qué pagas en un escenario masivo
Cuando haces una «actualización en bucle», normalmente piensas solo en la parte obvia: «voy a hacer N actualizaciones». Pero el ORM añade varios costes ocultos que en conjuntos de datos pequeños pasan desapercibidos, pero con miles de filas se convierten en un dolor tangible. Es más o menos como «ir a la tienda a por pan» andando — está bien, pero si necesitas transportar un piano, de repente te das cuenta de que «andar» no es un medio de transporte universal.
El primer coste es la materialización de los datos: Hibernate debe leer las filas de la BD, construir objetos Product a partir de ellas, posiblemente crear proxies para las relaciones, y ponerlo todo en la cache de primer nivel. Incluso si solo cambias un campo, ya has pagado por la lectura de todas las columnas necesarias para la entidad y por el trabajo de crear los objetos.
El segundo coste es la memoria del persistence context. Las entidades cargadas viven allí hasta el final de la transacción (o hasta que hagas clear()), y junto con ellas viven los snapshots para el dirty checking. En conjuntos grandes de datos no solo te topas con SQL, sino también con la física más básica: la memoria no es de goma, e incluso si lo fuera, el GC seguirá viniendo por ti — solo un poco más tarde y más agresivo.
El tercer coste es el dirty checking. Hibernate no «cree en tu palabra» de que has modificado un campo. Compara el estado actual de cada objeto managed con su snapshot. Con N=50 no lo notarás. Con N=20_000, de repente ves que el commit se vuelve sospechosamente lento — aunque el SQL parezca simple.
El cuarto coste es la cantidad de operaciones SQL. Sí, el batching puede empaquetarlas, pero el hecho sigue siendo: igualmente ejecutas un gran número de UPDATE (uno por fila, aunque sea en lotes). Y esto puede ser mucho peor que un solo SQL UPDATE ... WHERE ....
Para asimilarlo bien, es útil comparar «lo que hacemos» no por el código Java, sino por la forma de trabajo:
| Enfoque | Qué ocurre en la memoria de la aplicación | Qué ocurre en la BD | Coste típico |
|---|---|---|---|
| Entity-loop (managed) | Cargamos N entidades, mantenemos snapshots, hacemos dirty checking | N UPDATE (a veces en lotes) | CPU+RAM en la aplicación + muchas operaciones SQL |
| Bulk update/delete (una consulta de mutación) | No cargamos entidades como objetos | 1 UPDATE o DELETE según condición | Barato en memoria, pero cambian las reglas de sincronización del contexto (hablaremos de ello más adelante hoy) |
Y fíjate: en esta tabla aún no aparece la palabra «correcto/incorrecto». Aparece la palabra «coste». En una capa de persistencia madura dejamos de preguntar «¿cómo es más habitual?» y empezamos a preguntar: ¿qué forma de operación corresponde a la tarea?
3. Cuándo está justificado el entity-loop
Es importante no caer en el otro extremo: «¡Ajá! Los bucles son malos, vamos a hacer todo con bulk». Esto es más o menos como después del primer salario decidir que ya no hace falta cocinar en casa porque existe el delivery. El delivery es genial, pero no cuando quieres un borscht como el de la abuela (y la abuela, como sabemos, es un sistema legacy con fuertes invariantes).
El entity-loop sigue siendo una excelente solución cuando la lógica de negocio realmente depende del estado de cada entidad y de su comportamiento en memoria. Por ejemplo, si las reglas son diferentes para distintos productos, si usas métodos de dominio, si hay validaciones que no se pueden expresar con una sola condición en SQL, o si el cambio afecta no a un campo sino a todo un grafo (y quieres mantenerte dentro del unit of work habitual).
Aquí tienes un ejemplo simple de nuestro dominio: «si el SKU empieza con LEGACY-, ocultar el producto». Esto ya no es «el mismo valor para todos», sino una decisión por cada objeto.
import java.util.List;
// Cargamos los productos activos como entidades managed
List<Product> products = entityManager.createQuery("""
select p
from Product p
where p.status = :status
""", Product.class)
.setParameter("status", ProductStatus.ACTIVE)
.getResultList();
for (Product product : products) {
// La decisión depende de los datos concretos del producto: esto es precisamente un entity-loop "honesto"
if (product.getSku().startsWith("LEGACY-")) {
product.setStatus(ProductStatus.HIDDEN);
}
}
Aquí el bucle es bastante «honesto»: realmente observas los datos concretos de cada entidad y tomas una decisión. En estilo bulk también se podría expresar (por ejemplo, where p.sku like 'LEGACY-%'), pero el principio general es: si empiezas a escribir «if/else» basado en los datos de la entidad, es una señal fuerte de que el entity-loop puede estar justificado.
Y además un matiz importante: si ya has elegido el entity-loop, la técnica de ayer de flush()/clear() en bucles puede ser ese compromiso que mantenga la memoria bajo control. Pero hoy analizamos un paso anterior: ¿realmente era necesario elegir el loop?
4. Cuándo el bucle no tiene sentido: una acción para todos
Ahora llegamos a la idea principal de la lección. El bucle sobre entidades empieza a perder cuando tu operación, por su significado, se convierte en una operación sobre un conjunto de filas, y no sobre un conjunto de objetos. Es decir, cuando haces el mismo cambio (o la misma eliminación) bajo una condición simple. Entonces, cargar cada entidad es como abrir cada caja en el almacén para pegarle la misma pegatina. ¿Se puede? Se puede. ¿Pero realmente quieres hacerlo?
En nuestro proyecto hay dos ejemplos muy reales de candidatos a bulk: el cambio masivo de precio (Product repricing) y la limpieza técnica de datos históricos (InventorySnapshot cleanup). En ambos casos a menudo no hay necesidad de ver cada objeto en memoria — basta con una condición.
Si tomamos el ejemplo del mismo precio para todos los productos activos, el mismo significado se puede expresar con una sola consulta de mutación:
import java.math.BigDecimal;
// Bulk update: modificamos las filas directamente en la BD, sin cargar entidades en memoria
int updated = entityManager.createQuery("""
update Product p
set p.price = :newPrice
where p.status = :status
""")
// El valor que se escribirá en todas las filas que coincidan
.setParameter("newPrice", new Money(new BigDecimal("89.90"), "USD"))
.setParameter("status", ProductStatus.ACTIVE)
// Devuelve el número de filas actualizadas
.executeUpdate();
// Esto es precisamente el número de filas afectadas, no la cantidad de objetos managed
System.out.println("Updated rows = " + updated); // Updated rows = 42
Observa que aquí no decimos «Hibernate, por favor, cárgame 42 objetos y luego actualízalos uno por uno». Decimos: «Base de datos, cambia 42 filas según una condición». Es otra forma de operación. Y precisamente esta pregunta debes hacerte al principio: ¿necesito entidades en memoria o necesito una operación masiva sobre filas?
Un punto importante que solo señalaremos por ahora: las operaciones bulk modifican los datos directamente en la BD, y esto significa que el modelo habitual del persistence context comienza a comportarse de otra manera. El bulk en ese momento sortea parte de la mecánica managed de Hibernate, y de ahí surgen luego los objetos stale y toda la disciplina aparte en torno a flush, clear y refresh.
5. Bulk vs batching: tareas diferentes
Después de conocer el JDBC batching, es fácil que surja una confusión natural: «Si tengo batching, entonces ya he optimizado las operaciones masivas». Este es un error muy común y merece una aclaración aparte, porque de lo contrario estarás tratando una cosa con otra y te sorprenderás de por qué «en teoría lo aceleré todo» pero la aplicación sigue consumiendo memoria como un estudiante de universidad con fideos instantáneos.
El batching trata sobre cómo enviar muchos SQL similares (por ejemplo, 10_000 INSERT) de forma más eficiente: menos round-trips, más trabajo en una sola tanda. Pero el batching no elimina el modelo de entidades: sigues creando/cargando objetos, manteniéndolos en el persistence context, haciendo dirty checking, y en esencia ejecutas N operaciones lógicas, simplemente empaquetadas.
El bulk update/delete trata sobre qué operación haces: una única operación masiva en la BD. Aquí no hay «una entidad por una entidad», y por lo tanto no hay necesidad de pagar por la materialización y los snapshots. En cambio, aparecen otros matices: el bulk no «sincroniza» los objetos managed automáticamente, y eso es otro tipo de peligro.
Para no confundirte, es útil tener en mente una fórmula simple:
- si ves en el código un bucle for (entity : entities) { ... }, entonces estás en el mundo del entity-loop (incluso si tienes batching);
- si ves update ... where ... o delete ... where ... como un solo comando, entonces estás en el mundo del bulk.
Y sí, a veces la respuesta correcta será: «necesito entity-loop y batching». Pero en esta lección entrenamos una habilidad más básica: darse cuenta a tiempo de que el loop es completamente superfluo.
6. Candidatos a bulk: repricing y cleanup
Para que la teoría no se quede en «tablas abstractas», la vincularemos a nuestros dos escenarios del proyecto. Esto es útil además porque estos casos explican muy bien la diferencia entre «necesito el comportamiento de los objetos» y «necesito corregir datos de forma masiva».
Empecemos con la limpieza de los InventorySnapshot históricos. La versión ingenua se ve así: leemos los snapshots antiguos y eliminamos cada uno mediante remove(). Esto es de nuevo un entity-loop, pero en modo eliminación.
import java.time.LocalDate;
import java.util.List;
LocalDate border = LocalDate.now().minusDays(90);
// Leemos los snapshots como entidades (de nuevo materialización + persistence context)
List<InventorySnapshot> snapshots = entityManager.createQuery("""
select s
from InventorySnapshot s
where s.snapshotDate < :border
""", InventorySnapshot.class)
.setParameter("border", border)
.getResultList();
for (InventorySnapshot snapshot : snapshots) {
// remove() trabaja con una entidad managed: por eso la trajimos a memoria
entityManager.remove(snapshot);
}
Intuitivamente esto «funciona». Pero si se trata de una limpieza puramente técnica basada en un umbral de fecha, entonces el sentido de la operación es «eliminar todo lo anterior a una fecha». No necesitas ningún InventorySnapshot como objeto.
Entonces, una forma más honesta de la operación es un bulk delete:
import java.time.LocalDate;
LocalDate border = LocalDate.now().minusDays(90);
// Bulk delete: eliminamos filas directamente en la BD, sin cargar entidades
int deleted = entityManager.createQuery("""
delete from InventorySnapshot s
where s.snapshotDate < :border
""")
.setParameter("border", border)
// Devuelve el número de filas eliminadas
.executeUpdate();
System.out.println("Deleted rows = " + deleted); // Deleted rows = 12000
¿Ves cuánto más cerca está esto del significado? Hemos hecho una operación que coincide con la tarea. Y lo principal: no obligamos a Hibernate a trabajar como un cargador que lleva miles de objetos a la memoria solo para luego deshacerse de ellos.
Con el repricing de Product la historia es similar. Si necesitas aplicar la misma regla a un gran conjunto de productos (por ejemplo, «hacer que todos los productos ACTIVE pasen a HIDDEN» o «a todos los ACTIVE ponerles el precio X»), el bulk casi siempre parece más natural. Y si se necesita una regla compleja que depende de los datos del producto, entonces el loop sigue estando justificado. El objetivo de esta lección es aprender a ver la diferencia según la formulación de la acción de negocio.
Comprobación antes de la implementación
En la práctica, ayuda mucho una simple «pre-refactorización» — incluso antes de escribir código. Intenta formular tu tarea en dos frases: como una acción sobre objetos y como una acción sobre datos. Si la versión «sobre datos» resulta corta y precisa («actualiza todas las filas donde …»), entonces merece la pena considerar al menos el bulk. Si sin los objetos no puedes expresar la regla («para cada producto calcular un descuento según un montón de condiciones»), lo más probable es que estés en un entity-loop.
Esta comprobación es útil tenerla en mente como un pequeño diagrama de flujo:
flowchart TD
A[Necesito modificar datos masivamente] --> B{¿La lógica depende
de cada objeto?}
B -->|Sí| C["Entity-loop (managed)"]
B -->|No| D{¿Se puede expresar
con una sola operación en BD?}
D -->|Sí| E["Bulk update / bulk delete"]
D -->|Dudoso| F[Enfoque mixto, pero sin automatismo]
Observa: «dudoso» también es una respuesta válida. A veces la consulta bulk resulta demasiado compleja, y entonces es más honesto quedarse en el entity-loop, pero ya entendiendo por qué pagas y aplicando con antelación la disciplina de flush/clear. Lo principal es dejar de elegir el bucle «por costumbre».
7. Errores típicos al elegir bulk y loop
Error n.º 1: confundir bulk con batching, como si fueran lo mismo.
Después de conocer el JDBC batching surge la sensación: «Ahora cualquier operación masiva será rápida». Pero el batching solo empaqueta múltiples operaciones SQL, no elimina la materialización de entidades ni el dirty checking. Si haces el mismo cambio en miles de filas, el batching puede ser útil, pero el bulk casi siempre está en otra liga en cuanto a coste.
Error n.º 2: elegir bulk solo porque hay muchas filas, ignorando el sentido de la operación.
La cantidad es un mal criterio único. A veces hay muchas filas pero la lógica es realmente individual: por ejemplo, hay que verificar datos, tomar una decisión, llamar a un método de dominio, modificar diferentes campos con distintas reglas. En ese caso, el entity-loop será más honesto. El criterio correcto es la dependencia del estado de cada entidad, no el tamaño de la lista.
Error n.º 3: comparar los enfoques por la longitud del código Java, no por la forma de la operación.
A veces el bulk parece «más bonito» simplemente porque tiene menos código. Pero en ingeniería madura, lo importante es lo que hace tu código: cuántos datos trae a memoria, cuántas ejecuciones SQL realiza, qué efectos secundarios puede tener. Mira el shape del SQL y el papel del persistence context, no la cantidad de líneas en el IDE.
Error n.º 4: hacer una limpieza técnica (remove() en bucle) sin entender el coste del persistence context.
Eliminar en bucle también implica cargar objetos y mantenerlos en la cache de primer nivel hasta el final de la transacción. Incluso si ayer aprendiste a hacer flush/clear, puede seguir siendo un trabajo injustificado si la tarea se expresa con un solo delete ... where .... En modo bulk no solo aceleras la operación, sino que además acercas el código a su significado.
GO TO FULL VERSION