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.versionserve para otimistic locking quando múltiplas partes atualizam um pedido simultaneamente.order_itemsguardadish_nameepriceno 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-eventscom seu própriogroup.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 Redistrack: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_ide 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
orderseoutboxe 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
SELECTe depoisUPDATE. Sempre há uma janela entre os dois comandos para race condition. Você deve colocar a condição na cláusulaWHERE. - Enviar para Kafka dentro de
@Transactionalsem outbox. Transaction faz rollback mas a mensagem já saiu, ou vice-versa. - Usar um
SseEmittersem timeout. Conexões penduradas se acumulam gradualmente e drenam recursos. Você deve definir um timeout e deixar o cliente reconectar manualmente através deGET /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
UPDATEcondicional 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.