Diseño de sistema: pedidos, restaurantes, repartidores y estado en tiempo real

1. Resumen general

Una aplicación de entrega de comida tiene tres actores que interactúan simultáneamente con un pedido: el cliente lo realiza, el restaurante lo confirma y el repartidor lo entrega. Los tres procesos ocurren en paralelo, a través de redes móviles inestables, y se concentran durante la hora pico del mediodía.

Este artículo cubre todo el proceso de diseño: requisitos, modelo de datos, API, máquina de estados de pedidos, cómo asignar pedidos a repartidores sin duplicados, cómo mantener la consistencia entre la base de datos y Kafka, y cómo enviar actualizaciones de estado en tiempo real al cliente. Después de leerlo, tendrás un marco de diseño concreto que puedes aplicar o llevar a una entrevista de system design.

2. Requisitos

2.1. Requisitos funcionales

  • El cliente ve el menú, realiza el pedido, paga, rastrea el estado y la ubicación del repartidor.
  • El restaurante recibe el pedido, lo confirma o lo rechaza, y luego indica que el pedido está listo.
  • El repartidor activa su estado en línea, envía su ubicación periódicamente, acepta o rechaza ofertas de pedidos, y actualiza cuando ha recogido y entregado el pedido.
  • El cliente puede cancelar el pedido antes de que el restaurante comience a prepararlo.

2.2. Escala y restricciones

Asumimos los siguientes números como base para los cálculos:

Pedidos:      1.000.000 pedidos/día, hora pico (11:00–13:00) representa ~30%
              → ~42 pedidos/segundo en promedio en hora pico, picos hasta ~200 pedidos/segundo
Repartidores: 50.000 repartidores en línea, envían ubicación cada 4 segundos
              → ~12.500 escrituras de ubicación/segundo
Rastreo:      cliente abre pantalla de rastreo → cientos de miles de conexiones en tiempo real

De estos números, hay tres restricciones principales:

  • Los pedidos deben tener consistencia fuerte. No se puede perder un pedido ni asignar un pedido a dos repartidores.
  • La ubicación del repartidor tolera consistencia débil. Perder un punto de ubicación está bien, pero la escritura debe ser muy rápida. No escribimos ubicación en la base de datos relacional.
  • Crear un pedido debe ser idempotente. Las redes móviles frecuentemente causan reenvíos de solicitudes del cliente.

3. Arquitectura general

 App cliente / restaurante / repartidor
          │  REST + SSE/WebSocket
          ▼
     API Gateway (rate limit, auth)
          │
 ┌────────┼───────────────┬──────────────────┐
 ▼        ▼               ▼                  ▼
Order   Restaurant     Dispatch           Tracking
Service Service        Service            Service
 │ PostgreSQL           │ Redis GEO         │ Redis (ubicación, pub/sub)
 │ + outbox             │                   │
 └──────────► Kafka (order-events) ◄────────┘

Order Service es la fuente de verdad de los pedidos. Los demás servicios reaccionan a eventos en Kafka. La ubicación del repartidor solo reside en Redis.

Los ejemplos a continuación utilizan las siguientes dependencias:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

4. Modelo de datos

CREATE TABLE orders (
    id              BIGSERIAL PRIMARY KEY,
    customer_id     BIGINT      NOT NULL,
    restaurant_id   BIGINT      NOT NULL,
    shipper_id      BIGINT,
    status          VARCHAR(20) NOT NULL,
    total_amount    NUMERIC(12,0) NOT NULL,
    idempotency_key VARCHAR(64) NOT NULL,
    version         INT         NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (customer_id, idempotency_key)
);

CREATE TABLE order_items (
    order_id  BIGINT REFERENCES orders(id),
    dish_id   BIGINT NOT NULL,
    dish_name VARCHAR(200) NOT NULL,
    price     NUMERIC(12,0) NOT NULL,
    quantity  INT NOT NULL
);

CREATE TABLE outbox (
    id           BIGSERIAL PRIMARY KEY,
    aggregate_id BIGINT      NOT NULL,
    event_type   VARCHAR(50) NOT NULL,
    payload      JSONB       NOT NULL,
    published    BOOLEAN     NOT NULL DEFAULT false
);

CREATE INDEX idx_orders_restaurant_active ON orders (restaurant_id, status);
CREATE INDEX idx_outbox_unpublished ON outbox (id) WHERE published = false;

Algunos puntos importantes:

  • UNIQUE (customer_id, idempotency_key) previene pedidos duplicados cuando el cliente reenvía la solicitud.
  • version se usa para optimistic locking cuando múltiples actores actualizan un pedido.
  • order_items guarda dish_name y price en el momento del pedido. Si el restaurante cambia el precio después, no afecta facturas antiguas.
  • La tabla outbox se usa para la consistencia en la sección 8.

5. Máquina de estados de pedidos

Un pedido solo puede transitar por pasos válidos. Debes codificar estas reglas en un único lugar en lugar de dispersar if por todos los servicios:

public enum OrderStatus {
    PLACED, CONFIRMED, PREPARING, READY_FOR_PICKUP, PICKED_UP, DELIVERED, CANCELLED;

    private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = Map.of(
        PLACED,           Set.of(CONFIRMED, CANCELLED),
        CONFIRMED,        Set.of(PREPARING, CANCELLED),
        PREPARING,        Set.of(READY_FOR_PICKUP),
        READY_FOR_PICKUP, Set.of(PICKED_UP),
        PICKED_UP,        Set.of(DELIVERED),
        DELIVERED,        Set.of(),
        CANCELLED,        Set.of()
    );

    public boolean canTransitionTo(OrderStatus next) {
        return TRANSITIONS.get(this).contains(next);
    }
}

TRANSITIONS lista los estados siguientes válidos para cada estado. El cliente solo puede cancelar en PLACED o CONFIRMED. Una vez que el restaurante comienza a preparar (PREPARING), no hay vuelta a CANCELLED. Los dos últimos estados no tienen pasos siguientes.

Prueba que verifica estas reglas:

class OrderStatusTest {

    @Test
    void customerCanCancelBeforePreparing() {
        assertThat(OrderStatus.PLACED.canTransitionTo(OrderStatus.CANCELLED)).isTrue();
        assertThat(OrderStatus.CONFIRMED.canTransitionTo(OrderStatus.CANCELLED)).isTrue();
    }

    @Test
    void cannotCancelOnceKitchenStarted() {
        assertThat(OrderStatus.PREPARING.canTransitionTo(OrderStatus.CANCELLED)).isFalse();
    }

    @Test
    void deliveredIsTerminal() {
        assertThat(OrderStatus.values())
            .noneMatch(OrderStatus.DELIVERED::canTransitionTo);
    }
}

6. API

POST  /api/orders                         Cliente realiza un pedido (header Idempotency-Key)
GET   /api/orders/{id}                    Ver detalles del pedido
POST  /api/orders/{id}/cancel             Cliente cancela el pedido
POST  /api/restaurant/orders/{id}/confirm Restaurante confirma
POST  /api/restaurant/orders/{id}/ready   Restaurante indica que está listo
PUT   /api/shippers/me/location           Repartidor envía ubicación {lat, lng}
POST  /api/shippers/me/offers/{orderId}/accept   Repartidor acepta pedido
POST  /api/orders/{id}/pickup | /deliver  Repartidor actualiza
GET   /api/orders/{id}/stream             SSE: estado + ubicación del repartidor

La API se diseña alrededor de acciones (/confirm, /accept) en lugar de un genérico PATCH status=.... Esto facilita verificar permisos: solo el restaurante del pedido puede llamar /confirm, solo el repartidor asignado puede llamar /pickup.

7. Manejo de condiciones de carrera

7.1. Actualización de estado con optimistic locking

Supongamos que el cliente hace clic en cancelar exactamente cuando el restaurante hace clic en confirmar. Si ambos leen PLACED, ambos creen que es válido. Resolvemos con un UPDATE condicional:

@Repository
public class OrderRepository {

    private final JdbcClient jdbc;

    public OrderRepository(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    public boolean transition(long orderId, OrderStatus from, OrderStatus to, int version) {
        if (!from.canTransitionTo(to)) {
            throw new IllegalStateException(from + " -> " + to + " no es válido");
        }
        int updated = jdbc.sql("""
                UPDATE orders SET status = :to, version = version + 1
                WHERE id = :id AND status = :from AND version = :version
                """)
            .param("to", to.name())
            .param("id", orderId)
            .param("from", from.name())
            .param("version", version)
            .update();
        return updated == 1;
    }
}

La base de datos garantiza que solo uno de los dos UPDATE cumple con la condición version. El ganador recibe true. El perdedor recibe false y devuelve HTTP 409 junto con el estado más reciente, para que la app muestre “El restaurante acaba de confirmar el pedido”.

7.2. Asignación de pedidos a repartidores

Este es el punto más difícil. Necesitamos encontrar un repartidor cerca del restaurante y asegurar que cada pedido tiene solo un repartidor.

Paso 1: almacenar ubicación con Redis GEO. Cada vez que el repartidor envía su ubicación, la escribimos en un sorted set GEO y renovamos una clave de heartbeat:

@Service
public class ShipperLocationService {

    private final StringRedisTemplate redis;

    public ShipperLocationService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public void update(long shipperId, double lat, double lng) {
        String id = String.valueOf(shipperId);
        redis.opsForGeo().add("shipper:geo", new Point(lng, lat), id);
        redis.opsForValue().set("shipper:alive:" + id, "1", Duration.ofSeconds(30));
    }

    public List<String> findNearby(double lat, double lng) {
        var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
            .includeDistance().sortAscending().limit(20);
        var results = redis.opsForGeo().search("shipper:geo",
            GeoReference.fromCoordinate(lng, lat),
            new Distance(3, Metrics.KILOMETERS), args);
        return results.getContent().stream()
            .map(r -> r.getContent().getName())
            .filter(id -> Boolean.TRUE.equals(redis.hasKey("shipper:alive:" + id)))
            .toList();
    }
}

Point toma longitud primero, latitud después—un error muy fácil de cometer. Los miembros en un GEO set no expiran automáticamente. Por eso la clave shipper:alive:* con TTL de 30 segundos se usa para filtrar repartidores que han cerrado la app o perdieron señal. Un job periódico puede hacer ZREM en miembros muertos de shipper:geo.

Con 12.500 escrituras por segundo, una instancia de Redis maneja bien. Al escalar a múltiples ciudades, shard por región: shipper:geo:hcm, shipper:geo:hn.

Paso 2: invitar a cada repartidor, una invitación por persona. Dispatch Service envía invitaciones secuencialmente al repartidor más cercano. Cada invitación tiene un tiempo de expiración de 20 segundos. Para que un repartidor no reciba dos invitaciones simultáneamente, usamos un bloqueo con SET NX:

public boolean reserveShipperForOffer(String shipperId, long orderId) {
    Boolean ok = redis.opsForValue().setIfAbsent(
        "offer:shipper:" + shipperId, String.valueOf(orderId), Duration.ofSeconds(20));
    return Boolean.TRUE.equals(ok);
}

Si retorna false, el repartidor está viendo otra oferta, pasamos al siguiente.

Paso 3: confirmar asignación en la base de datos. El bloqueo Redis solo ayuda a orquestar. La decisión final debe estar en la base de datos:

public boolean assignShipper(long orderId, long shipperId) {
    return jdbc.sql("""
            UPDATE orders SET shipper_id = :shipperId, version = version + 1
            WHERE id = :orderId AND shipper_id IS NULL
              AND status IN ('CONFIRMED', 'PREPARING', 'READY_FOR_PICKUP')
            """)
        .param("shipperId", shipperId)
        .param("orderId", orderId)
        .update() == 1;
}

La condición shipper_id IS NULL asegura que aunque dos repartidores hagan clic en “Aceptar” (por ejemplo, una invitación antigua expira y se reenvía), solo uno gana.

Nota: No uses el bloqueo de Redis como fuente de verdad para la asignación. Redis puede fallar y perder el bloqueo. La restricción en la base de datos es la última capa de protección.

8. Consistencia entre base de datos y Kafka

Después de crear un pedido, Order Service debe emitir el evento OrderPlaced para que el restaurante reciba una notificación. Si escribes la base de datos y luego llamas kafkaTemplate.send(), el servicio puede caer entre los dos pasos. El resultado es que el pedido existe pero el restaurante nunca se entera. Este es el problema del dual write.

La solución es transactional outbox: escribe el evento en la tabla outbox en la misma transacción que el pedido.

@Transactional
public long placeOrder(PlaceOrderCommand cmd) {
    long orderId = orderRepository.insert(cmd); // lanza DuplicateKeyException si hay duplicado
    jdbc.sql("""
            INSERT INTO outbox (aggregate_id, event_type, payload)
            VALUES (:id, 'OrderPlaced', CAST(:payload AS JSONB))
            """)
        .param("id", orderId)
        .param("payload", toJson(cmd, orderId))
        .update();
    return orderId;
}

Ambos registros se confirman o revierten juntos. Si el cliente reenvía la misma Idempotency-Key, la restricción UNIQUE lanza DuplicateKeyException. El controlador captura este error, encuentra el pedido antiguo y lo devuelve en lugar de crear uno nuevo.

Un relay lee el outbox y lo empuja a Kafka:

@Scheduled(fixedDelay = 200)
@Transactional
public void relay() {
    List<OutboxEvent> events = jdbc.sql("""
            SELECT id, aggregate_id, event_type, payload::text AS payload FROM outbox
            WHERE published = false ORDER BY id LIMIT 100
            FOR UPDATE SKIP LOCKED
            """)
        .query(OutboxEvent.class).list();

    for (OutboxEvent e : events) {
        kafka.send("order-events", String.valueOf(e.aggregateId()), e.payload()).join();
        jdbc.sql("UPDATE outbox SET published = true WHERE id = :id")
            .param("id", e.id()).update();
    }
}

FOR UPDATE SKIP LOCKED permite ejecutar múltiples instancias de relay sin procesar el mismo registro dos veces. La clave del mensaje es orderId, por lo que todos los eventos del mismo pedido caen en la misma partición y mantienen el orden.

El relay asegura at-least-once. Si el servicio cae después de send pero antes de confirmar, el evento se reenviará. Por lo tanto, el consumidor debe ser idempotente, por ejemplo guardando event_id ya procesado o usando UPDATE ... WHERE status = ... como en la sección 7.1.

9. Estado en tiempo real para el cliente

El cliente necesita ver dos tipos de actualizaciones: estado del pedido (poco, importante) y ubicación del repartidor (mucha, puede perder). Server-Sent Events es lo suficientemente bueno porque el flujo de datos es unidireccional desde el servidor al cliente:

@RestController
public class OrderStreamController {

    private final Map<Long, List<SseEmitter>> emitters = new ConcurrentHashMap<>();

    @GetMapping(value = "/api/orders/{id}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter stream(@PathVariable long id) {
        SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
        emitters.computeIfAbsent(id, k -> new CopyOnWriteArrayList<>()).add(emitter);
        emitter.onCompletion(() -> emitters.get(id).remove(emitter));
        emitter.onTimeout(emitter::complete);
        return emitter;
    }

    public void push(long orderId, String eventName, Object data) {
        emitters.getOrDefault(orderId, List.of()).forEach(e -> {
            try {
                e.send(SseEmitter.event().name(eventName).data(data));
            } catch (IOException ex) {
                e.completeWithError(ex);
            }
        });
    }
}

Cada pedido tiene una lista de emitters (el cliente puede abrir en múltiples dispositivos). push envía un evento nombrado, por ejemplo status o location. El cliente recibe:

event:status
data:{"orderId":1024,"status":"PICKED_UP"}

event:location
data:{"lat":10.7769,"lng":106.7009}

Cuando se ejecutan múltiples instancias, la conexión SSE del cliente solo reside en una instancia. Hay dos formas para que el evento llegue al lugar correcto:

  • Estado del pedido: cada instancia de Tracking consume order-events con un group.id único, por lo que todas reciben todos los eventos pero solo empujan a los emitters que mantienen.
  • Ubicación del repartidor: cuando el pedido está en estado PICKED_UP, Tracking publica la ubicación en el canal Redis track:order:{id}. La instancia que mantiene la conexión se suscribe. No empujamos 12.500 mensajes/segundo a Kafka cuando solo una pequeña parte se ve.

Para reducir carga, puedes agrupar ubicaciones: el cliente solo necesita un punto cada 4–5 segundos e interpola el movimiento en el mapa.

10. Picos de carga en hora pico

Al mediodía o cuando se ejecuta una promoción, el tráfico puede multiplicarse 5–10 veces. Algunas técnicas ayudan a que el sistema aguante:

  • Cache del menú. El menú se lee mucho, se escribe poco. Cachéa en Redis por restaurant_id e invalida cuando el restaurante edita un plato. La mayoría de solicitudes de “ver restaurante” no tocarán la base de datos.
  • Rate limit por usuario y por restaurante en la gateway. También puedes pausar temporalmente la aceptación de pedidos en un restaurante sobrecargado (más de N pedidos en estado PLACED) en lugar de dejar que el cliente espere 40 minutos.
  • Separar escritura de procesamiento. El API de crear pedido solo escribe orders y outbox y retorna. Notificaciones, cálculo de tarifa, sugerencias de promoción se ejecutan asincronía vía Kafka.
  • Dispatch por lotes. Cuando la demanda supera la oferta, emparejar pedidos y repartidores cada 5–10 segundos (batch matching) da mejores resultados que invitar uno por uno. El sistema también puede agrupar dos pedidos del mismo restaurante para un repartidor.
  • Degradación controlada. Cuando Redis Tracking es lento, prioriza mantener el flujo de crear pedidos y pausa temporalmente el envío de ubicación. El cliente tolera que el mapa se congele unos segundos mejor que no poder hacer un pedido.

11. Errores comunes

  • Guardar ubicación del repartidor en PostgreSQL. 12.500 escrituras por segundo, cada una actualizando una fila, causa bloat y lock contention. Solo deberías guardar historial de rutas en lotes (por ejemplo, empujando vía Kafka a un almacén de datos) si necesitas conciliación.
  • Verificar estado con SELECT y luego UPDATE. Entre las dos sentencias siempre hay una brecha para race condition. Debes poner la condición en la cláusula WHERE.
  • Enviar Kafka dentro de @Transactional sin outbox. La transacción revierte pero el mensaje ya voló, o viceversa.
  • Usar un SseEmitter sin timeout. Las conexiones colgadas se acumulan y agotan recursos. Debes establecer timeout y dejar que el cliente reconecte usando GET /api/orders/{id} para obtener el estado más reciente.

Nota: La arquitectura de múltiples servicios con Kafka, Redis GEO y outbox solo vale la pena a escala suficiente. Con cientos de pedidos diarios, una aplicación Spring Boot monolítica, un PostgreSQL y polling cada 5 segundos es suficiente, y mucho más fácil de operar.

12. Conclusión

En este artículo, diseñaste un sistema de entrega de comida de principio a fin. Hay cuatro ideas principales:

  • Modelar el ciclo de vida del pedido usando una máquina de estados clara.
  • Resolver condiciones de carrera con UPDATE condicionales y optimistic locking.
  • Distribuir pedidos usando Redis GEO combinado con restricciones en la base de datos.
  • Mantener consistencia con Kafka mediante transactional outbox.

El principio transversal es categorizar datos según sus requisitos de consistencia. Los pedidos necesitan consistencia fuerte, así que viven en la base de datos relacional. La ubicación del repartidor tolera pérdida, así que va a Redis, priorizando velocidad. Una vez que separas estos dos tipos, la mayoría de los puntos difíciles se vuelven más manejables.

system-designspring-bootrediskafkacase-study