Diseño de sistema para alquiler de autos: calendario, depósito y cancelación
1. Descripción general
El alquiler de autos suena simple: el cliente elige el auto, elige las fechas, paga un depósito y recibe el auto. Lo más difícil es garantizar que un auto no sea reservado por dos personas al mismo tiempo. El sistema también debe manejar correctamente cuando la puerta de pago llama al webhook múltiples veces, y debe soportar picos de tráfico antes de fiestas como Tết o el 30 de abril.
Este artículo recorre todo el diseño: requisitos, modelo de datos, API, flujo de reserva, depósito y cancelación. Cada punto difícil viene acompañado de código de ejemplo usando Java 21, Spring Boot 3.x, PostgreSQL, Redis y Kafka. Al final, tendrás un marco de diseño listo para usar en un sistema de reservas por rango de tiempo, incluso cuando el objeto no sea un auto sino una habitación, un campo de fútbol o una sala de juntas.
2. Requisitos
2.1. Requisitos funcionales
- El cliente busca autos por ciudad y rango de tiempo, por ejemplo
2026-01-25 08:00→2026-01-28 20:00. - El cliente reserva (hold) un auto durante 15 minutos para pagar el depósito.
- Si el pago del depósito es exitoso, la reserva cambia a
CONFIRMED. Si pasan 15 minutos sin pagar, la reserva cambia aEXPIREDy el auto se libera. - El cliente que cancela el viaje recibe un reembolso según la política: antes de 72 horas reembolso completo, antes de 24 horas reembolso del 50%, más tarde no hay reembolso.
- Si el propietario del auto cancela, el cliente recibe un reembolso completo.
- Enviar notificaciones (SMS, push) cuando el estado de la reserva cambia.
2.2. Escala y restricciones
| Métrica | Normal | Pico (Tết) |
|---|---|---|
| Número de autos | 20.000 | 20.000 |
| Búsquedas | ~200 req/s | ~3.000 req/s |
| Reservas | ~10 req/s | ~300 req/s |
| Relación lectura/escritura | ~20:1 | ~10:1, concentrado en cientos de autos “populares” |
La restricción más importante es que nunca haya double-booking. El sistema puede devolver un error como “ese auto acaba de ser reservado”, pero no debe permitir dos contratos apuntando al mismo auto en el mismo período de tiempo. Por lo tanto, los datos de reserva deben tener consistencia fuerte. La página de búsqueda puede aceptar datos obsoletos hace unos pocos segundos.
3. Modelo de datos
PostgreSQL es la opción adecuada porque tiene el tipo tstzrange y restricción de exclusión. Con estas dos herramientas, la base de datos misma bloqueará dos períodos de tiempo superpuestos en el mismo auto.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE car (
id BIGSERIAL PRIMARY KEY,
plate VARCHAR(20) NOT NULL UNIQUE,
model VARCHAR(100) NOT NULL,
city VARCHAR(50) NOT NULL,
daily_price BIGINT NOT NULL
);
CREATE TABLE booking (
id BIGSERIAL PRIMARY KEY,
car_id BIGINT NOT NULL REFERENCES car(id),
customer_id BIGINT NOT NULL,
period TSTZRANGE NOT NULL,
status VARCHAR(20) NOT NULL,
deposit_amount BIGINT NOT NULL,
hold_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT no_overlap EXCLUDE USING gist (
car_id WITH =,
period WITH &&
) WHERE (status IN ('HOLD', 'CONFIRMED', 'ON_TRIP'))
);
CREATE TABLE payment (
id BIGSERIAL PRIMARY KEY,
booking_id BIGINT NOT NULL REFERENCES booking(id),
provider_txn_id VARCHAR(64) NOT NULL UNIQUE,
type VARCHAR(10) NOT NULL, -- DEPOSIT | REFUND
amount BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
Algunos puntos a notar:
no_overlapsignifica que no puede haber dos filas con el mismocar_idy conperiodsuperpuesto (&&). La condiciónWHEREexcluye las reservasCANCELLED,EXPIRED,COMPLETEDde la restricción, así una reserva cancelada no sigue ocupando el calendario.- La extensión
btree_gistes necesaria para combinar la operación=enBIGINTcon la operación&&en range en el mismo índice GiST. provider_txn_id UNIQUEes el fundamento de la idempotencia al procesar webhooks de pago.- La tabla
outboxsirve para enviar eventos a Kafka sin perder mensajes (ver sección 7).
Nota: El auto necesita tiempo para limpieza y revisión entre viajes. Deberías guardar
periodmás un buffer, por ejemplo 2 horas después de la devolución. No agregues el buffer en el código de la aplicación, porque eventualmente alguien olvidará hacerlo en algún lado.
4. Diseño de API
| Método | Endpoint | Descripción |
|---|---|---|
GET |
/cars?city=HN&from=…&to=… |
Buscar autos disponibles |
POST |
/bookings |
Reservar, devuelve bookingId e enlace de pago |
POST |
/payments/webhook |
Puerta de pago reporta resultado |
POST |
/bookings/{id}/cancel |
Cancelar viaje |
POST /bookings recibe el header Idempotency-Key. Cuando la app móvil reintenta por problemas de red, la solicitud duplicada no crea una nueva reserva. El servidor devuelve 409 Conflict cuando el auto ya está reservado, y 201 Created con el siguiente cuerpo cuando la reserva es exitosa:
{ "bookingId": 98123, "status": "HOLD", "holdExpiresAt": "2026-01-10T09:15:00+07:00",
"paymentUrl": "https://pay.example/checkout/98123" }
5. Flujo de reserva
5.1. Prevenir reservas duplicadas con restricción
La forma común pero incorrecta es “SELECT para verificar disponibilidad luego INSERT”. Dos solicitudes llegan al mismo tiempo, ambas ven el auto disponible, y ambas INSERT exitosamente. En cambio, simplemente INSERT y deja que la restricción de exclusión decida qué solicitud gana.
Por ejemplo, usando JdbcClient necesitas la siguiente dependencia:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
@Service
public class BookingService {
private final JdbcClient jdbc;
public BookingService(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public long hold(long carId, long customerId,
OffsetDateTime from, OffsetDateTime to, long deposit) {
try {
return jdbc.sql("""
INSERT INTO booking(car_id, customer_id, period, status,
deposit_amount, hold_expires_at)
VALUES (:carId, :customerId,
tstzrange(CAST(:from AS timestamptz), CAST(:to AS timestamptz), '[)'),
'HOLD', :deposit, now() + interval '15 minutes')
RETURNING id
""")
.param("carId", carId)
.param("customerId", customerId)
.param("from", from)
.param("to", to)
.param("deposit", deposit)
.query(Long.class)
.single();
} catch (DataIntegrityViolationException e) {
throw new CarUnavailableException(carId);
}
}
}
public class CarUnavailableException extends RuntimeException {
public CarUnavailableException(long carId) {
super("Auto " + carId + " ya está reservado en este período");
}
}
El rango '[)' incluye el punto inicial pero no el final. Gracias a esto, un viaje que termina a las 20:00 y otro que comienza a las 20:00 no se consideran superpuestos. Cuando se viola la restricción, PostgreSQL devuelve SQLState 23P01, y Spring lo traduce a DataIntegrityViolationException. Lo convertimos a CarUnavailableException para que el controller lo mapee a 409.
Cuando dos clientes reservan el auto 42 para el mismo período, el log se vería así:
customer=1001 -> 201 Created bookingId=98123 status=HOLD
customer=1002 -> 409 Conflict Auto 42 ya está reservado en este período
5.2. Expiración de reserva
Un trabajo scheduled convierte periódicamente las reservas HOLD expiradas a EXPIRED. Las reservas EXPIRED están fuera de la condición WHERE de la restricción, así que el auto se libera inmediatamente.
@Component
public class HoldExpiryJob {
private final JdbcClient jdbc;
public HoldExpiryJob(JdbcClient jdbc) {
this.jdbc = jdbc;
}
@Scheduled(fixedDelay = 30_000)
public void expireHolds() {
int expired = jdbc.sql("""
UPDATE booking SET status = 'EXPIRED'
WHERE status = 'HOLD' AND hold_expires_at < now()
""").update();
if (expired > 0) {
System.out.println("Reservas expiradas: " + expired);
}
}
}
Recuerda habilitar @EnableScheduling en una clase de configuración. Este trabajo se puede ejecutar en paralelo en múltiples instancias de forma segura, porque el UPDATE ya es idempotente.
6. Depósito: webhook idempotente y carrera con expiración
La puerta de pago típicamente llama al webhook al menos una vez, lo que significa que puede llamar dos, tres veces. Además, el webhook puede llegar después que la reserva haya expirado, por ejemplo si el cliente paga en el segundo 14:59 pero el banco procesa lentamente. Ambas situaciones deben ser manejadas.
@Service
public class PaymentService {
private final JdbcClient jdbc;
private final OutboxWriter outbox;
public PaymentService(JdbcClient jdbc, OutboxWriter outbox) {
this.jdbc = jdbc;
this.outbox = outbox;
}
@Transactional
public void onDepositPaid(long bookingId, String providerTxnId, long amount) {
int inserted = jdbc.sql("""
INSERT INTO payment(booking_id, provider_txn_id, type, amount)
VALUES (:bookingId, :txn, 'DEPOSIT', :amount)
ON CONFLICT (provider_txn_id) DO NOTHING
""")
.param("bookingId", bookingId).param("txn", providerTxnId).param("amount", amount)
.update();
if (inserted == 0) {
return; // webhook duplicado, ya procesado
}
int confirmed = jdbc.sql("""
UPDATE booking SET status = 'CONFIRMED', hold_expires_at = NULL
WHERE id = :id AND status = 'HOLD'
""").param("id", bookingId).update();
if (confirmed == 1) {
outbox.write("booking.confirmed", Map.of("bookingId", bookingId));
} else {
outbox.write("refund.requested", Map.of("bookingId", bookingId, "amount", amount));
}
}
}
El flujo de procesamiento es así:
- INSERT en
paymentconON CONFLICT DO NOTHING. Siinserted == 0entonces este es un webhook duplicado, ignoramos. - Cambiar estado con actualización condicional (
WHERE status = 'HOLD'). Esta sentencia actúa como compare-and-set, sin necesidad de lock explícito. - Si el update no afecta ninguna fila entonces la reserva ya expiró y posiblemente el auto pertenece a otro cliente. En este caso automáticamente solicitamos un reembolso, sin intentar “resucitar” la reserva.
Todas las operaciones están en una transacción. O el pago, estado de la reserva y evento outbox se guardan juntos, o nada se guarda.
7. Cancelación y reembolso
La política de cancelación es lógica pura, así que la separamos en una clase para facilitar las pruebas:
public final class CancellationPolicy {
private CancellationPolicy() {}
public static long refundAmount(long deposit, OffsetDateTime pickupAt,
OffsetDateTime cancelledAt, boolean cancelledByHost) {
if (cancelledByHost) {
return deposit;
}
long hoursBefore = Duration.between(cancelledAt, pickupAt).toHours();
if (hoursBefore >= 72) return deposit;
if (hoursBefore >= 24) return deposit / 2;
return 0;
}
}
Prueba con JUnit 5 y AssertJ:
class CancellationPolicyTest {
private final OffsetDateTime pickup = OffsetDateTime.parse("2026-01-25T08:00:00+07:00");
@Test
void refundsFullWhenCancelledThreeDaysBefore() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(72), false))
.isEqualTo(2_000_000);
}
@Test
void refundsHalfWhenCancelledOneDayBefore() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(30), false))
.isEqualTo(1_000_000);
}
@Test
void refundsNothingWhenCancelledLastMinute() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(2), false))
.isZero();
}
@Test
void refundsFullWhenHostCancels() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(1), true))
.isEqualTo(2_000_000);
}
}
En la capa de servicio, la cancelación también usa actualización condicional y RETURNING para obtener datos de cálculo de reembolso en la misma sentencia:
UPDATE booking SET status = 'CANCELLED'
WHERE id = :id AND status = 'CONFIRMED'
RETURNING lower(period) AS pickup_at, deposit_amount;
Si la sentencia no devuelve ninguna fila, la reserva ya fue cancelada o el cliente ya recibió el auto (ON_TRIP). En ese caso la API devuelve 409. Si hay filas devueltas, calculamos refundAmount y escribimos un evento refund.requested en outbox dentro de la misma transacción.
Nota: Siempre usa la hora del servidor (
Clockinyectado) comocancelledAt, no tomes la hora del cliente. Unos pocos minutos alrededor del marca de 24 horas pueden significar millones en diferencia.
8. Carga variable y entrega confiable de eventos
8.1. Separar lecturas de escrituras
Durante temporada alta, la mayoría de la carga viene de búsquedas. Los resultados de búsqueda pueden ser cacheados en Redis con clave avail:{city}:{date} y TTL de 30–60 segundos. Los datos en cache pueden estar obsoletos, y eso es aceptable, porque la restricción en PostgreSQL es la fuente de verdad final. En el peor caso, el cliente ve el auto “disponible” y recibe 409 al reservar.
8.2. Evitar acaparamiento de autos
Antes de Tết, algunos clientes (o bots) reservan muchos autos pero no pagan. Puedes limitar cada cliente a máximo 3 reservas cada 15 minutos usando Redis:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
public class HoldRateLimiter {
private final StringRedisTemplate redis;
public HoldRateLimiter(StringRedisTemplate redis) {
this.redis = redis;
}
public boolean tryAcquire(long customerId) {
String key = "hold-limit:" + customerId;
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) {
redis.expire(key, Duration.ofMinutes(15));
}
return count != null && count <= 3;
}
}
increment es una operación atómica, así que múltiples instancias llamándola al mismo tiempo cuentan correctamente. La 4ª reserva dentro de la ventana de 15 minutos recibe false, y la API devuelve 429 Too Many Requests.
8.3. Outbox a Kafka
Enviar a Kafka directamente dentro de la transacción es muy arriesgado. Si el commit a DB es exitoso pero Kafka falla, el evento se pierde. Si inviertes el orden, envías un evento “fantasma” para un cambio que nunca fue guardado. El patrón transactional outbox resuelve esto: guarda el evento en la tabla outbox dentro de la transacción, luego un relay lee esa tabla y la empuja a Kafka.
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
@Component
public class OutboxRelay {
private final JdbcClient jdbc;
private final KafkaTemplate<String, String> kafka;
public OutboxRelay(JdbcClient jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 1_000)
@Transactional
public void publish() {
var rows = jdbc.sql("""
SELECT id, topic, payload::text AS payload FROM outbox
WHERE published_at IS NULL ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED
""").query().listOfRows();
for (var row : rows) {
kafka.send((String) row.get("topic"), (String) row.get("payload")).join();
jdbc.sql("UPDATE outbox SET published_at = now() WHERE id = :id")
.param("id", row.get("id")).update();
}
}
}
FOR UPDATE SKIP LOCKED permite que múltiples instancias del relay corran en paralelo sin tomar las mismas filas. El relay aún puede duplicar un evento, por ejemplo si crashea después de send pero antes de UPDATE. Por eso el consumer (servicio de notificaciones, servicio de reembolso) debe ser idempotente por bookingId.
9. Errores comunes y cuándo no aplicar esto
- Verificar disponibilidad con SELECT luego INSERT. Esto siempre tiene condición de carrera. Deja que la restricción haga su trabajo.
- Usar lock en Redis en lugar de restricción. El lock puede expirar mientras estás usando, por ejemplo en una pausa de GC. Redis debería ser una capa de defensa secundaria, no la fuente de verdad.
- Guardar tiempo sin zona horaria. Usa
TIMESTAMPTZyOffsetDateTime, especialmente si después expandes a múltiples países. - Reembolsar de forma síncrona en la solicitud de cancelación. La puerta de pago lenta ralentizará el API de cancelación. Empuja el reembolso a través de outbox.
- No usar restricción de exclusión si fragmentas el calendario en múltiples bases de datos por criterios distintos a
car_id. La restricción solo tiene efecto dentro de una base de datos. Si necesitas fragmentar, hazlo porcar_idpara que todas las reservas de un auto estén en el mismo lugar.
10. Conclusión
El problema de alquiler de autos se resume en tres puntos principales. Primero, deja que la base de datos bloquee double-booking usando tstzrange y restricción de exclusión. Segundo, cambia estados usando actualización condicional combinada con unique key para manejar webhooks duplicados y carreras con expiración. Tercero, saca tareas secundarias como notificaciones y reembolsos del flujo principal usando transactional outbox y Kafka. Redis maneja los picos: caché de búsqueda y límite de rate para reservas. La fuente de verdad sigue siendo PostgreSQL.
Este marco de diseño aplica a la mayoría de sistemas de reservas por rango de tiempo. Solo reemplaza car con habitación de hotel, campo de fútbol o consulta médica.