Caso 2 · Uber/Lyft

Ride-sharing en tiempo real

Cliente solicita viaje y el sistema asigna un driver cercano en segundos, con location updates continuos y notificaciones push.

Recursos

Rider, Driver, Ride, Location, Notification.

Operaciones

Solicitar ride, broadcast a drivers, accept atómico, tracking.

Escala

Cientos de miles de drivers activos, location updates por segundo.

Riesgo

Doble accept, ubicación stale, partición regional caída.

Requisitos

Define qué es funcional y qué garantías de calidad firmas con el negocio.

Funcionales

  • Solicitar viaje con pickup/dropoff y tipo de vehículo.
  • Match con driver cercano disponible.
  • Tracking en vivo del driver y del rider.
  • Aceptar / cancelar ride (con políticas).
  • Cobro y rating al terminar el viaje.

No funcionales

  • Match < 5 s p95 en zona con cobertura.
  • Strong consistency en accept (no doble assign).
  • Disponibilidad 99.99% por ciudad.
  • Location updates cada 3–5 s por driver.
  • Privacidad: ubicación con TTL corto.

Cómo aclarar requisitos en la entrevista

Casos de uso típicos. Pico aeroportuario (alta demanda + drivers waiting), evento masivo (concierto, estadio) con surge, madrugada con baja densidad de oferta, ride compartido (Pool/UberX/Black), viaje programado a futuro, ride para alguien más (regalo / corporativo), ride con paradas múltiples, ride en zona con mala señal.

Preguntas que debes hacer:

• ¿Tier único (UberX) o múltiples (Pool, Black, XL, paquetes)? Cambia el matching.
• ¿Soportas viajes programados a futuro? Implica scheduler queue separado.
• ¿Multi-stop en un mismo ride?
• ¿Pagos in-app, efectivo, o ambos? Ojo con flujos cash y reconciliación.
• ¿Surge pricing dinámico o estático?
• ¿Hay cancellation fee, en qué condiciones?
• ¿Drivers full-time o gig? Cambia incentivos y SLA.
• ¿Ride compartido con detour máximo permitido?
• ¿Cobertura geográfica: una ciudad, país, global multi-region?

Frase clave: "Voy a asumir UberX único, pago in-app, una ciudad. Si me pides multi-tier o multi-stop, lo agrego como extensión al final."

Estimaciones de capacidad

Order-of-magnitude por año/día/segundo. Lo que justifica geo index, WebSocket y particionado por ciudad.

10M
MAU riders
~120M MAU/año estimado de uso.
~1M drivers activos · ~50K concurrentes online.
5M
Rides / día
~150M/mes · ~1.8B/año.
~60/s avg · ~600/s peak (hora pico).
~250K/s
Location updates
~21B/día · ~7.7T/año.
1M drivers × update / 4 s.
~1M
WS concurrentes
Riders + drivers en hora pico.
~50K nuevos handshakes/min.
~1.8 TB
Storage rides/año
1 KB × 5M/día = 5 GB/día.
~150 GB/mes; +location traces archivadas.
~100 MB
Redis GEO (live)
1M drivers × ~100 B vivos en RAM.
+ locks ~50 MB; sesiones ~1 GB.
30 s
TTL ubicación
Marca driver stale.
Heartbeat WS cada 15 s con jitter.
99.99%
SLO disponibilidad
~52 min/año · ~4 min/mes.
~8 s/día permitidos por ciudad.

Endpoints

Combinación REST + WebSocket. Las posiciones van por canal persistente; las acciones por HTTP.

MétodoPathDescripciónNotas
POST/api/ridesEl rider solicita un viaje desde A → B.Idempotency-Key recomendada.
GET/api/drivers/nearby?lat&lng&radiusDrivers candidatos por geohash/quadtree.Lectura cacheada cada pocos segundos.
PATCH/api/drivers/{driverId}/locationDriver envía latitud/longitud.Idealmente vía WebSocket.
POST/api/rides/{rideId}/acceptDriver acepta el ride.Lock atómico — first writer wins.
POST/api/rides/{rideId}/cancelCancela la solicitud.Penalización si aplica.
GET/api/rides/{rideId}Estado actual: pending, matched, in_progress, completed.

Cómo defiendo estas APIs en la entrevista

REST + WebSocket es la combinación correcta. Para acciones discretas con state transitions (crear ride, accept, cancel) uso HTTP REST porque me da idempotencia, retry semantics y status codes claros. Para flujos continuos (location updates del driver, ride offers, tracking en vivo) uso WebSocket porque mantiene una conexión persistente y evita la sobrecarga de re-handshake.

Idempotencia donde duele. POST /rides exige Idempotency-Key — sin esto, un timeout en el cliente puede cobrarte dos rides. POST /rides/{id}/accept es naturalmente idempotente (si ya aceptaste, devuelvo 200 con el estado actual; si otro driver ya aceptó, devuelvo 409 Conflict).

Status codes que importan. 201 al crear ride, 202 si el match es async, 409 en doble accept, 410 Gone si el ride expiró, 429 con Retry-After si rate limit.

Frase clave: "Las APIs siguen state machine del ride: pending → matched → in_progress → completed. Cada transición es una operación HTTP idempotente; las posiciones y notificaciones van por el canal WS persistente."

Ejemplo: solicitar ride

Servidor responde con un rideId; el matching ocurre asíncrono.

POST /api/rides
Idempotency-Key: 5f8a-...

{
  "riderId": "u_44",
  "pickup":  { "lat": 19.4326, "lng": -99.1332 },
  "dropoff": { "lat": 19.4978, "lng": -99.1269 },
  "vehicleClass": "comfort"
}

201 Created
{ "rideId": "r_9912", "status": "pending", "etaSeconds": 240 }

Arquitectura

flowchart LR R(["Rider App"]) -- WS / HTTP --> GW D(["Driver App"]) -- WS --> GW GW{{"🌐 API GATEWAY
WebSocket + REST"}} --> Ride["Ride Service
state machine"] GW --> Loc["Location Service"] Loc --> Geo Ride --> Match["Matcher
candidate ranking"] Match --> Geo Match --> Push["Push Service
FCM / APNs"] Push --> D Ride --> RDB Ride -. eventos .-> Stream Stream --> Pay["Payment Service"] Stream --> Rate["Rating Service"] subgraph Geo["💾 Redis GEO · geohash / S2"] direction TB g1["geo:drivers:city"] g2["lock:ride:id"] g3["session:driver:id"] end subgraph RDB["💾 Rides DB · Postgres + PostGIS"] direction TB r1["rides"] r2["drivers"] r3["vehicles"] r4["users"] r5["ratings"] end subgraph Stream["📨 Kafka"] direction TB k1["ride_events"] end classDef gateway fill:#fbbf24,stroke:#f59e0b,stroke-width:3px,color:#0f172a,font-weight:bold classDef service fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#d1fae5 classDef client fill:#1f2937,stroke:#94a3b8,stroke-width:2px,color:#e5e7eb classDef external fill:#1f2937,stroke:#94a3b8,stroke-width:2px,stroke-dasharray:5 5,color:#e5e7eb class GW gateway class Ride,Loc,Match,Pay,Rate service class R,D client class Push external

Cómo lo explico en la entrevista

Driver online. El driver abre la app y se conecta por WebSocket al Gateway con sticky session. Cada 3–5 s envía su posición; el Location Service hace GEOADD geo:drivers:{city} con TTL 30 s. Si deja de reportar, "desaparece" del índice, así nunca matcheo a un driver fantasma.

Rider pide viaje. El rider hace POST /api/rides con Idempotency-Key. El Ride Service crea la fila en Postgres en estado pending y emite un evento al stream. El Matcher consume el evento, llama GEORADIUS en Redis para los N drivers más cercanos, los rankea por ETA y reputación, y publica un RIDE_OFFER a cada candidato por Pub/Sub que cae al WebSocket del driver.

Accept atómico. Cuando el primer driver toca aceptar, hace POST /rides/{id}/accept. Aquí está la parte crítica: SETNX lock:ride:{id} driver_id con TTL 30 s. El primero gana, los demás reciben 409. Confirmo a ambos por WebSocket. Esto evita doble assignment sin necesidad de transacciones distribuidas.

Frase clave: trato la posición como estado en memoria con TTL en Redis GEO, la transición de estados del ride como SQL transaccional, y el matching como un servicio de eventos. Cada componente hace lo que mejor sabe hacer.

Por qué cada componente

WebSocket + sticky LBConexión persistente bidireccional. Sticky garantiza que el mismo cliente siempre toca el mismo pod (state local).
Redis GEOGEOADD / GEORADIUS en O(N+log M). Mucho más simple que un quadtree custom.
Geohash / S2Particionar por celda permite escalar matching por zona sin coordinar globalmente.
SETNX con TTLCompare-and-swap distribuido para resolver carreras de accept sin tx 2PC.
Postgres rides tableEstado del ride es ACID: pending → matched → in_progress → completed. SQL transacciones sobre estados.
Pub/Sub para fan-outEl matcher publica una oferta; los WS de los candidatos la reciben. Desacopla matcher de delivery.
FCM / APNs pushCobertura cuando la app está en background o sin WS abierto (señal mala, batería).
Kafka ride_eventsPermite que payment, rating y analytics consuman el ciclo de vida sin acoplarse al ride service.

Cuellos de botella

  • Hot regions. Aeropuertos y centros saturan un solo geohash bucket en Redis.
  • WebSocket fan-in. 1M+ conexiones TCP simultáneas por servidor.
  • Stale locations. Mala señal del driver hace que parezca disponible pero no responde.
  • Race en accept. Múltiples drivers tocando aceptar a la vez con latencia variable.
  • Cross-region failover. Si una región cae, los rides activos huérfanos.

Mejoras / cómo escala

  • Particionar por celda S2 más fina en zonas calientes, replicar reads.
  • Lua script en Redis: lock + GEOREM + STATE update en un solo round-trip atómico.
  • Backpressure en location updates: descarta intermedios si llegan demasiado rápido.
  • Multi-region active-active por ciudad, sin tráfico cross-region en path crítico.
  • Surge pricing local para balancear oferta/demanda en zonas hot.
  • Pre-asignar pool de drivers para zonas con alta predicibilidad (aeropuerto a las 8 AM).

Por qué estos servicios cloud (vs alternativas)

Redis (ElastiCache / Memorystore)geo index + lock

Por qué: primitivas GEOADD/GEORADIUS nativas con sub-ms latency. SETNX con TTL para CAS distribuido en accept atómico. Lua scripts para combinar geo + lock atómico.

vs PostGIS: queries más expresivas pero ~10× latencia, transacciones caras para 250K updates/s. vs Elasticsearch geo_point: overkill, indexado costoso. vs Quadtree custom: reinventar la rueda y operar el cluster.

PostgreSQL (RDS / Cloud SQL)rides DB

Por qué: ACID en transición de estados del ride (pending → matched → in_progress → completed), tipo GEOGRAPHY con PostGIS para almacenar pickups, foreign keys a users/drivers/vehicles.

vs Spanner / CockroachDB: overkill para single-city; agrega ~10 ms a cada commit. vs DynamoDB: sin transacciones multi-row; el ciclo del ride se vuelve frágil. vs Cassandra: last-write-wins no funciona para state machines.

Apache Kafkaride_events stream

Por qué: múltiples consumers (matcher, payment, rating, analytics) consumen el mismo stream sin coordinar. Particionado por city aísla regiones. Replay para debugging y backfill.

vs Pub/Sub (GCP): aceptable, pero menos control granular de retention y orden por partition. vs Kinesis (AWS): equivalente pero AWS-only y cuotas más rígidas. vs SQS: queue, no stream multi-consumer.

FCM + APNspush notifications

Por qué: proveedores oficiales de Google/Apple — única forma de despertar app cerrada con baja latencia y batería razonable. Soporte de prioridades, collapse keys, voice push.

vs SNS (AWS): agrega un hop, latencia +50–100 ms, sin features avanzadas de plataforma. vs Pusher / OneSignal: third-party, dependes de su SLA. vs solo WebSocket: falla cuando la app está en background.

WebSocket Gateway stickyreal-time channel

Por qué: conexión persistente bidireccional para location updates y ride offers. Sticky LB garantiza que la misma conexión hit el mismo pod (state local del ride/driver).

vs HTTP polling: ~250K req/s extra, batería del driver muerta. vs Server-Sent Events: unidireccional. vs gRPC bidi-streaming: aceptable pero requiere cliente compatible y proxy.

Diseño de base de datos

Postgres para rides y usuarios, Redis GEO para posiciones, particionado por ciudad/región.

rides
ride_idUUID PK
rider_idUUID FK
driver_idUUID FK NULL
statusENUM
pickupGEOGRAPHY
dropoffGEOGRAPHY
fare_centsBIGINT
created_atTIMESTAMPTZ
idx (rider_id, created_at DESC)
idx (driver_id, status)
drivers
driver_idUUID PK
nameTEXT
license_noVARCHAR
vehicle_idUUID FK
statusENUM
rating_avgNUMERIC(2,1)
idx (status) para filtrar online/idle
vehicles
vehicle_idUUID PK
plateVARCHAR(16)
typeENUM
colorVARCHAR
idx (plate UNIQUE)
Redis · GEO + lock
geo:drivers:{city}GEOSET
lock:ride:{ride_id}STRING SETNX
session:driver:{id}HASH
GEOADD / GEORADIUS para nearby
SETNX para CAS en accept (TTL 30 s)

Cómo defiendo este diseño de base de datos

Tres datos, tres herramientas. Postgres para entidades estables (rides, drivers, vehicles) con foreign keys y ACID — el state machine del ride no admite errores. Redis GEO para posiciones (alta frecuencia, TTL natural, primitivas geoespaciales nativas) — un Postgres con PostGIS no aguanta 250K updates/s. Kafka para eventos del ride (downstream consumers: payment, rating, analytics).

Particionado por ciudad. Drivers, rides, geo set — todo particionado por city. El matching siempre ocurre dentro de una ciudad; cross-city no existe. Esto da scale lineal: agrega una ciudad, agrega capacidad, sin coordinar globalmente.

Índices y types específicos. En rides: (rider_id, created_at DESC) para "mis rides recientes", (driver_id, status) para "rides activos del driver". Tipo GEOGRAPHY para pickup/dropoff aprovecha PostGIS para queries de área. Redis sorted set geo:drivers:{city} hace GEOADD y GEORADIUS en O(log N).

Frase clave: "El ciclo de vida del ride vive en Postgres porque ACID; las posiciones en Redis porque latencia y TTL; los eventos en Kafka porque desacople. Cada decisión está justificada por el patrón de acceso."

Decisiones técnicas

TemaDecisiónPor qué
Indexación geoGeohash o QuadTree con buckets por ciudad.Lookups O(log N) por área, partición regional natural.
Location updatesWebSocket cada 3–5s, fallback a HTTP PATCH.Reduce overhead vs HTTP en cada update.
MatchingServicio dedicado que escucha eventos de ride creado.Aísla la latencia del request del rider.
Accept atómicoCompare-and-swap en estado del ride.Evita dos drivers aceptando el mismo viaje.
StorageDB relacional para rides, Redis para posiciones.ACID en la transacción, velocidad para geo.
NotificacionesPush provider externo + WebSocket fallback.Cobertura cuando la app está en background.

Cómo defiendo estas decisiones técnicas

Cada decisión está atada a un constraint. Match < 5 s p95 → broadcast paralelo + push (no espero a que el primero responda). Strong consistency en accept → SETNX atómico, no 2PC distribuido. 250K location updates/s → WebSocket con pseudo-binary protocol, no HTTP. 1M conexiones concurrentes → sticky LB con scaling horizontal.

Tradeoffs explícitos. Geohash da partition natural pero zonas calientes saturan un bucket — tradeoff aceptado a cambio de simplicidad operativa. WebSocket sticky requiere migration cuando un pod falla, pero la complejidad de stateless con state externo es peor. Push providers externos agregan dependencia, pero son la única forma de despertar app cerrada.

Métricas que validan cada decisión. Tiempo desde request → match (target p95 < 5 s). Tasa de doble-accept (debe ser 0; alerta a > 0.01%). Stale location rate (drivers que parecen disponibles pero no responden — alerta a > 5%). Push delivery rate (target > 95%). WebSocket reconnection storm tras deploy.

Cuándo cambio de opinión. Si el SLA es match < 1 s → pre-asigno pool de drivers a zonas calientes. Si voy multi-region active-active → S2 cells en lugar de geohash, con consensus para cross-region drivers. Si caigo en alta contención de accept → uso lease + handoff explícito en lugar de SETNX puro.

Frase clave: "Cada decisión balanced contra un trade. Sticky LB cuesta complejidad de failover pero gana en latencia. Geohash cuesta hot regions pero gana en simplicidad. Un sistema sin tradeoffs explícitos es un sistema sin defensa en la entrevista."

Pitfalls

Doble aceptación

Sin lock, dos drivers pueden aceptar. Usa CAS en una columna accepted_by nullable.

Ubicación stale

Si los updates se pierden, expones drivers que ya no están. Marca TTL de 30s.

Hot region

Aeropuertos y centros saturan un shard. Replica y particiona por celda.

Script de respuesta: “Modelaría Ride como recurso REST y los location updates como canal WebSocket. El matching es un servicio que consume eventos del stream y consulta un índice geoespacial (geohash) en Redis. El accept es una operación CAS para prevenir doble asignación. Particionaría por ciudad/región y usaría push notifications para drivers en background. Mediría p95 de tiempo desde request hasta match.”

Tiers de almacenamiento

Posiciones live en RAM, rides recientes en SSD, históricos baratos en object storage.

Hot · in-memory
Geo set de drivers vivos, locks de rides en accept, sesiones WS, ETA cache.
Redis GEO + ElastiCache / Memorystore
~$36/GB/mes · TTL 30 s en posiciones
Warm · SSD operacional
Rides últimos 90 días, drivers, vehicles, ratings activos.
RDS Postgres con PostGIS / Cloud SQL
~$0.10–0.20/GB/mes
Cold · object storage IA
Rides 90 d – 7 años para legal/compliance, GPS traces archivadas.
S3 Standard-IA / GCS Nearline
~$0.0125/GB/mes
Frozen · archive
Rides > 7 años (anonimizados), recibos, evidencia para disputas legales.
S3 Glacier Deep Archive
~$0.001/GB/mes · retrieval 12 h

Cómo defenderlo en la entrevista

El truco con GPS traces. Cada ride genera ~100 KB de location updates (lat/lng cada 3–5 s × 15 min). A 5M rides/día son ~500 GB/día. Mantenerlas en SSD es muy caro. Las muevo a S3 IA tras 30 días y a Glacier tras 1 año.

Lifecycle automático. S3 lifecycle rule por prefijo: rides/2024/ → IA tras 90 días → Glacier tras 365 días. Postgres parte solo orders < 90 días; archive tablespace con foreign data wrappers a S3.

Frase clave: "Las posiciones live cuestan caro en RAM pero son sub-ms; rides recientes en SSD por ACID; historiales en S3 IA por compliance; archive en Glacier porque solo el regulador o un investigador los toca."

Versión alternativa: con Load Balancer (no API Gateway)

Para ride-sharing es relevante: el WS sticky y la latencia low define mucho del diseño.

flowchart LR R(["Rider App"]) --> NLB D(["Driver App"]) --> NLB NLB{{"⚖️ L4 NLB STICKY
WebSocket-friendly"}} --> Pod["API Pods
+ JWT verifier middleware
+ Rate limit (Redis)"] Pod --> Ride["Ride Service
state machine"] Pod --> Loc["Location Service"] Loc --> Geo Ride --> Match["Matcher"] Match --> Geo Match --> Push["FCM / APNs"] Push --> D Ride --> RDB Ride -. eventos .-> Stream subgraph Geo["💾 Redis GEO"] direction TB g1["geo:drivers:city"] g2["lock:ride:id"] g3["session:driver:id"] end subgraph RDB["💾 Rides DB · Postgres"] direction TB r1["rides"] r2["drivers"] r3["vehicles"] r4["users"] r5["ratings"] end subgraph Stream["📨 Kafka"] direction TB k1["ride_events"] end classDef lb fill:#34d399,stroke:#10b981,stroke-width:3px,color:#0f172a,font-weight:bold classDef service fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#d1fae5 classDef client fill:#1f2937,stroke:#94a3b8,stroke-width:2px,color:#e5e7eb classDef external fill:#1f2937,stroke:#94a3b8,stroke-width:2px,stroke-dasharray:5 5,color:#e5e7eb class NLB lb class Pod,Ride,Loc,Match service class R,D client class Push external

API Gateway vs Load Balancer: cómo defender cada uno

El WebSocket cambia las reglas. Necesito sticky sessions porque el state del ride/driver vive en memoria del pod. Tanto API Gateway como L7 LB pueden hacer sticky, pero L4 NLB tiene menor latencia y mayor throughput. Para 250K location updates/s, los ~10 ms extra del Gateway compounded son costosos.

Frase clave: "Para ride-sharing me inclino por NLB: el WebSocket de los drivers tiene 1M conexiones concurrentes y location updates a 250K/s; un API Gateway agrega latencia y costo por evento que no necesito. Auth lo hago con JWT verificado en el pod (verificación local con public key, no llamada externa)."

Cuándo elegir API Gateway

  • Driver/partner API pública con rate limits por developer key.
  • API monetizada con cuotas para integradores third-party.
  • Multiple consumer types con políticas distintas (riders vs drivers vs ops).
  • Centralizar OAuth flows para login con Google/Apple.
  • Per-route caching para endpoints como /cities o /pricing.

Cuándo elegir Load Balancer

  • WebSocket masivo con 1M+ conexiones — NLB sticky lo maneja mejor.
  • Latencia crítica en location updates (250K/s, cada 3–5 s).
  • Costo a esta escala — gateway por evento dispara el costo.
  • Auth simple: JWT con verifier en pod, sin OAuth complejo.
  • Service mesh (Envoy/Istio) ya cubre auth/observability por sidecar.
  • Mobile-first: la app móvil es propia, no hay third-party que justifique gateway.