MÓDULO 14 · CONCEITO 05 DE 12

Ride-sharing Matching — geolocalização, tempo real e consistência

Design do sistema de matching motorista-passageiro com latência <5s. O problema central: indexar 10M posições GPS em tempo real e buscar por proximidade em millisegundos. Geohash e Redis GEO como solução. A race condition de dois passageiros solicitando o mesmo motorista. Trip state machine. WebSocket com pub/sub para comunicação em tempo real. Surge pricing por zona geográfica.

Tempo de leitura ~30 min Pré-requisito 04 · Twitter Timeline · 02 · Capacity Estimation Próximo 06 · Messaging System →

Em 2014, o Uber tinha um problema chamado internamente de "phantom cars". Motoristas apareciam no mapa do app do passageiro, mas quando o passageiro solicitava a corrida, nenhum motorista estava disponível — o mapa mostrava uma realidade que já não existia. A causa era a diferença entre o modelo de dados do sistema de localização e a realidade física: carros em movimento, conectividade intermitente, e atualizações de GPS que chegavam com atraso ou fora de ordem. O sistema de localização não refletia "onde o carro está agora" mas "onde o carro estava quando a última atualização chegou" — uma distinção crítica quando o carro se move a 60 km/h.

O sistema de matching de um ride-sharing combina três classes de problemas que raramente aparecem juntos em outros sistemas: indexação espacial de dados que mudam continuamente (a localização de cada motorista muda a cada 4 segundos), coordenação distribuída para evitar que dois passageiros reservem o mesmo motorista simultaneamente, e comunicação bidirecional em tempo real entre dois tipos de clientes móveis (motorista e passageiro) através de uma infraestrutura de servidores stateful.

Requisitos e estimation

Requisitos funcionais:

# Estimation
# Motoristas ativos globalmente no pico: 5M (dos 10M cadastrados)
# Passageiros ativos no pico: 20M

# Location updates:
# 5M motoristas × 1 update a cada 4s = 1.250.000 writes/s
# Cada update: driver_id + lat + lng + timestamp = ~50 bytes
# Throughput: 1.25M × 50B = 62.5 MB/s de writes de localização

# Matching requests:
# Suposição: 10M corridas/dia → ~116 corridas/s → pico ~350/s
# Cada matching: busca de motoristas próximos + oferta + aceite

# Real-time tracking durante corridas:
# 2M corridas simultâneas no pico × 1 update/s de posição = 2M reads/s
# (passageiro acompanhando motorista no mapa durante a corrida)

# Conclusão crítica:
# O gargalo do sistema não é matching (350/s — trivial)
# É a atualização de localização: 1.25M writes/s em um store espacial

O problema de indexação espacial

A query fundamental do sistema é: "dado que um passageiro está em (lat, lng), quais motoristas disponíveis estão dentro de X km?" Essa é uma busca por proximidade num espaço bidimensional contínuo — um problema que banco de dados relacionacionais resolvem mal sem extensões especializadas.

-- Abordagem ingênua: bounding box
SELECT driver_id, lat, lng
FROM drivers_available
WHERE lat BETWEEN :lat - 0.045 AND :lat + 0.045  -- ~5km em latitude
  AND lng BETWEEN :lng - 0.055 AND :lng + 0.055  -- ~5km em longitude (varia com lat)
ORDER BY (lat - :lat)^2 + (lng - :lng)^2  -- distância euclidiana aproximada
LIMIT 20;

-- Problemas:
-- 1. Full table scan se não há índice adequado
-- 2. Índices B-tree numa coluna não ajudam para queries em duas colunas simultaneamente
-- 3. Com 5M drivers ativos e 1.25M updates/s: índice fica sob write pressure constante
-- 4. Distância euclidiana em lat/lng ignora a curvatura da Terra (imprecisa em distâncias >10km)

-- Alternativa: PostGIS (extensão espacial do PostgreSQL)
-- Usa índice GiST (R-tree) otimizado para dados espaciais
SELECT driver_id,
       ST_Distance(location::geography, ST_MakePoint(:lng, :lat)::geography) AS dist_meters
FROM drivers_available
WHERE ST_DWithin(location::geography, ST_MakePoint(:lng, :lat)::geography, 5000) -- 5km
ORDER BY dist_meters
LIMIT 20;

-- PostGIS funciona bem para queries ocasionais, mas
-- 1.25M writes/s invalidam o índice GiST continuamente → performance degrada sob carga

Geohash — indexação espacial com strings

Geohash é uma técnica de discretização do espaço geográfico: o mundo é dividido recursivamente em células retangulares, e cada célula recebe uma string identificadora. Strings com o mesmo prefixo representam células adjacentes — o que transforma busca espacial em busca por prefixo de string, uma operação que bancos de dados e sistemas de cache conhecem muito bem.

# Como geohash funciona:
# 1. Dividir o mundo em 32 células com um caractere (base32):
#    Longitude: [-180, 0) → bit 0 | [0, 180] → bit 1
#    Latitude:  [-90,  0) → bit 0 | [0,  90] → bit 1
#    ... intercalar bits de longitude e latitude ...
#    Codificar em base32 (0-9 + b-z exceto a, i, l, o)

# Precisão por número de caracteres:
# 1 char:  ±2500km   (5000×5000 km)  — um por continente
# 4 chars: ±20km     (40×20 km)      — nível de cidade
# 6 chars: ±610m     (1.2×0.6 km)    — bairro
# 7 chars: ±76m      (150×75 m)      — quadra
# 8 chars: ±19m      (40×20 m)       — edifício
# 9 chars: ±2.4m                     — precisão de GPS

import geohash2  # Python library

# Motorista em São Paulo (Av. Paulista)
driver_lat, driver_lng = -23.5613, -46.6561
gh = geohash2.encode(driver_lat, driver_lng, precision=7)
# → "6gyf4rr" (precisão de ~75m, suficiente para ride-sharing)

# Passageiro a 500m de distância
passenger_lat, passenger_lng = -23.5567, -46.6584
passenger_gh = geohash2.encode(passenger_lat, passenger_lng, precision=7)
# → "6gyf4rr" (mesma célula!) ou "6gyf4rq" (célula adjacente)

# A propriedade crítica:
# Mesma célula geohash = geograficamente próximos (quase sempre)
# Mas: pontos próximos podem ter geohashes diferentes se estão em lados opostos
# de uma fronteira de célula → solução: buscar a célula central + 8 células vizinhas

As 9 células do geohash

# O problema da fronteira de célula:
# Dois pontos separados por 10m podem estar em células geohash diferentes
# se estão em lados opostos da fronteira da célula.

# Solução: buscar sempre a célula do passageiro + 8 células vizinhas

neighbors = geohash2.neighbors("6gyf4rr")
# → ["6gyf4rx", "6gyf4rw", "6gyf4rq", "6gyf4rm", "6gyf4rp",
#    "6gyf4r8", "6gyf4r9", "6gyf4rd"]  # 8 vizinhos + célula central

search_cells = [passenger_gh] + neighbors  # 9 células ao total

# No Redis: para cada célula, buscar drivers disponíveis
# SMEMBERS drivers:6gyf4rr → {driver_id_1, driver_id_2, ...}
# SMEMBERS drivers:6gyf4rx → {driver_id_5, driver_id_8, ...}
# ...
# União de todos os 9 sets → candidatos para matching
# Filtrar por distância real (haversine) para remover falsos positivos nas bordas

Redis GEO — a solução de produção

Redis tem comandos GEO nativos desde a versão 3.2 (2016), baseados em geohash e implementados sobre Sorted Set. Eles resolvem exatamente o problema de busca por proximidade com performance de Redis:

# Estrutura de dados: Redis Sorted Set
# Score = geohash de 52 bits (representação interna do Redis GEO)
# Member = driver_id

# Ao receber update de localização do motorista:
GEOADD drivers_available -46.6561 -23.5613 "driver:12345"
# Complexidade: O(log N) onde N é o número de motoristas no set
# Throughput: Redis processa ~100k GEOADD/s por thread

# Ao buscar motoristas próximos ao passageiro:
GEOSEARCH drivers_available
  FROMLONLAT -46.6584 -23.5567  # posição do passageiro
  BYRADIUS 5 km
  ASC                           # ordenado por distância
  COUNT 20                      # top 20 mais próximos
  WITHCOORD                     # retornar coordenadas
  WITHDIST                      # retornar distância em km

# Resultado:
# [
#   ["driver:12345", 0.42, [-46.6561, -23.5613]],  # 420m de distância
#   ["driver:67890", 1.13, [-46.6623, -23.5501]],  # 1.13km
#   ...
# ]

# Remover motorista quando ele aceita uma corrida (não mais disponível):
ZREM drivers_available "driver:12345"

# Verificar posição de um motorista específico (durante a corrida):
GEOPOS drivers_on_trip "driver:12345"

Sharding do Redis GEO para 1.25M updates/s

# 1.25M GEOADD/s está acima do que um único Redis node aguenta.
# Sharding por região geográfica (geohash de 1-2 chars = país/continente):

# Shard mapping (geohash prefix → Redis instance):
SHARD_MAP = {
    "6g": "redis-sa-east",        # América do Sul
    "9q": "redis-us-west",        # EUA Costa Oeste
    "dr": "redis-us-east",        # EUA Costa Leste
    "u0": "redis-eu-west",        # Europa Ocidental
    "w3": "redis-ap-southeast",   # Ásia Sudeste
    # ...
}

def get_shard(lat, lng):
    gh_prefix = geohash2.encode(lat, lng, precision=2)
    return SHARD_MAP.get(gh_prefix, "redis-default")

# Na prática, com 5M motoristas ativos distribuídos globalmente:
# ~500k por região → 500k ÷ 4s = 125k updates/s por shard
# Um Redis node aguenta 100k ops/s → 2 nodes por região por segurança

# Busca de motoristas: sempre no shard da região do passageiro
# (corridas cross-região são raras e tratadas com routing especial)

O matching service — pontuação e oferta

# Fluxo de matching ao receber solicitação de corrida:
def match_passenger(passenger_id, passenger_lat, passenger_lng, vehicle_type):
    # 1. Buscar candidatos próximos (Redis GEO)
    candidates = redis.geosearch(
        f"drivers_available:{get_shard(passenger_lat, passenger_lng)}",
        fromlonlat=(passenger_lng, passenger_lat),
        byradius=5,  # km
        unit="km",
        count=50,    # top 50 candidatos antes de filtrar
        sort="ASC",
        withdist=True, withcoord=True
    )

    # 2. Filtrar por tipo de veículo e rating mínimo
    eligible = []
    for driver_id, distance_km, (lng, lat) in candidates:
        driver = driver_service.get(driver_id)
        if driver.vehicle_type != vehicle_type: continue
        if driver.rating < 4.5: continue
        eligible.append((driver_id, distance_km, driver))

    if not eligible:
        return no_drivers_available()

    # 3. Calcular score para cada candidato
    def score(driver_id, distance_km, driver):
        eta_seconds = routing_service.get_eta(driver.lat, driver.lng,
                                              passenger_lat, passenger_lng)
        return (
            0.5 * (1 / (eta_seconds + 1)) +   # ETA: quanto menor, melhor
            0.3 * (driver.rating / 5.0) +      # Rating normalizado
            0.2 * (driver.acceptance_rate)     # Taxa de aceite do motorista
        )

    ranked = sorted(eligible,
                    key=lambda x: score(x[0], x[1], x[2]),
                    reverse=True)

    # 4. Oferecer para motoristas em ordem de score até aceite
    for driver_id, distance_km, driver in ranked:
        result = offer_to_driver(driver_id, passenger_id, timeout_seconds=15)
        if result == "ACCEPTED":
            return create_trip(driver_id, passenger_id)
        # Se DECLINED ou TIMEOUT: tentar próximo

A race condition — dois passageiros, um motorista

O problema de consistência mais crítico do sistema: se dois passageiros A e B estão próximos e o motorista D é o melhor match para ambos, ambos os matching services vão selecionar D simultaneamente. O motorista recebe duas solicitações e aceita uma — qual dos dois passageiros fica sem carro?

# Solução: distributed lock com Redis SET NX (Set if Not eXists)

def offer_to_driver(driver_id, passenger_id, timeout_seconds=15):
    lock_key = f"driver_lock:{driver_id}"

    # Tentativa atômica de adquirir o lock
    # NX = só seta se a chave não existir
    # EX = TTL em segundos (release automático se o processo morrer)
    acquired = redis.set(
        lock_key,
        passenger_id,     # valor = quem adquiriu o lock
        nx=True,          # only if not exists
        ex=timeout_seconds + 5  # TTL = timeout do motorista + buffer
    )

    if not acquired:
        # Driver já está sendo oferecido para outro passageiro
        # Quem tem o lock pode verificar: GET driver_lock:{driver_id}
        # → retorna o passenger_id que tem o lock
        return "DRIVER_BUSY"

    try:
        # Lock adquirido: enviar notificação para o motorista via WebSocket
        websocket_gateway.send(driver_id, {
            "type": "TRIP_REQUEST",
            "passenger_id": passenger_id,
            "pickup_location": passenger_location,
            "expires_at": time.now() + timeout_seconds
        })

        # Aguardar resposta do motorista (com timeout)
        response = wait_for_driver_response(driver_id, timeout=timeout_seconds)

        if response == "ACCEPTED":
            return "ACCEPTED"
        else:
            return "DECLINED"  # ou TIMEOUT

    finally:
        # Liberar o lock SEMPRE — mesmo em exceção
        # Usar Lua script para garantir atomicidade: só deletar se eu sou o dono
        lua_script = """
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
        """
        redis.eval(lua_script, 1, lock_key, passenger_id)

O script Lua garante que apenas o detentor original do lock pode liberá-lo — evitando o cenário onde o lock expira durante o processamento, outro passageiro adquire o lock, e então o primeiro libera o lock do segundo.

Trip state machine

# Estados de uma corrida e transições válidas:

STATES = {
    "MATCHING":           ["DRIVER_EN_ROUTE", "CANCELLED"],
    "DRIVER_EN_ROUTE":    ["DRIVER_ARRIVED", "CANCELLED"],
    "DRIVER_ARRIVED":     ["TRIP_STARTED", "CANCELLED"],
    "TRIP_STARTED":       ["TRIP_COMPLETED", "CANCELLED"],
    "TRIP_COMPLETED":     [],  # terminal
    "CANCELLED":          [],  # terminal
}

def transition_trip_state(trip_id, new_state, actor_id):
    trip = redis.hgetall(f"trip:{trip_id}")
    current_state = trip["state"]

    if new_state not in STATES[current_state]:
        raise InvalidTransition(f"{current_state} → {new_state} não permitido")

    # Transição atômica com Lua script (evita race condition de duas transições simultâneas)
    lua_script = """
    local current = redis.call("hget", KEYS[1], "state")
    if current ~= ARGV[1] then
        return redis.error_reply("STATE_CHANGED: expected " .. ARGV[1] .. " got " .. current)
    end
    redis.call("hset", KEYS[1], "state", ARGV[2])
    redis.call("hset", KEYS[1], "updated_at", ARGV[3])
    return "OK"
    """
    redis.eval(lua_script, 1,
               f"trip:{trip_id}",
               current_state, new_state, str(time.now()))

    # Persistir no banco para durabilidade (async, fora do caminho crítico)
    db_write_queue.publish({
        "trip_id": trip_id,
        "new_state": new_state,
        "actor_id": actor_id,
        "timestamp": time.now()
    })

    # Notificar ambas as partes via WebSocket
    notify_trip_participants(trip_id, new_state)

WebSocket com pub/sub — comunicação em tempo real

Tanto motoristas quanto passageiros precisam de comunicação bidirecional em tempo real. HTTP polling é inadequado (latência alta, desperdício de banda). WebSocket mantém uma conexão TCP persistente — mas cria um problema de roteamento: como o matching service (running on server A) envia uma mensagem para um motorista conectado via WebSocket no server B?

# Problema:
# Driver D está conectado ao WebSocket server WS-2
# Matching service está rodando no App server AS-1
# AS-1 precisa enviar uma mensagem para D, mas não tem a conexão de D

# Solução: pub/sub via Redis

# Ao conectar o motorista D ao WebSocket server WS-2:
redis.set(f"driver_ws:{driver_id}", "ws-server-2")  # routing table
# WS-2 subscreve no canal do motorista:
redis.subscribe(f"channel:driver:{driver_id}")

# Quando matching service quer enviar mensagem para D:
redis.publish(
    f"channel:driver:{driver_id}",
    json.dumps({"type": "TRIP_REQUEST", "passenger_id": "P123", ...})
)

# Redis entrega a mensagem para todos os subscribers do canal
# WS-2 é o subscriber → WS-2 encontra a conexão WebSocket de D → envia

# Tracking de localização durante a corrida (passageiro acompanha motorista):
# Driver D envia atualização de GPS via WebSocket para WS-2
# WS-2 publica no canal da corrida:
redis.publish(f"channel:trip:{trip_id}:location",
              json.dumps({"lat": driver_lat, "lng": driver_lng}))
# Passenger app (em qualquer WS server) é subscriber desse canal
# → recebe a atualização e atualiza o pin no mapa

Escalando WebSocket servers

# WebSocket servers são stateful (cada conexão é persistente)
# → não podem ser escalados com round-robin simples no load balancer

# Sticky sessions: o load balancer sempre roteia o mesmo cliente para o mesmo WS server
# Implementação: cookie de sessão ou hash do IP

# Mas: se o WS server cai, as conexões morrem
# Solução no client: reconnect automático com exponential backoff
# Na reconexão: o client pode cair em qualquer WS server (round-robin)
# → o novo WS server subscreve nos canais relevantes para o cliente

# Healthcheck: motoristas enviam heartbeat a cada 30s
# Se o server não recebe heartbeat de um motorista em 60s:
# → marcar motorista como offline no Location Service
# → remover do pool de motoristas disponíveis
# → o cliente vai reconectar automaticamente

Surge pricing — demanda vs oferta por zona

# Surge é calculado por zona geográfica (geohash de precisão 4-5 chars)
# A cada 30 segundos, para cada zona ativa:

def calculate_surge(zone_geohash):
    # Motoristas disponíveis na zona (e zonas adjacentes)
    available_drivers = count_available_drivers(zone_geohash)

    # Solicitações de corrida nos últimos 5 minutos
    recent_requests = redis.zcount(
        f"requests:{zone_geohash}",
        time.now() - 300,  # 5 minutos atrás
        time.now()
    )

    if available_drivers == 0:
        return MAX_SURGE_MULTIPLIER  # 4.0x

    demand_ratio = recent_requests / available_drivers

    # Mapeamento demand_ratio → surge multiplier
    if demand_ratio < 0.5:   return 1.0   # sem surge
    elif demand_ratio < 1.0: return 1.2
    elif demand_ratio < 1.5: return 1.5
    elif demand_ratio < 2.0: return 2.0
    else:                    return min(demand_ratio, 4.0)

# Armazenar no Redis com TTL de 60s (atualizado a cada 30s)
redis.setex(f"surge:{zone_geohash}", 60, surge_multiplier)

# Ao mostrar preço para o passageiro:
passenger_zone = geohash2.encode(lat, lng, precision=5)
surge = float(redis.get(f"surge:{passenger_zone}") or 1.0)
estimated_price = base_price * surge

ETA — tempo estimado de chegada

# ETA é necessário em dois momentos:
# 1. Antes do matching: para scoring dos motoristas candidatos
# 2. Durante a corrida: mostrado ao passageiro enquanto espera

# Problema: calcular ETA real (considerando tráfego e rotas) é caro
# Google Maps Platform API: ~$5/1000 requests
# Com 350 matchings/s × 20 candidatos = 7.000 ETAs/s → ~$1M/dia sem cache

# Solução: caching de ETA por par (origin_geohash, dest_geohash)
# Origin = posição do motorista (geohash 6 chars = ~600m)
# Dest = posição do passageiro (geohash 6 chars)

def get_eta(driver_lat, driver_lng, passenger_lat, passenger_lng):
    origin_gh = geohash2.encode(driver_lat, driver_lng, precision=6)
    dest_gh   = geohash2.encode(passenger_lat, passenger_lng, precision=6)
    cache_key = f"eta:{origin_gh}:{dest_gh}"

    cached_eta = redis.get(cache_key)
    if cached_eta:
        return int(cached_eta)

    # Cache miss: chamar routing service (interno ou Google Maps)
    eta_seconds = routing_service.get_eta(
        (driver_lat, driver_lng),
        (passenger_lat, passenger_lng)
    )
    # Cachear por 2 minutos (tráfego muda; precisão de geohash absorve movimentos pequenos)
    redis.setex(cache_key, 120, eta_seconds)
    return eta_seconds

# Para scoring durante o matching, uma estimativa baseada em distância euclidiana
# com um fator de "tortuosidade" de rodovia é suficiente:
def estimate_eta_fast(distance_km, city_speed_factor=1.4):
    # speed_factor > 1: rotas reais são mais longas que linha reta
    avg_speed_kmh = 30  # velocidade média em área urbana
    return (distance_km * city_speed_factor / avg_speed_kmh) * 3600  # segundos

Arquitetura completa

Driver App → WebSocket Gateway (sticky session)
                  ↓ GPS update (a cada 4s)
            Location Service
                  ↓ GEOADD
            Redis Cluster (shardado por região)
            ["drivers_available:us-east", "drivers_available:eu-west", ...]

Passenger App → API Gateway → Matching Service
                                    ↓ GEOSEARCH (busca candidatos)
                              Redis Cluster (Location)
                                    ↓ scoring + oferta
                              PUBLISH channel:driver:{id}
                                    ↓ Redis Pub/Sub
                              WebSocket Gateway (servidor do motorista)
                                    ↓ WebSocket
                              Driver App (exibe solicitação de corrida)

                              ↓ driver aceita
                              SET driver_lock:{id} NX (distributed lock)
                              Trip Service → cria trip no Redis + queue para DB
                              PUBLISH channel:trip:{id}:state → ambas as partes

Durante a corrida:
Driver App → WebSocket Gateway → PUBLISH channel:trip:{id}:location
           → Passenger App (qualquer WS Gateway via Redis Pub/Sub)

Persistência:
  Redis → source of truth para estado em tempo real (trips, locations)
  PostgreSQL → dados históricos (trips concluídas, ratings, pagamentos)
  Kafka → eventos de transição de estado (audit trail, analytics)
geohash como primitive de sistemas distribuídos

Geohash transforma um problema bidimensional contínuo (coordenadas GPS) num problema unidimensional discreto (prefixo de string) — e essa transformação abre toda a toolchain de sistemas distribuídos que foi construída para strings e integers. Redis Sorted Set, particionamento de banco por prefix, consistent hashing, cache key por zona — todos funcionam naturalmente com geohash como chave. O mesmo princípio aparece em outros contextos: S2 Cells (Google), H3 (Uber), e geomeses são variações da mesma ideia. A escolha entre eles é de precisão, forma da célula (quadrado vs hexágono), e hierarquia — mas o problema que resolvem é o mesmo.

Decisões de engenharia

Geohash vs S2 Cells vs H3 para indexação espacial

Geohash é simples (strings base32), amplamente suportado (Redis GEO usa internamente), mas tem o problema de células retangulares com distorção em latitudes altas. S2 (Google) usa células sobre uma esfera — sem distorção, mas mais complexo. H3 (Uber, open-source) usa hexágonos — cada célula tem exatamente 6 vizinhas iguais (vs 8 do geohash, que incluem diagonais menores), o que simplifica a busca de vizinhança.

Regra prática: geohash + Redis GEO para a maioria dos casos (simplicidade e suporte nativo). H3 quando o sistema opera globalmente em latitudes altas (Escandinávia, Canadá) onde a distorção do geohash é problemática ou quando a uniformidade de área de célula é crítica para cálculos de density.

Frequência de atualização de localização: 1s vs 4s vs 30s

1s: localização quase em tempo real, mas 10M drivers × 1 update/s = 10M writes/s no Redis — 10× o volume estimado. Bateria do dispositivo descarrega mais rápido. 4s: o padrão do Uber — compromisso entre frescor da localização e custo de infraestrutura. 30s: adequado para rastreamento de entregas (velocidade baixa), mas não para ride-sharing (carro a 60 km/h percorre 500m em 30s — a localização no mapa seria muito imprecisa).

Regra prática: adaptar a frequência ao contexto: alta frequência (1-4s) quando a velocidade de movimento é alta e a precisão de localização é crítica para o UX (motorista em área urbana). Baixa frequência (30-60s) quando velocidade é baixa ou precisão é menos crítica (entrega a pé, rastreamento de frota).

Distributed lock vs optimistic locking para o matching

Distributed lock com Redis (SET NX) é pessimista: bloqueia o recurso antes de usá-lo, garantindo exclusividade mas criando um ponto de espera. Optimistic locking (compare-and-swap no banco) é otimista: tenta a operação e detecta conflito apenas se outro escritor atualizou. Para o matching, o lock pessimista é correto: o "recurso" (motorista) é escasso e altamente disputado — falhar rápido e tentar o próximo candidato é melhor que tentar e falhar na persistência.

Regra prática: lock pessimista quando a contenção é alta e o custo de rollback é significativo. Optimistic quando contenção é baixa e rollback é barato. Para matching de ride-sharing: pesimista. Para atualizações de rating de motorista: optimistic.

WebSocket vs SSE vs Long Polling para atualizações em tempo real

WebSocket é bidirecional — motorista envia GPS, passageiro recebe localização. SSE (Server-Sent Events) é unidirecional (server → client) — adequado para o passageiro acompanhar o motorista, mas não para o motorista enviar GPS. Long Polling simula real-time com HTTP — funciona mas tem overhead de conexão por poll. Para ride-sharing, onde tanto motorista quanto passageiro precisam enviar e receber: WebSocket é a única escolha adequada.

Regra prática: WebSocket quando comunicação é bidirecional ou quando a latência de cada mensagem importa. SSE quando o cliente só precisa receber (feed de notificações, dashboard em tempo real). Long Polling apenas quando WebSocket/SSE não é suportado pelo ambiente (proxies corporativos bloqueiam WebSocket).

Perguntas de entrevista

Como o sistema trata o caso em que o motorista aceita a corrida mas a conexão cai antes da confirmação chegar ao passageiro?

Este é um problema de exactly-once delivery numa rede não-confiável — o problema dos generais bizantinos aplicado a ride-sharing. O motorista pressionou "aceitar" no app, o evento foi enviado ao servidor, mas a resposta de confirmação não chegou de volta antes da conexão cair. O motorista não sabe se a corrida foi confirmada; o passageiro não sabe se haverá um motorista.

A solução correta opera em duas camadas:

1. Idempotência no servidor: a ação de "aceitar corrida" do motorista é idempotente. Se o mesmo evento chegar duas vezes (retry do client), o servidor verifica o estado atual da corrida:

  • Se a corrida está em MATCHING e o lock pertence a este motorista → confirmar o aceite
  • Se a corrida já está em DRIVER_EN_ROUTE com este motorista → retornar confirmação (idempotente)
  • Se a corrida foi cancelada ou aceita por outro motorista → retornar conflito

2. Reconciliação no client: ao reconectar, o app do motorista envia seu estado local ("eu acho que aceitei a corrida X"). O servidor compara com o estado da corrida e responde com o estado canônico. O app sincroniza para o estado do servidor.

3. Timeout com escalonamento: se após N segundos o passageiro não recebeu confirmação de motorista, o matching service tenta o próximo candidato na lista rankeada. O distributed lock com TTL garante que o lock seja liberado automaticamente se o processo que o adquiriu morrer sem liberá-lo.

Como o sistema escala para eventos de alta demanda (show, jogo de futebol, réveillon)?

Eventos concentram geograficamente a demanda: 50.000 pessoas saindo de um estádio ao mesmo tempo num raio de 500m. Isso cria dois problemas simultâneos:

1. Spike de writes no Location Service: motoristas em movimento convergindo para a área do evento. Mitigação: Redis Cluster com sharding por região já trata horizontalmente. O spike é absorvido pelos shards da região — sem necessidade de intervenção.

2. Spike de requests de matching: 50k pessoas solicitando corrida em minutos. O matching service é stateless e pode ser escalado horizontalmente com autoscaling baseado em CPU/requisições/s. A fila de matching pode acumular — tratar com prioridade de filas (passageiros esperando há mais tempo têm prioridade).

3. Esgotamento de motoristas na zona: com mais demanda que oferta, matching vai falhar para muitos passageiros. Surge pricing é o mecanismo de equilíbrio — preços mais altos atraem motoristas de zonas vizinhas. O sistema calcula surge por zona a cada 30s — durante o evento, o surge da zona do estádio vai subir rapidamente, tornando a área mais atrativa para motoristas.

4. Degradação graciosa: se a fila de matching supera N segundos de espera esperada, informar o passageiro com tempo de espera estimado em vez de deixar o spinner girar indefinidamente. UX honesto durante pico é melhor que silêncio.

Como o sistema garante que motoristas offline ou com GPS impreciso não apareçam como disponíveis para passageiros?

Este é o problema dos "phantom cars" mencionado na introdução — a diferença entre "motorista marcado como disponível no banco" e "motorista realmente disponível agora". Três mecanismos em conjunto resolvem o problema:

1. TTL nas entradas do Redis GEO: em vez de apenas GEOADD, manter uma chave separada com TTL por motorista:

SETEX driver_active:{driver_id} 10 "1"  # TTL de 10s
GEOADD drivers_available lng lat driver_id

Se o motorista não enviar update em 10s, a chave expira. No momento de busca, filtrar candidatos cujo driver_active:{driver_id} expirou — eles estão offline.

2. Heartbeat de WebSocket: o servidor WebSocket envia ping a cada 30s. Se o cliente não responder em 5s, a conexão é considerada morta. O servidor remove o motorista do pool de disponíveis e publica um evento "driver_disconnected" para o Location Service.

3. Validação de freshness no matching: ao buscar candidatos com GEOSEARCH, verificar quando foi o último GPS update de cada candidato. Se o update foi há mais de 15s, excluir do ranking (o carro pode ter se movido significativamente).

Combinando os três: um motorista precisa enviar GPS updates ativos (TTL), ter conexão WebSocket ativa (heartbeat), e ter atualizado recentemente (freshness check) para aparecer como disponível. Qualquer falha em uma das três condições o remove do pool.

Como funciona o ride-sharing (pool) onde múltiplos passageiros compartilham o mesmo carro?

Ride-sharing (Uber Pool, Lyft Shared) adiciona complexidade significativa ao matching: em vez de um passageiro por carro, um carro pode ter 2-3 passageiros com origens e destinos diferentes.

O algoritmo de matching muda de "encontre o motorista mais próximo" para "encontre o motorista mais próximo que pode adicionar este passageiro na rota existente sem atrasar muito os passageiros já no carro".

Isso requer um cálculo de routing mais sofisticado:

def can_add_passenger(trip, new_passenger):
    # Rota atual: driver → pickup_A → destination_A
    # Nova rota candidata: driver → pickup_new → pickup_A → dest_A → dest_new
    # (ou outra ordem dependendo das coordenadas)

    # Calcular tempo adicional para passageiro A (atual):
    detour_seconds = routing_service.get_route_time(
        [driver_position, new_passenger.pickup, passenger_a.pickup, passenger_a.destination]
    ) - routing_service.get_route_time(
        [driver_position, passenger_a.pickup, passenger_a.destination]
    )

    # Aceitar se o desvio for menor que threshold (ex: 5 minutos)
    return detour_seconds < 300

O estado do trip fica mais complexo: a trip_state_machine precisa rastrear múltiplos passageiros com estados independentes (cada um pode ser picked_up e dropped_off em momentos diferentes). O distributed lock ainda é necessário, mas agora protege "slots no veículo" em vez de "disponibilidade do veículo".

Como o sistema detecta e previne fraudes — motoristas e passageiros falsos?

Fraudes em ride-sharing têm padrões específicos que o sistema pode detectar:

GPS spoofing (motoristas): motoristas falsos que manipulam seu GPS para aparecerem em locais rentáveis sem estarem fisicamente lá. Sinais: posição muda instantaneamente (teleporte), aceleração impossível (de 0 a 100 km/h em 1s), coordenadas dentro de oceano ou edifício. Detecção: validar consistência física da sequência de updates (velocidade máxima, aceleração máxima, proximity a redes Wi-Fi/cell towers conhecidas).

Trip fraud (motorista + passageiro conluiados): criar corridas fictícias para ganhar créditos ou bônus. Sinais: corrida muito curta mas cobrada como longa, motorista e passageiro com o mesmo device ID histórico, padrão de corridas sempre entre os mesmos dois endpoints. Detecção: análise de grafo de viagens, ML model treinado em padrões de fraude histórica, verificação de rota via GPS vs rota cobrada.

Account takeover: credenciais de passageiro roubadas usadas para corridas. Detecção: device fingerprinting, geolocalização de login vs localização de solicitação de corrida, comportamento anômalo (N corridas em curto período).

A prevenção opera em múltiplas camadas: validação em tempo real no matching (GPS consistency), análise assíncrona pós-corrida (ML model), e revisão manual para casos flagged. O challenge é que muita prevenção de fraude gera falsos positivos que afetam usuários legítimos — o threshold precisa ser calibrado com dados.

Exercícios práticos

Exercício 1 — Redis GEO: indexar e buscar drivers por proximidade

Com Redis local, implemente o Location Service: (a) endpoint que recebe updates de GPS de motoristas e executa GEOADD, (b) endpoint que recebe coordenadas do passageiro e executa GEOSEARCH retornando os 10 motoristas mais próximos com distância. Popule 10.000 motoristas com coordenadas aleatórias numa área geográfica real (ex: São Paulo). Meça: latência de GEOADD individual, latência de GEOSEARCH com 10k motoristas, e como a latência varia com o raio de busca (1km vs 5km vs 20km).

Critério: GEOSEARCH retorna motoristas em ordem crescente de distância corretamente. Latência de GEOSEARCH com 10k motoristas e raio de 5km fica abaixo de 5ms. Você consegue articular por que GEOSEARCH tem complexidade O(N+log M) e não O(M).

Exercício 2 — Implementar o distributed lock para matching

Implemente o mecanismo de lock com Redis SET NX para o problema de matching. Crie dois processos de matching simultâneos que tentam reservar o mesmo "motorista". Verifique que apenas um sucede e o outro recebe DRIVER_BUSY. Implemente o script Lua para release atômico do lock (só libera se sou o dono). Teste o cenário de crash: adquira o lock, encerre o processo sem liberar, aguarde o TTL expirar e verifique que o lock foi liberado automaticamente. Um terceiro processo pode adquirir o lock após o TTL?

Critério: em nenhuma execução dois processos simultâneos conseguem adquirir o mesmo lock. O script Lua de release não libera o lock se o valor armazenado não corresponde ao caller. O lock expira automaticamente após o TTL configurado.

Exercício 3 — Trip state machine com transições atômicas

Implemente a trip state machine no Redis com transições atômicas via Lua script. Estados: MATCHING → DRIVER_EN_ROUTE → DRIVER_ARRIVED → TRIP_STARTED → COMPLETED. Crie um endpoint para cada transição de estado (aceitar, chegar, iniciar, completar). Implemente a validação: transições inválidas (ex: TRIP_STARTED → MATCHING) devem retornar erro. Teste concorrência: dois processos tentam fazer a transição MATCHING → DRIVER_EN_ROUTE simultaneamente — apenas um deve suceder.

Critério: o Lua script garante que a transição só ocorre se o estado atual é o esperado. Transições inválidas retornam erro sem modificar o estado. Em teste de concorrência, exatamente um dos dois processos sucede.

Exercício 4 — WebSocket com pub/sub para rastreamento em tempo real

Implemente um servidor WebSocket simplificado com Redis Pub/Sub. Dois clientes conectam: um "motorista" que envia sua localização a cada 2s, um "passageiro" que recebe atualizações de localização. O servidor de motorista publica no canal trip:123:location; o servidor do passageiro subscreve nesse canal. Teste com clientes em diferentes processos (simulando diferentes servidores WebSocket): o passageiro recebe atualizações do motorista mesmo sem estar no mesmo processo?

Critério: o passageiro recebe atualizações de localização do motorista com latência < 100ms mesmo quando conectados a instâncias diferentes do servidor WebSocket. A latência de entrega é mensurável com timestamps nos eventos.

Exercício 5 — Calcular surge pricing por zona

Implemente o cálculo de surge pricing. Use geohash de 5 chars para zonas (~5km × 5km). Para cada solicitação de corrida, registrar no Redis com ZADD (score = timestamp). A cada 30s, calcular o surge de cada zona ativa: contar requests nos últimos 5 minutos (ZCOUNT) e motoristas disponíveis na zona (GEOSEARCH). Surge multiplier = max(1.0, requests / drivers). Testar: simular 100 requests em 1 zona com 5 motoristas disponíveis → qual é o surge esperado? E com 0 motoristas?

Critério: o surge é calculado corretamente por zona sem afetar performance do matching. Com 0 motoristas disponíveis, o surge retorna MAX_SURGE. O valor do surge é acessível com latência < 1ms (leitura do Redis) ao calcular o preço para o passageiro.

Referências

  1. article Uber Engineering — Engineering the Architecture of Uber's Marketplace eng.uber.com · a arquitetura real do marketplace do Uber — Location Service, Dispatch System e as decisões de design
  2. article Uber Engineering — H3: Uber's Hexagonal Hierarchical Spatial Index eng.uber.com · o sistema de indexação espacial com hexágonos que o Uber open-sourced — alternativa ao geohash com células mais uniformes
  3. docs Redis — GEO Commands redis.io/docs/commands/#geo · documentação dos comandos GEOADD, GEOSEARCH, GEOPOS — com complexidade, exemplos e casos de uso
  4. article Nishtala, Y. — TAO: Facebook's Distributed Data Store for the Social Graph USENIX ATC 2013 · armazenamento de grafos em escala — relevante para o design do follow graph e estruturas similares
  5. article Uber Engineering — Introducing Domain-Oriented Microservices eng.uber.com · como o Uber organizou os serviços (Location, Matching, Trip) com fronteiras claras de domínio
  6. article Lyft Engineering — How Lyft Discovers and Matches Drivers and Passengers eng.lyft.com · a perspectiva do Lyft sobre matching — semelhanças e diferenças em relação ao Uber
  7. article Redis — Distributed Locks with Redis (Redlock) redis.io/docs/manual/patterns/distributed-locks · o algoritmo Redlock para locks distribuídos mais robustos — quando SET NX simples não é suficiente
  8. book Martin Kleppmann — DDIA Chapter 8: The Trouble with Distributed Systems O'Reilly · 2017 · clocks, ordering, e as limitações fundamentais dos sistemas distribuídos — contexto teórico para os problemas de consistência do matching
  9. article Geohash.org — Geohash Explained geohash.org · a explicação original do algoritmo geohash com exemplos de precisão por número de caracteres
  10. article Uber Engineering — Surge Pricing: How It Works eng.uber.com · os detalhes do algoritmo de surge pricing — como demand/supply é medido por zona e como o multiplier é calculado
  11. article Slack Engineering — Scaling Slack's Real-time Messaging slack.engineering · WebSocket + pub/sub em escala de milhões de conexões simultâneas — padrão similar ao WebSocket Gateway de ride-sharing
  12. book Alex Xu — System Design Interview vol. 2 — Design Proximity Service bytebytego.com · tratamento aprofundado de busca por proximidade com geohash — base para o Location Service do ride-sharing