Neste guia aprofundado, vamos construir o Uber do zero.
Recursos selecionados para complementar sua leitura
Software Architect
Pós-graduado em arquitetura de software e soluções. Conecto profundidade técnica com resultados de negócio para entregar produtos que as pessoas realmente usam. Também mentoro desenvolvedores e criadores em programas ao vivo, podcasts e iniciativas de comunidade focadas em tecnologia inclusiva.
Checklist de 47 pontos para encontrar bugs, riscos de segurança e problemas de performance antes do lançamento.
Continue explorando tópicos similares

Um guia de arquitetura em nível de produção para sistemas de delivery estilo iFood, cobrindo descoberta de restaurantes, checkout, pagamento, despacho de entregadores, rastreamento em tempo real, previsão de ETA, precificação dinâmica, cancelamentos, reembolsos e confiabilidade em picos de almoço e jantar.

Um guia completo de system design para construir uma plataforma social como o Twitter, lidando com bilhões de tweets, timelines em tempo real e assimetria massiva de leitura/escrita.
Templates testados em produção, usados por desenvolvedores. Economize semanas de setup no seu próximo projeto.
Consultorias modulares para founders e CTOs fracionados. Você recebe diagnóstico acionável e acompanhamento direto comigo.
2 vagas para consultorias no Q2

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.
Lado do Passageiro:
Lado do Motorista:
Plataforma:
| 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 |
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
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.
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
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.
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.
// ===== 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.
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.
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.
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.
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.
Uma quadtree divide recursivamente o espaço 2D em quatro quadrantes. Cada nó folha contém um número gerenciável de motoristas.
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;
}
private subdivide(): void {
const { x, y, width, height } = this.boundary;
const hw = width / 2, hh = height / 2;
this.children = [
new QuadTree({ x, y, width: hw, height: hh }), // NW
new QuadTree({ x: x + hw, y, width: hw, height: hh }), // NE
new QuadTree({ x, y: y + hh, width: hw, height: hh }), // SW
new QuadTree({ x: x + hw, y: y + hh, width: hw, height: hh }), // SE
];
// Re-inserir motoristas existentes nos filhos
for (const driver of this.drivers) {
for (const child of this.children) {
if (child.insert(driver)) break;
}
}
this.drivers = [];
}
}
Limitações da quadtree:
Pense assim: uma quadtree funciona bem para dados estáticos, como pontos de interesse em um mapa (restaurantes, hospitais, postos de gasolina). Mas motoristas de Uber são alvos em movimento constante. Rebalancear uma árvore 1,25 milhão de vezes por segundo é computacionalmente inviável.
Geohash codifica uma coordenada 2D em uma string 1D. Localizações próximas compartilham prefixos comuns.
Centro de São Paulo: -23.5489, -46.6388
Geohash (precisão 7): 6gyf4bf
Localizações próximas compartilham prefixo:
6gyf4bf → edifício específico
6gyf4b → vizinhança (~150m × 150m)
6gyf4 → bairro (~1,2km × 600m)
6gyf → região da cidade (~5km × 5km)
6gy → região metropolitana
Encontrando motoristas próximos:
1. Calcular geohash do ponto de embarque: "6gyf4b"
2. Consulta: SELECT * FROM driver_locations WHERE geohash LIKE '6gyf4b%'
3. Também consultar 8 células vizinhas (tratar casos de borda)
class GeohashIndex {
private driversByCell: Map<string, Set<string>> = new Map();
private readonly PRECISION = 6; // células de ~1,2km × 600m
updateDriverLocation(driverId: string, lat: number, lng: number): void {
// Remover da célula antiga
const oldHash = this.driverLocations.get(driverId);
if (oldHash) {
this.driversByCell.get(oldHash)?.delete(driverId);
}
// Adicionar à nova célula
const newHash = geohash.encode(lat, lng, this.PRECISION);
if (!this.driversByCell.has(newHash)) {
this.driversByCell.set(newHash, new Set());
}
this.driversByCell.get(newHash)!.add(driverId);
this.driverLocations.set(driverId, newHash);
}
findNearbyDrivers(lat: number, lng: number, radiusKm: number): string[] {
const centerHash = geohash.encode(lat, lng, this.PRECISION);
const neighbors = geohash.neighbors(centerHash); // 8 células vizinhas
const candidates: string[] = [];
for (const cell of [centerHash, ...neighbors]) {
const drivers = this.driversByCell.get(cell);
if (drivers) {
candidates.push(...drivers);
}
}
// Filtrar por distância exata
return candidates.filter(driverId => {
const driverLoc = this.getDriverLocation(driverId);
return haversineDistance(lat, lng, driverLoc.lat, driverLoc.lng) <= radiusKm;
});
}
}
O geohash é uma melhoria significativa sobre a quadtree para o nosso caso de uso. Ele funciona muito bem com o Redis (chaves são strings, prefixos permitem buscas eficientes) e a atualização de posição é uma operação O(1): remover da célula antiga, inserir na nova. Mas ainda tem um problema fundamental: as células são retangulares.
O Uber criou o H3, um sistema open-source de grade hexagonal hierárquica. O H3 divide toda a superfície da Terra em hexágonos em múltiplas resoluções.
Por que hexágonos em vez de quadrados (geohash)?
Quadrados (geohash):
- Cada célula tem 8 vizinhos
- Distâncias até centros dos vizinhos variam (diagonal ≠ adjacente)
- Efeitos de borda nos limites das células
Hexágonos (H3):
- Cada célula tem apenas 6 vizinhos
- TODOS os vizinhos são equidistantes do centro
- Melhor aproximação de círculos (área de cobertura)
- Sem distorção diagonal
Para entender por que isso importa na prática: imagine que um passageiro pede uma corrida na esquina da Rua Augusta com a Alameda Santos. Se essa esquina fica exatamente no canto de uma célula geohash quadrada, o motorista mais próximo pode estar em uma célula diagonal, cuja distância ao centro é 41% maior que a distância a um vizinho direto. Com hexágonos, todos os vizinhos estão à mesma distância, eliminando essa distorção.
Na resolução 7, cada hexágono cobre aproximadamente 5.161 m². Para ter uma referência visual: isso é mais ou menos o tamanho de um quarteirão grande no bairro de Pinheiros em São Paulo. Na resolução 9, cada hexágono cobre cerca de 105 m², o equivalente a um cruzamento com a calçada ao redor. Essa granularidade dupla permite que o sistema use resolução 7 para o matching (encontrar motoristas na vizinhança) e resolução 9 para o embarque preciso (guiar o motorista até o ponto exato).
import h3 from 'h3-js';
class H3SpatialIndex {
private readonly MATCHING_RESOLUTION = 7; // ~5.161 m² por hex
private readonly SURGE_RESOLUTION = 7; // Mesma para surge pricing
private readonly PICKUP_RESOLUTION = 9; // ~105 m² para embarque preciso
// Índice de motoristas baseado em Redis
// Chave: h3_index → Valor: Set de driver_ids
async updateDriverLocation(
driverId: string,
lat: number,
lng: number
): Promise<void> {
const newH3Index = h3.latLngToCell(lat, lng, this.MATCHING_RESOLUTION);
// Remover da célula antiga
const oldH3Index = await this.redis.get(`driver:${driverId}:h3`);
if (oldH3Index && oldH3Index !== newH3Index) {
await this.redis.srem(`h3:${oldH3Index}:drivers`, driverId);
}
// Adicionar à nova célula
await this.redis.sadd(`h3:${newH3Index}:drivers`, driverId);
await this.redis.set(`driver:${driverId}:h3`, newH3Index);
// Armazenar localização precisa para cálculo de ETA
await this.redis.geoadd('driver_locations', lng, lat, driverId);
}
async findNearbyDrivers(
lat: number,
lng: number,
ringSize: number = 2 // Buscar em anéis concêntricos
): Promise<NearbyDriver[]> {
const centerH3 = h3.latLngToCell(lat, lng, this.MATCHING_RESOLUTION);
// Obter hexágonos em anéis expandindo (k-ring)
// ringSize=1: centro + 6 vizinhos = 7 hexágonos
// ringSize=2: + 12 mais = 19 hexágonos (~3km de raio)
const hexRing = h3.gridDisk(centerH3, ringSize);
// Buscar motoristas de todos os hexágonos em paralelo
const pipeline = this.redis.pipeline();
for (const hex of hexRing) {
pipeline.smembers(`h3:${hex}:drivers`);
}
const results = await pipeline.exec();
const driverIds = results.flat().filter(Boolean) as string[];
// Obter distâncias precisas usando Redis GEO
const driversWithDistance = await this.redis.georadius(
'driver_locations',
lng, lat,
5, 'km', // raio máximo de 5km
'WITHCOORD', 'WITHDIST',
'COUNT', 20, // Top 20 mais próximos
'ASC' // Ordenados por distância
);
// Filtrar apenas motoristas disponíveis
return this.filterAvailable(driversWithDistance);
}
}
Observe a elegância dessa solução: ela combina dois mecanismos complementares. O H3 atua como um filtro grosso e ultra-rápido (quais motoristas estão nas células próximas?), enquanto o Redis GEO fornece as distâncias precisas entre os candidatos pré-filtrados. Isso transforma uma consulta que poderia varrer 5 milhões de motoristas em uma busca direcionada sobre dezenas ou centenas de candidatos.
| Abordagem | Prós | Contras | Caso de Uso |
|---|---|---|---|
| Consulta por Raio (PostGIS) | Simples, precisa | Lenta em escala, gargalo no BD | Pequena escala (<10K motoristas) |
| Quadtree | Boa para dados estáticos | Rebalanceamento caro | Pontos de interesse |
| Geohash | Simples, amigável com Redis | Células quadradas, problemas de borda | Cache, busca aproximada |
| Hexágonos H3 | Vizinhos equidistantes, hierárquica | Ligeiramente mais complexa | Matching de motoristas (escolha do Uber) |
Quando um passageiro solicita uma corrida, o serviço de matching precisa encontrar o melhor motorista disponível. "Melhor" considera múltiplos fatores: distância, ETA, avaliação do motorista, compatibilidade do tipo de veículo e equilíbrio de oferta e demanda.
Este é o problema mais fascinante da arquitetura do Uber. Parece simples na superfície ("pegue o motorista mais perto"), mas a realidade é muito mais complexa. O motorista mais próximo em linha reta pode estar do outro lado de um rio, de um morro, ou em uma via expressa sem retorno. E mesmo que esteja perto geograficamente, pode ter uma avaliação baixa ou um histórico de cancelamentos.
A função de pontuação (scoring) é onde a mágica acontece. Cada candidato recebe uma nota entre 0 e 1, ponderando múltiplos fatores. Os pesos desses fatores foram calibrados ao longo de anos com base em dados reais de milhões de corridas.
interface MatchCandidate {
driverId: string;
distanceMeters: number;
etaSeconds: number;
driverRating: number;
vehicleType: string;
acceptanceRate: number; // Probabilidade histórica de aceitação
completionRate: number; // Taxa de conclusão de corridas
currentStreak: number; // Corridas consecutivas completadas
}
function calculateMatchScore(
candidate: MatchCandidate,
rideRequest: RideRequest
): number {
const weights = {
eta: 0.35, // Minimizar tempo de espera
distance: 0.20, // Preferir motoristas mais próximos
rating: 0.15, // Motoristas com avaliação mais alta são preferidos
acceptance: 0.15, // Motoristas com maior probabilidade de aceitar
completion: 0.10, // Motoristas que completam corridas
fairness: 0.05, // Distribuir corridas de forma justa
};
// Normalizar cada fator para escala 0-1
const etaScore = 1 - Math.min(candidate.etaSeconds / 600, 1); // faixa de 0-10 min
const distanceScore = 1 - Math.min(candidate.distanceMeters / 5000, 1); // 0-5km
const ratingScore = (candidate.driverRating - 4.0) / 1.0; // 4.0-5.0 → 0-1
const acceptanceScore = candidate.acceptanceRate;
const completionScore = candidate.completionRate;
// Justiça: preferir motoristas que esperaram mais por uma corrida
const fairnessScore = Math.min(candidate.currentStreak / 5, 1);
return (
weights.eta * etaScore +
weights.distance * distanceScore +
weights.rating * ratingScore +
weights.acceptance * acceptanceScore +
weights.completion * completionScore +
weights.fairness * (1 - fairnessScore) // Inverter: recompensar menos corridas recentes
);
}
Um detalhe que vale destaque: o componente de justiça (fairness) no score. Sem ele, motoristas em áreas movimentadas como o centro de São Paulo receberiam todas as corridas, enquanto motoristas em bairros mais afastados ficariam ociosos. O score de justiça garante que, entre dois motoristas igualmente qualificados, aquele que está há mais tempo sem corrida tenha prioridade. Isso não é só ético; é economicamente racional. Motoristas que ficam muito tempo sem corrida acabam desligando o aplicativo, reduzindo a oferta global.
// Orquestrador de matching
class MatchingService {
async findMatch(rideRequest: RideRequest): Promise<MatchResult> {
// Busca em anéis expandindo: começar perto, expandir se não encontrar
for (let ring = 1; ring <= 5; ring++) {
const candidates = await this.spatialIndex.findNearbyDrivers(
rideRequest.pickup.lat,
rideRequest.pickup.lng,
ring
);
// Filtrar por compatibilidade de tipo de veículo
const compatible = candidates.filter(d =>
this.isCompatible(d.vehicleType, rideRequest.ride_type)
);
if (compatible.length === 0) continue;
// Calcular ETAs em paralelo
const withETA = await Promise.all(
compatible.map(async (driver) => ({
...driver,
etaSeconds: await this.etaService.calculate(
driver.location,
rideRequest.pickup
)
}))
);
// Pontuar e ordenar candidatos
const scored = withETA
.map(c => ({ ...c, score: calculateMatchScore(c, rideRequest) }))
.sort((a, b) => b.score - a.score);
// Oferecer ao melhor candidato
const match = await this.offerRide(scored[0], rideRequest);
if (match) return match;
// Se recusado, tentar próximos candidatos
for (let i = 1; i < Math.min(scored.length, 3); i++) {
const fallback = await this.offerRide(scored[i], rideRequest);
if (fallback) return fallback;
}
}
throw new NoDriversAvailableError('Nenhum motorista encontrado nas proximidades');
}
private async offerRide(
driver: ScoredCandidate,
ride: RideRequest
): Promise<MatchResult | null> {
// Enviar oferta de corrida via WebSocket
const response = await this.websocket.sendWithTimeout(
driver.driverId,
{ type: 'RIDE_OFFER', ride, eta: driver.etaSeconds },
15_000 // timeout de 15 segundos
);
if (response?.accepted) {
return {
driverId: driver.driverId,
rideId: ride.id,
estimatedPickupTime: driver.etaSeconds
};
}
return null;
}
}
Repare na estratégia de anéis expandindo (expanding rings). O sistema começa buscando motoristas no anel mais próximo (ring=1, cerca de 1km de raio). Se não encontrar nenhum compatível, expande para o anel seguinte (ring=2, cerca de 3km), e assim por diante até ring=5. Essa abordagem é elegante porque, em áreas densas como o centro de São Paulo, geralmente encontra um motorista logo no primeiro anel, economizando processamento. Já em áreas mais afastadas, como a zona leste, pode precisar expandir mais, mas ainda assim evita varrer toda a cidade.
O rastreamento de localização é o fluxo de dados mais intenso de todo o sistema. São 1,25 milhão de atualizações por segundo, 24 horas por dia, 7 dias por semana. Esse é o tipo de problema que separa sistemas amadores de sistemas de escala planetária.
Os dados brutos do GPS são surpreendentemente barulhentos. Em áreas urbanas densas como a Avenida Paulista, com seus arranha-céus de ambos os lados, o sinal GPS pode saltar dezenas de metros entre leituras consecutivas. O processamento adequado desses dados é crucial para uma experiência de usuário suave.
class LocationProcessor {
// Filtro de Kalman para suavização do GPS
private kalmanFilters: Map<string, KalmanFilter> = new Map();
async processLocationUpdate(update: LocationUpdate): Promise<void> {
// Passo 1: Validar dados GPS
if (!this.isValidGPS(update)) {
this.metrics.increment('invalid_gps_update');
return;
}
// Passo 2: Verificação anti-spoofing
if (await this.isGPSSpoofed(update)) {
await this.flagDriver(update.driver_id, 'GPS_SPOOFING');
return;
}
// Passo 3: Aplicar filtro de Kalman para suavização
const smoothed = this.applyKalmanFilter(update);
// Passo 4: Ajustar à via mais próxima (snap to road)
const snapped = await this.snapToRoad(smoothed);
// Passo 5: Atualizar índice geoespacial em memória
await this.h3Index.updateDriverLocation(
update.driver_id,
snapped.lat,
snapped.lng
);
// Passo 6: Enviar push ao passageiro (se motorista está em corrida ativa)
const activeRide = await this.getActiveRide(update.driver_id);
if (activeRide) {
await this.pushToRider(activeRide.rider_id, {
type: 'DRIVER_LOCATION',
location: snapped,
heading: update.heading,
eta_seconds: await this.recalculateETA(snapped, activeRide)
});
}
// Passo 7: Armazenar para analytics (assíncrono)
await this.kafka.publish('location-history', {
driver_id: update.driver_id,
location: snapped,
timestamp: update.timestamp,
accuracy: update.accuracy
});
}
private isValidGPS(update: LocationUpdate): boolean {
// Verificar limites (latitude: -90 a 90, longitude: -180 a 180)
if (Math.abs(update.location.lat) > 90 || Math.abs(update.location.lng) > 180) {
return false;
}
// Verificar precisão (rejeitar se precisão GPS > 100m)
if (update.accuracy > 100) return false;
// Verificar velocidade (rejeitar se > 200 km/h — provavelmente erro GPS)
if (update.speed > 55.56) return false; // 200 km/h em m/s
return true;
}
private async isGPSSpoofed(update: LocationUpdate): Promise<boolean> {
const lastLocation = await this.getLastLocation(update.driver_id);
if (!lastLocation) return false;
const timeDelta = (update.timestamp - lastLocation.timestamp) / 1000;
const distance = haversineDistance(
lastLocation.lat, lastLocation.lng,
update.location.lat, update.location.lng
);
// Verificar se o movimento é fisicamente possível
// Velocidade máxima razoável: 150 km/h = 41,67 m/s
const maxPossibleDistance = timeDelta * 41.67;
return distance > maxPossibleDistance * 1.5; // margem de 50%
}
}
Vamos falar sobre o snap to road (ajuste à via). O GPS diz que o motorista está em um ponto no meio do quarteirão. Mas sabemos que carros andam em ruas, não por dentro de prédios. O snap to road pega a coordenada bruta e a "gruda" na via mais próxima do grafo rodoviário. Isso é especialmente importante em São Paulo, onde ruas paralelas como a Rebouças e a Heitor Penteado estão separadas por apenas 50 metros, e o GPS pode facilmente confundir uma com a outra.
A verificação anti-spoofing é outra peça crítica. Fraudes com GPS falso são um problema real: motoristas que usam aplicativos de GPS falso para simular que estão em áreas de surge pricing ou para completar corridas fantasma. A verificação é simples mas eficaz: se a distância entre duas leituras consecutivas implica uma velocidade impossível (digamos, teletransportar-se 5km em 4 segundos), o sistema sinaliza o motorista para investigação.
O Uber divide cada cidade em zonas hexagonais (usando H3 na resolução 7). Para cada zona, monitora continuamente a proporção entre solicitações de corrida e motoristas disponíveis.
Pense na Virada Cultural de São Paulo: milhares de pessoas no centro da cidade à meia-noite, todas querendo Uber ao mesmo tempo. O número de motoristas na região é o mesmo de qualquer terça-feira normal. Sem precificação dinâmica, o resultado seria simples: ninguém consegue Uber. Com surge pricing, três coisas acontecem simultaneamente: o preço mais alto faz alguns passageiros reconsiderarem (reduzindo a demanda), motoristas de regiões próximas são atraídos pelo preço maior (aumentando a oferta), e o mercado se reequilibra naturalmente.
class SurgePricingService {
private readonly H3_RESOLUTION = 7;
private readonly UPDATE_INTERVAL_MS = 60_000; // Recalcular a cada minuto
private readonly MIN_SURGE = 1.0;
private readonly MAX_SURGE = 5.0;
async calculateSurge(h3Cell: string): Promise<number> {
// Obter oferta e demanda atuais
const demand = await this.getDemandCount(h3Cell); // Solicitações nos últimos 5 min
const supply = await this.getSupplyCount(h3Cell); // Motoristas disponíveis na célula
if (supply === 0) return this.MAX_SURGE;
if (demand === 0) return this.MIN_SURGE;
// Surge base da proporção oferta/demanda
const ratio = demand / supply;
// Curva de surge (linear por partes)
let surge: number;
if (ratio <= 1.0) {
surge = 1.0; // Sem surge: oferta >= demanda
} else if (ratio <= 2.0) {
surge = 1.0 + (ratio - 1.0) * 0.5; // 1.0x → 1.5x
} else if (ratio <= 3.0) {
surge = 1.5 + (ratio - 2.0) * 1.0; // 1.5x → 2.5x
} else if (ratio <= 5.0) {
surge = 2.5 + (ratio - 3.0) * 0.75; // 2.5x → 4.0x
} else {
surge = Math.min(4.0 + (ratio - 5.0) * 0.2, this.MAX_SURGE);
}
// Aplicar suavização (não mudar o surge de forma abrupta)
const previousSurge = await this.getPreviousSurge(h3Cell);
const smoothedSurge = previousSurge * 0.7 + surge * 0.3;
// Arredondar para o 0.1 mais próximo
return Math.round(Math.max(this.MIN_SURGE, Math.min(this.MAX_SURGE, smoothedSurge)) * 10) / 10;
}
async calculateFare(ride: RideRequest): Promise<FareEstimate> {
const pickupCell = h3.latLngToCell(
ride.pickup.lat, ride.pickup.lng, this.H3_RESOLUTION
);
const surgeMultiplier = await this.calculateSurge(pickupCell);
const route = await this.routeService.getRoute(ride.pickup, ride.dropoff);
const baseFare = 250; // R$2,50 base
const perKm = 120; // R$1,20 por km
const perMinute = 30; // R$0,30 por min
const bookingFee = 200; // R$2,00 taxa de agendamento
const distanceFare = (route.distanceMeters / 1000) * perKm;
const timeFare = (route.durationSeconds / 60) * perMinute;
const subtotal = baseFare + distanceFare + timeFare;
const surgedFare = Math.round(subtotal * surgeMultiplier);
const total = surgedFare + bookingFee;
return {
baseFare,
distanceFare: Math.round(distanceFare),
timeFare: Math.round(timeFare),
surgeMultiplier,
bookingFee,
total,
currency: 'BRL',
estimatedDuration: route.durationSeconds,
estimatedDistance: route.distanceMeters
};
}
}
Observe a suavização (smoothing) na linha previousSurge * 0.7 + surge * 0.3. Sem essa suavização, o surge oscilaria violentamente a cada minuto. Imagine o surge indo de 1.0x para 3.0x e voltando para 1.2x em três minutos consecutivos. Isso criaria uma experiência terrível para o passageiro ("o preço triplicou, espera, agora voltou, espera, subiu de novo"). A suavização faz com que o surge suba e desça gradualmente, dando tempo para o mercado reagir.
Outro detalhe importante: a curva de surge não é linear. Ela é linear por partes (piecewise linear), subindo mais rápido nas faixas iniciais (de 1.0x a 2.5x) e mais devagar nas faixas altas (de 2.5x a 5.0x). A lógica é econômica: um aumento de 1.0x para 1.5x já afasta passageiros com viagens opcionais e atrai motoristas adicionais. Mas ir de 3.0x para 5.0x já é território de emergência, e mudanças drásticas nessa faixa causam mais irritação do que benefício real ao equilíbrio do mercado.
A máquina de estados é um dos padrões de design mais importantes nesta arquitetura. Ela garante que uma corrida só pode estar em um estado válido e só pode transicionar para estados permitidos. Isso previne bugs como uma corrida que está "COMPLETED" e de repente volta para "IN_PROGRESS", ou um pagamento que é cobrado duas vezes porque o sistema não sabia em que estado a corrida estava.
class RideStateMachine {
private readonly transitions: Record<RideStatus, RideStatus[]> = {
REQUESTING: ['MATCHING', 'CANCELLED'],
MATCHING: ['DRIVER_ASSIGNED', 'NO_DRIVERS', 'CANCELLED'],
NO_DRIVERS: ['MATCHING', 'CANCELLED'],
DRIVER_ASSIGNED: ['ARRIVING', 'CANCELLED'],
ARRIVING: ['WAITING', 'CANCELLED'],
WAITING: ['IN_PROGRESS', 'NO_SHOW'],
NO_SHOW: ['COMPLETED'],
IN_PROGRESS: ['COMPLETED'],
COMPLETED: [],
CANCELLED: [],
};
async transition(rideId: string, newStatus: RideStatus): Promise<Ride> {
const ride = await this.rideStore.get(rideId);
const allowedTransitions = this.transitions[ride.status];
if (!allowedTransitions.includes(newStatus)) {
throw new InvalidTransitionError(
`Não é possível transicionar de ${ride.status} para ${newStatus}`
);
}
// Atualizar status
ride.status = newStatus;
ride[`${newStatus.toLowerCase()}_at`] = new Date();
// Persistir
await this.rideStore.update(ride);
// Publicar evento para consumidores downstream
await this.kafka.publish('ride-events', {
type: `RIDE_${newStatus}`,
rideId,
ride,
timestamp: Date.now()
});
// Disparar efeitos colaterais
await this.handleSideEffects(ride, newStatus);
return ride;
}
private async handleSideEffects(ride: Ride, newStatus: RideStatus): Promise<void> {
switch (newStatus) {
case 'DRIVER_ASSIGNED':
// Notificar passageiro: motorista está a caminho
await this.notifyRider(ride.rider_id, {
type: 'DRIVER_ASSIGNED',
driver: await this.getDriverInfo(ride.driver_id!),
eta: ride.estimated_pickup_time
});
// Marcar motorista como indisponível no índice espacial
await this.spatialIndex.setDriverUnavailable(ride.driver_id!);
break;
case 'COMPLETED':
// Calcular tarifa final
const fare = await this.fareCalculator.calculateFinal(ride);
await this.rideStore.updateFare(ride.id, fare);
// Processar pagamento
await this.paymentService.charge(ride.rider_id, fare);
// Pagar motorista
await this.paymentService.creditDriver(ride.driver_id!, fare);
// Tornar motorista disponível novamente
await this.spatialIndex.setDriverAvailable(ride.driver_id!);
break;
case 'CANCELLED':
// Verificar se taxa de cancelamento se aplica
if (this.shouldChargeCancellationFee(ride)) {
await this.paymentService.chargeCancellation(ride.rider_id, 500); // R$5,00
}
// Tornar motorista disponível
if (ride.driver_id) {
await this.spatialIndex.setDriverAvailable(ride.driver_id);
}
break;
}
}
}
Cada transição de estado dispara efeitos colaterais (side effects) específicos. Quando a corrida muda para DRIVER_ASSIGNED, o motorista é removido do índice espacial para que ninguém mais tente fazer match com ele. Quando a corrida muda para COMPLETED, o pagamento é processado e o motorista volta ao pool de disponíveis. Quando há um cancelamento, verificamos se a taxa se aplica (geralmente, se o motorista já estava a caminho e o passageiro cancelou depois de um certo tempo).
Esse padrão de publicar eventos via Kafka a cada transição é poderoso porque desacopla a lógica de negócio dos consumidores. O serviço de analytics, por exemplo, não precisa saber nada sobre a máquina de estados. Ele simplesmente consome os eventos do tópico ride-events e atualiza seus dashboards. Se o time de marketing quiser enviar um e-mail após cada corrida completada, basta criar um novo consumidor Kafka sem alterar nenhum código existente.
A predição de ETA é um dos problemas mais desafiadores tecnicamente, e é também um dos que mais impacta a experiência do usuário. Quando o aplicativo diz "seu motorista chega em 3 minutos" e o motorista chega em 3 minutos, isso gera confiança. Quando diz 3 minutos e demora 12, o passageiro fica frustrado, mesmo que a corrida em si seja excelente.
A complexidade está nos fatores que afetam o tempo de chegada: trânsito em tempo real (a Marginal Tietê pode estar parada), semáforos, obras, condições climáticas (São Paulo no horário de chuva de verão é outro cenário de tráfego completamente diferente), e até eventos locais (jogo do Corinthians na Arena).
class ETAService {
async calculateETA(
origin: GeoPoint,
destination: GeoPoint
): Promise<ETAResult> {
// Passo 1: Obter rota do motor de roteamento (OSRM/Valhalla)
const route = await this.routeEngine.getRoute(origin, destination);
// Passo 2: ETA base do motor de roteamento (assume tráfego livre)
const baseDuration = route.duration_seconds;
// Passo 3: Aplicar ajuste de tráfego em tempo real
const trafficMultiplier = await this.getTrafficMultiplier(route.segments);
// Passo 4: Aplicar correção ML baseada em padrões históricos
const mlCorrection = await this.mlModel.predict({
origin,
destination,
baseETA: baseDuration,
dayOfWeek: new Date().getDay(),
hourOfDay: new Date().getHours(),
weather: await this.weatherService.getCurrent(origin),
isHoliday: this.isHoliday()
});
const adjustedETA = Math.round(
baseDuration * trafficMultiplier * mlCorrection.factor
);
return {
eta_seconds: adjustedETA,
distance_meters: route.distance_meters,
confidence: mlCorrection.confidence,
route_polyline: route.polyline
};
}
private async getTrafficMultiplier(
segments: RouteSegment[]
): Promise<number> {
let totalWeight = 0;
let totalMultiplier = 0;
for (const segment of segments) {
const h3Cell = h3.latLngToCell(
segment.midpoint.lat,
segment.midpoint.lng,
8 // Alta resolução para tráfego
);
// Dados de velocidade em tempo real de motoristas ativos na área
const avgSpeed = await this.redis.get(`traffic:${h3Cell}:speed`);
const freeFlowSpeed = segment.speed_limit;
const multiplier = avgSpeed ? freeFlowSpeed / Number(avgSpeed) : 1.0;
totalMultiplier += multiplier * segment.distance;
totalWeight += segment.distance;
}
return totalWeight > 0 ? totalMultiplier / totalWeight : 1.0;
}
}
O aspecto mais elegante desse sistema é que o Uber usa seus próprios motoristas como sensores de tráfego. Quando milhares de motoristas estão dirigindo por São Paulo, cada um reportando sua velocidade a cada 4 segundos, o sistema constrói um mapa de tráfego em tempo real muito mais preciso do que qualquer sensor fixo de estrada poderia fornecer. Se os motoristas na Marginal Pinheiros estão se movendo a 15 km/h em vez dos 70 km/h do limite, o sistema sabe que há congestionamento e ajusta os ETAs de todas as rotas que passam por ali.
O modelo de ML adiciona uma camada de inteligência que captura padrões que o tráfego em tempo real sozinho não consegue prever. Por exemplo: são 16h55 de uma sexta-feira, e o modelo sabe que historicamente, às 17h de sexta-feira, a Ponte das Bandeiras fica completamente parada. Mesmo que agora o tráfego ainda esteja razoável, o modelo já ajusta o ETA para cima, antecipando o congestionamento que começará nos próximos minutos.
O sistema de pagamentos é onde a consistência importa mais do que em qualquer outro lugar da arquitetura. Enquanto o matching pode tolerar inconsistência eventual (se dois passageiros brevemente virem o mesmo motorista como disponível, o pior que acontece é que um deles recebe um "motorista não disponível" e precisa esperar alguns segundos por outro), com dinheiro não existe margem para erro. Um pagamento cobrado duas vezes ou não cobrado significa perda financeira real.
O padrão de pré-autorização → captura é fundamental. Quando a corrida começa, o sistema não cobra o passageiro. Em vez disso, faz uma retenção (hold) no cartão, reservando o valor estimado. Quando a corrida termina e o valor final é calculado, o sistema captura o valor real (que pode ser menor ou maior que a estimativa). Isso é importante por dois motivos:
Proteção do passageiro: Se a corrida for cancelada, o valor retido é automaticamente liberado. Não há necessidade de estorno, que pode levar dias para aparecer no extrato.
Proteção do Uber: A retenção garante que o passageiro tem saldo/crédito disponível antes de iniciar a corrida. Sem ela, o motorista poderia completar uma corrida de R$100 e descobrir no final que o cartão do passageiro foi recusado.
O Ledger Service (serviço de contabilidade) é a fonte da verdade para todos os valores financeiros do sistema. Ele opera com contabilidade de dupla entrada: cada transação tem um débito e um crédito correspondente. A soma de todos os débitos deve ser sempre igual à soma de todos os créditos. Qualquer discrepância é detectada imediatamente e dispara um alerta.
No Brasil, o sistema de pagamentos tem uma complexidade adicional: o Pix. Muitos passageiros preferem pagar via Pix, o que exige uma integração diferente dos cartões de crédito tradicionais. O Pix é uma transação instantânea e irreversível, o que muda o fluxo de pré-autorização. Em vez de reter um valor no cartão, o sistema pode solicitar um Pix pré-corrida ou processar o pagamento imediatamente após a conclusão da viagem.
O sistema de despacho opera em um nível acima do matching individual. Enquanto o matching resolve o problema "qual motorista é melhor para esta corrida", o despacho resolve o problema "como otimizar o matching em toda a cidade para minimizar tempos de espera globais".
Pense da seguinte forma: se três passageiros pedem Uber simultaneamente na Vila Madalena, e há três motoristas disponíveis sendo um na Vila Madalena, um em Pinheiros e um no Butantã, o matching ingênuo daria o motorista da Vila Madalena para o primeiro passageiro que pediu. Mas o despacho inteligente analisa as três solicitações juntas e pode perceber que, considerando as direções de viagem dos motoristas e os pontos de embarque exatos, a distribuição ótima é diferente.
Esse é essencialmente um problema de assignment (atribuição) em pesquisa operacional, resolvido em tempo real para milhares de variáveis simultaneamente. O Uber usa variações do algoritmo húngaro e programação linear para otimizar esses batches de corrida a cada poucos segundos em cada cidade.
Na prática, cidades grandes como São Paulo são subdivididas em regiões de despacho. Cada região opera de forma semi-independente, com um coordenador que gerencia corridas que cruzam fronteiras entre regiões. Isso permite que o sistema escale horizontalmente: adicionar mais capacidade computacional para uma região movimentada sem afetar as demais.
Cada tipo de dado tem características diferentes que exigem tecnologias diferentes. Usar um único banco de dados para tudo seria o equivalente a usar um canivete suíço para construir uma casa. Pode até funcionar para tarefas pequenas, mas não escala.
Vamos entender a lógica por trás de cada escolha:
PostgreSQL para dados transacionais (usuários, corridas, pagamentos): esses dados exigem garantias ACID. Quando um pagamento é processado, ele precisa ser atômico (tudo ou nada), consistente (respeitar regras de negócio), isolado (transações concorrentes não interferem) e durável (uma vez confirmado, está confirmado). Nenhum banco NoSQL oferece essas garantias com a mesma robustez.
Redis para dados em tempo real (localizações, cache de corridas ativas, surge pricing): esses dados mudam constantemente e precisam ser acessados em sub-milissegundos. Redis opera inteiramente em memória, oferecendo latências de microssegundos. A desvantagem (persistência limitada) não é um problema porque esses dados são efêmeros por natureza.
Cassandra para dados de séries temporais (histórico de localização, eventos de analytics): o Cassandra foi projetado para cenários de escrita massiva. Com 1,25 milhão de atualizações de localização por segundo, precisamos de um banco que absorva esse volume sem engasgar. O Cassandra faz isso distribuindo as escritas linearmente entre os nós do cluster.
Kafka como barramento de eventos: conecta todos os serviços de forma assíncrona. Quando um motorista atualiza sua localização, esse evento precisa chegar a múltiplos consumidores (índice geoespacial, cache, analytics, ETA). Kafka garante que cada evento é entregue a todos os consumidores, na ordem correta, sem perda.
// Corridas: Sharding por city_id
// Justificativa: Corridas são geograficamente locais; consultas são por cidade
class RideShardRouter {
getShard(cityId: string): string {
// Cada cidade ou grupo de cidades tem seu próprio shard
// Cidades grandes (São Paulo, Rio, Belo Horizonte) recebem shards dedicados
// Cidades menores são agrupadas por região
const shardMap: Record<string, string> = {
'sao_paulo': 'shard-sa-east-1',
'rio_de_janeiro': 'shard-sa-east-2',
'belo_horizonte': 'shard-sa-east-3',
'new_york': 'shard-us-east-1',
'london': 'shard-eu-west-1',
'mumbai': 'shard-ap-south-1',
// ... 10.000+ cidades mapeadas para ~50 shards
};
return shardMap[cityId] || this.hashToShard(cityId);
}
}
// Usuários: Sharding por user_id (hash consistente)
// Justificativa: Consultas de usuários são por ID, não por geografia
class UserShardRouter {
private readonly VIRTUAL_NODES = 150;
private ring: ConsistentHashRing;
getShard(userId: string): string {
return this.ring.getNode(userId);
}
}
A decisão de shard por cidade para corridas e por user_id para usuários reflete um insight importante: padrões de acesso a dados determinam a estratégia de sharding. Corridas são consultadas por contexto geográfico ("mostrar todas as corridas ativas em São Paulo"), então faz sentido que todas as corridas de São Paulo estejam no mesmo shard. Já os dados de usuário são consultados por ID ("buscar o perfil do usuário X"), então o sharding por hash do ID distribui a carga uniformemente entre os shards.
O hash consistente para usuários é particularmente importante para escalabilidade. Quando adicionamos ou removemos um shard, apenas uma fração dos dados precisa ser redistribuída, em vez de tudo. Os 150 nós virtuais garantem uma distribuição uniforme mesmo com poucos shards físicos.
O cache é o que transforma um sistema "funcional" em um sistema "rápido". Sem cache, cada consulta iria ao banco de dados. Com cache, a maioria das consultas é respondida em memória, reduzindo a latência de milissegundos para microssegundos.
const cacheStrategy = {
// Localizações dos motoristas: Redis GEO + sets H3
driverLocations: {
store: 'Redis',
ttl: 30, // 30 segundos (obsoleto após ~7 atualizações)
eviction: 'TTL', // Expirar automaticamente localizações obsoletas
size: '~50GB', // 5M motoristas × 10KB
},
// Corridas ativas: Redis Hash
activeRides: {
store: 'Redis',
ttl: 0, // Sem expiração (removido explicitamente ao completar)
size: '~14GB', // 14M corridas simultâneas × 1KB
},
// Surge pricing: Redis Hash por célula H3
surgeMultipliers: {
store: 'Redis',
ttl: 60, // Recalculado a cada minuto
size: '~500MB',
},
// Cache de rotas: Redis com LRU
routeCache: {
store: 'Redis',
ttl: 300, // 5 minutos (tráfego muda)
maxSize: '10GB',
eviction: 'LRU',
keyPattern: 'route:{origin_h3}:{dest_h3}',
},
// Perfis de usuário: Redis + cache local
userProfiles: {
store: 'Redis + Caffeine (L1)',
ttl: 3600, // 1 hora
l1TTL: 60, // 1 minuto cache local
},
};
Repare que cada tipo de dado tem uma estratégia de TTL (Time To Live) diferente, alinhada com a frequência de mudança:
Localizações de motoristas: TTL de 30 segundos. Se um motorista não envia uma atualização em 30 segundos, algo está errado (talvez o aplicativo fechou ou a conexão caiu), e a localização obsoleta deve ser descartada para não mostrar motoristas "fantasma" no mapa.
Corridas ativas: sem TTL. Uma corrida ativa é explicitamente criada quando começa e removida quando termina. Não queremos que uma corrida "expire" do cache no meio do trajeto.
Surge pricing: TTL de 60 segundos, sincronizado com o intervalo de recálculo. Cada minuto, os valores são recalculados e o cache é atualizado.
Cache de rotas: TTL de 5 minutos. A rota entre dois pontos não muda, mas as condições de tráfego ao longo dessa rota mudam. Cinco minutos é um bom compromisso entre frescor dos dados e economia de chamadas ao motor de roteamento.
O cache de dois níveis (L1 + L2) para perfis de usuário é uma otimização que vale destacar. O L1 (Caffeine, cache local na JVM) responde em nanosegundos e evita até mesmo a ida ao Redis. O L2 (Redis) funciona como backup quando o L1 expira ou quando outro pod do serviço precisa do dado. Isso é especialmente útil para dados que são lidos frequentemente mas mudam raramente, como perfis de usuário.
O Uber usa uma arquitetura baseada em células (cell-based architecture) onde cada cidade (ou grupo de cidades) é uma "célula" independente que pode falhar sem afetar outras cidades.
Cenário de falha: shard de São Paulo fica indisponível
Impacto com arquitetura baseada em células:
OK - Nova York: não afetada
OK - Londres: não afetada
OK - Mumbai: não afetada
FALHA - São Paulo: degradado (leitura da réplica, sem novas corridas)
ALERTA - Campinas: pode ser afetada se estiver na mesma célula
Sem arquitetura baseada em células:
FALHA - TODAS as cidades afetadas por falha em cascata
Esse conceito é uma das lições mais importantes de engenharia de sistemas distribuídos. Quando você tem um sistema monolítico global, um bug em uma query do PostgreSQL pode derrubar o serviço para 130 milhões de usuários. Com isolamento por célula, esse mesmo bug afetaria apenas os usuários de uma região.
Na prática, isso significa que cada célula tem seus próprios bancos de dados, caches, filas de mensagens e instâncias de serviço. Elas compartilham apenas serviços verdadeiramente globais, como autenticação de usuário e o gateway de pagamento. Mesmo esses serviços globais são projetados com circuit breakers para que uma falha no gateway de pagamento não impeça o matching de corridas (a cobrança pode ser feita depois).
Uma das características que separa sistemas robustos de sistemas frágeis é a capacidade de degradar graciosamente em vez de falhar catastroficamente. O Uber define níveis explícitos de degradação:
Nível 0 (Serviço Completo):
OK - Matching em tempo real
OK - Surge pricing
OK - Predição de ETA (baseada em ML)
OK - Otimização de rotas
OK - Analytics completo
Nível 1 (Degradação Menor):
OK - Matching em tempo real
OK - Surge pricing (valores em cache)
ALERTA - Predição de ETA (fallback apenas para motor de roteamento)
OK - Otimização de rotas
FALHA - Analytics com atraso
Nível 2 (Degradação Significativa):
OK - Matching em tempo real (apenas motorista mais próximo, sem scoring)
FALHA - Surge pricing → tarifa fixa
ALERTA - ETA → estimativa baseada em distância
FALHA - Otimização de rotas desabilitada
FALHA - Analytics offline
Nível 3 (Emergência — manter corridas funcionando):
OK - Matching básico (motorista disponível mais próximo)
FALHA - Sem surge, sem ETA, sem otimização
OK - Corridas ativas continuam (rastreamento de localização funciona)
OK - Pagamentos (infraestrutura separada)
O princípio fundamental é: em caso de falha, priorizar a funcionalidade essencial. As pessoas precisam chegar em casa. Se o modelo de ML de ETA falhar, os passageiros ainda podem pegar Uber, apenas a estimativa de tempo será menos precisa. Se o surge pricing falhar, cobrar tarifa normal. Se até o scoring de matching falhar, conectar ao motorista mais próximo. Cada nível de degradação remove funcionalidades de luxo para preservar as essenciais.
Essa abordagem requer disciplina de engenharia. Cada feature precisa ser projetada com um fallback claro. Os times usam feature flags para controlar os níveis de degradação, permitindo que operadores desabilitem funcionalidades não essenciais em segundos quando detectam problemas.
"Você não pode melhorar o que não pode medir." Essa frase, atribuída a Peter Drucker, é especialmente verdadeira para sistemas distribuídos. Em um sistema com centenas de microsserviços, a observabilidade é o que permite que os engenheiros entendam o que está acontecendo, detectem problemas antes que os usuários percebam, e diagnostiquem incidentes rapidamente.
interface UberSLIs {
// Matching
match_success_rate: number; // Alvo: > 95%
match_latency_p99: number; // Alvo: < 30s
average_pickup_time: number; // Alvo: < 5 min
driver_utilization: number; // Alvo: 60-70%
// Localização
location_update_latency_p99: number; // Alvo: < 100ms
location_staleness: number; // Alvo: < 10s
gps_accuracy_p50: number; // Alvo: < 10m
// Negócio
rides_per_hour: number;
surge_areas_percent: number; // Alvo: < 20%
cancellation_rate: number; // Alvo: < 5%
eta_accuracy: number; // Alvo: ±2 min
// Infraestrutura
kafka_consumer_lag: number; // Alvo: < 5000
redis_memory_usage: number; // Alvo: < 80%
websocket_connections: number;
}
Algumas dessas métricas merecem explicação mais detalhada:
match_success_rate > 95%: significa que, de cada 100 solicitações de corrida, pelo menos 95 resultam em um motorista atribuído. Os 5% restantes são casos onde não há motoristas disponíveis na região (em geral áreas muito remotas ou horários de demanda extrema). Se essa métrica cai abaixo de 95%, pode indicar problemas no matching, falta de motoristas, ou bugs no sistema.
driver_utilization 60-70%: esse é um equilíbrio delicado. Se a utilização for muito alta (>80%), significa que não há motoristas ociosos suficientes para absorver picos de demanda, resultando em tempos de espera longos. Se for muito baixa (<50%), significa que há motoristas demais para a demanda, e eles ficarão insatisfeitos com seus ganhos.
kafka_consumer_lag < 5000: mede quantas mensagens no Kafka ainda não foram processadas. Se esse número cresce, significa que os consumidores não estão conseguindo acompanhar o volume de produção. Um lag alto no tópico de localização, por exemplo, significa que as posições dos motoristas no mapa estão defasadas.
redis_memory_usage < 80%: deixar 20% de margem é importante para absorver picos. Se o Redis chegar a 100%, pode começar a despejar chaves importantes ou, pior, travar.
Na prática, o time de operações monitora essas métricas em dashboards em tempo real, com alertas configurados para desvios significativos. Um aumento repentino na taxa de cancelamento, por exemplo, pode indicar um problema no matching (motoristas sendo atribuídos a corridas muito distantes) ou um problema no pagamento (cartões sendo recusados).
A correlação entre métricas é especialmente valiosa. Se o kafka_consumer_lag sobe ao mesmo tempo que o match_latency_p99 aumenta, provavelmente há um gargalo no processamento de eventos que está atrasando o matching. Ferramentas como Grafana com datasources do Prometheus permitem criar dashboards que mostram essas correlações visualmente.
Se você está se preparando para entrevistas de system design em empresas de tecnologia, o Uber é um dos exercícios mais completos que existem. Ele cobre praticamente todos os pilares: computação geoespacial, tempo real, consistência de dados, escalabilidade horizontal, precificação dinâmica e tolerância a falhas.
Minutos 0-5: Esclarecimento de Requisitos
- "Estamos projetando apenas o matching de passageiros, ou a plataforma completa?"
- Esclarecer escala: "Quantas corridas simultâneas?"
- Insight chave: "Esse é fundamentalmente um problema geoespacial em tempo real"
Minutos 5-15: Arquitetura de Alto Nível
- Desenhar serviços principais: Ride, Matching, Location, Pricing
- Mostrar fluxo de dados: solicitação do passageiro → matching → notificação do motorista
- Destacar WebSocket para comunicação em tempo real
Minutos 15-30: Mergulho Profundo em Geoespacial + Matching
- Explicar H3/Geohash para indexação espacial
- Algoritmo de matching com scoring
- Fluxo de oferta ao motorista com timeout
Minutos 30-40: Sistemas de Suporte
- Mecânica do surge pricing
- Máquina de estados da corrida
- Fluxo de pagamento (pré-autorização → captura)
Minutos 40-45: Escala e Confiabilidade
- Arquitetura baseada em células
- Degradação gradual
- Métricas principais
Dica de ouro para entrevistas: o entrevistador não quer que você crie a solução perfeita. Ele quer ver como você pensa sobre o problema. Demonstre que você entende os trade-offs em cada decisão. "Escolhi H3 em vez de geohash porque os vizinhos equidistantes eliminam distorção diagonal, mas o custo é uma complexidade de implementação ligeiramente maior." Esse tipo de raciocínio comparativo é o que impressiona.
Outra dica: comece simples e itere. Não tente desenhar 15 serviços no diagrama inicial. Comece com Rider App → API → Matching → Driver App. Depois adicione complexidade conforme o entrevistador guia a conversa.
| Decisão | Opção A | Opção B | Escolha do Uber |
|---|---|---|---|
| Índice Espacial | Quadtree | Hexágonos H3 | H3 |
| Protocolo Tempo Real | REST Polling | WebSocket | WebSocket |
| Armazenamento de Localização | PostgreSQL + PostGIS | Redis GEO | Redis GEO |
| Estratégia de Matching | Motorista mais próximo | Matching com scoring | Matching com scoring |
| Cálculo de Surge | Global | Por zona hexagonal | Por zona hexagonal (H3) |
| Dados de Corrida | SQL | NoSQL | SQL (ACID para pagamentos) |
Cada linha dessa tabela representa uma decisão arquitetural que tem implicações profundas no sistema. Vamos detalhar algumas:
WebSocket vs REST Polling: REST polling significa que o aplicativo do passageiro ficaria perguntando ao servidor "onde está meu motorista?" a cada X segundos. Com 30 milhões de conexões simultâneas, isso geraria uma quantidade absurda de requisições inúteis (a maioria retornaria "posição não mudou"). WebSocket inverte o modelo: o servidor avisa o aplicativo quando a posição muda, eliminando requisições desnecessárias e reduzindo a latência de atualização para milissegundos.
SQL vs NoSQL para dados de corrida: essa é uma decisão que parece contra-intuitiva. NoSQL geralmente escala melhor horizontalmente. Mas corridas envolvem pagamentos, e pagamentos exigem transações ACID. Se o sistema debitar o cartão do passageiro mas falhar ao creditar o motorista (porque o NoSQL não garante atomicidade entre documentos), temos um problema sério. PostgreSQL com sharding por cidade oferece as garantias ACID necessárias com escala suficiente para o volume do Uber.
Tão importante quanto saber o que fazer é saber o que não fazer. Estes são os erros mais comuns que vemos em implementações de sistemas similares ao Uber.
ERRADO: Usar banco de dados relacional para localizações de motoristas em tempo real
Ruim: UPDATE drivers SET lat=X, lng=Y WHERE id=Z (5M escritas/min no PostgreSQL)
Resultado: Banco de dados derrete sob pressão de escrita
CORRETO: Usar Redis com comandos geoespaciais
Bom: GEOADD driver_locations lng lat driver_id
Resultado: Em memória, atualizações em sub-milissegundos, consultas geo nativas
O PostgreSQL foi projetado para durabilidade: cada escrita vai para o WAL (Write-Ahead Log), depois para disco. Isso é excelente para dados que precisam sobreviver a reinicializações, mas é um overhead desnecessário para dados que são substituídos a cada 4 segundos. Redis sacrifica durabilidade em troca de velocidade, o que é exatamente o trade-off correto para localizações efêmeras.
ERRADO: Fazer matching apenas com o motorista mais próximo
Ruim: Sempre atribuir o motorista mais próximo por distância em linha reta
Resultado: Motorista do outro lado de um rio (5 min de carro) escolhido em vez de motorista a 2 quarteirões (1 min de carro)
CORRETO: Pontuar candidatos usando ETA, avaliação, taxa de aceitação e justiça
Bom: Calcular ETA real de condução, considerar qualidade do motorista e distribuição justa
Resultado: Melhor experiência do passageiro, melhor utilização dos motoristas, menos cancelamentos
Em São Paulo, esse anti-padrão é especialmente problemático. A cidade tem muitos rios canalizados (Pinheiros, Tietê), viadutos, e ruas de mão única que fazem com que a distância euclidiana (linha reta) seja uma péssima aproximação da distância real de condução. Dois pontos separados por 500 metros em linha reta podem estar a 3km de distância real se houver um rio entre eles.
ERRADO: Matching síncrono de corrida na requisição da API
Ruim: POST /rides → esperar 30 segundos pelo match → retornar resposta
Resultado: Timeout HTTP, UX terrível, conexões desperdiçadas
CORRETO: Matching assíncrono com atualizações via WebSocket
Bom: POST /rides → retornar ride_id imediatamente → enviar match via WebSocket
Resultado: Resposta instantânea, atualizações de status em tempo real, escalável
Esse é um padrão arquitetural que se aplica a qualquer operação de longa duração em APIs. Nunca bloqueie uma conexão HTTP esperando por algo que pode demorar mais de alguns segundos. Retorne imediatamente com um identificador e notifique o cliente quando o resultado estiver pronto. Isso é válido não só para matching de corridas, mas para processamento de imagens, geração de relatórios, e qualquer outra operação assíncrona.
ERRADO: Surge pricing global único
Ruim: "Surge é 2,5x em toda a cidade de São Paulo"
Resultado: Precificação injusta, motoristas não se deslocam para áreas carentes de oferta
CORRETO: Micro-zonas de surge usando hexágonos H3
Bom: Cada hexágono (~1,2km²) tem surge independente baseado em oferta/demanda local
Resultado: Sinais de mercado precisos, motoristas se movem para zonas de alta demanda
Imagine aplicar surge 2,5x em toda São Paulo. Um passageiro em Pinheiros, onde há 200 motoristas disponíveis e 50 solicitações, pagaria 2,5x. Enquanto isso, na Vila Prudente, onde há 5 motoristas e 80 solicitações, os passageiros pagariam o mesmo 2,5x, que é insuficiente para atrair motoristas de outras regiões. Com micro-zonas, Pinheiros teria surge 1.0x (oferta abundante) e Vila Prudente teria surge 3,5x, criando um incentivo real para motoristas se deslocarem.
Projetar o Uber em escala é uma aula completa sobre sistemas distribuídos em tempo real. Os insights arquiteturais fundamentais são:
Indexação geoespacial com H3 fornece a fundação para consultas eficientes de motoristas próximos, zonas de surge pricing e análise de oferta/demanda, tudo usando o mesmo sistema de grade hexagonal.
Conexões WebSocket são essenciais para a comunicação bidirecional e de alta frequência necessária entre motoristas e a plataforma. APIs REST não conseguem lidar com 1,25M atualizações de localização por segundo.
Matching com scoring supera o simples "motorista mais próximo" ao considerar ETA, qualidade do motorista, probabilidade de aceitação e justiça na distribuição, resultando em melhores resultados para passageiros, motoristas e a plataforma.
Arquitetura baseada em células isola falhas em cidades individuais, prevenindo quedas em cascata pela plataforma global.
A máquina de estados da corrida fornece um modelo limpo para gerenciar o ciclo de vida complexo de uma corrida, da solicitação até a conclusão, com transições e efeitos colaterais bem definidos.
Precificação dinâmica usa proporções de oferta/demanda no nível de micro-zonas para equilibrar o mercado em tempo real, incentivando motoristas a se moverem para áreas carentes de oferta.
Entender esses padrões te equipa não apenas para entrevistas de system design sobre o Uber, mas para qualquer sistema em tempo real, geoespacial e de marketplace, desde delivery de comida até logística e frotas de veículos autônomos.
A complexidade do Uber não está em nenhum componente individual. Cada serviço, isoladamente, é relativamente simples. A complexidade emerge da interação entre centenas de componentes operando em tempo real, em escala global, com requisitos de confiabilidade que não permitem margem para erro. É essa orquestração que faz do Uber um dos sistemas mais fascinantes da engenharia de software moderna.
| Métrica | Valor |
|---|---|
| Usuários Ativos Mensais | 130M |
| Corridas Diárias | 24M |
| Motoristas Simultâneos | 5M |
| Atualizações de Localização | 1,25M/seg |
| QPS de Solicitação de Corrida | ~50K/seg |
| Alvo de Latência de Match | < 30 seg |
| Intervalo de Atualização do Motorista | 4 segundos |
| Resolução H3 (matching) | 7 (~5.161 m²) |
| Resolução H3 (embarque) | 9 (~105 m²) |
| Surge Máximo | 5,0x |
| Componente | Tecnologia | Por Quê |
|---|---|---|
| Localizações de Motoristas | Redis GEO | Consultas geoespaciais sub-ms, em memória |
| Índice Espacial | Hexágonos H3 | Vizinhos equidistantes, hierárquico |
| Registros de Corridas | PostgreSQL | ACID para pagamentos, dados estruturados |
| Histórico de Localização | Cassandra | Séries temporais, alta vazão de escrita |
| Barramento de Eventos | Apache Kafka | Durável, ordenado, alta vazão |
| Comunicação Tempo Real | WebSocket | Bidirecional, baixa latência |
| Motor de Roteamento | OSRM/Valhalla | Computação rápida de rotas |
| Tiles de Mapa | CDN + Mapbox | Distribuição global |
| Busca | Elasticsearch | Busca de motoristas/localizações |