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 a EXPIRED y 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_overlap significa que no puede haber dos filas con el mismo car_id y con period superpuesto (&&). La condición WHERE excluye las reservas CANCELLED, EXPIRED, COMPLETED de la restricción, así una reserva cancelada no sigue ocupando el calendario.
  • La extensión btree_gist es necesaria para combinar la operación = en BIGINT con la operación && en range en el mismo índice GiST.
  • provider_txn_id UNIQUE es el fundamento de la idempotencia al procesar webhooks de pago.
  • La tabla outbox sirve 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 period má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í:

  1. INSERT en payment con ON CONFLICT DO NOTHING. Si inserted == 0 entonces este es un webhook duplicado, ignoramos.
  2. Cambiar estado con actualización condicional (WHERE status = 'HOLD'). Esta sentencia actúa como compare-and-set, sin necesidad de lock explícito.
  3. 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 (Clock inyectado) como cancelledAt, 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 TIMESTAMPTZ y OffsetDateTime, 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 por car_id para 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.

system-designspring-bootpostgresqlkafkaredis