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 paraCANCELLED. - 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
shipperIdcomo 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
filterremove pontos comshipperIddiferente do path. Na prática, você deve extrairshipperIdde 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
0se 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
GEOADDrecebe 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
GEOADDno script, as chaves devem estar no mesmo slot. Então use hash tags como{hcm}:loc:42e{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 compararversion. - 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.