TechLead
Leccion 22 de 30
6 min de lectura
Diseño de Sistemas

Diseño de Sistemas: Servicio de Viajes Compartidos

Diseña un servicio de viajes compartidos como Uber o Lyft cubriendo emparejamiento de conductores, indexación geoespacial, cálculo de ETA y algoritmos de precios dinámicos.

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.

Continuar Aprendiendo