¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
dev
Pablo Daniel Folgar Hace 1 día Cargando comentarios…
Imagina el Cyber Monday. Miles de usuarios compiten por el último asiento disponible en un vuelo de oferta. Sin una estrategia robusta, tu aplicación podría colapsar o, peor aún, ¡generar un overbooking!
En este post explicaremos 4 estrategias para gestionar la concurrencia en la reserva de asientos en una aplicación hecha con Java 21, Spring Boot 3.5.x y sobre una base de datos PostgreSQL.
Esta aplicación funciona perfectamente para aplicaciones donde la concurrencia NO es un problema.
@Override
@Transactional
public Seat assignSeat(Seat seat) {
LOGGER.info("Trying to assign seat: {}", seat);
// 1. Hacemos búsqueda para validar que el asiento que queremos reservar existe
Seat seatFound = seatRepository.findById(seat.id()).orElseThrow(() -> {
LOGGER.info("Seat with id {} was not found", seat.id());
return new NoSuchElementException(String.format("Seat with id %d was not found", seat.id()));
});
// 2. Si el asiento existe verificamos si ya está asignado a un usuario
if(Objects.nonNull(seatFound.userId())){
// 3. Si el asiento está asignado al mismo usuario devolvemos la confirmación de la reserva ( Idempotencia )
if(Objects.equals(seatFound.userId(), seat.userId())){
LOGGER.info("Seat with id {} is already assigned to same user {}", seat.id(), seat.userId());
return seatFound;
}
// 3. Si el asiento está asignado a otro usuario lanzamos una excepción indicando que el asiento ya no se encuentra disponible
LOGGER.info("Seat with id {} is already assigned to user {}", seat.id(), seat.userId());
throw new SeatAlreadyAssignedException(String.format("Seat with id %d is already assigned", seat.id()));
}
// 4. Si el asiento EXISTE y NO está asignado podemos generar la reserva solicitada
return seatRepository.assignSeatToUser(new Seat(seat.id(), seat.userId()));
}
El problema surge cuando múltiples usuarios intentan reservar el mismo asiento al mismo tiempo.
Sin un control adecuado, dos usuarios podrían pasar las verificaciones iniciales (ya que el asiento aún no ha sido marcado como ocupado por el otro) y ambos acabarían recibiendo la confirmación de reserva cuando, en la base de datos, este asiento SOLO está reservado para uno de los dos usuarios.
Vemos un ejemplo:
Con la herramienta JMeter hemos simulado dos usuarios concurrentes tratando de reservar el mismo asiento y el resultado que obtuvimos es que ambos usuarios han recibido la confirmación de reserva para el asiento número 1 pero, en la base de datos vemos que solo un usuario tiene asignado el asiento.
seat_id [PK] bigint |
user_id integrer |
|
---|---|---|
1 | 1 | 1 |
¿Podemos imaginarnos el problema que vamos a tener cuando el usuario 2 llegue al aeropuerto con su reserva confirmada, se le indique que no tiene la reserva realizada y que ese asiento lo tiene otro usuario?
A continuación, explicaremos algunas soluciones para abordar este problema.
La forma más directa de manejar la concurrencia en Java es utilizando la palabra reservada synchronized.
Al aplicarla a un método, garantizamos que solo un hilo pueda ejecutar ese método en una instancia particular del objeto a la vez.
@Override
@Transactional
public synchronized Seat assignSeat(Seat seat) {
// MISMO BLOQUE DE CÓDIGO QUE EN EL EJEMPLO ORIGINAL
}
Veamos un ejemplo:
Volvemos a hacer la misma ejecución de 2 usuarios concurrentes con JMeter y esta vez mientras que el usuario 1 recibe una respuesta de error, el usuario 2 recibe la confirmación de su reserva.
seat_id [PK] bigint |
user_id integrer |
|
---|---|---|
1 | 1 | 2 |
Ventajas:
Desventajas:
Dentro de las opciones que tenemos para implementar bloqueos distribuidos podemos elegir entre Apache ZooKeeper y Hazelcast.
Sin embargo, integrar ZooKeeper o Hazelcast añade un componente de infraestructura adicional y puede aumentar la complejidad y el costo de mantenimiento del sistema.
No tiene sentido realizar esta configuración de infraestructura cuando con las siguientes estrategías que vamos a ver se puede resolver el problema inicial.
El bloqueo pesimista es una estrategia a nivel de base de datos que bloquea explícitamente un registro (o una fila) en el momento en que se lee para su modificación.
Esto evita que otros hilos o transacciones accedan a ese registro hasta que el bloqueo sea liberado.
El método assignSeat(Seat seat) sigue en con su lógica original:
@Override
@Transactional
public Seat assignSeat(Seat seat) {
// MISMO BLOQUE DE CÓDIGO QUE EN EL EJEMPLO ORIGINAL
}
Mientras que, en nuestra interfaz de capa de acceso a datos (Repositorio), que implementa la interfaz JpaRepository, se agrega un método que, al momento de hacer la búsqueda de la entidad de dominio, establece el lock.
@Repository
public interface SeatJPaRepository extends JpaRepository<SeatEntity, Long> {
/** * Busca un asiento por su número y aplica un bloqueo pesimista (WRITE) sobre él.
* Esto previene que otras transacciones lean o modifiquen este registro hasta que la transacción actual finalice.
* @param seatId El número del asiento
* @return Un Optional con el asiento encontrado o un Optional Empty si no existe el asiento
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<SeatEntity> findWithLockingBySeatId(Long seatId);
}
Veamos un ejemplo. Volvemos a hacer la misma ejecución de 2 usuarios concurrentes con JMeter. En los logs vemos que dos threads distintos intentan asignar el mismo asiento en el mismo instante:
2025-07-02 09:36:08.250 [http-nio-8080-exec-1] INFO c.p.d.c.s.SeatServiceImpl - Trying to assign seat: Seat[id=1, userId=2]
2025-07-02 09:36:08.250 [http-nio-8080-exec-2] INFO c.p.d.c.s.SeatServiceImpl - Trying to assign seat: Seat[id=1, userId=1]
También podemos observar que ambos threads hacen la misma consulta a la base de datos en el mismo instante:
2025-07-02 09:36:08.338 [http-nio-8080-exec-2] DEBUG org.hibernate.SQL -
/* <criteria> */ select se1_0.seat_id, se1_0.user_id from seat se1_0 where se1_0.seat_id=? for no key update
2025-07-02 09:36:08.338 [http-nio-8080-exec-1] DEBUG org.hibernate.SQL -
/* <criteria> */ select se1_0.seat_id, se1_0.user_id from seat se1_0 where se1_0.seat_id=? for no key update
Pero solo el thread 2 (usuario 1) es el que realiza la modificación del registro:
2025-07-02 09:36:08.419 [http-nio-8080-exec-2] DEBUG org.hibernate.SQL -
/* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat set user_id=? where seat_id=?
En la base de datos vemos que solo el usuario 1 tiene asignado el asiento.
seat_id [PK] bigint |
user_id integrer |
|
---|---|---|
1 | 1 | 1 |
Mientras que el usuario 2 recibe la respuesta que NO pudo confirmar su asiento. Si vemos el log:
2025-07-02 09:36:08.431 [http-nio-8080-exec-1] INFO c.p.d.c.s.SeatServiceImpl - Seat with id 1 is already assigned to another user 1
Ventajas
Desventajas
El bloqueo optimista asume que las colisiones son raras. En lugar de bloquear el registro, se utiliza un mecanismo de "versión" (generalmente una columna numérica o de marca de tiempo) en la tabla.
Cuando una transacción lee un registro, también lee su número de versión y, al intentar actualizar el registro, verifica si el número de versión en la base de datos coincide con el que se leyó inicialmente.
Si no coinciden, significa que otra transacción modificó el registro y la operación actual falla.
El método assignSeat(Seat seat) sigue en con su lógica original.
@Override
@Transactional
public Seat assignSeat(Seat seat) {
// MISMO BLOQUE DE CÓDIGO QUE EN EL EJEMPLO ORIGINAL
}
No se realizan cambios en la lógica del código original en ninguno de sus servicios, el único cambio que se aplica es a nivel de definición de entidad de dominio. Necesitamos modificar la entidad SeatEntity agregando la columna @Version:
@Entity
@Table(name = "seat")
public class SeatEntity implements Serializable {
@Id
@Column(name = "seat_id", nullable = false)
private Long seatId;
@Column(name = "user_id")
private Integer userId;
@Version // Esta columna es manejada automáticamente por Hibernate
private Integer version;
}
Veamos un ejemplo. Volvemos a hacer la misma ejecución de 2 usuarios concurrentes con JMeter. En los logs observamos que dos threads distintos intentan asignar el mismo asiento en el mismo instante:
2025-07-02 10:42:22.491 [http-nio-8080-exec-1] INFO c.p.d.c.s.SeatServiceImpl - Trying to assign seat: Seat[id=1, userId=2]
2025-07-02 10:42:22.491 [http-nio-8080-exec-2] INFO c.p.d.c.s.SeatServiceImpl - Trying to assign seat: Seat[id=1, userId=1]
También podemos observar que ambos threads hacen la misma consulta a la base de datos en el mismo instante:
2025-07-02T10:42:22.533+02:00 DEBUG 27632 --- [concurrency] [nio-8080-exec-2] org.hibernate.SQL : select se1_0.seat_id, se1_0.user_id, se1_0.version from seat se1_0 where se1_0.seat_id=?
2025-07-02T10:42:22.533+02:00 DEBUG 27632 --- [concurrency] [nio-8080-exec-1] org.hibernate.SQL : select se1_0.seat_id, se1_0.user_id, se1_0.version from seat se1_0 where se1_0.seat_id=?
Luego, vemos que ambos thread están intentando actualizar al mismo tiempo:
2025-07-02T10:42:22.590+02:00 DEBUG 27632 --- [concurrency] [nio-8080-exec-2] org.hibernate.SQL : /* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat set user_id=?, version=? where seat_id=? and version=?
2025-07-02T10:42:22.590+02:00 DEBUG 27632 --- [concurrency] [nio-8080-exec-1] org.hibernate.SQL : /* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat set user_id=?, version=? where seat_id=? and version=?
En este punto podemos ver que, al momento de realizar la actualización del registro, en la condición where usa el campo version para garantizar que está modificando la versión del registro que había leído previamente. El usuario 1 recibe la confirmación y, si vamos a la base de datos, vemos que efectivamente el usuario 1 tiene asignado el asiento 1 y que la versión del registro pasó de 0 a 1.
seat_id [PK] bigint |
user_id integrer |
version integrer |
|
---|---|---|---|
1 | 1 | 1 | 1 |
¿Qué pasó con el usuario 2 que también intentó hacer el update? Recibió el mensaje de NO confirmación. Y para entender por qué no pudo hacer el update, vamos a ver los logs:
2025-07-02T10:42:22.619+02:00 ERROR 27632 --- [concurrency] [nio-8080-exec-1] c.p.d.c.m.e.GlobalExceptionHandler : Received ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.paradigmadigital.demo.concurrency.models.entities.SeatEntity#2]
Esto se da porque el update del usuario 2 se hizo después del update del usuario 1, quien ya había cambiado la versión del registro de 0 a 1. Entonces, cuando el usuario 2 quiso hacer el update con la condición where seat_id=1 and version=0, esa condición no se cumple y la aplicación lanza una excepción.
Ventajas
Desventajas
El método encargado de hacer la reserva se ejecuta de manera transaccional (como en todas las soluciones anteriores) pero, para esta solución, cambiamos el nivel de aislamiento que vamos a usar.
La anotación @Transactional tiene configurado un nivel de aislamiento DEFAULT. Esto supone que, cuando Spring crea una nueva transacción, el nivel de aislamiento será el configurado por defecto de nuestra base de datos.
Los niveles de aislamiento se utilizan para evitar los efectos secundarios de concurrencia en una transacción.
Consideraciones para la configuración de los niveles de aislamiento: ya que este post está orientado a resolver el problema programáticamente, es necesario destacar que la configuración de aislamiento también se puede configurar a nivel de base de datos.
El método assignSeat(Seat seat) sigue en con su lógica original. El único cambio que se realiza es en la configuración del nivel de aislamiento dentro de la anotación @Transactional.
Tenemos dos opciones:
@Override
@Transactional(isolation = Isolation.REPEATABLE_READ)
// No permite el acceso simultáneo a una fila.
public Seat assignSeat(Seat seat) {
// MISMO BLOQUE DE CÓDIGO QUE EN EL EJEMPLO ORIGINAL
}
@Override
@Transactional(isolation = Isolation.SERIALIZABLE)
// Es el nivel más alto de aislamiento. Evita todos los efectos secundarios de concurrencia, pero puede conducir a la tasa de acceso concurrente más baja porque ejecuta llamadas concurrentes secuencialmente.
public Seat assignSeat(Seat seat) {
// MISMO BLOQUE DE CÓDIGO QUE EN EL EJEMPLO ORIGINAL
}
Veamos un ejemplo. Volvemos a hacer la misma ejecución de 2 usuarios concurrentes con JMeter. En los logs vemos que dos threads distintos intentan asignar el mismo asiento en el mismo instante:
2025-07-03 10:05:24.827 [http-nio-8080-exec-1] INFO c.p.d.c.s.SeatServiceImpl - Trying to assign seat: Seat[id=1, userId=2]
2025-07-03 10:05:24.826 [http-nio-8080-exec-2] INFO c.p.d.c.s.SeatServiceImpl - Trying to assign seat: Seat[id=1, userId=1]
También podemos observar que ambos threads hacen la misma consulta a la base de datos en el mismo instante:
2025-07-03 10:05:24.861 [http-nio-8080-exec-1] DEBUG org.hibernate.SQL - select se1_0.seat_id, se1_0.user_id from seat se1_0 where se1_0.seat_id=?
2025-07-03 10:05:24.861 [http-nio-8080-exec-2] DEBUG org.hibernate.SQL - select se1_0.seat_id, se1_0.user_id from seat se1_0 where se1_0.seat_id=?
Luego vemos que ambos thread están intentando actualizar al mismo tiempo:
2025-07-03 10:05:24.921 [http-nio-8080-exec-1] DEBUG org.hibernate.SQL - /* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat set user_id=? where seat_id=?
2025-07-03 10:05:24.921 [http-nio-8080-exec-2] DEBUG org.hibernate.SQL - /* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat set user_id=? where seat_id=?
Mientras vemos que el usuario 1 recibe la confirmación de la asignación de su asiento, lo verificamos en la base de datos:
seat_id [PK] bigint |
user_id integrer |
|
---|---|---|
1 | 1 | 1 |
El usuario 2 recibe un mensaje que NO pudo confirmar su reserva:
2025-07-03 10:05:24.947 [http-nio-8080-exec-1] ERROR c.p.d.c.m.e.GlobalExceptionHandler - Received RuntimeException: could not execute statement [ERROR: could not serialize access due to concurrent update] [/* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat set user_id=? where seat_id=?]; SQL [/* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat set user_id=? where seat_id=?]
Cabe destacar que el resultado es el mismo usando cualquiera de los dos niveles de aislamiento mencionado.
Ventajas
Desventajas
Ventajas
Desventajas
La elección de la estrategia para el manejo de concurrencia depende en gran medida de las características de tu aplicación, el motor de base de datos, la cantidad de usuarios concurrentes y throughput.
El uso de synchronized es ideal para escenarios de concurrencia interna a una única instancia de la aplicación y donde el impacto en el rendimiento es aceptable. Es la solución más sencilla, pero la menos escalable y no apta para entornos distribuidos.
Para estos entornos, soluciones como Apache ZooKeeper o Hazelcast pueden ofrecer bloqueos distribuidos, aunque con la contrapartida de añadir complejidad y costo de infraestructura.
El bloqueo pesimista es la opción más segura cuando la consistencia de los datos es crítica y la demanda sobre un recurso específico es muy alta. Hay que tener un buen control del lock para no generar deadlocks.
El bloqueo optimista es una de las mejores soluciones cuando se busca un equilibrio entre integridad y rendimiento. Minimiza la sobrecarga de la base de datos y permite que la mayoría de las transacciones se completen sin esperas.
Los niveles de aislamiento son recomendables en escenarios donde se prefiere delegar el control de la concurrencia a la base de datos, ajustando el nivel de rigor según la necesidad. Esta opción ofrece una granularidad más fina permitiendo tener una configuración particular para cada operación.
Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.
Cuéntanos qué te parece.