Diseño de sistema de reserva de hotel: inventario por noche y evitar sobreventa
1. Descripción general
El problema de la reserva de hotel suena simple. Verificar si hay habitaciones disponibles y permitir la reserva. Pero cuando dos clientes presionan “Reservar” simultáneamente para la última habitación de la noche del 31/12, el sistema fácilmente vende una habitación a dos personas.
Este artículo aborda un diseño práctico: requisitos, modelo de inventario por noche, API, flujo de retención y confirmación, cómo prevenir la sobreventa con PostgreSQL, y cómo manejar picos de carga. Después de leerlo, podrás construir por tu cuenta el núcleo del sistema de reserva con Spring Boot 3.x sin caer en sobreventa involuntaria.
2. Requisitos
2.1. Requisitos funcionales
- Buscar habitaciones disponibles por hotel, tipo de habitación, fecha de entrada (check-in) y fecha de salida (check-out).
- Retener la habitación durante 10 minutos para que el cliente pague.
- Confirmar la reserva después de que el pago sea exitoso.
- Cancelar la reserva y devolver el inventario.
2.2. Escala y restricciones
- Alrededor de 50.000 hoteles. Cada hotel tiene en promedio 5 tipos de habitaciones. El sistema abre ventas 365 días antes.
- La cantidad de lecturas es mucho mayor que la cantidad de escrituras. La proporción de búsquedas respecto a reservas es aproximadamente 100:1.
- Alrededor de 50 reservas por segundo durante las horas pico. Durante una venta flash, este número puede aumentar 20 veces.
- Restricción dura: no vender más del número de habitaciones configuradas. Los resultados de búsqueda pueden estar desfasados unos segundos.
El número de filas de inventario es aproximadamente 50.000 × 5 × 365 ≈ 91 millones de filas. Una tabla PostgreSQL con índices bien diseñados puede manejar este volumen.
3. Modelo de datos
La decisión más importante de este artículo: el cliente no reserva una habitación específica (número 302), sino un tipo de habitación para un rango de noches. El sistema solo asigna el número de habitación en el check-in. Por lo tanto, el inventario se guarda con la clave (hotel, tipo_habitación, noche).
CREATE TABLE room_inventory (
hotel_id BIGINT NOT NULL,
room_type_id BIGINT NOT NULL,
stay_date DATE NOT NULL,
total_rooms INT NOT NULL,
reserved INT NOT NULL DEFAULT 0,
PRIMARY KEY (hotel_id, room_type_id, stay_date),
CONSTRAINT chk_not_oversold CHECK (reserved >= 0 AND reserved <= total_rooms)
);
CREATE TABLE booking (
id BIGSERIAL PRIMARY KEY,
idempotency_key VARCHAR(64) NOT NULL UNIQUE,
customer_id BIGINT NOT NULL,
hotel_id BIGINT NOT NULL,
room_type_id BIGINT NOT NULL,
check_in DATE NOT NULL,
check_out DATE NOT NULL,
quantity INT NOT NULL,
status VARCHAR(16) NOT NULL, -- HOLD, CONFIRMED, CANCELLED, EXPIRED
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_booking_expiry ON booking (expires_at) WHERE status = 'HOLD';
Explicación de los puntos principales:
- Una estadía del 10/12 al 13/12 ocupa 3 noches: 10, 11 y 12. La fecha de check-out no cuenta. Todas las consultas usan el rango semiabierto
[check_in, check_out). CHECK (reserved <= total_rooms)es la última barrera. Si el código tiene un bug, la base de datos rechazará en lugar de permitir sobreventa silenciosamente.- El índice parcial
idx_booking_expirycontiene solo las reservas en estadoHOLD. El trabajo de limpieza escanea rápidamente sin que el índice crezca desproporcionadamente con el historial.
Nota: Muchos hoteles sobrevenden intencionalmente un 3–5% sabiendo que habrá cancelaciones. Puedes representar esta política configurando
total_roomsmayor que el número de habitaciones físicas. No flexibilices la lógica de prevención de sobreventa.
4. API
GET /hotels/{hotelId}/availability?checkIn=2026-12-10&checkOut=2026-12-13
POST /bookings (Header: Idempotency-Key) -> 201, status=HOLD
POST /bookings/{id}/confirm (llamar después de que el pago sea exitoso)
POST /bookings/{id}/cancel
Solicitud para crear una reserva:
public record HoldRequest(long customerId, long hotelId, long roomTypeId,
LocalDate checkIn, LocalDate checkOut, int quantity) {
public HoldRequest {
if (!checkOut.isAfter(checkIn)) throw new IllegalArgumentException("checkOut debe ser después de checkIn");
if (quantity < 1) throw new IllegalArgumentException("quantity >= 1");
}
public int nights() {
return (int) ChronoUnit.DAYS.between(checkIn, checkOut);
}
}
El header Idempotency-Key es generado por el cliente (UUID). Cuando la red es inestable, el cliente puede reintentar sin crear dos reservas.
5. Flujo de procesamiento
Cliente ──► POST /bookings ──► restar inventario todas las noches (1 transacción) ──► reserva HOLD, vence después de 10'
──► pago ──► POST /confirm ──► HOLD → CONFIRMED
└► pasa 10' sin pagar ──► trabajo de limpieza: HOLD → EXPIRED, devuelve inventario
El inventario se resta inmediatamente cuando se crea la retención, no se espera al pago. Esto garantiza que si el cliente entra en la página de pago, ciertamente hay habitación disponible. A cambio, el sistema debe tener un mecanismo para devolver la habitación cuando la retención vence.
6. Desafío 1: Prevención de sobreventa
6.1. Por qué “leer-luego-escribir” no es seguro
El enfoque ingenuo es usar SELECT para obtener las habitaciones restantes, verificar en Java, y luego UPDATE. Dos transacciones pueden leer ambas reserved = 9, total = 10 y escribir ambas reserved = 10. El resultado es que dos clientes retienen la misma habitación. Este es el clásico problema de lost update.
6.2. Bloquear en orden y actualizar condicionalmente
Agregar dependencia:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
El repositorio usa consultas nativas:
public interface InventoryRepository extends Repository<RoomInventory, InventoryId> {
@Query(value = """
SELECT stay_date FROM room_inventory
WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
AND stay_date >= :checkIn AND stay_date < :checkOut
ORDER BY stay_date
FOR UPDATE
""", nativeQuery = true)
List<LocalDate> lockNights(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut);
@Modifying
@Query(value = """
UPDATE room_inventory SET reserved = reserved + :qty
WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
AND stay_date >= :checkIn AND stay_date < :checkOut
AND total_rooms - reserved >= :qty
""", nativeQuery = true)
int reserve(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut, int qty);
@Modifying
@Query(value = """
UPDATE room_inventory SET reserved = reserved - :qty
WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
AND stay_date >= :checkIn AND stay_date < :checkOut
""", nativeQuery = true)
int release(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut, int qty);
}
Servicio para crear retención:
@Service
public class BookingService {
private static final Duration HOLD_TTL = Duration.ofMinutes(10);
private final InventoryRepository inventory;
private final BookingRepository bookings;
private final Clock clock;
public BookingService(InventoryRepository inventory, BookingRepository bookings, Clock clock) {
this.inventory = inventory;
this.bookings = bookings;
this.clock = clock;
}
@Transactional
public Booking hold(HoldRequest req, String idempotencyKey) {
Optional<Booking> existing = bookings.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) return existing.get();
inventory.lockNights(req.hotelId(), req.roomTypeId(), req.checkIn(), req.checkOut());
int updated = inventory.reserve(req.hotelId(), req.roomTypeId(),
req.checkIn(), req.checkOut(), req.quantity());
if (updated != req.nights()) {
throw new RoomUnavailableException(req.hotelId(), req.checkIn(), req.checkOut());
}
return bookings.save(Booking.hold(req, idempotencyKey, Instant.now(clock).plus(HOLD_TTL)));
}
}
El proceso funciona de la siguiente manera:
lockNightsbloquea las filas de inventario en orden porstay_date. Supongamos que el cliente A reserva 10–13 y el cliente B reserva 11–14. Si el bloqueo no es en un orden fijo, A retiene la noche 10 esperando la noche 11, B retiene la noche 12 esperando la noche 11, y ambos pueden tener deadlock. Cuando todas las transacciones bloquean en el mismo orden, esta situación no ocurre.reservesolo sumareserveden las noches que aún tienen espacio disponible. Devuelve el número de filas actualizadas.- Si el número de filas es menor que el número de noches, puede ser porque una noche está completa o no está abierta para la venta (sin fila). El servicio lanza
RoomUnavailableException. Como es unaRuntimeException,@Transactionalhace rollback de todo, incluyendo las noches ya sumadas.
El resultado con la última habitación cuando dos solicitudes llegan simultáneamente:
T1: lockNights (obtiene bloqueo) → reserve = 3 filas → INSERT booking → COMMIT
T2: lockNights (espera T1) → reserve = 0 filas → RoomUnavailableException → ROLLBACK → HTTP 409
Aquí no necesitas elevar el nivel de aislamiento a SERIALIZABLE. READ COMMITTED junto con bloqueo de filas es suficiente.
6.3. Idempotencia en reintentos simultáneos
Dos solicitudes con la misma Idempotency-Key pueden pasar ambas el paso findByIdempotencyKey. La restricción UNIQUE bloqueará la segunda solicitud con DataIntegrityViolationException. Capturas esta excepción en la capa de controlador, relees la reserva por clave, y devuelves el resultado anterior. La transacción perdida hace rollback, por lo que el inventario que había restado también se devuelve.
7. Desafío 2: Expiración de retención y confirmación contenciosa
El trabajo de limpieza se ejecuta cada 30 segundos:
@Component
public class HoldExpiryJob {
private final BookingRepository bookings;
private final InventoryRepository inventory;
public HoldExpiryJob(BookingRepository bookings, InventoryRepository inventory) {
this.bookings = bookings;
this.inventory = inventory;
}
@Scheduled(fixedDelay = 30_000)
@Transactional
public void expireHolds() {
// SELECT ... WHERE status='HOLD' AND expires_at < now() LIMIT 500 FOR UPDATE SKIP LOCKED
for (Booking b : bookings.lockExpiredHolds(500)) {
inventory.release(b.getHotelId(), b.getRoomTypeId(), b.getCheckIn(), b.getCheckOut(), b.getQuantity());
b.markExpired();
}
}
}
FOR UPDATE SKIP LOCKED te permite ejecutar múltiples instancias sin procesar duplicadamente una reserva. Cada lote obtiene solo 500 filas para mantener transacciones cortas.
Recuerda habilitar @EnableScheduling en una clase @Configuration. Cuando necesites liberar inventario, también llama a release en el mismo orden de noches que durante el bloqueo para evitar deadlock con el hilo hold.
El conflicto más peligroso es: el cliente termina de pagar justo cuando el trabajo está expirando la retención. Lo resuelves con un UPDATE condicional del estado:
UPDATE booking SET status = 'CONFIRMED'
WHERE id = :id AND status = 'HOLD' AND expires_at > now();
Si la declaración devuelve 0 filas, la retención ya expiró y la habitación puede pertenecer a otra persona. En este punto, el servicio debe reembolsar o intentar hold nuevamente antes de confirmar. Nunca cambies EXPIRED a CONFIRMED sin devolver el inventario.
8. Desafío 3: Picos de carga
Las lecturas son 100 veces más que las escrituras, por lo que la búsqueda no debe tocar filas bloqueadas. Puedes cachear el estado de las habitaciones en Redis con un TTL corto:
@Service
public class AvailabilityCache {
private final StringRedisTemplate redis;
public AvailabilityCache(StringRedisTemplate redis) {
this.redis = redis;
}
public Optional<Integer> get(long hotelId, long roomTypeId, LocalDate night) {
String v = redis.opsForValue().get(key(hotelId, roomTypeId, night));
return Optional.ofNullable(v).map(Integer::valueOf);
}
public void put(long hotelId, long roomTypeId, LocalDate night, int available) {
redis.opsForValue().set(key(hotelId, roomTypeId, night), String.valueOf(available), Duration.ofSeconds(30));
}
private String key(long h, long rt, LocalDate d) {
return "avail:%d:%d:%s".formatted(h, rt, d);
}
}
Principios a mantener:
- Redis solo sirve para lecturas. PostgreSQL es la fuente de verdad. Si el resultado de búsqueda muestra “1 habitación disponible” pero el paso
holdfalla, el cliente recibe HTTP 409 con el mensaje “se acaba de acabar las habitaciones”. Ninguna reserva incorrecta se crea. - Después de cada
hold,releaseocancel, escribes un evento en la tabla outbox dentro de la misma transacción. Un relay envía este evento al topic Kafkainventory-changed. El consumidor elimina la clave de Redis o actualiza el índice de búsqueda. El outbox garantiza que no se pierda el evento si la aplicación falla justo después del commit. - Cuando hay una venta flash, el bloqueo de filas se concentra en unos pocos hoteles “calientes”. Debes limitar la tasa por
hotelIden el API gateway y establecerlock_timeoutalrededor de 2 segundos para la transacción de reserva. De esta manera, las solicitudes fallan rápidamente en lugar de agotar el connection pool.
9. Pruebas de sobreventa
La prueba más importante es cuando múltiples threads compiten por la última habitación. Necesitas PostgreSQL real, ya que H2 no simula correctamente el comportamiento de bloqueo. Agregar dependencia:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class BookingServiceConcurrencyTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired BookingService bookingService;
@Autowired JdbcTemplate jdbc;
@Test
void onlyOneGuestGetsTheLastRoom() throws Exception {
for (int d = 10; d <= 12; d++) {
jdbc.update("INSERT INTO room_inventory VALUES (1, 1, ?, 1, 0)", LocalDate.of(2026, 12, d));
}
var req = new HoldRequest(42L, 1L, 1L, LocalDate.of(2026, 12, 10), LocalDate.of(2026, 12, 13), 1);
AtomicInteger success = new AtomicInteger();
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<?>> futures = IntStream.range(0, 20)
.mapToObj(i -> pool.submit(() -> {
try {
bookingService.hold(req, "key-" + i);
success.incrementAndGet();
} catch (RoomUnavailableException ignored) { }
}))
.toList();
for (Future<?> f : futures) f.get();
}
assertThat(success.get()).isEqualTo(1);
assertThat(jdbc.queryForList("SELECT reserved FROM room_inventory", Integer.class))
.containsOnly(1);
}
}
Veinte virtual threads llaman simultáneamente a hold con claves diferentes. La prueba afirma que exactamente un thread tiene éxito, y las tres noches tienen reserved = 1. Ninguna noche tiene incremento adicional por rollback incompleto. La tabla se crea usando el script schema.sql de la sección 3, o mediante migración Flyway.
10. Errores comunes
- Contar la fecha de check-out como una noche. Este error hace que el cliente se reste una noche de inventario adicional. Usa el rango
[check_in, check_out)en todas partes. - Verificar el inventario en Java después de
SELECTsin bloqueo. Este enfoque causa lost update. La condición debe estar en elUPDATEo después deFOR UPDATE. - No comparar el número de filas actualizadas con el número de noches. Si una noche aún no tiene fila de inventario,
UPDATEaún “tiene éxito” para las otras noches. - Llamar al API de pago dentro de la transacción con bloqueo. El bloqueo se mantiene durante segundos, y todo el hotel se bloquea. Debes separar los pasos: hold (commit), pago, luego confirm.
- Usar Redis
DECRcomo fuente de verdad. Cuando Redis falla y pierde datos, pierdes todo el inventario. Este enfoque solo es viable cuando la carga excede ampliamente la capacidad de la base de datos, y aun así necesitas reconciliar con DB.
Nota: El modelo de conteo por tipo de habitación no es apropiado cuando el cliente elige una habitación específica, como la número 302, por ejemplo en una casa de huéspedes o villa privada. En ese caso, necesitas una tabla
room_night(room_id, stay_date)con restricciónUNIQUE, o una restricción de exclusión endaterangede PostgreSQL.
11. Conclusión
El núcleo del sistema de reserva de hotel es una tabla de inventario por noche. Encima de ella hay una transacción corta: bloquea las noches en orden, actualiza condicionalmente y verifica el número de filas afectadas. La restricción CHECK es la última red de seguridad. Las retenciones temporales con un trabajo de limpieza usando SKIP LOCKED resuelven el problema de clientes que abandonan el pago. El UPDATE condicional del estado resuelve conflictos entre confirm y expire.
La parte de lectura se separa en Redis y se sincroniza a través de outbox con Kafka, por lo que la búsqueda puede manejar grandes cargas sin comprometer la consistencia en las escrituras. Puedes comenzar con este diseño, y solo particionar por hotel_id cuando un nodo PostgreSQL realmente no tenga suficiente capacidad.