ExpertКейс7 min

Uber-like система

Проектирование Uber: геолокация водителей, matching, real-time tracking, surge pricing, ETA

Uber-like система (Ride-hailing)

Проектирование платформы для заказа такси: real-time геолокация, matching водителей, отслеживание поездки, surge pricing, ETA.

Шаг 1: Требования

Функциональные требования

  1. Для пассажиров: запрос поездки, выбор типа авто, отслеживание водителя, оплата
  2. Для водителей: выход на линию, принятие/отклонение заказа, навигация, earnings
  3. Matching: подбор ближайшего доступного водителя
  4. Pricing: расчёт стоимости, surge pricing при высоком спросе
  5. ETA: предсказание времени прибытия и поездки
  6. Rating: двусторонняя система оценок

Нефункциональные требования

  1. Matching latency < 3 секунды
  2. Location update frequency: каждые 3-5 секунд
  3. 1M+ concurrent drivers
  4. 10M+ rides per day
  5. 99.99% доступность для ride matching

Шаг 2: Оценка нагрузки

Метрика Значение
Active drivers 1M
Location updates/sec ~300K (1M / 3 sec)
Ride requests/sec ~200 (10M / 86400 * 2 peak)
Concurrent rides ~500K
GPS data per day ~150 GB

Шаг 3: High-Level архитектура

┌────────────────┐         ┌────────────────┐
│  Rider App     │         │  Driver App    │
│  (iOS/Android) │         │  (iOS/Android) │
└───────┬────────┘         └───────┬────────┘
        │                          │
        │  WebSocket               │  WebSocket
        │                          │
┌───────▼──────────────────────────▼────────┐
│              API Gateway + LB             │
└───────┬──────────────┬───────────┬────────┘
        │              │           │
┌───────▼────┐  ┌──────▼─────┐  ┌─▼──────────────┐
│ Ride       │  │ Location   │  │ Matching       │
│ Service    │  │ Service    │  │ Service        │
└───────┬────┘  └──────┬─────┘  └─┬──────────────┘
        │              │          │
┌───────▼────┐  ┌──────▼─────┐  ┌─▼──────────────┐
│ Payment    │  │ Redis/     │  │ Pricing        │
│ Service    │  │ Location   │  │ Service        │
│            │  │ Store      │  │ (Surge)        │
└────────────┘  └────────────┘  └────────────────┘
        │              │          │
┌───────▼──────────────▼──────────▼────────┐
│         PostgreSQL  |  Kafka  |  S3      │
└──────────────────────────────────────────┘

Шаг 4: Детальный дизайн

4.1 Location Service (Driver Position Tracking)

<?php

declare(strict_types=1);

final class LocationService
{
    private const POSITION_TTL = 30; // seconds

    public function __construct(
        private readonly \Redis $redis,
        private readonly QueuePublisher $queue,
    ) {}

    /**
     * Update driver's position (called every 3-5 seconds)
     */
    public function updateDriverPosition(
        string $driverId,
        float $lat,
        float $lng,
        float $heading,
        float $speed,
    ): void {
        // 1. Update in Redis GEO (for proximity queries)
        $this->redis->geoAdd('drivers:active', $lng, $lat, $driverId);

        // 2. Store detailed position data
        $this->redis->setex(
            "driver:pos:{$driverId}",
            self::POSITION_TTL,
            json_encode([
                'lat' => $lat,
                'lng' => $lng,
                'heading' => $heading,
                'speed' => $speed,
                'updated_at' => microtime(true),
            ]),
        );

        // 3. If driver is on a ride, publish for rider tracking
        $rideId = $this->redis->get("driver:ride:{$driverId}");
        if ($rideId !== false) {
            $this->queue->publish("ride.location.{$rideId}", [
                'driver_id' => $driverId,
                'lat' => $lat,
                'lng' => $lng,
                'heading' => $heading,
                'speed' => $speed,
            ]);
        }
    }

    /**
     * Find nearby available drivers
     */
    public function findNearbyDrivers(
        float $lat,
        float $lng,
        float $radiusKm = 5.0,
        int $limit = 20,
    ): array {
        // GEORADIUS returns sorted by distance
        $nearby = $this->redis->geoRadius(
            'drivers:active',
            $lng,
            $lat,
            $radiusKm,
            'km',
            [
                'WITHDIST',
                'WITHCOORD',
                'COUNT' => $limit,
                'ASC',
            ],
        );

        $drivers = [];
        foreach ($nearby as $driver) {
            $driverId = $driver[0];

            // Check if driver is available (not on a ride)
            $status = $this->redis->get("driver:status:{$driverId}");
            if ($status !== 'available') {
                continue;
            }

            // Get detailed position
            $posData = $this->redis->get("driver:pos:{$driverId}");
            $pos = $posData ? json_decode($posData, true) : null;

            $drivers[] = [
                'driver_id' => $driverId,
                'distance_km' => (float) $driver[1],
                'lat' => $pos['lat'] ?? (float) $driver[2][1],
                'lng' => $pos['lng'] ?? (float) $driver[2][0],
                'heading' => $pos['heading'] ?? 0,
                'eta_minutes' => $this->estimateEta((float) $driver[1]),
            ];
        }

        return $drivers;
    }

    /**
     * Go online/offline
     */
    public function setDriverStatus(string $driverId, string $status): void
    {
        $this->redis->set("driver:status:{$driverId}", $status);

        if ($status === 'offline') {
            $this->redis->zRem('drivers:active', $driverId);
        }
    }

    private function estimateEta(float $distanceKm): float
    {
        // Simple estimation: avg city speed ~25 km/h + pickup time
        return round(($distanceKm / 25.0) * 60 + 2, 1); // minutes
    }
}

4.2 Matching Service

<?php

declare(strict_types=1);

final class MatchingService
{
    private const SEARCH_RADIUS_KM = 5.0;
    private const MAX_SEARCH_RADIUS_KM = 15.0;
    private const OFFER_TIMEOUT_SEC = 15;

    public function __construct(
        private readonly LocationService $location,
        private readonly DriverRepository $drivers,
        private readonly \Redis $redis,
        private readonly QueuePublisher $queue,
    ) {}

    /**
     * Find and assign a driver for a ride request
     */
    public function matchDriver(RideRequest $request): MatchResult
    {
        $radius = self::SEARCH_RADIUS_KM;
        $attempt = 0;

        while ($radius <= self::MAX_SEARCH_RADIUS_KM) {
            $attempt++;

            // 1. Find nearby available drivers
            $candidates = $this->location->findNearbyDrivers(
                $request->pickupLat,
                $request->pickupLng,
                $radius,
                limit: 10,
            );

            if (empty($candidates)) {
                $radius *= 1.5; // Expand search
                continue;
            }

            // 2. Score candidates
            $scored = $this->scoreCandidates($candidates, $request);

            // 3. Send offer to best candidate
            foreach ($scored as $candidate) {
                $offerId = $this->sendOffer($candidate['driver_id'], $request);

                // 4. Wait for driver response (with timeout)
                $response = $this->waitForResponse($offerId, self::OFFER_TIMEOUT_SEC);

                if ($response === 'accepted') {
                    return new MatchResult(
                        matched: true,
                        driverId: $candidate['driver_id'],
                        eta: $candidate['eta_minutes'],
                        distance: $candidate['distance_km'],
                    );
                }

                // Driver declined or timed out, try next
            }

            $radius *= 1.5;
        }

        return MatchResult::noDriverAvailable();
    }

    /**
     * Score candidates based on multiple factors
     */
    private function scoreCandidates(array $candidates, RideRequest $request): array
    {
        foreach ($candidates as &$candidate) {
            $driverId = $candidate['driver_id'];
            $driver = $this->drivers->find($driverId);

            // Weighted scoring
            $score = 0;

            // Distance (closer = better), weight: 40%
            $distScore = 1.0 / (1.0 + $candidate['distance_km']);
            $score += $distScore * 0.4;

            // Rating (higher = better), weight: 30%
            $ratingScore = ($driver->rating ?? 4.5) / 5.0;
            $score += $ratingScore * 0.3;

            // Acceptance rate, weight: 20%
            $acceptScore = ($driver->acceptanceRate ?? 0.8);
            $score += $acceptScore * 0.2;

            // Vehicle match, weight: 10%
            $vehicleMatch = $this->vehicleMatchScore($driver, $request->rideType);
            $score += $vehicleMatch * 0.1;

            $candidate['score'] = $score;
        }

        // Sort by score descending
        usort($candidates, fn ($a, $b) => $b['score'] <=> $a['score']);

        return $candidates;
    }

    private function sendOffer(string $driverId, RideRequest $request): string
    {
        $offerId = bin2hex(random_bytes(16));

        // Set driver status to "offered"
        $this->redis->setex("driver:offer:{$driverId}", self::OFFER_TIMEOUT_SEC, $offerId);

        // Push offer to driver via WebSocket
        $this->queue->publish("driver.offer.{$driverId}", [
            'offer_id' => $offerId,
            'pickup' => [
                'lat' => $request->pickupLat,
                'lng' => $request->pickupLng,
                'address' => $request->pickupAddress,
            ],
            'dropoff' => [
                'lat' => $request->dropoffLat,
                'lng' => $request->dropoffLng,
                'address' => $request->dropoffAddress,
            ],
            'estimated_fare' => $request->estimatedFare,
            'ride_type' => $request->rideType,
            'timeout' => self::OFFER_TIMEOUT_SEC,
        ]);

        return $offerId;
    }

    private function waitForResponse(string $offerId, int $timeout): string
    {
        // BL(ocking)POP on Redis list with timeout
        $result = $this->redis->blPop(["offer_response:{$offerId}"], $timeout);

        if ($result === null) {
            return 'timeout';
        }

        return $result[1]; // 'accepted' or 'declined'
    }
}

4.3 Ride Service

<?php

declare(strict_types=1);

final class RideService
{
    public function __construct(
        private readonly RideRepository $rides,
        private readonly LocationService $location,
        private readonly PricingEngine $pricing,
        private readonly PaymentService $payment,
        private readonly MatchingService $matching,
        private readonly NotificationService $notifications,
        private readonly \Redis $redis,
    ) {}

    public function requestRide(RideRequest $request): RideResponse
    {
        // 1. Calculate fare estimate
        $fareEstimate = $this->pricing->estimate(
            $request->pickupLat, $request->pickupLng,
            $request->dropoffLat, $request->dropoffLng,
            $request->rideType,
        );

        $request->estimatedFare = $fareEstimate->amount;

        // 2. Match driver
        $match = $this->matching->matchDriver($request);

        if (!$match->matched) {
            return new RideResponse(
                status: 'no_drivers',
                message: 'No drivers available in your area',
            );
        }

        // 3. Create ride record
        $ride = $this->rides->create(
            riderId: $request->riderId,
            driverId: $match->driverId,
            pickupLat: $request->pickupLat,
            pickupLng: $request->pickupLng,
            dropoffLat: $request->dropoffLat,
            dropoffLng: $request->dropoffLng,
            rideType: $request->rideType,
            estimatedFare: $fareEstimate->amount,
            surgeMultiplier: $fareEstimate->surgeMultiplier,
            status: 'driver_assigned',
        );

        // 4. Link driver to ride
        $this->redis->set("driver:ride:{$match->driverId}", $ride->id);
        $this->redis->set("driver:status:{$match->driverId}", 'on_ride');

        // 5. Notify rider
        $this->notifications->send(new NotificationRequest(
            userId: $request->riderId,
            templateCode: 'driver_assigned',
            channel: 'push',
            variables: [
                'driver_name' => $match->driverName ?? '',
                'eta' => $match->eta,
                'vehicle' => $match->vehicle ?? '',
            ],
        ));

        return new RideResponse(
            status: 'driver_assigned',
            rideId: $ride->id,
            driverId: $match->driverId,
            eta: $match->eta,
            estimatedFare: $fareEstimate,
        );
    }

    /**
     * Driver arrives at pickup
     */
    public function driverArrived(string $rideId, string $driverId): void
    {
        $this->rides->updateStatus($rideId, 'driver_arrived');

        $ride = $this->rides->find($rideId);
        $this->notifications->send(new NotificationRequest(
            userId: $ride->riderId,
            templateCode: 'driver_arrived',
            channel: 'push',
        ));
    }

    /**
     * Start the trip
     */
    public function startTrip(string $rideId, string $driverId): void
    {
        $this->rides->updateStatus($rideId, 'in_progress');
        $this->rides->setStartTime($rideId, new \DateTimeImmutable());
    }

    /**
     * Complete the trip and process payment
     */
    public function completeTrip(
        string $rideId,
        string $driverId,
        float $dropoffLat,
        float $dropoffLng,
    ): TripSummary {
        $ride = $this->rides->find($rideId);

        // 1. Calculate actual fare (distance + time + surge)
        $actualFare = $this->pricing->calculateActual(
            $ride->pickupLat, $ride->pickupLng,
            $dropoffLat, $dropoffLng,
            $ride->startedAt,
            new \DateTimeImmutable(),
            $ride->surgeMultiplier,
            $ride->rideType,
        );

        // 2. Process payment
        $paymentResult = $this->payment->charge(
            amount: $actualFare->total,
            riderId: $ride->riderId,
            rideId: $rideId,
        );

        // 3. Update ride record
        $this->rides->complete($rideId, [
            'actual_fare' => $actualFare->total,
            'distance_km' => $actualFare->distanceKm,
            'duration_min' => $actualFare->durationMin,
            'dropoff_lat' => $dropoffLat,
            'dropoff_lng' => $dropoffLng,
            'payment_id' => $paymentResult->id,
        ]);

        // 4. Release driver
        $this->redis->del("driver:ride:{$driverId}");
        $this->redis->set("driver:status:{$driverId}", 'available');

        // 5. Schedule driver payout
        $driverPayout = $actualFare->total * 0.75; // 75% to driver
        $this->payment->scheduleDriverPayout($driverId, $driverPayout, $rideId);

        return new TripSummary(
            rideId: $rideId,
            fare: $actualFare->total,
            distanceKm: $actualFare->distanceKm,
            durationMin: $actualFare->durationMin,
        );
    }
}

4.4 Surge Pricing

<?php

declare(strict_types=1);

final class SurgePricingService
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly LocationService $location,
    ) {}

    /**
     * Calculate surge multiplier for an area
     */
    public function getSurgeMultiplier(float $lat, float $lng): float
    {
        $geohash = $this->getGeohash($lat, $lng, 5); // ~5km area
        $cacheKey = "surge:{$geohash}";

        // Check cached value
        $cached = $this->redis->get($cacheKey);
        if ($cached !== false) {
            return (float) $cached;
        }

        // Calculate demand/supply ratio
        $demand = $this->getRecentDemand($geohash);  // ride requests
        $supply = $this->getAvailableDrivers($lat, $lng); // available drivers

        $multiplier = $this->calculateMultiplier($demand, $supply);

        // Cache for 5 minutes
        $this->redis->setex($cacheKey, 300, (string) $multiplier);

        return $multiplier;
    }

    private function calculateMultiplier(int $demand, int $supply): float
    {
        if ($supply === 0) {
            return 3.0; // Max surge
        }

        $ratio = $demand / $supply;

        return match (true) {
            $ratio < 1.0 => 1.0,   // No surge
            $ratio < 1.5 => 1.25,
            $ratio < 2.0 => 1.5,
            $ratio < 3.0 => 2.0,
            $ratio < 5.0 => 2.5,
            default      => 3.0,   // Max surge cap
        };
    }

    private function getRecentDemand(string $geohash): int
    {
        // Count ride requests in this area in last 5 minutes
        return (int) $this->redis->get("demand:{$geohash}") ?: 0;
    }

    public function trackDemand(float $lat, float $lng): void
    {
        $geohash = $this->getGeohash($lat, $lng, 5);
        $this->redis->incr("demand:{$geohash}");
        $this->redis->expire("demand:{$geohash}", 300);
    }
}

Шаг 5: Масштабирование

Компонент Технология Стратегия
Driver locations Redis Cluster Geo-sharded
Ride matching Stateless workers Regional
Trip data PostgreSQL Sharded by city
GPS tracking Kafka -> ClickHouse Stream processing
ETA prediction ML service Pre-computed per cell
Surge pricing Redis Per-geohash, 5min TTL
Payments Stripe/Braintree Async settlement

Возможные вопросы интервьюера

  1. Как масштабировать location updates до 300K/sec?

    • Redis Cluster с geo-sharding
    • Batching: driver отправляет batch каждые 3 сек
    • UDP для non-critical position updates
  2. Как обеспечить справедливый matching?

    • Weighted scoring (distance + rating + acceptance)
    • Round-robin при одинаковом score
    • Penalize frequent decliners
  3. Как работает ETA prediction?

    • Historical trip data + ML model
    • Real-time traffic data
    • Road graph (OSRM/Google Directions)
    • Periodic recalculation grid
  4. Как обрабатывать rider cancellation?

    • Free cancellation в первые 2 минуты
    • Cancellation fee после
    • Driver компенсация за потраченное время