Design de um sistema de delivery de comida com Kafka

1. Visão geral

Um aplicativo de delivery de comida tem três partes que atuam em um pedido: o cliente que faz o pedido, o restaurante que confirma e o entregador que recebe para entregar. Essas três operações acontecem simultaneamente, em redes móveis instáveis, e se concentram na hora do almoço.

Este artigo passa por todo o processo de design: requisitos, modelo de dados, API, máquina de estados, como distribuir pedidos para entregadores sem duplicação, como manter consistência entre o banco de dados e Kafka, e como enviar o status em tempo real para o cliente. Ao final, você terá um framework de design concreto para aplicar ou levar para uma entrevista de system design.

2. Requisitos

2.1. Requisitos funcionais

  • O cliente visualiza o menu, faz o pedido, paga, acompanha o status e a localização do entregador.
  • O restaurante recebe o pedido, confirma ou nega, depois informa que o prato está pronto.
  • O entregador ativa o status online, envia a localização periodicamente, aceita ou recusa ofertas de pedidos, e atualiza quando pegou e quando entregou.
  • O cliente pode cancelar o pedido antes do restaurante começar a preparar.

2.2. Escala e restrições

Assumimos os seguintes números como base para os cálculos:

Pedidos:    1.000.000 pedidos/dia, horário de pico (11h–13h) representa ~30%
            → ~42 pedidos/segundo em média no pico, picos de até ~200 pedidos/segundo
Entregadores: 50.000 entregadores online, enviando localização a cada 4 segundos
            → ~12.500 escritas de localização/segundo
Rastreamento: cliente abre tela de rastreamento → centenas de milhares de conexões em tempo real

A partir desses números, há três restrições principais:

  • Pedidos devem ter consistência forte. Não é permitido perder um pedido nem atribuir um pedido a dois entregadores.
  • Localização do entregador aceita consistência fraca. Perder um ponto de localização é aceitável, mas a escrita deve ser muito rápida. Não armazenamos localização no banco de dados relacional.
  • Fazer um pedido deve ser idempotente. Redes móveis frequentemente causam clientes a reenviar a requisição.

3. Arquitetura geral

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

Order Service é a fonte de verdade dos pedidos. Os outros serviços reagem aos eventos no Kafka. A localização do entregador fica apenas no Redis.

Os exemplos abaixo usam as seguintes dependências:

<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 dados

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;

Há alguns pontos a observar:

  • UNIQUE (customer_id, idempotency_key) impede pedidos duplicados quando o cliente reenvia a requisição.
  • version serve para otimistic locking quando múltiplas partes atualizam um pedido simultaneamente.
  • order_items guarda dish_name e price no momento do pedido. Mesmo que o restaurante mude o preço depois, a fatura antiga não muda.
  • A tabela outbox é usada para a seção de consistência na seção 8.

5. Máquina de estados de pedidos

Um pedido deve seguir apenas estados válidos. Você deve codificar essa regra em um único lugar em vez de espalhá-la em if por todos os serviços:

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 listra os estados posteriores válidos para cada estado. O cliente só pode cancelar nos estados PLACED ou CONFIRMED. Quando o restaurante já está preparando (PREPARING), o pedido não pode mais voltar a CANCELLED. Os dois últimos estados não têm próximos passos.

Teste para verificar essa regra:

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 faz pedido (header Idempotency-Key)
GET   /api/orders/{id}                    Ver detalhes do pedido
POST  /api/orders/{id}/cancel             Cliente cancela pedido
POST  /api/restaurant/orders/{id}/confirm Restaurante confirma
POST  /api/restaurant/orders/{id}/ready   Restaurante diz que prato está pronto
PUT   /api/shippers/me/location           Entregador envia localização {lat, lng}
POST  /api/shippers/me/offers/{orderId}/accept   Entregador aceita pedido
POST  /api/orders/{id}/pickup | /deliver  Entregador atualiza status
GET   /api/orders/{id}/stream             SSE: status + localização do entregador

A API é projetada para ações (/confirm, /accept) em vez de um padrão genérico PATCH status=.... Isso torna a verificação de permissões mais fácil: apenas o restaurante do pedido pode chamar /confirm, e apenas o entregador atribuído pode chamar /pickup.

7. Lidar com condições de corrida

7.1. Atualização de status com otimistic locking

Suponha que o cliente clique em cancelar exatamente quando o restaurante confirma. Se ambos lerem PLACED, ambos acham que é válido. Resolvemos isso com uma instrução 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 + " não é 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;
    }
}

O banco de dados garante que apenas um dos dois UPDATE atende a condição version. O vencedor recebe true. O perdedor recebe false e retorna HTTP 409 com o status mais recente, para que o app mostre “O restaurante acaba de confirmar o pedido”.

7.2. Distribuir pedidos para entregadores

Este é o ponto mais difícil. Precisamos encontrar um entregador perto do restaurante e garantir que cada pedido tenha apenas um entregador.

Passo 1: armazenar localização com Redis GEO. Cada vez que o entregador envia sua localização, escrevemos em um sorted set GEO e renovamos uma chave 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 recebe longitude primeiro, depois latitude, um erro muito fácil de cometer. Membros no conjunto GEO não expiram automaticamente. Por isso a chave shipper:alive:* com TTL de 30 segundos é usada para filtrar entregadores que desligaram o app ou perderam sinal. Um job periódico pode ZREM membros mortos do shipper:geo.

Com 12.500 escritas por segundo, uma instância Redis ainda responde bem. Ao expandir para várias cidades, você faz shard por região: shipper:geo:hcm, shipper:geo:hn.

Passo 2: convidar cada entregador, um convite por vez. Dispatch Service envia convites em sequência para os entregadores mais próximos. Cada convite tem prazo de 20 segundos. Para garantir que um entregador não recebe dois convites simultaneamente, usamos um lock com 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);
}

Se false, o entregador está vendo outro convite, então passamos para a próxima pessoa.

Passo 3: confirmar a atribuição no banco de dados. O lock Redis apenas ajuda na orquestração. A decisão final deve estar no banco de dados:

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;
}

A condição shipper_id IS NULL garante que mesmo que dois entregadores cliquem em “Aceitar” (por exemplo, se o convite antigo expirou e foi reenviado), apenas um vence.

Nota: Não use o lock do Redis como fonte de verdade para atribuição de pedidos. Redis pode failover e perder o lock. Nesse caso, a restrição no banco de dados é a camada de proteção final.

8. Consistência entre banco de dados e Kafka

Depois de criar um pedido, Order Service deve emitir um evento OrderPlaced para que o restaurante receba a notificação. Se você escrever no banco de dados e depois chamar kafkaTemplate.send(), o serviço pode morrer entre os dois passos. O resultado é que o pedido existe mas o restaurante nunca fica sabendo. Este é o problema dual write.

A solução é transactional outbox: escrever o evento na tabela outbox na mesma transaction que o pedido.

@Transactional
public long placeOrder(PlaceOrderCommand cmd) {
    long orderId = orderRepository.insert(cmd); // lança DuplicateKeyException se houver chave duplicada
    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;
}

Os dois registros fazem commit ou rollback juntos. Se o cliente reenvia a mesma Idempotency-Key, a restrição UNIQUE lança DuplicateKeyException. O controller captura esse erro, encontra o pedido antigo e retorna esse pedido em vez de criar um novo.

Um relay lê o outbox e envia para o 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 executar múltiplas instâncias de relay sem processar registros duplicados. A chave da mensagem é orderId, então todos os eventos do mesmo pedido caem na mesma partição e mantêm a ordem correta.

O relay garante at-least-once. Se o serviço morrer depois de send mas antes de fazer commit, o evento será reenviado. Por isso o consumer deve ser idempotente, por exemplo armazenando event_id já processado ou usando UPDATE ... WHERE status = ... como na seção 7.1.

9. Status em tempo real para o cliente

O cliente precisa ver dois tipos de atualizações: status do pedido (poucos, importantes) e localização do entregador (muitos, pode perder alguns). Server-Sent Events é uma escolha suficientemente boa porque o fluxo de dados vai apenas em uma direção, do servidor para o 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 tem uma lista de emitters (o cliente pode abrir em vários dispositivos). push envia um evento nomeado, por exemplo status ou location. O cliente recebe:

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

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

Ao executar múltiplas instâncias, a conexão SSE do cliente fica apenas em uma instância. Há duas maneiras de o evento chegar no lugar certo:

  • Status do pedido: cada instância Tracking consome order-events com seu próprio group.id, então toda instância recebe todos os eventos e apenas envia para o emitter que está mantendo.
  • Localização do entregador: quando o pedido está no estado PICKED_UP, Tracking publica a localização em um canal Redis track:order:{id}. A instância que mantém a conexão se inscreve nesse canal. Não enviamos 12.500 mensagens/segundo pelo Kafka quando apenas uma pequena parte é visualizada.

Para reduzir a carga, você pode agregar localizações: o cliente recebe um ponto apenas a cada 4–5 segundos e interpola o movimento no mapa.

10. Lidar com picos de tráfego no horário de pico

No horário do almoço ou durante promoções, o tráfego pode aumentar 5–10 vezes. Algumas técnicas ajudam o sistema a ficar estável:

  • Cache de menu. Menu é lido muitas vezes, escrito pouco. Você faz cache no Redis por restaurant_id e invalida o cache quando o restaurante muda um prato. A maioria das requisições de “visualizar restaurante” não toca o banco de dados.
  • Rate limit por usuário e por restaurante no gateway. Você também pode pausar temporariamente o recebimento de pedidos em um restaurante sobrecarregado (mais de N pedidos no estado PLACED) em vez de fazer o cliente esperar 40 minutos.
  • Separar o caminho de escrita do caminho de processamento. A API de fazer pedido apenas escreve orders e outbox e retorna imediatamente. Notificações, cálculo de taxa de entrega, e sugestões de promoção rodam de forma assíncrona via Kafka.
  • Dispatch em lote. Quando a demanda supera a oferta, casar pedidos e entregadores a cada 5–10 segundos (batch matching) dá melhores resultados do que convidar cada um imediatamente. O sistema também pode agrupar dois pedidos do mesmo restaurante para um entregador.
  • Degradação controlada. Quando o Tracking Redis fica lento, priorize manter o fluxo de fazer pedidos e pause temporariamente o envio de localização. O cliente aceita o mapa congelado alguns segundos em vez de não conseguir fazer um pedido.

11. Erros comuns

  • Salvar a localização do entregador no PostgreSQL. 12.500 escritas por segundo, cada uma atualizando uma linha, causa table bloat e lock contention. Você deve apenas armazenar histórico de trajeto em lote (por exemplo, enviando via Kafka para um data warehouse) se precisar fazer reconciliação.
  • Verificar o status com SELECT e depois UPDATE. Sempre há uma janela entre os dois comandos para race condition. Você deve colocar a condição na cláusula WHERE.
  • Enviar para Kafka dentro de @Transactional sem outbox. Transaction faz rollback mas a mensagem já saiu, ou vice-versa.
  • Usar um SseEmitter sem timeout. Conexões penduradas se acumulam gradualmente e drenam recursos. Você deve definir um timeout e deixar o cliente reconectar manualmente através de GET /api/orders/{id} para obter o status mais recente.

Nota: Arquitetura de múltiplos serviços com Kafka, Redis GEO e outbox só vale a pena em escala suficientemente grande. Com alguns poucos pedidos por dia, um único monolith Spring Boot, um PostgreSQL e polling a cada 5 segundos é suficiente e muito mais fácil de operar.

12. Conclusão

Neste artigo, você projetou um sistema de delivery de comida do zero até a implementação. Há quatro pontos principais:

  • Modelar pedidos com uma máquina de estados clara.
  • Resolver condições de corrida com UPDATE condicional e otimistic locking.
  • Distribuir pedidos usando Redis GEO combinado com restrições no banco de dados.
  • Manter consistência com Kafka através de transactional outbox.

O princípio central é classificar dados de acordo com seus requisitos de consistência. Pedidos precisam de consistência forte, então vivem no banco de dados relacional. Localização de entregadores aceita perda de dados, então passam pelo Redis, priorizando velocidade. Uma vez que você separou esses dois tipos de dados, a maioria dos pontos difíceis do problema fica mais fácil de lidar.

system-designspring-bootrediskafkacase-study