ExpertКейс6 min

Airbnb-like платформа

Проектирование Airbnb: поиск жилья, бронирование, отзывы, платежи, matching хостов и гостей

Проектирование маркетплейса для краткосрочной аренды жилья -- комплексная система, объединяющая поиск, бронирование, платежи, отзывы и коммуникацию.

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

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

  1. Для гостей: поиск жилья по локации, датам, гостям, фильтрам; бронирование; оплата; отзывы
  2. Для хостов: создание листинга; управление календарём; ценообразование; приём/отклонение бронирований
  3. Для обоих: обмен сообщениями; уведомления; профили и верификация
  4. Платёжная система: выплаты хостам, удержание комиссии, возвраты

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

  1. Latency поиска < 500ms
  2. Consistency бронирований (no double booking)
  3. 5M+ листингов, 50M+ пользователей
  4. Доступность 99.99%
  5. Multi-region deployment

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

┌──────────┐    ┌───────────────┐    ┌──────────────────────────────────────┐
│  Client  │───>│  API Gateway  │───>│  Microservices                       │
│  (Web/   │    │  + CDN        │    │                                      │
│   App)   │    └───────────────┘    │  ┌──────────┐  ┌───────────────────┐ │
└──────────┘                         │  │ Search   │  │ Booking           │ │
                                     │  │ Service  │  │ Service           │ │
                                     │  └────┬─────┘  └────────┬──────────┘ │
                                     │       │                 │            │
                                     │  ┌────▼─────┐  ┌───────▼──────────┐ │
                                     │  │ Listing  │  │ Payment          │ │
                                     │  │ Service  │  │ Service          │ │
                                     │  └──────────┘  └──────────────────┘ │
                                     │                                      │
                                     │  ┌──────────┐  ┌──────────────────┐ │
                                     │  │ User     │  │ Messaging        │ │
                                     │  │ Service  │  │ Service          │ │
                                     │  └──────────┘  └──────────────────┘ │
                                     │                                      │
                                     │  ┌──────────┐  ┌──────────────────┐ │
                                     │  │ Review   │  │ Notification     │ │
                                     │  │ Service  │  │ Service          │ │
                                     │  └──────────┘  └──────────────────┘ │
                                     └──────────────────────────────────────┘

Шаг 3: Ключевые компоненты

3.1 Listing Service

<?php

declare(strict_types=1);

final class ListingService
{
    public function __construct(
        private readonly ListingRepository $repository,
        private readonly SearchIndexer $indexer,
        private readonly ImageService $images,
        private readonly GeocodingService $geocoding,
    ) {}

    public function createListing(CreateListingRequest $request): Listing
    {
        // 1. Geocode the address
        $coordinates = $this->geocoding->geocode($request->address);

        // 2. Process and upload images
        $imageUrls = [];
        foreach ($request->images as $image) {
            $imageUrls[] = $this->images->processAndUpload(
                $image,
                sizes: ['thumbnail', 'medium', 'large'],
            );
        }

        // 3. Create listing
        $listing = $this->repository->create(
            hostId: $request->hostId,
            title: $request->title,
            description: $request->description,
            propertyType: $request->propertyType,
            roomType: $request->roomType,
            maxGuests: $request->maxGuests,
            bedrooms: $request->bedrooms,
            bathrooms: $request->bathrooms,
            amenities: $request->amenities,
            address: $request->address,
            latitude: $coordinates->lat,
            longitude: $coordinates->lng,
            pricePerNight: $request->pricePerNight,
            images: $imageUrls,
            houseRules: $request->houseRules,
        );

        // 4. Index for search
        $this->indexer->indexListing($listing);

        return $listing;
    }

    public function updateCalendar(
        string $listingId,
        string $hostId,
        array $dateUpdates,
    ): void {
        $listing = $this->repository->findByIdAndHost($listingId, $hostId);

        foreach ($dateUpdates as $update) {
            $this->repository->upsertCalendarDate(
                listingId: $listingId,
                date: new \DateTimeImmutable($update['date']),
                available: $update['available'],
                price: $update['price'] ?? null,
                minNights: $update['min_nights'] ?? null,
            );
        }

        // Update search index with new availability
        $this->indexer->updateAvailability($listingId);
    }
}

3.2 Search Service

<?php

declare(strict_types=1);

final class ListingSearchService
{
    public function __construct(
        private readonly ElasticsearchClient $elastic,
        private readonly \Redis $redis,
        private readonly AvailabilityChecker $availability,
    ) {}

    public function search(SearchRequest $request): SearchResults
    {
        // 1. Build Elasticsearch query
        $query = $this->buildQuery($request);

        // 2. Execute search
        $esResults = $this->elastic->search([
            'index' => 'listings',
            'body' => $query,
        ]);

        // 3. Filter by availability (post-search)
        $listings = [];
        foreach ($esResults['hits']['hits'] as $hit) {
            $listingId = $hit['_id'];

            // Check availability in PostgreSQL
            $isAvailable = $this->availability->isAvailable(
                $listingId,
                $request->checkIn,
                $request->checkOut,
            );

            if ($isAvailable) {
                $priceBreakdown = $this->availability->calculatePrice(
                    $listingId,
                    $request->checkIn,
                    $request->checkOut,
                );

                $listings[] = array_merge($hit['_source'], [
                    'total_price' => $priceBreakdown->total,
                    'price_per_night' => $priceBreakdown->avgPerNight,
                    'distance_km' => $hit['sort'][0] ?? null,
                ]);
            }
        }

        return new SearchResults(
            listings: $listings,
            total: $esResults['hits']['total']['value'],
            filters: $this->extractFilters($esResults),
        );
    }

    private function buildQuery(SearchRequest $request): array
    {
        $must = [];
        $filter = [];

        // Location (geo search)
        $filter[] = [
            'geo_distance' => [
                'distance' => ($request->radiusKm ?? 25) . 'km',
                'location' => [
                    'lat' => $request->latitude,
                    'lon' => $request->longitude,
                ],
            ],
        ];

        // Guests
        $filter[] = ['range' => ['max_guests' => ['gte' => $request->guests]]];

        // Property type
        if ($request->propertyType) {
            $filter[] = ['term' => ['property_type' => $request->propertyType]];
        }

        // Price range
        if ($request->minPrice || $request->maxPrice) {
            $priceFilter = [];
            if ($request->minPrice) $priceFilter['gte'] = $request->minPrice;
            if ($request->maxPrice) $priceFilter['lte'] = $request->maxPrice;
            $filter[] = ['range' => ['price_per_night' => $priceFilter]];
        }

        // Amenities
        if ($request->amenities) {
            $filter[] = ['terms' => ['amenities' => $request->amenities]];
        }

        // Star rating / Superhost
        if ($request->superhostOnly) {
            $filter[] = ['term' => ['is_superhost' => true]];
        }

        return [
            'query' => [
                'bool' => [
                    'must' => $must ?: [['match_all' => new \stdClass()]],
                    'filter' => $filter,
                ],
            ],
            'sort' => $this->getSortOrder($request->sortBy, $request->latitude, $request->longitude),
            'size' => $request->limit,
            'from' => $request->offset,
            'aggs' => [
                'price_ranges' => [
                    'range' => [
                        'field' => 'price_per_night',
                        'ranges' => [
                            ['to' => 50],
                            ['from' => 50, 'to' => 100],
                            ['from' => 100, 'to' => 200],
                            ['from' => 200],
                        ],
                    ],
                ],
                'property_types' => [
                    'terms' => ['field' => 'property_type'],
                ],
            ],
        ];
    }
}

3.3 Booking Service

<?php

declare(strict_types=1);

final class AirbnbBookingService
{
    public function __construct(
        private readonly AvailabilityChecker $availability,
        private readonly BookingRepository $bookings,
        private readonly PaymentService $payment,
        private readonly MessagingService $messaging,
        private readonly NotificationService $notifications,
        private readonly \Redis $redis,
    ) {}

    /**
     * Request a booking (may require host approval)
     */
    public function requestBooking(BookingRequest $request): BookingResult
    {
        // 1. Verify availability
        if (!$this->availability->isAvailable(
            $request->listingId,
            $request->checkIn,
            $request->checkOut,
        )) {
            throw new UnavailableException('Dates not available');
        }

        // 2. Calculate price
        $price = $this->availability->calculatePrice(
            $request->listingId,
            $request->checkIn,
            $request->checkOut,
        );

        // Service fee (guest pays ~14%, host pays ~3%)
        $guestFee = round($price->total * 0.14, 2);
        $hostFee = round($price->total * 0.03, 2);
        $totalCharge = $price->total + $guestFee;

        // 3. Pre-authorize payment (hold, not charge yet)
        $authResult = $this->payment->authorize(
            amount: $totalCharge,
            userId: $request->guestId,
        );

        if (!$authResult->success) {
            throw new PaymentException('Payment authorization failed');
        }

        // 4. Lock dates temporarily
        $locked = $this->availability->lockDates(
            $request->listingId,
            $request->checkIn,
            $request->checkOut,
        );

        if (!$locked) {
            $this->payment->cancelAuthorization($authResult->id);
            throw new UnavailableException('Dates were just taken');
        }

        // 5. Create booking record
        $booking = $this->bookings->create(
            guestId: $request->guestId,
            listingId: $request->listingId,
            hostId: $request->hostId,
            checkIn: $request->checkIn,
            checkOut: $request->checkOut,
            guests: $request->guests,
            subtotal: $price->total,
            guestFee: $guestFee,
            hostFee: $hostFee,
            totalCharge: $totalCharge,
            hostPayout: $price->total - $hostFee,
            paymentAuthId: $authResult->id,
            status: $request->instantBook ? 'confirmed' : 'pending',
        );

        if ($request->instantBook) {
            // Capture payment immediately
            $this->payment->capture($authResult->id);
            $this->availability->confirmDates($request->listingId, $request->checkIn, $request->checkOut);
        } else {
            // Notify host for approval (24h timeout)
            $this->notifications->send(new NotificationRequest(
                userId: $request->hostId,
                templateCode: 'booking_request',
                channel: 'push',
                variables: ['booking_id' => $booking->id],
            ));

            // Set 24h timeout for host response
            $this->redis->setex("booking_timeout:{$booking->id}", 86400, '1');
        }

        return new BookingResult(
            bookingId: $booking->id,
            status: $booking->status,
            totalCharge: $totalCharge,
        );
    }

    /**
     * Host approves or declines booking request
     */
    public function hostResponse(string $bookingId, string $hostId, bool $approved): void
    {
        $booking = $this->bookings->findByIdAndHost($bookingId, $hostId);

        if ($booking->status !== 'pending') {
            throw new InvalidStateException('Booking is not pending');
        }

        if ($approved) {
            $this->payment->capture($booking->paymentAuthId);
            $this->bookings->updateStatus($bookingId, 'confirmed');
            $this->availability->confirmDates(
                $booking->listingId,
                $booking->checkIn,
                $booking->checkOut,
            );

            $this->notifications->send(new NotificationRequest(
                userId: $booking->guestId,
                templateCode: 'booking_confirmed',
                channel: 'push',
            ));
        } else {
            $this->payment->cancelAuthorization($booking->paymentAuthId);
            $this->bookings->updateStatus($bookingId, 'declined');
            $this->availability->unlockDates(
                $booking->listingId,
                $booking->checkIn,
                $booking->checkOut,
            );

            $this->notifications->send(new NotificationRequest(
                userId: $booking->guestId,
                templateCode: 'booking_declined',
                channel: 'push',
            ));
        }
    }
}

3.4 Review System

<?php

declare(strict_types=1);

final class ReviewService
{
    private const REVIEW_WINDOW_DAYS = 14; // 14 days after checkout

    public function __construct(
        private readonly ReviewRepository $reviews,
        private readonly BookingRepository $bookings,
        private readonly ListingRepository $listings,
        private readonly SearchIndexer $indexer,
    ) {}

    /**
     * Both guest and host can leave reviews
     * Reviews are revealed simultaneously (double-blind)
     */
    public function submitReview(SubmitReviewRequest $request): Review
    {
        $booking = $this->bookings->find($request->bookingId);

        // Validate review window
        $checkOut = new \DateTimeImmutable($booking->checkOut);
        $deadline = $checkOut->modify(sprintf('+%d days', self::REVIEW_WINDOW_DAYS));

        if (new \DateTimeImmutable() > $deadline) {
            throw new ReviewWindowExpiredException('Review window has passed');
        }

        // Create review (but don't reveal yet)
        $review = $this->reviews->create(
            bookingId: $request->bookingId,
            reviewerId: $request->reviewerId,
            revieweeId: $request->revieweeId,
            listingId: $booking->listingId,
            overallRating: $request->overallRating,
            ratings: $request->detailedRatings, // cleanliness, accuracy, etc.
            comment: $request->comment,
            isPublic: false, // Hidden until both parties review
        );

        // Check if both parties have reviewed
        $bothReviewed = $this->reviews->bothPartiesReviewed($request->bookingId);

        if ($bothReviewed) {
            // Reveal both reviews simultaneously
            $this->reviews->publishReviews($request->bookingId);

            // Update listing average rating
            $this->updateListingRating($booking->listingId);
        }

        return $review;
    }

    private function updateListingRating(string $listingId): void
    {
        $stats = $this->reviews->getListingStats($listingId);

        $this->listings->updateRating(
            $listingId,
            $stats['average_rating'],
            $stats['total_reviews'],
        );

        // Update search index
        $this->indexer->updateRating($listingId, $stats);
    }
}

Шаг 4: Payment Flow

Booking Request:
  1. Guest -> Platform: Pre-authorize $X
  2. Host approves (or auto-approve)
  3. Platform captures authorization

After Checkout:
  4. Platform holds money for 24h (dispute window)
  5. Platform transfers to host: $X - 3% commission
  6. If dispute: hold for investigation

Cancellation:
  - Flexible: full refund if > 24h before check-in
  - Moderate: 50% refund if > 5 days
  - Strict: 50% refund if > 14 days

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

Компонент Технология Стратегия
Search Elasticsearch Geo-sharded clusters
Listings PostgreSQL Sharded by region
Bookings PostgreSQL Sharded by listing_id
Calendar Redis + PostgreSQL Redis for availability cache
Images CDN + S3 Multi-size, lazy resize
Messaging Cassandra Partitioned by conversation

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

  1. Instant Book vs Request to Book?

    • Instant: автоматическое подтверждение (лучше UX)
    • Request: хост одобряет (24h timeout, лучше для хоста)
    • Instant увеличивает конверсию на ~20%
  2. Как обрабатывать конфликты календаря?

    • Optimistic locking на calendar dates
    • Race condition: первый booking побеждает
    • iCal sync для внешних платформ
  3. Как предотвратить fraud?

    • Верификация документов (ID, phone)
    • ML для обнаружения подозрительных листингов
    • Escrow: деньги удерживаются до checkout
    • Reviews system для trust
  4. Как масштабировать поиск с availability check?

    • Pre-computed availability bitmap в Redis
    • Search -> filter by availability -> paginate
    • Периодическая синхронизация bitmap