Projetando rastreamento de pedidos com localização em tempo real

1. Visão Geral

Quando você faz um pedido de comida ou solicitação de entrega, o cliente quer saber em qual estado o pedido está e onde o entregador está no mapa. Este artigo projeta um sistema assim, do requisito até o código. O artigo passa por modelo de dados, APIs, fluxos de processamento, e então aborda três pontos difíceis: contenção ao receber o pedido, consistência de dados e picos de carga.

Depois de ler, você terá uma arquitetura de referência e trechos de código Spring Boot 3.x para começar a implementação.

2. Requisitos

2.1. Requisitos Funcionais

  • Entregador envia sua posição GPS periodicamente pelo app.
  • Cliente visualiza estado do pedido e posição do entregador quase em tempo real, com latência abaixo de 3 segundos.
  • Entregador recebe o pedido. Cada pedido é atribuído a exatamente um entregador.
  • O estado do pedido segue o ciclo de vida: PENDING → ASSIGNED → PICKED_UP → DELIVERING → DELIVERED. De alguns estados, pode-se fazer a transição para CANCELLED.
  • Manter histórico de estados para reconciliação e tratamento de reclamações.

2.2. Estimativa de Escala

Métrica Valor
Entregadores online no horário de pico 50.000
Intervalo de envio de posição 4 segundos
Escritas de posição ~12.500 msg/s
Clientes com tela de rastreamento aberta ~200.000 conexões
Novos pedidos no horário de pico ~300 pedidos/s

2.3. Restrições

  • Localização é dados temporários. Perder alguns pontos é aceitável, mas nunca deve-se exibir uma posição antiga sobrescrevendo uma mais recente.
  • Estado do pedido é dados críticos. Não pode ser perdido e nunca deve estar fora de ordem.
  • Redes móveis são instáveis, então o app reenviará dados e os enviará em lotes.

A partir disso, há uma decisão crucial: separar dois fluxos. O fluxo de localização precisa de alta vazão e aceita perda de dados. O fluxo de estado precisa de transações e durabilidade.

3. Arquitetura Geral

App entregador ──HTTP batch──> Location API ──> Kafka [shipper-locations]
                                                   │
                                                   ▼
                                        Location Consumer ──> Redis (posição mais recente, GEO)
                                                   │
                                                   └──> Redis Pub/Sub [order:{id}:loc]
                                                               │
App cliente <──────── SSE ──────── Tracking Gateway <────────────┘

App entregador ──HTTP──> Order Service ──> PostgreSQL (orders, order_events, outbox)
                                              │
                                              └──> Outbox relay ──> Kafka [order-events]

Cada componente tem uma responsabilidade:

  • Kafka absorve a carga de escrita de localização e mantém a ordem por entregador, usando shipperId como chave.
  • Redis mantém apenas a localização mais recente de cada entregador. O sistema não escreve cada ponto GPS no banco de dados relacional.
  • PostgreSQL é a fonte de verdade para o estado do pedido.
  • SSE envia dados em uma direção do servidor para o cliente. SSE é mais simples que WebSocket e suficiente para este caso de uso.

4. Modelo de Dados

4.1. PostgreSQL para Pedidos

CREATE TABLE orders (
    id          BIGSERIAL PRIMARY KEY,
    customer_id BIGINT      NOT NULL,
    shipper_id  BIGINT,
    status      VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    version     INT         NOT NULL DEFAULT 0,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE order_events (
    id         BIGSERIAL PRIMARY KEY,
    order_id   BIGINT      NOT NULL REFERENCES orders(id),
    from_status VARCHAR(20),
    to_status  VARCHAR(20) NOT NULL,
    actor_id   BIGINT      NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE outbox (
    id           BIGSERIAL PRIMARY KEY,
    aggregate_id BIGINT      NOT NULL,
    payload      JSONB       NOT NULL,
    published    BOOLEAN     NOT NULL DEFAULT false,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_outbox_unpublished ON outbox(id) WHERE published = false;

A coluna version serve para optimistic locking. A tabela order_events é um histórico apenas de anexação, nunca é modificada. A tabela outbox garante que eventos sejam publicados no Kafka sem perder nada. A seção 7 explicará isso em detalhes.

4.2. Redis para Localização

shipper:loc:{shipperId}         HASH  {lat, lng, ts}       TTL 120s
shippers:geo:{city}             GEO   member = shipperId
shipper:active-order:{shipperId} STRING orderId

TTL de 120 segundos faz com que um entregador que perdeu a conexão desapareça automaticamente do sistema. Você não precisa de um job separado de limpeza. A chave GEO é separada por cidade, então o comando GEOSEARCH ao encontrar o entregador mais próximo não precisa varrer o país todo.

5. APIs

POST /api/v1/shippers/{shipperId}/locations      body: [ {lat,lng,ts}, ... ]  -> 202
POST /api/v1/orders/{orderId}/claim              -> 200 | 409
POST /api/v1/orders/{orderId}/transitions        body: {to: "PICKED_UP"}  -> 200 | 409
GET  /api/v1/orders/{orderId}                    -> estado + posição atual
GET  /api/v1/orders/{orderId}/track              -> text/event-stream

A API de localização retorna 202 Accepted porque ela apenas envia dados para o Kafka e responde imediatamente. Esta API aceita dados em lote, permitindo que o app junte os pontos que ficaram retidos quando perdeu conexão e depois os envie de uma vez.

6. Fluxo de Processamento de Localização

Você precisa das seguintes dependências:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</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>

Configure o serializador JSON para o Kafka:

spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      acks: 1
    consumer:
      group-id: location-consumer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "com.nonla.tracking"

O valor acks: 1 é uma escolha deliberada. Perder alguns pontos de GPS quando o leader do broker cai é aceitável, em troca de menor latência de escrita do que acks: all.

6.1. Receber Localização

public record LocationUpdate(long shipperId, double lat, double lng, long ts) {}

@RestController
@RequestMapping("/api/v1/shippers")
public class LocationController {

    private final KafkaTemplate<String, LocationUpdate> kafka;

    public LocationController(KafkaTemplate<String, LocationUpdate> kafka) {
        this.kafka = kafka;
    }

    @PostMapping("/{shipperId}/locations")
    public ResponseEntity<Void> push(@PathVariable long shipperId,
                                     @RequestBody List<LocationUpdate> batch) {
        batch.stream()
             .filter(u -> u.shipperId() == shipperId)
             .forEach(u -> kafka.send("shipper-locations", String.valueOf(shipperId), u));
        return ResponseEntity.accepted().build();
    }
}

Dois pontos a observar:

  • A chave é shipperId. Todos os pontos do mesmo entregador caem na mesma partição, então o consumer os processa na ordem exata em que foram enviados.
  • O filtro filter remove pontos com shipperId diferente do path. Na prática, você deve extrair shipperId de um JWT em vez de confiar no corpo da requisição.

6.2. Escrever Localização Mais Recente, Descartar Pontos Antigos

A ordem na partição não é suficiente. O app pode reenviar um lote antigo após recuperar a conexão, e esse lote pode chegar depois de um ponto mais recente. Portanto, o consumer deve comparar ts e só escrever se o ponto for mais novo. A comparação e escrita precisam ser atômicas, então usamos um script Lua:

@Component
public class LocationConsumer {

    private static final RedisScript<Long> UPSERT_IF_NEWER = RedisScript.of("""
        local cur = redis.call('HGET', KEYS[1], 'ts')
        if cur and tonumber(cur) >= tonumber(ARGV[3]) then return 0 end
        redis.call('HSET', KEYS[1], 'lat', ARGV[1], 'lng', ARGV[2], 'ts', ARGV[3])
        redis.call('EXPIRE', KEYS[1], 120)
        return 1
        """, Long.class);

    private final StringRedisTemplate redis;

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

    @KafkaListener(topics = "shipper-locations", concurrency = "6")
    public void onLocation(LocationUpdate u) {
        Long updated = redis.execute(UPSERT_IF_NEWER,
                List.of("shipper:loc:" + u.shipperId()),
                String.valueOf(u.lat()), String.valueOf(u.lng()), String.valueOf(u.ts()));

        if (updated == null || updated == 0) return; // ponto antigo, ignorar

        redis.opsForGeo().add("shippers:geo:hcm", new Point(u.lng(), u.lat()),
                String.valueOf(u.shipperId()));

        String orderId = redis.opsForValue().get("shipper:active-order:" + u.shipperId());
        if (orderId != null) {
            redis.convertAndSend("order:" + orderId + ":loc",
                    "{\"lat\":%s,\"lng\":%s,\"ts\":%d}".formatted(u.lat(), u.lng(), u.ts()));
        }
    }
}

O fluxo de processamento é assim:

  • O script retorna 0 se o ponto for mais antigo ou igual ao ponto armazenado. Neste caso, o consumer para, sem atualizar GEO e sem enviar para o cliente.
  • O comando GEOADD recebe parâmetros na ordem longitude primeiro, latitude depois. Este é um erro muito comum.
  • O sistema só publica quando o entregador tem um pedido ativo. Entregadores ociosos não geram tráfego de fan-out.

Nota: O script acima toca apenas uma chave, então funciona em Redis Cluster. Se você quiser incluir GEOADD no script, as chaves devem estar no mesmo slot. Então use hash tags como {hcm}:loc:42 e {hcm}:geo.

6.3. Enviar para o Cliente com SSE

@RestController
public class TrackingController implements MessageListener {

    private final Map<String, Set<SseEmitter>> subscribers = new ConcurrentHashMap<>();

    @GetMapping(value = "/api/v1/orders/{orderId}/track",
                produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter track(@PathVariable String orderId) {
        SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
        Set<SseEmitter> set = subscribers.computeIfAbsent(orderId, k -> ConcurrentHashMap.newKeySet());
        set.add(emitter);
        Runnable cleanup = () -> set.remove(emitter);
        emitter.onCompletion(cleanup);
        emitter.onTimeout(cleanup);
        emitter.onError(e -> cleanup.run());
        return emitter;
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String channel = new String(message.getChannel());   // order:123:loc
        String orderId = channel.split(":")[1];
        String body = new String(message.getBody());
        subscribers.getOrDefault(orderId, Set.of()).forEach(e -> {
            try {
                e.send(SseEmitter.event().name("location").data(body));
            } catch (IOException ex) {
                e.completeWithError(ex);
            }
        });
    }
}

Registre o listener com padrão order:*:loc:

@Bean
RedisMessageListenerContainer container(RedisConnectionFactory cf, TrackingController listener) {
    var c = new RedisMessageListenerContainer();
    c.setConnectionFactory(cf);
    c.addMessageListener(listener, new PatternTopic("order:*:loc"));
    return c;
}

O cliente recebe eventos assim:

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

O gateway é stateless, então você pode escalar para quantas instâncias quiser. A instância que mantém a conexão SSE do cliente receberá a mensagem via Pub/Sub e a enviará para baixo. Com 200.000 conexões, você deve habilitar virtual threads (spring.threads.virtual.enabled=true) ou migrar para WebFlux, para que cada conexão não mantenha uma platform thread.

7. Pontos Difíceis e Como Resolvê-los

7.1. Contenção: Dois Entregadores Recebem o Mesmo Pedido

Um novo pedido é exibido para vários entregadores próximos. Dois podem clicar em “Receber” ao mesmo tempo. O caminho errado é ler o estado, verificar PENDING, depois escrever, porque há uma janela entre a leitura e a escrita. O caminho correto é usar UPDATE com condição:

@Service
public class OrderClaimService {

    private final JdbcTemplate jdbc;

    public OrderClaimService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public boolean claim(long orderId, long shipperId) {
        int rows = jdbc.update("""
            UPDATE orders
               SET shipper_id = ?, status = 'ASSIGNED', version = version + 1, updated_at = now()
             WHERE id = ? AND status = 'PENDING'
            """, shipperId, orderId);
        if (rows == 0) return false; // outro entregador já recebeu

        jdbc.update("INSERT INTO order_events(order_id, from_status, to_status, actor_id) VALUES (?, 'PENDING', 'ASSIGNED', ?)",
                orderId, shipperId);
        jdbc.update("INSERT INTO outbox(aggregate_id, payload) VALUES (?, ?::jsonb)",
                orderId, "{\"orderId\":%d,\"status\":\"ASSIGNED\",\"shipperId\":%d}".formatted(orderId, shipperId));
        return true;
    }
}

PostgreSQL bloqueia a linha durante o UPDATE. O segundo entregador deve aguardar, depois reavalia a condição status = 'PENDING', vê que não corresponde mais e recebe rows == 0. O controller retorna 409 Conflict, e o app mostra a mensagem “Pedido já foi recebido”. Você não precisa de distributed lock.

7.2. Consistência: State Machine e Outbox

Toda transição de estado deve ser válida. Você codifica as regras de transição direto em um enum:

public enum OrderStatus {
    PENDING, ASSIGNED, PICKED_UP, DELIVERING, DELIVERED, CANCELLED;

    public boolean canTransitionTo(OrderStatus next) {
        return switch (this) {
            case PENDING    -> next == ASSIGNED || next == CANCELLED;
            case ASSIGNED   -> next == PICKED_UP || next == CANCELLED;
            case PICKED_UP  -> next == DELIVERING;
            case DELIVERING -> next == DELIVERED;
            case DELIVERED, CANCELLED -> false;
        };
    }
}

Teste para o comportamento principal:

class OrderStatusTest {

    @Test
    void shouldAllowHappyPath() {
        assertThat(OrderStatus.PENDING.canTransitionTo(OrderStatus.ASSIGNED)).isTrue();
        assertThat(OrderStatus.DELIVERING.canTransitionTo(OrderStatus.DELIVERED)).isTrue();
    }

    @Test
    void shouldRejectCancelAfterPickup() {
        assertThat(OrderStatus.PICKED_UP.canTransitionTo(OrderStatus.CANCELLED)).isFalse();
    }

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

Ao fazer a transição de estado, você usa a condição WHERE id = ? AND version = ?. Se nenhuma linha for atualizada, significa que alguém alterou o pedido antes. Neste caso, você retorna 409 e deixa o cliente recarregar.

O padrão outbox resolve o problema “escrita no DB bem-sucedida, mas envio para Kafka falhou”. O evento é escrito na tabela outbox na mesma transação que orders. Um relay periódico lê as linhas não publicadas e as envia para o Kafka:

@Scheduled(fixedDelay = 500)
@Transactional
public void relay() {
    var rows = jdbc.queryForList(
        "SELECT id, aggregate_id, payload::text AS payload FROM outbox WHERE published = false ORDER BY id LIMIT 200 FOR UPDATE SKIP LOCKED");
    for (var r : rows) {
        kafka.send("order-events", r.get("aggregate_id").toString(), (String) r.get("payload")).join();
        jdbc.update("UPDATE outbox SET published = true WHERE id = ?", r.get("id"));
    }
}

A cláusula FOR UPDATE SKIP LOCKED permite que múltiplos relays rodem em paralelo sem processar a mesma linha duas vezes. O relay garante at-least-once: se morrer após enviar mas antes de marcar published, o evento será enviado novamente. Por isso, o consumer de order-events deve ser idempotente. Por exemplo, o consumer ignora eventos com version menor ou igual ao version já processado.

O consumer de order-events também é o lugar onde você define ou remove a chave shipper:active-order:{shipperId}. Assim, o fluxo de localização sabe para qual pedido enviar.

7.3. Picos de Carga

Ao meio-dia, chuva forte ou dia de promoção podem triplicar ou quintuplicar a carga em minutos. Aqui estão as formas de lidar com isso:

  • Kafka como buffer. Se o consumer ficar lento, o lag aumenta, mas a API ainda responde rápido. Você pré-provisiona mais partições (por exemplo, 48), porque aumentar partições enquanto está rodando embaralha a ordem pela chave.
  • Descartar pontos muito antigos. Quando o lag é grande, um ponto GPS de 30 segundos atrás não tem valor para exibição. O consumer pode ignorá-lo: if (System.currentTimeMillis() - u.ts() > 30_000) return;.
  • Intervalo de envio adaptativo. O servidor retorna um header sugerindo, por exemplo, X-Next-Interval: 8, para que o app espaçe mais o envio quando o sistema está sobrecarregado. Entregadores parados enviam com menos frequência.
  • Agregar no cliente. O app agrupa 2–3 pontos em uma requisição. O número de requisições HTTP cai muito, enquanto o número de mensagens no Kafka permanece.
  • Rate limit por entregador. Você limita cada entregador a no máximo 1 requisição/segundo para bloquear app com defeito ou cliente falso fazendo spam.
  • Separar recursos. O fluxo de localização roda em cluster Redis e grupo de instâncias separado. Se este fluxo cair, o recebimento de pedidos e atualização de estado ainda funcionam normalmente.

8. Erros Comuns e Quando Não Fazer Assim

  • Escrever cada ponto GPS no PostgreSQL. 12.500 inserts/s somado a índices rapidamente bloqueia o banco. Se você precisar salvar a rota para reconciliação, deixe um consumer separado escrever em lotes em um armazenamento apropriado como TimescaleDB ou object storage, com frequência mais baixa.
  • Usar distributed lock (Redisson) para receber o pedido. Esta forma é mais complexa e propensa a erro do que UPDATE com condição na fonte de verdade.
  • Confiar na ordem de chegada das requisições. Redes móveis não garantem ordem. Você sempre deve comparar ts, e para estado deve comparar version.
  • Redis Pub/Sub para dados críticos. Pub/Sub é fire-and-forget. Se o subscriber perde conexão, perde a mensagem. Isso é aceitável para localização, mas não para eventos de estado. Eventos de estado devem passar por Kafka. Quando o cliente reconecta SSE, ele chama GET /orders/{id} para ter um snapshot atualizado.

Nota: Se seu sistema tem apenas alguns centenas de entregadores, esta arquitetura é exagero. Um service único escrevendo localização no Redis, combinado com client polling a cada 5 segundos, é suficiente. Adicione Kafka e gateway SSE quando os números reais te forçarem a isso.

9. Conclusão

Este artigo projetou um sistema de rastreamento de pedidos e localização de entregadores seguindo o princípio central: separar dados temporários de dados críticos.

  • Localização passa por Kafka depois vai para Redis. O sistema aceita perda de dados, mas bloqueia sobrescrita de ponto antigo usando script Lua que compara timestamp.
  • Estado do pedido fica no PostgreSQL. Contenção é resolvida com UPDATE com condição, consistência é mantida com state machine e padrão outbox.
  • Picos de carga são absorvidos por Kafka, combinado com descartar pontos antigos, intervalo de envio adaptativo e separação de recursos.

A partir desta base, você pode estender com recursos como cálculo de ETA, agrupamento de pedidos por localização usando GEOSEARCH, ou salvar a rota para reconciliação.

system-designspring-bootkafkaredisrealtime