Diseñando un Servicio de Viajes Compartidos
Plataformas de viajes compartidos como Uber y Lyft conectan pasajeros con conductores en tiempo real. Estos sistemas deben manejar millones de actualizaciones de ubicación concurrentes, realizar consultas geoespaciales rápidas, calcular ETAs precisos y ajustar precios dinámicamente. La arquitectura requiere un balance cuidadoso de rendimiento en tiempo real, fiabilidad y escalabilidad.
Requisitos Principales
- Funcionales: Solicitar un viaje, emparejar con un conductor cercano, rastrear viaje en tiempo real, calcular tarifa, procesar pago
- No Funcionales: Emparejamiento sub-segundo, actualizaciones de ubicación en tiempo real cada 3-4 segundos, 99.99% de disponibilidad, escala global
- Escala: ~1 millón de conductores activos, ~10 millones de viajes/día, ~10 millones de actualizaciones de ubicación/minuto
Arquitectura de Alto Nivel
El sistema se divide en varios servicios principales: un servicio de emparejamiento, un servicio de ubicación, un servicio de viajes, un servicio de precios y un servicio de notificaciones. Cada uno opera independientemente y se comunica a través de una combinación de APIs síncronas y eventos asíncronos.
| Servicio | Responsabilidad | Tecnología Clave |
|---|---|---|
| Servicio de Ubicación | Ingerir y almacenar ubicaciones de conductores en tiempo real | Redis con índices geoespaciales |
| Servicio de Emparejamiento | Encontrar y asignar el mejor conductor para una solicitud | Algoritmo personalizado + datos de ubicación |
| Servicio de Viajes | Gestionar el ciclo de vida del viaje de solicitud a completación | PostgreSQL + máquina de estados |
| Servicio de Precios | Calcular tarifas y aplicar precios dinámicos | Análisis de demanda en tiempo real |
| Servicio de Notificaciones | Enviar actualizaciones a pasajeros y conductores | WebSocket + notificaciones push |
Emparejamiento de Pasajeros con Conductores
El algoritmo de emparejamiento es el corazón del sistema. Cuando un pasajero solicita un viaje, el sistema debe encontrar el mejor conductor disponible considerando distancia, tiempo estimado de llegada, calificación del conductor y tipo de vehículo.
interface RideRequest {
riderId: string;
pickupLocation: GeoPoint;
dropoffLocation: GeoPoint;
rideType: "ECONOMY" | "PREMIUM" | "XL";
timestamp: Date;
}
interface DriverCandidate {
driverId: string;
location: GeoPoint;
distanceKm: number;
etaMinutes: number;
rating: number;
vehicleType: string;
acceptanceRate: number;
}
async function matchDriver(request: RideRequest): Promise<DriverCandidate | null> {
// Step 1: Find nearby available drivers within radius
let searchRadiusKm = 3;
let candidates: DriverCandidate[] = [];
while (candidates.length < 5 && searchRadiusKm <= 10) {
candidates = await locationService.findNearbyDrivers(
request.pickupLocation,
searchRadiusKm,
request.rideType
);
searchRadiusKm += 2;
}
if (candidates.length === 0) return null;
// Step 2: Calculate ETA for each candidate
const withETA = await Promise.all(
candidates.map(async (driver) => ({
...driver,
etaMinutes: await etaService.calculate(
driver.location,
request.pickupLocation
),
}))
);
// Step 3: Score and rank candidates
const scored = withETA.map((driver) => ({
...driver,
score: calculateMatchScore(driver),
}));
scored.sort((a, b) => b.score - a.score);
return scored[0];
}
function calculateMatchScore(driver: DriverCandidate): number {
const etaScore = Math.max(0, 100 - driver.etaMinutes * 10);
const ratingScore = driver.rating * 20;
const acceptanceScore = driver.acceptanceRate * 50;
return etaScore * 0.5 + ratingScore * 0.3 + acceptanceScore * 0.2;
}Rastreo de Ubicación e Indexación Geoespacial
Los conductores envían actualizaciones de ubicación cada 3-4 segundos. Con 1 millón de conductores activos, esto es aproximadamente 300,000 actualizaciones por segundo. El sistema debe ingerir estas actualizaciones y soportar consultas geoespaciales rápidas.
// Redis Geospatial Commands for driver location
// GEOADD drivers longitude latitude driverId
// GEORADIUS drivers longitude latitude radius km
interface GeoPoint {
latitude: number;
longitude: number;
}
class LocationService {
private redis: RedisClient;
async updateDriverLocation(
driverId: string,
location: GeoPoint
): Promise<void> {
await this.redis.geoadd(
"active_drivers",
location.longitude,
location.latitude,
driverId
);
await this.redis.xadd("driver_locations", "*", {
driverId,
lat: location.latitude.toString(),
lng: location.longitude.toString(),
timestamp: Date.now().toString(),
});
}
async findNearbyDrivers(
point: GeoPoint,
radiusKm: number,
rideType: string
): Promise<DriverCandidate[]> {
const results = await this.redis.georadius(
"active_drivers",
point.longitude,
point.latitude,
radiusKm,
"km",
"WITHCOORD",
"WITHDIST",
"ASC",
"COUNT",
20
);
const available = await this.filterAvailable(results, rideType);
return available;
}
}Para escalas muy grandes, puedes particionar conductores por región geográfica usando geohashes o celdas S2. Cada celda se mapea a una instancia Redis específica, así una consulta solo impacta el shard relevante.
Cálculo de ETA
El cálculo de ETA es más complejo que una simple división de distancia. Los ETAs precisos requieren datos de red vial, tráfico en tiempo real, patrones históricos y modelos de machine learning.
- Grafo de red vial: Usar datos de OpenStreetMap para construir un grafo ponderado de carreteras con distancias y límites de velocidad
- Camino más corto: Usar algoritmo de Dijkstra o A* para cálculo de rutas
- Ajuste de tráfico: Multiplicar tiempos de viaje por segmento con factores de tráfico en tiempo real
- Patrones históricos: Usar modelos ML entrenados con datos de viajes pasados para predecir tiempos de viaje para rutas específicas en horarios específicos
- Predicción por segmento: Dividir rutas en segmentos y predecir el tiempo de viaje de cada segmento independientemente para mejor precisión
Algoritmo de Precios Dinámicos
Los precios dinámicos equilibran oferta y demanda. Cuando la demanda de pasajeros excede los conductores disponibles en un área, los precios aumentan para incentivar más conductores y reducir la demanda.
interface SurgeZone {
zoneId: string;
boundary: GeoPoint[];
currentMultiplier: number;
activeDrivers: number;
pendingRequests: number;
recentCompletedTrips: number;
}
function calculateSurgeMultiplier(zone: SurgeZone): number {
const demandSupplyRatio = zone.pendingRequests / Math.max(zone.activeDrivers, 1);
if (demandSupplyRatio < 1.0) return 1.0; // No surge
if (demandSupplyRatio < 1.5) return 1.2;
if (demandSupplyRatio < 2.0) return 1.5;
if (demandSupplyRatio < 3.0) return 2.0;
if (demandSupplyRatio < 4.0) return 2.5;
return 3.0; // Cap at 3x
// In production, use a smooth function:
// return Math.min(3.0, 1.0 + Math.log2(demandSupplyRatio) * 0.8);
}
async function updateSurgePricing(): Promise<void> {
const zones = await getActiveZones();
for (const zone of zones) {
const drivers = await locationService.countDriversInZone(zone);
const requests = await tripService.countPendingInZone(zone);
zone.activeDrivers = drivers;
zone.pendingRequests = requests;
zone.currentMultiplier = calculateSurgeMultiplier(zone);
await saveSurgeZone(zone);
}
}Gestión de Viajes
Cada viaje sigue una máquina de estados bien definida. El servicio de viajes gestiona las transiciones y asegura consistencia entre todos los componentes.
enum TripState {
REQUESTED = "REQUESTED",
DRIVER_ASSIGNED = "DRIVER_ASSIGNED",
DRIVER_EN_ROUTE = "DRIVER_EN_ROUTE",
ARRIVED = "ARRIVED",
IN_PROGRESS = "IN_PROGRESS",
COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED",
}
const VALID_TRANSITIONS: Record<TripState, TripState[]> = {
[TripState.REQUESTED]: [TripState.DRIVER_ASSIGNED, TripState.CANCELLED],
[TripState.DRIVER_ASSIGNED]: [TripState.DRIVER_EN_ROUTE, TripState.CANCELLED],
[TripState.DRIVER_EN_ROUTE]: [TripState.ARRIVED, TripState.CANCELLED],
[TripState.ARRIVED]: [TripState.IN_PROGRESS, TripState.CANCELLED],
[TripState.IN_PROGRESS]: [TripState.COMPLETED],
[TripState.COMPLETED]: [],
[TripState.CANCELLED]: [],
};Actualizaciones en Tiempo Real vía WebSocket
Tanto los pasajeros como los conductores necesitan actualizaciones en tiempo real. Las conexiones WebSocket proporcionan comunicación bidireccional de baja latencia para rastreo de ubicación, cambios de estado de viaje y notificaciones.
Arquitectura WebSocket
- Gestión de conexiones: Usar un gateway WebSocket (ej. clúster de Socket.IO) que maneje millones de conexiones concurrentes
- Backbone Pub/Sub: Usar Redis Pub/Sub o Kafka para enrutar mensajes a la instancia correcta del servidor WebSocket
- Enrutamiento basado en canales: Cada viaje activo tiene un canal. Tanto el pasajero como el conductor se suscriben al canal de su viaje
- Fallback: Si WebSocket falla, recurrir a long polling o notificaciones push
- Heartbeat: Enviar pings periódicos para detectar conexiones inactivas y limpiar recursos
Compensaciones Clave de Diseño
Frecuencia de actualización de ubicación: Actualizaciones más frecuentes significan mejor precisión pero mayor ancho de banda y costos de servidor. Intervalos de 3-4 segundos proporcionan un buen equilibrio. Durante viajes activos, puedes aumentar la frecuencia a 1-2 segundos para mejor rastreo.
Estrategia de emparejamiento: Enviar la solicitud al único mejor conductor es más simple pero arriesga rechazo. Transmitir a múltiples conductores y tomar la primera aceptación reduce el tiempo de espera pero puede causar problemas de coordinación.