Projetando o Uber em Escala: Um Guia Completo de System Design
Resumo: Um guia completo de system design para construir uma plataforma de compartilhamento de corridas lidando com milhões de viagens simultâneas, matching geoespacial em tempo real, precificação dinâmica e predição de ETA em escala global.
Publicado: Fevereiro 2026
Tempo de leitura: 50 minutos
Palavras-chave: #SystemDesign #Uber #Ridesharing #Geoespacial #TempoReal #PrecificaçãoDinâmica #Matching #Escalabilidade #EntrevistaTécnica
A cada minuto, 24.000 corridas são iniciadas no Uber ao redor do mundo. Um passageiro em São Paulo abre o aplicativo e, em 4 segundos, o sistema já escaneou milhares de motoristas próximos, calculou ETAs usando dados de trânsito em tempo real, encontrou o motorista ideal e iniciou a corrida. Tudo isso enquanto gerencia 14 milhões de outras viagens simultâneas acontecendo em 72 países.
Esse não é simplesmente um problema de "conectar passageiro ao motorista". Esse é um motor de matching geoespacial em tempo real operando em escala planetária, combinado com um sistema de precificação dinâmica que equilibra oferta e demanda em milhares de micro-mercados simultaneamente.
Neste guia aprofundado, vamos construir o Uber do zero. Você vai entender as decisões arquiteturais exatas que tornam o matching em tempo real possível, por que a indexação geoespacial com hexágonos H3 substituiu as quadtrees, como o surge pricing realmente funciona por dentro, e por que o sistema de pagamentos precisa de garantias de consistência mais fortes do que o sistema de matching.
Vamos projetar o sistema que mudou o transporte urbano para sempre.
Sumário
- Análise de Requisitos
- Cálculos de Envelope
- Arquitetura de Alto Nível
- Design da API
- Modelagem de Dados
- Indexação Geoespacial: A Fundação
- Matching Motorista-Passageiro: O Problema Central
- Rastreamento de Localização em Tempo Real
- Precificação Dinâmica (Surge)
- Ciclo de Vida da Corrida e Máquina de Estados
- Predição de ETA
- Sistema de Pagamentos
- Sistema de Despacho em Escala
- Arquitetura de Banco de Dados
- Estratégia de Cache
- Confiabilidade e Tolerância a Falhas
- Monitoramento e Observabilidade
- Dicas para Entrevistas
- Anti-Padrões
- Conclusão
- Referências
- Referência Rápida
Análise de Requisitos
Requisitos Funcionais
Lado do Passageiro:
- Solicitar uma corrida — Selecionar ponto de embarque/desembarque, escolher tipo de corrida (UberX, Comfort, Black)
- Rastreamento em tempo real — Ver a localização do motorista ao vivo no mapa
- Estimativa de ETA — Saber quando o motorista chega e a duração da viagem
- Estimativa de tarifa — Ver o preço antes de confirmar
- Histórico de corridas — Viagens anteriores, recibos, avaliações
- Pagamentos — Múltiplos métodos de pagamento, divisão de tarifa
- Avaliação e feedback — Avaliar o motorista após a viagem
Lado do Motorista:
- Ficar online/offline — Alternar disponibilidade
- Aceitar/recusar solicitações — Decisão com tempo limitado (15 segundos)
- Navegação — Direções curva a curva
- Painel de ganhos — Ganhos em tempo real, resumos diários/semanais
- Reporte de localização — Atualizações GPS contínuas enquanto online
Plataforma:
- Motor de matching — Conectar passageiros aos motoristas ideais
- Precificação dinâmica — Ajustar preços baseado em oferta/demanda
- Otimização de despacho — Minimizar tempos de espera pela cidade
- Detecção de fraude — Identificar viagens fraudulentas, GPS falso
- Analytics — Dashboard de operações por cidade
Requisitos Não-Funcionais
| Requisito | Alvo | Justificativa |
|---|---|---|
| Latência de Matching | < 30 segundos | Passageiro espera match quase instantâneo |
| Frequência de Atualização de Localização | A cada 4 segundos | Animação suave no mapa, ETA preciso |
| Precisão do ETA | ±2 minutos | Confiança e confiabilidade |
| Disponibilidade | 99,99% | Transporte é crítico para segurança |
| Consistência de Pagamento | Forte (ACID) | Dinheiro não pode ser perdido ou duplicado |
| Consistência de Matching | Eventual | Breve inconsistência é aceitável |
| Precisão GPS | < 10 metros | Localização de embarque precisa |
Estimativas de Escala
Usuários Ativos Mensais: 130 milhões (passageiros + motoristas)
Passageiros Ativos Diários: 25 milhões
Motoristas Ativos (simultâneos): 5 milhões
Corridas por dia: 24 milhões (24K corridas iniciando por minuto)
Atualizações de localização dos motoristas: 5M motoristas × 15 atualizações/min = 75M atualizações/min = 1,25M/seg
QPS de pico para matching: ~50.000 solicitações de corrida/seg
Cidades: 10.000+ em 72 países
Cálculos de Envelope
Antes de mergulhar na arquitetura, precisamos ter uma noção clara dos volumes que o sistema vai precisar suportar. Esses cálculos "de guardanapo" são fundamentais em entrevistas de system design e ajudam a guiar as escolhas tecnológicas que faremos adiante.
Armazenamento
Registros de corridas:
24M corridas/dia × 2KB por corrida = 48 GB/dia
Anual: 48 GB × 365 = ~17,5 TB/ano
Localizações de motoristas (dados quentes — apenas últimos 30 minutos):
5M motoristas × 1 atualização/4seg × 100 bytes = 500M × 100B = 50 GB
(Mantido em memória/Redis, não persistido a longo prazo)
Histórico de localização (armazenamento frio para analytics):
1,25M atualizações/seg × 100 bytes × 86.400 seg = ~10,8 TB/dia
(Armazenado em formato colunar, comprimido ~5x = ~2 TB/dia)
Dados de mapa:
Grafo da rede viária: ~200 GB (global)
Pontos de interesse: ~50 GB
Largura de Banda
Atualizações de localização dos motoristas (entrada):
1,25M/seg × 100 bytes = 125 MB/seg (gerenciável)
Servir tiles de mapa:
25M passageiros × média de 20 carregamentos de tiles/sessão = 500M tiles/dia
Tile médio: 50 KB → 25 TB/dia (servido via CDN)
Requisição/resposta de corrida:
50K/seg × 1KB = 50 MB/seg
Conexões WebSocket (rastreamento em tempo real):
30M conexões simultâneas × 500 bytes/seg = 15 GB/seg
Esses números podem parecer assustadores à primeira vista. Mas a boa notícia é que, com as tecnologias certas e uma arquitetura bem pensada, cada um desses desafios tem uma solução elegante. Vamos explorar exatamente isso nas próximas seções.
Arquitetura de Alto Nível
graph TB
subgraph "Client Layer"
RA[Rider App<br/>iOS/Android]
DA[Driver App<br/>iOS/Android]
end
subgraph "Edge Layer"
CDN[CDN<br/>Map Tiles, Static Assets]
LB[Load Balancer<br/>L4/L7]
WS[WebSocket Gateway<br/>Real-Time Communication]
end
subgraph "API Layer"
AG[API Gateway<br/>Auth, Rate Limiting]
end
subgraph "Core Services"
RS[Ride Service<br/>Trip Management]
MS[Matching Service<br/>Driver-Rider Matching]
LS[Location Service<br/>GPS Processing]
PS[Pricing Service<br/>Surge & Fare Calc]
ES[ETA Service<br/>Arrival Prediction]
PAY[Payment Service<br/>Billing & Payouts]
NS[Notification Service<br/>Push & SMS]
US[User Service<br/>Profiles & Auth]
DS[Dispatch Service<br/>City-Level Optimization]
end
subgraph "Geospatial Layer"
GI[Geospatial Index<br/>H3 Hexagonal Grid]
RT[Route Engine<br/>OSRM/Valhalla]
MP[Map Service<br/>Mapbox/Google Maps]
end
subgraph "Data & Messaging"
KF[Apache Kafka<br/>Event Streaming]
RD[(Redis Cluster<br/>Location Cache<br/>Geospatial Index)]
PG[(PostgreSQL<br/>Users, Trips)]
CS[(Cassandra<br/>Location History)]
ES2[(Elasticsearch<br/>Search & Analytics)]
S3[(S3<br/>Receipts, Logs)]
end
RA & DA --> CDN
RA & DA --> LB
LB --> AG
LB --> WS
AG --> RS & MS & PS & ES & PAY & US
WS --> LS & NS
DA -->|GPS every 4s| WS
WS --> LS
LS --> RD
LS --> KF
MS --> GI
GI --> RD
MS --> ES
ES --> RT
RS --> KF
KF --> PAY & NS & DS
RS --> PG
LS --> CS
PAY --> PG
style MS fill:#e1f5fe
style GI fill:#fff3e0
style PS fill:#fce4ec
style KF fill:#f3e5f5
Por Que Esta Arquitetura?
-
O WebSocket Gateway é separado do API Gateway REST porque as atualizações de localização dos motoristas são de alta frequência, baixa latência e bidirecionais. REST não dá conta de 1,25M atualizações de localização por segundo.
-
O Índice Geoespacial está em memória (Redis) porque as posições dos motoristas mudam a cada 4 segundos. Consultas geoespaciais baseadas em disco seriam lentas demais.
-
Matching e Pricing são serviços separados porque escalam de forma diferente — matching é CPU-bound (computação geoespacial), pricing é I/O-bound (agregação de dados de mercado).
-
Kafka como backbone de eventos desacopla o caminho de escrita em tempo real (atualizações de localização, eventos de corrida) dos consumidores downstream (analytics, cobrança, notificações).
Pense nessa arquitetura como uma orquestra: cada serviço é um instrumento diferente, tocando sua parte, mas todos sincronizados pelo Kafka que atua como o maestro. Se o violino (serviço de pricing) desafina, os demais instrumentos continuam tocando normalmente. Esse é o poder do desacoplamento.
Design da API
Endpoints REST Principais
// ===== Ride Service API =====
interface RideRequest {
rider_id: string;
pickup: GeoPoint; // { lat: number, lng: number }
dropoff: GeoPoint;
ride_type: 'UberX' | 'Comfort' | 'Black' | 'XL' | 'Pool';
payment_method_id: string;
scheduled_at?: string; // Para corridas agendadas (ISO 8601)
}
interface RideEstimate {
ride_type: string;
fare_estimate: {
min: number; // em centavos
max: number;
currency: string;
surge_multiplier: number;
};
eta_pickup: number; // segundos até o motorista chegar
eta_dropoff: number; // duração total da viagem em segundos
distance_meters: number;
}
interface Ride {
id: string;
status: RideStatus;
rider: UserSummary;
driver?: DriverSummary;
pickup: GeoPoint;
dropoff: GeoPoint;
route?: GeoPoint[]; // polilinha da rota
fare?: FareBreakdown;
created_at: string;
started_at?: string;
completed_at?: string;
}
type RideStatus =
| 'REQUESTING' // Passageiro enviou a solicitação
| 'MATCHING' // Procurando um motorista
| 'DRIVER_ASSIGNED' // Motorista aceitou, a caminho do embarque
| 'ARRIVING' // Motorista a 1 min do embarque
| 'WAITING' // Motorista no ponto de embarque, aguardando passageiro
| 'IN_PROGRESS' // Viagem iniciada
| 'COMPLETED' // Viagem finalizada
| 'CANCELLED'; // Cancelada pelo passageiro ou motorista
// POST /api/v2/rides/estimate → RideEstimate[]
// POST /api/v2/rides → Ride (cria solicitação de corrida)
// GET /api/v2/rides/:id → Ride
// PUT /api/v2/rides/:id/cancel → Ride
// POST /api/v2/rides/:id/rate → void
// ===== Driver API =====
interface DriverStatus {
driver_id: string;
status: 'OFFLINE' | 'AVAILABLE' | 'EN_ROUTE' | 'ON_TRIP';
location: GeoPoint;
heading: number; // graus da bússola
speed: number; // m/s
vehicle: VehicleInfo;
}
// PUT /api/v2/drivers/status → { status: 'AVAILABLE' | 'OFFLINE' }
// POST /api/v2/drivers/location → void (alta frequência, preferir WebSocket)
// ===== Atualizações de Localização (WebSocket) =====
interface LocationUpdate {
driver_id: string;
location: GeoPoint;
heading: number;
speed: number;
accuracy: number; // precisão GPS em metros
timestamp: number; // Unix milissegundos
battery_level?: number;
}
// WebSocket: wss://location.uber.com/v2/stream
// Motorista envia LocationUpdate a cada 4 segundos
// Servidor envia atualizações de corrida, novas solicitações
Um detalhe importante sobre o design dessa API: repare que separamos as estimativas de tarifa (estimate) da criação efetiva da corrida. Isso não é por acaso. Quando o passageiro abre o aplicativo na Avenida Paulista e digita "Aeroporto de Guarulhos", ele quer ver o preço estimado antes de confirmar. Essa separação permite que o front-end faça múltiplas chamadas de estimativa sem criar corridas fantasma no sistema.
Rate Limiting
const rateLimits = {
ride_request: { max: 10, window: '1m', per: 'rider' },
ride_estimate: { max: 60, window: '1m', per: 'rider' },
location_update: { max: 20, window: '1m', per: 'driver' }, // ~1 a cada 3s
driver_status: { max: 10, window: '1m', per: 'driver' },
};
O rate limiting é a primeira linha de defesa contra abusos e também protege o sistema de sobrecarga acidental. Imagine um bug no aplicativo do motorista que envia atualizações de GPS 100 vezes por segundo em vez de uma a cada 4 segundos. Sem rate limiting, um único dispositivo com defeito poderia sobrecarregar o pipeline de localização inteiro.
Modelagem de Dados
Diagrama Entidade-Relacionamento
erDiagram
RIDER {
bigint id PK
varchar name
varchar email
varchar phone
float rating
int trip_count
timestamp created_at
}
DRIVER {
bigint id PK
varchar name
varchar email
varchar phone
float rating
varchar license_number
varchar status "OFFLINE|AVAILABLE|ON_TRIP"
jsonb current_location "lat, lng, heading"
timestamp created_at
}
VEHICLE {
bigint id PK
bigint driver_id FK
varchar make
varchar model
int year
varchar color
varchar license_plate
varchar type "UberX|Comfort|Black|XL"
}
RIDE {
bigint id PK
bigint rider_id FK
bigint driver_id FK
varchar status
point pickup_location
point dropoff_location
varchar pickup_address
varchar dropoff_address
int fare_cents
varchar currency
float surge_multiplier
float distance_meters
int duration_seconds
int rating_by_rider
int rating_by_driver
timestamp requested_at
timestamp matched_at
timestamp started_at
timestamp completed_at
}
PAYMENT {
bigint id PK
bigint ride_id FK
bigint rider_id FK
int amount_cents
varchar currency
varchar status "PENDING|CHARGED|REFUNDED"
varchar payment_method_id
timestamp created_at
}
LOCATION_HISTORY {
bigint driver_id
timestamp recorded_at
double latitude
double longitude
float heading
float speed
float accuracy
}
RIDER ||--o{ RIDE : "solicita"
DRIVER ||--o{ RIDE : "dirige"
DRIVER ||--|| VEHICLE : "possui"
RIDE ||--|| PAYMENT : "gera"
DRIVER ||--o{ LOCATION_HISTORY : "reporta"
Um ponto importante sobre essa modelagem: repare que a tabela RIDE armazena tanto pickup_location (ponto geográfico) quanto pickup_address (endereço textual). Isso é intencional. O ponto geográfico é usado para cálculos de matching e roteamento, enquanto o endereço textual é exibido para o passageiro e o motorista na interface do aplicativo. São preocupações diferentes que coexistem no mesmo registro.
Outro detalhe relevante: os valores monetários são armazenados em centavos (fare_cents, amount_cents). Nunca use float ou double para dinheiro. Parece básico, mas a quantidade de sistemas em produção que fazem isso errado é surpreendente. Um centavo perdido por arredondamento, multiplicado por 24 milhões de corridas diárias, vira um buraco financeiro considerável.
Indexação Geoespacial: A Fundação
Este é o coração da arquitetura do Uber. Tudo depende de responder uma pergunta de forma eficiente: "Quais motoristas disponíveis estão perto desta localização?"
Vamos explorar quatro abordagens diferentes, cada uma com seus prós e contras, para entender por que o Uber chegou à solução que usa hoje.
Abordagem 1: Consulta por Raio (Ingênua)
A abordagem mais simples que vem à mente: fazer uma consulta SQL com filtro geográfico.
-- Encontrar todos os motoristas disponíveis dentro de 3km do embarque
SELECT * FROM drivers
WHERE status = 'AVAILABLE'
AND ST_DWithin(
current_location,
ST_MakePoint(-46.6388, -23.5489), -- Embarque em São Paulo (Av. Paulista)
3000 -- raio de 3km
)
ORDER BY ST_Distance(current_location, ST_MakePoint(-46.6388, -23.5489))
LIMIT 10;
Problema: Com 5 milhões de motoristas ativos, mesmo com um índice espacial do PostGIS, essa consulta é lenta demais a 50K requisições/segundo. O banco de dados se torna o gargalo. Imagine o horário de pico em São Paulo, com milhares de pessoas saindo do trabalho na região da Faria Lima e Paulista, todas pedindo Uber simultaneamente. O PostgreSQL simplesmente não aguenta esse volume de consultas geoespaciais em tempo real.
Abordagem 2: Quadtree
Uma quadtree divide recursivamente o espaço 2D em quatro quadrantes. Cada nó folha contém um número gerenciável de motoristas.
graph TB
R["World<br/>(Root)"]
R --> NW["NW Quadrant"]
R --> NE["NE Quadrant"]
R --> SW["SW Quadrant"]
R --> SE["SE Quadrant"]
NW --> NW1["NW.NW"]
NW --> NW2["NW.NE<br/>Driver A<br/>Driver B"]
NW --> NW3["NW.SW<br/>Driver C"]
NW --> NW4["NW.SE"]
NE --> NE1["NE.NW<br/>Driver D<br/>Driver E<br/>Driver F"]
NE --> NE2["NE.NE"]
NE --> NE3["NE.SW"]
NE --> NE4["NE.SE<br/>Driver G"]
style NW2 fill:#e8f5e9
style NW3 fill:#e8f5e9
style NE1 fill:#e8f5e9
style NE4 fill:#e8f5e9
class QuadTree {
private boundary: BoundingBox;
private drivers: Driver[] = [];
private children: QuadTree[] | null = null;
private readonly MAX_DRIVERS = 50;
insert(driver: Driver): boolean {
if (!this.boundary.contains(driver.location)) return false;
if (this.drivers.length < this.MAX_DRIVERS && !this.children) {
this.drivers.push(driver);
return true;
}
if (!this.children) this.subdivide();
for (const child of this.children!) {
if (child.insert(driver)) return true;
}
return false;
}
queryRange(range: BoundingBox): Driver[] {
const found: Driver[] = [];
if (!this.boundary.intersects(range)) return found;
for (const driver of this.drivers) {
if (range.contains(driver.location)) {
found.push(driver);
}
}
if (this.children) {
for (const child of this.children) {
found.push(...child.queryRange(range));
}
}
return found;
}
Versão adaptada ao limite de caracteres do TabNews. O artigo completo está no blog: https://lemon.dev.br/pt/blog/uber-system-design