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.
~1M drivers activos · ~50K concurrentes online.
~60/s avg · ~600/s peak (hora pico).
1M drivers × update / 4 s.
~50K nuevos handshakes/min.
~150 GB/mes; +location traces archivadas.
+ locks ~50 MB; sesiones ~1 GB.
Heartbeat WS cada 15 s con jitter.
~8 s/día permitidos por ciudad.
Endpoints
Combinación REST + WebSocket. Las posiciones van por canal persistente; las acciones por HTTP.
| Método | Path | Descripción | Notas |
|---|---|---|---|
| POST | /api/rides | El rider solicita un viaje desde A → B. | Idempotency-Key recomendada. |
| GET | /api/drivers/nearby?lat&lng&radius | Drivers candidatos por geohash/quadtree. | Lectura cacheada cada pocos segundos. |
| PATCH | /api/drivers/{driverId}/location | Driver envía latitud/longitud. | Idealmente vía WebSocket. |
| POST | /api/rides/{rideId}/accept | Driver acepta el ride. | Lock atómico — first writer wins. |
| POST | /api/rides/{rideId}/cancel | Cancela 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
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
GEOADD / GEORADIUS en O(N+log M). Mucho más simple que un quadtree custom.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)
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.
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.
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.
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.
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.
(rider_id, created_at DESC)idx
(driver_id, status)(status) para filtrar online/idle(plate UNIQUE)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
| Tema | Decisión | Por qué |
|---|---|---|
| Indexación geo | Geohash o QuadTree con buckets por ciudad. | Lookups O(log N) por área, partición regional natural. |
| Location updates | WebSocket cada 3–5s, fallback a HTTP PATCH. | Reduce overhead vs HTTP en cada update. |
| Matching | Servicio dedicado que escucha eventos de ride creado. | Aísla la latencia del request del rider. |
| Accept atómico | Compare-and-swap en estado del ride. | Evita dos drivers aceptando el mismo viaje. |
| Storage | DB relacional para rides, Redis para posiciones. | ACID en la transacción, velocidad para geo. |
| Notificaciones | Push 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
Sin lock, dos drivers pueden aceptar. Usa CAS en una columna accepted_by nullable.
Si los updates se pierden, expones drivers que ya no están. Marca TTL de 30s.
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.
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.
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
/citieso/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.