Проектирование системы бронирования авиабилетов
Введение
Авиаиндустрия — один из самых технически сложных доменов в мире: более 4 миллиардов пассажиров ежегодно, свыше 100 000 коммерческих рейсов в сутки, сотни авиакомпаний, тысячи аэропортов и триллионы возможных комбинаций маршрутов. Система бронирования авиабилетов — это не просто «магазин с товарами». Это живой организм, в котором цена на одно и то же место может измениться 30 раз за день, место может «исчезнуть» за секунду между вашим поиском и нажатием «Купить», а успешная транзакция требует синхронизации трёх независимых сторон: пассажира, авиакомпании и глобальной дистрибутивной системы (GDS).
Исторически авиабилеты продавались через GDS — Global Distribution Systems (глобальные дистрибутивные системы): Amadeus, Sabre и Travelport. GDS — это посредники, которые агрегируют инвентарь авиакомпаний и предоставляют его турагентствам и OTA (Online Travel Agencies). Современные метапоисковики — Aviasales, Skyscanner, Google Flights — не продают билеты напрямую: они агрегируют предложения от OTA и авиакомпаний, показывают пользователю лучшие варианты и перенаправляют его на сайт продавца.
Чем это отличается от обычного e-commerce?
В отличие от Amazon (см. Главу 34), где товар лежит на складе с фиксированной ценой, авиабилет:
- имеет ограниченный и невозобновляемый инвентарь — если самолёт улетел, место исчезло навсегда;
- стоит по-разному в зависимости от того, кто спрашивает, когда и через какой канал;
- технически «хранится» не у вас, а у авиакомпании через GDS-посредника;
- требует строгого соответствия паспортных данных — ошибка в имени пассажира может сделать билет недействительным.
Booking.com (см. Главу 37) — ближайший аналог по архитектуре: тоже агрегатор с динамическим инвентарём от партнёров. Но авиация добавляет дополнительный уровень сложности: маршруты с пересадками, сложные тарифные правила, жёсткие регуляции IATA и легаси-интеграции через XML/SOAP.
В этой главе мы спроектируем систему, способную обрабатывать 50 000+ поисковых запросов в секунду при пиковой нагрузке, хранить информацию о десятках миллионов бронирований и гарантировать, что проданный билет не будет продан дважды.
Функциональные и нефункциональные требования
Три стороны системы
Путешественник Авиакомпания / OTA Метапоисковик
────────────── ────────────────────── ─────────────
Поиск маршрутов Управление инвентарём Агрегация предложений
Сравнение цен Публикация тарифов Дедупликация
Бронирование Получение PNR-запросов Кэширование
Онлайн-регистрация Управление бронированиями Монетизация (CPA)
Уведомления Аналитика продаж Отслеживание цен
PNR (Passenger Name Record) — уникальная запись бронирования в системе авиакомпании, шестизначный буквенно-цифровой код (например, AB1C2D), который является «доказательством» вашего бронирования.
Функциональные требования
Поиск:
- Поиск рейсов по направлению, дате, числу пассажиров (один сегмент, туда-обратно, мультисегмент)
- Фильтрация по: цене, авиакомпании, числу пересадок, времени вылета/прилёта, аэропорту пересадки
- Сортировка по: цене, длительности, удобству (собственная метрика)
- Автокомплит аэропортов и городов
Бронирование:
- Временная блокировка места (seat hold) на 15–20 минут
- Ввод данных пассажиров, оплата, получение PNR
- Выбор мест, доп. услуги (багаж, питание)
- Отмена и изменение бронирования (согласно тарифным правилам)
Нефункциональные требования:
| Параметр | Целевое значение |
|---|---|
| Поисковый запрос (p95) | < 2 секунды |
| Бронирование (p99) | < 5 секунд |
| Доступность системы бронирования | 99.95% |
| Доступность поиска | 99.9% (деградация допустима) |
| Пиковый QPS (поиск) | 50 000 req/s |
| Пиковый QPS (бронирование) | 500 req/s |
| Данные не потеряны | Гарантия для завершённых бронирований |
Поиск и бронирование — это принципиально разные пути по требованиям к consistency. Подробнее о CAP-компромиссах — в Главе 12: Согласованность в распределённых системах.
Оценка масштаба
Возьмём систему уровня Aviasales/Skyscanner:
Трафик:
- 10 млн уникальных пользователей в сутки
- Среднее число поисковых сессий на пользователя: 5
- → 50 млн поисков/сутки ≈ 580 поисков/с в среднем
- Пик (пятница вечером, старт распродажи): ×100 = 50 000 поисков/с
- 1% поисков конвертируется в бронирование → 500 000 бронирований/сутки ≈ 6 бронирований/с в среднем
Хранилище:
- Один результат поиска (20 маршрутов): ~50 KB JSON
- Кэш поиска TTL 10 мин: 50 млн / 6 уникальных запросов = ~8 млн уникальных ключей × 50 KB = 400 GB (активный Redis-кластер)
- Бронирования: 500 000/день × 365 дней × 5 лет × 2 KB = ~1.8 TB (PostgreSQL)
- Тарифная база (fare rules) от GDS: ~200 GB (реплицируется ежедневно)
Запросы к GDS:
- Каждый поиск → fan-out к 5–15 провайдерам
- 50 000 поисков/с × 10 провайдеров = 500 000 запросов/с к внешним GDS
- GDS лимитируют: 10–50 запросов/с на ключ → нужны тысячи API-ключей и агрессивное кэширование
Высокоуровневая архитектура
┌──────────────────────────────────────────┐
│ КЛИЕНТЫ │
│ Web (React) iOS Android API │
└──────────────────┬───────────────────────┘
│ HTTPS
┌──────────────────▼───────────────────────┐
│ CDN + API Gateway │
│ (статика, rate limiting, auth, routing) │
└──────┬───────────────────┬───────────────┘
│ │
┌─────────────────────────────────────────────────────────┐
│SEARCH PATH (read-heavy) BOOKING PATH (write, ACID)│
└─────────────────────────────────────────────────────────┘
│ │
┌───────────▼──────────┐ ┌────────────▼───────────┐
│ Search Service │ │ Booking Service │
│ (stateless, ×N) │ │ (saga orchestrator) │
└───────────┬──────────┘ └────────────┬───────────┘
│ │
┌───────────▼──────────┐ ┌────────────▼───────────┐
│ Redis Search Cache │ │ Inventory Service │
│ TTL: 5–15 min │ │ (seat hold, locking) │
└───────────┬──────────┘ └────────────┬───────────┘
│ cache miss │
┌───────────▼──────────┐ ┌────────────▼───────────┐
│ GDS Gateway │ │ Payment Service │
│ (fan-out, adapter) │ │ (PSP integration) │
└──┬──────┬──────┬─────┘ └────────────┬───────────┘
│ │ │ │
Amadeus Sabre NDC APIs ┌───────────▼────────────┐
(XML) (SOAP) (REST/JSON) │ Ticketing Service │
│ (PNR creation, GDS) │
┌──────────────────────┐ └───────────┬────────────┘
│ Pricing Engine │ │
│ (fare calc, rules) │ ┌───────────▼────────────┐
└──────────────────────┘ │ Notification Service │
│ │ (email, SMS, push) │
┌───────────▼──────────┐ └───────────────────────-┘
│ Itinerary Builder │
│ (маршрутостроение) │
└──────────────────────┘
│
┌───────────▼──────────┐
│ Elasticsearch │
│ (аэропорты, маршруты,│
│ аналитика поиска) │
└──────────────────────┘
Балансировка нагрузки между репликами Search Service и горизонтальное масштабирование — в Главе 8: Балансировка нагрузки.
Поиск рейсов: Search Path
Fan-out к GDS и авиакомпаниям
Когда пользователь ищет рейс SVO → JFK, система не может ответить мгновенно из собственной базы — инвентарь хранится у авиакомпаний. Search Service выполняет fan-out (веерный запрос) параллельно к 10–50 источникам данных.
Запрос пользователя: SVO → JFK, 15 июня, 1 взрослый
│
▼
┌─────────────────────────────────────────────────────┐
│ Search Service │
│ │
│ Проверяем Redis Cache ──► HIT? → вернуть кэш │
│ │ MISS │
│ ▼ │
│ Fan-out (asyncio / goroutines): │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ │
│ │Amadeus │ │ Sabre │ │Aeroflot │ │ S7 NDC │ │
│ │timeout │ │timeout │ │ API │ │ REST │ │
│ │ 1.5s │ │ 1.5s │ │ 1.5s │ │ 1.5s │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬─────┘ │
│ └───────────┼───────────┘ │ │
│ ▼ │ │
│ Собираем ответы (partial results OK) │ │
│ Нормализация → общая схема │ │
│ Дедупликация (один рейс из 3 GDS) │ │
│ Сортировка + фильтрация │ │
│ Сохранить в Redis (TTL 10 мин) ◄─────────┘ │
│ → вернуть пользователю │
└─────────────────────────────────────────────────────┘
Ключевые принципы fan-out:
- Partial results — если 3 из 10 провайдеров не ответили за 1.5 секунды, возвращаем результаты от остальных 7. Пользователь видит предупреждение: «Возможно, не все рейсы отображены».
- Timeout ≠ Error — медленный GDS не должен блокировать весь ответ.
- Дедупликация — один и тот же рейс AF1234 может прийти от Amadeus, Sabre и напрямую от Air France. Ключ дедупликации:
airline_code + flight_number + date + origin + destination.
import asyncio from dataclasses import dataclass from typing import Optional @dataclass class SearchRequest: origin: str # "SVO" destination: str # "JFK" date: str # "2026-06-15" passengers: int # 1 @dataclass class FlightOffer: flight_id: str airline: str price: float currency: str origin: str destination: str departure_time: str arrival_time: str provider: str # откуда пришло async def search_provider(provider_name: str, request: SearchRequest, timeout: float = 1.5) -> list[FlightOffer]: """Запрос к одному GDS/авиакомпании с таймаутом.""" try: async with asyncio.timeout(timeout): # здесь — реальный HTTP/SOAP вызов к провайдеру results = await gds_clients[provider_name].search(request) return normalize_to_common_schema(provider_name, results) except (asyncio.TimeoutError, Exception) as e: # логируем, но не падаем — частичный ответ лучше ошибки metrics.increment(f"gds.{provider_name}.timeout_or_error") return [] async def aggregate_search(request: SearchRequest) -> list[FlightOffer]: """Fan-out к всем провайдерам, агрегация результатов.""" providers = ["amadeus", "sabre", "travelport", "aeroflot_ndc", "s7_ndc", "lotpolish_ndc"] tasks = [search_provider(p, request) for p in providers] results_per_provider = await asyncio.gather(*tasks) # Flatten + дедупликация all_offers: dict[str, FlightOffer] = {} for offers in results_per_provider: for offer in offers: key = f"{offer.airline}_{offer.flight_id}_{offer.date}" if key not in all_offers: all_offers[key] = offer else: # Берём минимальную цену из дублей if offer.price < all_offers[key].price: all_offers[key] = offer return sorted(all_offers.values(), key=lambda o: o.price)
Кэширование результатов поиска
Каждый запрос к GDS стоит денег (буквально: Amadeus берёт деньги за транзакцию). Поэтому кэш — это не только производительность, но и экономия.
Ключ кэша: search:{origin}:{dest}:{date}:{pax_count}:{cabin_class}
TTL: 5–15 минут — компромисс между свежестью цен и стоимостью запросов.
Подробнее о стратегиях кэширования и инвалидации — в Главе 6: Кэширование.
async def search_with_cache(request: SearchRequest, redis_client) -> list[FlightOffer]: cache_key = (f"search:{request.origin}:{request.destination}" f":{request.date}:{request.passengers}") # L1: Redis cache cached = await redis_client.get(cache_key) if cached: metrics.increment("search.cache.hit") return deserialize(cached) metrics.increment("search.cache.miss") results = await aggregate_search(request) # Сохраняем в кэш только если результатов достаточно if len(results) >= 3: ttl = compute_ttl(request.date) # ближе к дате — TTL меньше await redis_client.setex(cache_key, ttl, serialize(results)) return results def compute_ttl(flight_date: str) -> int: """Адаптивный TTL: чем ближе вылет, тем короче кэш.""" days_until_flight = (parse_date(flight_date) - today()).days if days_until_flight < 3: return 180 # 3 минуты — цены меняются быстро elif days_until_flight < 30: return 600 # 10 минут else: return 900 # 15 минут — за 2 месяца цены стабильнее
Ценообразование и тарифы
Fare Classes и динамическое ценообразование
Каждое кресло в самолёте принадлежит fare class (классу тарифа) — буквенному коду (Y, B, M, H, Q, V, W, S...). Буква — это не класс обслуживания (эконом/бизнес), а тарифный «ведёрко» (fare bucket) с разной ценой и правилами.
Эконом-класс самолёта Boeing 737 (150 мест):
┌─────────────────────────────────────────────────────┐
│ Класс Y: 10 мест × $800 (возвратный, дорогой) │
│ Класс B: 15 мест × $550 (частично возвратный) │
│ Класс M: 25 мест × $380 (невозвратный, гибкий) │
│ Класс H: 30 мест × $250 (невозвратный) │
│ Класс Q: 40 мест × $180 (раннее бронирование) │
│ Класс V: 20 мест × $120 (акционный) │
│ Класс W: 10 мест × $90 (промо, строгие правила) │
└─────────────────────────────────────────────────────┘
Revenue Management System (RMS) авиакомпании управляет открытием/закрытием fare buckets в режиме реального времени. Когда дешёвые V-места заканчиваются, система «открывает» Q-места по более высокой цене. Это и есть динамическое ценообразование.
Displayed Price vs Bookable Price
Важнейшее различие: цена в поиске и цена при бронировании могут отличаться.
- Displayed price — цена из кэша поиска (получена 7 минут назад)
- Bookable price — цена, которую GDS подтвердит прямо сейчас при попытке забронировать
Именно поэтому перед финальным оформлением система всегда делает price revalidation — свежий запрос к GDS для подтверждения актуальности цены.
Fare rules (тарифные правила) определяют:
- Возвратность (refundable / non-refundable)
- Возможность изменения (changeable)
- Минимальный/максимальный срок пребывания
- Включённый багаж
- Штрафы за отмену (% от стоимости)
Revenue Management: как авиакомпания максимизирует выручку
Revenue Management System (RMS) — это ML-система, которая решает задачу оптимизации: как распределить места по fare buckets, чтобы суммарная выручка была максимальной.
Наивный подход:
Продать все 150 мест по $120 → выручка$18 000
RMS подход:
10 мест × $800 =$8 000 (бизнес-путешественники, покупают за 1-2 дня)
15 мест × $550 =$8 250 (гибкий тариф)
25 мест × $380 =$9 500 (средний сегмент)
30 мест × $250 =$7 500 (раннее бронирование)
40 мест × $180 =$7 200 (за 2-3 месяца)
20 мест × $120 =$2 400 (промо)
10 мест × $90 =$900 (минимальный тариф)
─────────────────────────
Итого: $43 750 (2.4x выручка!)
Ключевые факторы RMS:
- Booking curve — сколько дней до вылета, как быстро бронируются места
- Историческая загрузка — аналогичные рейсы в прошлые годы
- Конкуренция — цены конкурентов на тот же маршрут
- Сезонность — летние каникулы, праздники, деловые сезоны
- No-show rate — процент неявки (основа для overbooking)
def should_close_fare_bucket( flight_id: str, fare_class: str, days_to_departure: int, seats_sold: int, total_capacity: int, historical_demand: float # ожидаемый спрос на оставшийся период ) -> bool: """ Упрощённая эвристика: закрыть дешёвый класс, если ожидаемый спрос превышает оставшиеся места (EMSR — Expected Marginal Seat Revenue). """ remaining_seats = total_capacity - seats_sold # Если осталось мало мест и дешёвый класс — закрываем load_factor = seats_sold / total_capacity if load_factor > 0.85 and fare_class in ('V', 'W', 'S'): return True # закрыть дешёвые классы # Если до вылета < 7 дней — закрыть промо-тарифы if days_to_departure < 7 and fare_class in ('Q', 'V', 'W', 'S'): return True # EMSR: если ожидаемый спрос на более дорогие классы # покрывает оставшиеся места — закрыть этот класс if historical_demand > remaining_seats * 0.9: return True return False
Динамическое ценообразование в контексте ride-sharing (surge pricing) разобрано в Главе 41: Проектирование Uber. Управление пиковым спросом при ограниченном inventory — в Главе 40: Проектирование системы Flash Sales.
Пересадки и маршрутостроение
Itinerary Building: граф рейсов
Построение маршрута SVO → JFK с одной пересадкой — это задача обхода графа, где вершины — аэропорты, рёбра — рейсы.
Алгоритмы обхода графа (BFS/DFS) и задачи нахождения путей подробно разбираются в Главе 43: Проектирование Google Maps применительно к дорожным сетям.
Граф рейсов (фрагмент):
SVO ──AF1234──► CDG ──AF12──► JFK (Air France)
│ ▲
└───LH1780──► FRA ──LH400──► JFK (Lufthansa)
│ ▲
└───TK2───► IST ──TK13───► JFK (Turkish)
Minimum Connection Time (MCT) — минимальное время пересадки, установленное аэропортом. Если MCT в CDG = 90 минут, рейс прилетающий в 10:00 + рейс улетающий в 11:15 = недопустимая стыковка.
from collections import defaultdict from datetime import datetime, timedelta AIRPORT_MCT = { "CDG": 90, # минуты "FRA": 45, "IST": 60, "JFK": 60, "AMS": 40, } def build_itineraries(origin: str, destination: str, date: str, all_flights: list[dict], max_stops: int = 2) -> list[list[dict]]: """ BFS по графу рейсов с ограничениями по времени. Возвращает список маршрутов (каждый маршрут = список сегментов). """ # Индексируем рейсы по аэропорту вылета flights_by_origin: dict[str, list] = defaultdict(list) for flight in all_flights: flights_by_origin[flight["origin"]].append(flight) # Очередь: (текущий_аэропорт, время_прибытия, маршрут_до_сих_пор) queue = [(origin, datetime.fromisoformat(date + "T00:00:00"), [])] results = [] while queue: current_airport, earliest_departure, path = queue.pop(0) if len(path) > max_stops: continue for flight in flights_by_origin[current_airport]: dep_time = datetime.fromisoformat(flight["departure_time"]) # Проверяем MCT если это не первый сегмент if path: prev_arrival = datetime.fromisoformat(path[-1]["arrival_time"]) mct = AIRPORT_MCT.get(current_airport, 60) if (dep_time - prev_arrival).total_seconds() / 60 < mct: continue # слишком короткая пересадка new_path = path + [flight] if flight["destination"] == destination: results.append(new_path) elif len(new_path) <= max_stops: queue.append((flight["destination"], dep_time, new_path)) return results
Self-transfer vs Interline
Interline — пересадка, защищённая договором между авиакомпаниями: багаж перегружается автоматически, при задержке первого рейса вас посадят на следующий. Self-transfer — пассажир сам забирает багаж и регистрируется заново. Это дешевле, но рискованнее.
Метапоисковики (Skyscanner, Kiwi.com) активно строят виртуальные маршруты — комбинации рейсов разных авиакомпаний без interline-соглашения. Kiwi.com даже продаёт страховку на случай потери стыковки при self-transfer.
Бронирование: Booking Flow
Жизненный цикл бронирования
Пользователь Booking System GDS / Авиакомпания
│ │ │
│─── Выбрал маршрут ───────────►│ │
│ │── Price Revalidation ─────────►│
│ │◄── Актуальная цена + fare_key──│
│◄── Подтверждена цена ─────────│ │
│ │ │
│─── Ввёл данные пассажиров ───►│ │
│ │── Seat Hold (PNR hold, 15 min)►│
│ │◄── Hold Confirmation ──────────│
│◄── "Место забронировано" ─────│ │
│ │ │
│─── Ввёл данные карты ────────►│ │
│ │── Charge Card ────────────────►│ (PSP)
│ │◄── Payment OK ─────────────────│
│ │ │
│ │── Issue Ticket ───────────────►│
│ │◄── PNR Created (AB1C2D) ───────│
│◄── Билет выслан на email ─────│ │
│ │ │
Конечный автомат бронирования
SEARCHING
│ пользователь выбрал маршрут
▼
SELECTED
│ price revalidation OK
▼
PENDING_PAYMENT ─── seat hold TTL истёк ───► EXPIRED
│ оплата успешна
▼
PAYMENT_RECEIVED
│ запрос билета в GDS
▼
TICKETING ─── GDS недоступен ───► TICKETING_FAILED
│ PNR создан │ retry
▼ │
CONFIRMED ◄────────────────────────────┘
│
├─── пассажир отменил ───► CANCELLATION_PENDING
│ │ возврат средств
│ ▼
│ REFUNDED / VOIDED
│
└─── онлайн-регистрация ──► CHECKED_IN
│ посадочный выдан
▼
BOARDED
Saga Pattern: распределённая транзакция
Бронирование авиабилета затрагивает несколько независимых систем. Использовать единую ACID-транзакцию между ними невозможно. Применяем Saga Pattern — цепочку локальных транзакций с компенсирующими действиями при сбое.
Saga Pattern подробно разобран в Главе 42: Uber Eats / DoorDash на примере оплаты заказа.
Шаг 1: Seat Hold (GDS)
Успех → Шаг 2
Сбой → завершить (нет мест)
Шаг 2: Charge Card (PSP)
Успех → Шаг 3
Сбой → Компенсация: Release Seat Hold → завершить
Шаг 3: Issue Ticket (GDS)
Успех → Шаг 4
Сбой → Компенсация: Refund Card + Release Seat Hold → завершить
Шаг 4: Send Notification (Email/SMS)
Сбой → retry (некритично, билет уже выдан)
Успех → DONE
class BookingOrchestrator: """Оркестратор Saga для бронирования авиабилета.""" async def execute(self, booking_request: BookingRequest) -> BookingResult: booking_id = generate_booking_id() compensations = [] # стек компенсирующих транзакций try: # Шаг 1: Seat Hold hold = await self.gds.hold_seat( flight_id=booking_request.flight_id, fare_key=booking_request.fare_key, passengers=booking_request.passengers, ttl_minutes=15 ) compensations.append( lambda: self.gds.release_hold(hold.hold_id) ) await self.db.update_booking_status(booking_id, "SEAT_HELD") # Шаг 2: Charge Card payment = await self.payment_service.charge( amount=booking_request.total_price, currency=booking_request.currency, card_token=booking_request.card_token, idempotency_key=f"booking_{booking_id}" ) compensations.append( lambda: self.payment_service.refund(payment.transaction_id) ) await self.db.update_booking_status(booking_id, "PAID") # Шаг 3: Issue Ticket pnr = await self.gds.issue_ticket( hold_id=hold.hold_id, passengers=booking_request.passengers, payment_ref=payment.transaction_id ) await self.db.update_booking_status( booking_id, "CONFIRMED", pnr=pnr.code ) # Шаг 4: Notify (некритично) asyncio.create_task( self.notifier.send_confirmation(booking_id, pnr.code) ) return BookingResult(success=True, pnr=pnr.code) except Exception as e: # Откат в обратном порядке for compensate in reversed(compensations): try: await compensate() except Exception as comp_err: # Компенсация не удалась — создаём задачу для ручного разбора await self.dead_letter_queue.push({ "booking_id": booking_id, "error": str(comp_err), "action": "manual_review" }) return BookingResult(success=False, error=str(e))
Dead Letter Queue для обработки сбоев компенсаций — паттерн из Главы 44: FedEx/UPS.
Inventory Management: управление доступностью мест
Overbooking: намеренная продажа лишних мест
Авиакомпании намеренно продают 105–110% мест от вместимости самолёта. Причина: статистически 5–10% пассажиров не явятся на рейс (no-show). Если самолёт улетит с пустыми местами — убыток. Расчёт уровня overbooking — задача Revenue Management.
Ghost Availability: призрачные места
Проблема: пользователь видит в поиске место за $150, нажимает «Купить» — и получает ошибку «мест нет». Это происходит потому что:
- Поисковый результат был закэширован 8 минут назад
- За это время другой пользователь купил последнее V-место
- GDS обновил инвентарь, но кэш ещё актуален
Решения:
- Price revalidation перед оплатой (всегда)
- Optimistic locking при обновлении инвентаря
- Short TTL для направлений с высоким спросом
Overbooking: почему продают 105% мест
Авиакомпании намеренно продают больше мест, чем есть в самолёте. Причина — no-show rate: 5–15% пассажиров не приходят на рейс. Если не делать overbooking, самолёт летит с пустыми креслами.
Самолёт: 150 мест
No-show rate: 8%
Overbooking rate: 5%
Продано мест: 150 × 1.05 = 158
Ожидаемый no-show: 158 × 0.08 = ~13
Ожидаемых пассажиров: 158 - 13 = 145 → 5 свободных мест, все довольны
Worst case: все 158 пришли → 8 человек не сядут → компенсация
RMS рассчитывает оптимальный уровень overbooking для каждого рейса на основе:
- Исторический no-show rate для данного маршрута и дня недели
- Стоимость компенсации (отказ в посадке: $400–1300 по регуляции EU/US)
- Маржа от дополнительно проданных мест
Ghost Availability: «фантомные» места
Ghost availability — ситуация, когда поиск показывает доступные места, но при попытке бронирования GDS отвечает «мест нет». Это происходит из-за кэширования:
t=0: GDS: 3 места в классе Q → кэш Redis (TTL=10 мин)
t=3min: Два пассажира бронируют через другой канал → осталось 1 место
t=7min: Наш пользователь видит кэшированный результат "3 места" → выбирает
t=8min: Price revalidation → GDS: "1 место" → пользователь видит другую цену
t=9min: Бронирование → GDS: "0 мест в Q, предлагаем M за +$100"
Стратегии снижения ghost availability:
- Короткий TTL для рейсов с высоким спросом (< 3 дней до вылета)
- Probabilistic availability — показывать «мало мест» вместо точного числа
- Price guarantee — если цена выросла между поиском и бронированием, взять разницу на себя (улучшает UX, стоит денег)
Optimistic Locking для инвентаря
-- Таблица доступности мест CREATE TABLE flight_inventory ( flight_id VARCHAR(20) NOT NULL, fare_class CHAR(1) NOT NULL, total_seats INT NOT NULL, available_seats INT NOT NULL DEFAULT 0, overbooking_limit INT NOT NULL DEFAULT 0, version BIGINT NOT NULL DEFAULT 0, -- для optimistic lock updated_at TIMESTAMP DEFAULT NOW(), PRIMARY KEY (flight_id, fare_class) ); -- Безопасное уменьшение инвентаря с optimistic locking UPDATE flight_inventory SET available_seats = available_seats - :requested_seats, version = version + 1, updated_at = NOW() WHERE flight_id = :flight_id AND fare_class = :fare_class AND available_seats >= :requested_seats AND version = :expected_version; -- условие optimistic lock -- Если affected_rows = 0 → конфликт, повторить запрос -- (либо кто-то другой забрал место, либо fare bucket закрыт)
async def hold_seats( flight_id: str, fare_class: str, seats: int, session_id: str, db_pool ) -> bool: """ Временная блокировка мест (seat hold) на 15 минут. Если пользователь не завершит бронирование — места вернутся. """ MAX_RETRIES = 3 for attempt in range(MAX_RETRIES): async with db_pool.acquire() as conn: # Читаем текущий inventory с версией row = await conn.fetchrow( "SELECT available_seats, version FROM flight_inventory " "WHERE flight_id = $1 AND fare_class =$2", flight_id, fare_class ) if not row or row['available_seats'] < seats: return False # Optimistic update result = await conn.execute( "UPDATE flight_inventory SET " "available_seats = available_seats - $1, " "version = version + 1 " "WHERE flight_id = $2 AND fare_class =$3 " "AND version = $4 AND available_seats >=$1", seats, flight_id, fare_class, row['version'] ) if result == "UPDATE 1": # Записать hold с TTL await redis.set( f"seat_hold:{session_id}:{flight_id}:{fare_class}", seats, ex=900 # 15 минут ) return True # Конфликт — повторить return False
Паттерны работы с конкурентными обновлениями, optimistic vs pessimistic locking и версионность — в Главе 12: Согласованность в распределённых системах. Аналогичная проблема конкурентного доступа к inventory рассмотрена в Главе 37: Проектирование Booking.com и Главе 40: Проектирование Flash Sales.
Кэширование на нескольких уровнях
| Уровень | Технология | Что кэшируем | TTL | Причина |
|---|---|---|---|---|
| L0: Client-side | Browser / Mobile | Статика (JS, CSS, изображения) | 1 час – 1 год | CDN offload |
| L1: CDN | CloudFlare / Akamai | Страницы, API-ответы для популярных маршрутов | 5–15 мин | Снизить нагрузку на серверы |
| L2: Application | Redis Cluster | Результаты поиска, fare rules | 5–15 мин | Дорогие GDS-запросы |
| L3: Database | PostgreSQL buffer pool | Горячие записи бронирований | Управляется СУБД | Быстрые чтения |
| L4: GDS-side | Amadeus cache | Тарифная сетка | 24 часа | Стабильные данные |
Почему 5–15 минут — правильный TTL для поиска:
- Меньше 5 минут → слишком много cache miss → GDS-перерасход
- Больше 15 минут → устаревшие цены → пользователи злятся на расхождение цен
Cache invalidation по событию: если авиакомпания объявила flash sale, нужно срочно инвалидировать кэш для затронутых маршрутов. GDS шлёт event → Kafka → инвалидатор удаляет ключи в Redis.
Адаптивный TTL: умное кэширование
Не все маршруты одинаковые. Популярный маршрут SVO → IST обновляется чаще, чем редкий KZN → TBS. Адаптивный TTL учитывает это:
def calculate_adaptive_ttl( route: str, departure_date: str, days_to_departure: int, search_frequency: int, # запросов за последний час last_price_change_minutes: int ) -> int: """ Адаптивный TTL в секундах для кэша результатов поиска. """ base_ttl = 900 # 15 минут по умолчанию # Близкий вылет → цены меняются чаще → короче TTL if days_to_departure < 3: base_ttl = 120 # 2 минуты elif days_to_departure < 7: base_ttl = 300 # 5 минут elif days_to_departure < 30: base_ttl = 600 # 10 минут # Популярный маршрут → больше запросов → оправдан длинный TTL if search_frequency > 100: base_ttl = int(base_ttl * 1.5) # Цена недавно менялась → короче TTL if last_price_change_minutes < 10: base_ttl = min(base_ttl, 180) return max(60, min(base_ttl, 1800)) # от 1 до 30 минут
Cache Key Design
Правильный ключ кэша — критически важен для hit rate:
# Плохо: слишком специфичный ключ → низкий hit rate key = f"search:{origin}:{dest}:{date}:{adults}:{children}:{class}:{airline}:{bags}" # Хорошо: базовый запрос → высокий hit rate, фильтрация post-cache key = f"search:{origin}:{dest}:{date}:{pax_count}" # Фильтрация по классу, авиакомпании, багажу — на уровне приложения
Разделение на cache key (что ищем) и post-cache filter (что фильтруем) — ключевой паттерн. Мы кэшируем широкий результат и фильтруем его в памяти, а не создаём отдельный кэш для каждой комбинации фильтров.
Паттерны инвалидации кэша, write-through и write-behind — в Главе 6: Кэширование.
Интеграция с GDS и авиакомпаниями
GDS vs IATA NDC
| Критерий | GDS (Amadeus/Sabre) | IATA NDC |
|---|---|---|
| Протокол | XML/SOAP (легаси) | REST/JSON (современный) |
| Данные | Стандартизированный инвентарь | Расширенный контент авиакомпании |
| Цена | Наценка GDS (+$3–8/сегмент) | Прямые тарифы авиакомпании |
| Динамический контент | Ограничен | Полный (апгрейды, допуслуги) |
| Охват | Все авиакомпании | Только NDC-участники (~70% крупных) |
| Сложность интеграции | Высокая (легаси API) | Средняя (разные реализации у авиакомпаний) |
NDC (New Distribution Capability) — стандарт IATA 2012 года, позволяющий авиакомпаниям продавать билеты напрямую через REST API, минуя GDS-посредников. Для авиакомпаний это выгодно: нет комиссии GDS, возможность показывать персонализированные предложения.
Адаптер-паттерн для нормализации
Каждый GDS возвращает данные в своём формате. Адаптер приводит всё к единой внутренней схеме.
from abc import ABC, abstractmethod class GDSAdapter(ABC): """Интерфейс адаптера для любого GDS/авиакомпании.""" @abstractmethod async def search_flights(self, request: SearchRequest) -> list[FlightOffer]: pass @abstractmethod async def hold_seat(self, flight_id: str, passengers: list) -> HoldResult: pass @abstractmethod async def issue_ticket(self, hold_id: str, payment_ref: str) -> PNR: pass class AmadeusAdapter(GDSAdapter): """Адаптер для Amadeus GDS (XML/SOAP).""" async def search_flights(self, request: SearchRequest) -> list[FlightOffer]: # Формируем XML-запрос в формате Amadeus OTA xml_request = build_amadeus_ota_request(request) xml_response = await self.soap_client.call( "OTA_AirLowFareSearchRQ", xml_request ) # Парсим SOAP-ответ и приводим к общей схеме return parse_amadeus_ota_response(xml_response) class S7NDCAdapter(GDSAdapter): """Адаптер для S7 Airlines NDC (REST/JSON).""" async def search_flights(self, request: SearchRequest) -> list[FlightOffer]: json_request = { "CoreQuery": { "OriginDestinations": [{ "Departure": {"AirportCode": request.origin, "Date": request.date}, "Arrival": {"AirportCode": request.destination} }] } } response = await self.http_client.post( "/airshoppingrq", json=json_request ) return parse_ndc_response(response.json())
Rate Limiting и Circuit Breaker
GDS устанавливают лимиты: например, Amadeus Production допускает 40 транзакций/секунду на API-ключ. При превышении — блокировка ключа на 60 секунд.
Стратегии:
- Пул API-ключей с round-robin распределением
- Sliding window rate limiter перед каждым GDS-клиентом
- Circuit Breaker: если GDS возвращает ошибки > 50% за 30 секунд — прекращаем запросы на 60 секунд
Circuit Breaker паттерн детально разобран в Главе 41: Uber и Главе 42: Uber Eats.
Базы данных: выбор и схемы
Матрица выбора СУБД
| Компонент | Технология | Обоснование |
|---|---|---|
| Бронирования | PostgreSQL | ACID, сложные JOIN, строгая консистентность |
| Инвентарь рейсов | PostgreSQL | Optimistic locking, транзакции |
| Кэш поиска | Redis Cluster | Быстрый TTL, сериализация JSON |
| Тарифная база | PostgreSQL + Redis | Стабильна, нужен индекс + кэш |
| Поиск аэропортов | Elasticsearch | Full-text, автокомплит, fuzzy |
| Аналитика цен | ClickHouse | Колоночная, OLAP, временные ряды |
Выбор между SQL и NoSQL, партиционирование таблиц — Глава 4: Хранение данных и Глава 13: Шардирование.
SQL-схемы ключевых таблиц
-- Рейсы (статические данные от авиакомпаний) CREATE TABLE flights ( flight_id VARCHAR(30) PRIMARY KEY, -- "SU100_20260615" airline_code CHAR(2) NOT NULL, -- "SU" flight_number VARCHAR(6) NOT NULL, -- "100" origin_iata CHAR(3) NOT NULL, -- "SVO" destination_iata CHAR(3) NOT NULL, -- "JFK" departure_time TIMESTAMPTZ NOT NULL, arrival_time TIMESTAMPTZ NOT NULL, aircraft_type VARCHAR(10), -- "B77W" status VARCHAR(20) DEFAULT 'SCHEDULED' ); -- Тарифные правила CREATE TABLE fare_rules ( fare_key VARCHAR(64) PRIMARY KEY, -- хэш от параметров тарифа flight_id VARCHAR(30) REFERENCES flights(flight_id), fare_class CHAR(1) NOT NULL, base_price NUMERIC(10,2) NOT NULL, currency CHAR(3) NOT NULL DEFAULT 'RUB', baggage_kg INT DEFAULT 0, is_refundable BOOLEAN DEFAULT FALSE, is_changeable BOOLEAN DEFAULT FALSE, change_fee NUMERIC(10,2) DEFAULT 0, cancellation_fee NUMERIC(10,2) DEFAULT 0, valid_until TIMESTAMPTZ NOT NULL, -- fare expiry created_at TIMESTAMPTZ DEFAULT NOW() ); -- Бронирования CREATE TABLE bookings ( booking_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, pnr_code VARCHAR(10) UNIQUE, -- "AB1C2D", заполняется после тикетинга status VARCHAR(30) NOT NULL DEFAULT 'PENDING', total_price NUMERIC(12,2) NOT NULL, currency CHAR(3) NOT NULL, fare_key VARCHAR(64) REFERENCES fare_rules(fare_key), hold_expires_at TIMESTAMPTZ, payment_ref VARCHAR(64), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Пассажиры в бронировании CREATE TABLE booking_passengers ( id BIGSERIAL PRIMARY KEY, booking_id UUID REFERENCES bookings(booking_id), first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, birth_date DATE NOT NULL, passport_number VARCHAR(20), -- хранится зашифрованным passport_expiry DATE, nationality CHAR(2), passenger_type VARCHAR(10) DEFAULT 'ADT' -- ADT/CHD/INF ); -- Сегменты маршрута (для multi-segment бронирований) CREATE TABLE booking_segments ( id BIGSERIAL PRIMARY KEY, booking_id UUID REFERENCES bookings(booking_id), segment_order SMALLINT NOT NULL, flight_id VARCHAR(30) REFERENCES flights(flight_id), fare_class CHAR(1), seat_number VARCHAR(5) ); -- Индексы для производительности CREATE INDEX idx_bookings_user_id ON bookings(user_id); CREATE INDEX idx_bookings_pnr ON bookings(pnr_code); CREATE INDEX idx_bookings_status ON bookings(status) WHERE status != 'CONFIRMED'; CREATE INDEX idx_flights_route_date ON flights(origin_iata, destination_iata, departure_time);
Партиционирование таблицы бронирований
Через 5 лет таблица bookings вырастет до ~1 млрд строк. Партиционируем по месяцу создания:
-- Партиционирование по дате (PostgreSQL declarative partitioning) CREATE TABLE bookings_2026_01 PARTITION OF bookings FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'); CREATE TABLE bookings_2026_02 PARTITION OF bookings FOR VALUES FROM ('2026-02-01') TO ('2026-03-01'); -- ...и т.д.
Партиционирование и шардирование PostgreSQL — Глава 13: Шардирование и партиционирование.
Масштабирование и надёжность
Сезонные пики
Авиаперевозки имеют чёткие пиковые периоды:
Нагрузка на систему бронирования (относительная):
Янв ████░░░░░░░░░░░░░░░░ 40% (после Нового Года)
Фев ████░░░░░░░░░░░░░░░░ 40%
Мар ██████░░░░░░░░░░░░░░ 55%
Апр ████████░░░░░░░░░░░░ 70%
Май ██████████░░░░░░░░░░ 80% (начало сезона)
Июн ████████████████░░░░ 95% (ПИКОВЫЙ СЕЗОН)
Июл ████████████████████ 100% (МАКСИМУМ)
Авг ███████████████████░ 95%
Сен ████████░░░░░░░░░░░░ 70%
Окт ██████░░░░░░░░░░░░░░ 55%
Ноя ████░░░░░░░░░░░░░░░░ 45%
Дек ████████████░░░░░░░░ 85% (Новый Год, праздники)
Стратегии масштабирования:
- Auto-scaling Search Service (stateless) — горизонтальное масштабирование при нагрузке
- Read replicas PostgreSQL для запросов на чтение бронирований
- Pre-warming кэша перед ожидаемыми пиками (старт распродажи «чёрная пятница»)
- Queue-based load leveling для операций тикетинга: не обращаемся к GDS напрямую, кладём в Kafka, обрабатываем равномерно
Горизонтальное масштабирование, auto-scaling и репликация — Глава 2: Ключевые характеристики и Глава 14: Репликация данных.
Graceful Degradation
При недоступности GDS система не должна полностью «упасть»:
GDS недоступен (Circuit Breaker OPEN):
├── Search: показываем кэшированные результаты с предупреждением
│ "Цены могут быть неактуальны. Обновлено 12 мин назад"
├── Booking: блокируем новые бронирования для затронутых авиакомпаний
└── Existing bookings: доступны из PostgreSQL, уведомления работают
Оплата PSP недоступна:
├── Показываем альтернативные способы оплаты
└── Предлагаем «забронировать без оплаты» на 30 мин (hold only)
Мультирегиональность
Разворачиваем инфраструктуру в нескольких регионах: Москва (основной), Амстердам/Франкфурт (Европа), Сингапур (Азия).
- Search Service — active-active во всех регионах (можно читать из ближайшего)
- Booking Service — active-passive с автоматическим failover (консистентность важнее)
- PostgreSQL — primary в Москве, read replicas в других регионах
- Redis — отдельные кластеры в каждом регионе для кэша поиска
Active-Active и Active-Passive репликация — Глава 14: Репликация данных. Мультирегиональность — Глава 2: Ключевые характеристики.
Безопасность и комплаенс
PCI DSS: данные платёжных карт
Авиасистемы принимают платежи — обязателен PCI DSS (Payment Card Industry Data Security Standard). Ключевые требования:
- Никогда не хранить CVV, полный PAN карты на своих серверах
- Все платёжные операции — через токенизацию: храним токен, выданный PSP (Stripe, Braintree, CloudPayments)
- Отдельная сеть/сегмент для компонентов, работающих с картами
PII: паспортные данные пассажиров
Паспортные данные — персональная информация высочайшей чувствительности:
- Шифрование на уровне БД: поля
passport_number,birth_dateхранятся зашифрованными (AES-256) - Ключи шифрования — в отдельном KMS (Key Management Service), не в БД
- Маскирование в логах:
*****1234вместо полного номера - Data residency: данные российских пассажиров хранятся в РФ (152-ФЗ); европейских — в ЕС (GDPR)
PSD2/SCA для Европы
Для платежей в ЕС с 2019 года обязательна Strong Customer Authentication (SCA) — двухфакторная аутентификация покупателя. Реализуется через 3D Secure 2.0.
Безопасность API, шифрование, токенизация и compliance — Глава 17: Безопасность.
Жизненный цикл бронирования: сквозной поток
Проследим путь пользователя от поиска до посадочного талона через все сервисы:
1. ПОИСК
Пользователь → API Gateway → Search Service
Search Service → Redis: MISS
Search Service → GDS Gateway → Fan-out (Amadeus + Sabre + S7 NDC)
Результаты собраны → нормализация → дедупликация
Сохранить в Redis (TTL 10 мин)
Ответ пользователю: 47 маршрутов от $189
2. ВЫБОР И ПРОВЕРКА ЦЕНЫ
Пользователь выбрал SU100 за $235
Booking Service → GDS: price revalidation
GDS: $235 подтверждена, fare_key=abc123, действует 15 мин
Пользователю: "Цена подтверждена"
3. ДАННЫЕ ПАССАЖИРА
Пользователь вводит ФИО, дату рождения, паспорт
Данные валидируются (имя совпадает с документом?)
Passenger Service: шифруем и сохраняем в PostgreSQL
4. SEAT HOLD
Booking Service → GDS: hold_seat(flight_id, fare_key, pax)
GDS: место заблокировано, hold_id=XYZ789, TTL=15 мин
Booking: статус → PENDING_PAYMENT
5. ОПЛАТА
Пользователь вводит карту → токен через JS PSP SDK (карта не попадает на сервер)
Booking Service → Payment Service → PSP: charge($235, token)
PSP: 3DS challenge (SCA если Европа)
PSP: approved, transaction_id=TXN_001
Booking: статус → PAYMENT_RECEIVED
6. ТИКЕТИНГ
Booking Service → GDS Gateway: issue_ticket(hold_id, pax_data, TXN_001)
GDS → Airline: создать PNR
Airline → GDS: PNR = "AB1C2D"
Booking: статус → CONFIRMED, pnr_code = "AB1C2D"
7. УВЕДОМЛЕНИЕ
Notification Service ← событие из Kafka (booking.confirmed)
Email: "Ваш билет: SVO→JFK, SU100, 15 июня, PNR: AB1C2D"
SMS: краткое подтверждение
Push: если установлено мобильное приложение
8. ОНЛАЙН-РЕГИСТРАЦИЯ (за 24 ч до вылета)
Пользователь → Check-in Service
Check-in Service → Airline API: web_check_in(pnr, pax)
Airline: выбор места, выдача boarding pass
Boarding pass: PDF + Apple/Google Wallet
Компромиссы и ограничения
Freshness vs Cost
Каждый запрос к GDS стоит от 0.01. При 50 000 поисков/с × 10 провайдеров без кэша — 500 000 GDS-транзакций/с × 2 500/с = $216 млн/сутки. Это нереально. Кэш сокращает реальное число запросов в 10–50 раз.
Компромисс: агрессивный кэш = экономия денег, но устаревшие цены и «ghost availability». Решение: price revalidation при бронировании + честное UI-предупреждение о времени обновления.
Consistency vs Availability при бронировании
Применяем CAP-теорему к разным компонентам:
| Компонент | Выбор CAP | Обоснование |
|---|---|---|
| Search (кэш) | AP (availability + partition tolerance) | Чуть устаревшие цены допустимы |
| Inventory (места) | CP (consistency + partition tolerance) | Нельзя продать одно место дважды |
| Bookings DB | CP | Финансовые данные требуют консистентности |
| Notification | AP | Задержанное уведомление лучше, чем ошибка |
Real-time vs Cached Inventory
Метапоисковики (Aviasales) видят shadow inventory — кэшированную копию инвентаря. OTA (Booking.com Flights, Expedia) бронируют через реальный GDS-инвентарь. Разница порождает ситуации: Aviasales показывает 5 мест, реальных остаётся 2.
Сложность интеграций
Легаси GDS API (XML/SOAP с документацией 2003 года) — серьёзный технический долг. NDC улучшает ситуацию, но каждая авиакомпания реализует его по-своему. Поддержка 50+ интеграций требует выделенной команды.
Авиабилеты vs Отели vs E-commerce: сравнение inventory-моделей
| Характеристика | Авиабилеты | Отели (Booking.com) | E-commerce (Amazon) |
|---|---|---|---|
| Inventory owner | Авиакомпания (через GDS) | Отель (через extranet) | Продавец (через API) |
| Доступ к inventory | Платный ($0.003–0.01/запрос) | Бесплатный (push-модель) | Бесплатный |
| Скорость изменения цены | Минуты | Часы–дни | Секунды–минуты |
| Overbooking | Стандартная практика (5–10%) | Редко (штрафы) | Невозможно |
| Expiry | Рейс улетел — товар исчез | Дата прошла — номер свободен | Нет expiry |
| Комбинаторика | Пересадки: O(N²) комбинаций | Одна точка | Одна единица товара |
| Cancellation cost | $50–200 штраф | Часто бесплатно | Бесплатно |
Проектирование системы бронирования отелей с free cancellation — Глава 37: Проектирование Booking.com. E-commerce inventory — Глава 34: Проектирование Amazon.
Итоги
Ключевые архитектурные решения:
- Search Path отделён от Booking Path — разные требования к consistency и latency
- Fan-out с partial results — 10–50 провайдеров параллельно, таймаут 1.5с, возврат частичных данных
- Адаптивный TTL кэша — 3–15 мин в зависимости от близости даты вылета
- Saga Pattern для распределённой транзакции бронирования
- Optimistic Locking для инвентаря — предотвращает двойную продажу
- Price Revalidation перед оплатой — устраняет расхождение кэш/реальность
- Circuit Breaker для каждого GDS — деградация вместо каскадных сбоев
- Адаптер-паттерн — единый интерфейс для 50+ разных GDS/NDC
Ключевые числа:
| Метрика | Значение |
|---|---|
| Пиковый QPS поиска | 50 000 req/s |
| Таймаут GDS запроса | 1.5 сек |
| TTL кэша поиска | 3–15 мин (адаптивный) |
| Seat Hold TTL | 15–20 мин |
| Cache Hit Rate (цель) | > 90% |
| Снижение GDS-запросов за счёт кэша | 10–50× |
| Размер Redis-кластера (кэш поиска) | ~400 GB |
| Uptime бронирования | 99.95% |
Вопросы для самопроверки
Базовый уровень
- Что такое GDS и зачем он нужен авиасистемам?
- Чем отличается fare class от класса обслуживания (эконом/бизнес)?
- Что означает аббревиатура PNR?
- Почему цена в поиске может отличаться от цены при бронировании?
- Что такое MCT и почему нельзя строить маршруты без этого ограничения?
Архитектурный уровень
- Почему Search Path и Booking Path проектируются с разными требованиями к consistency?
- Как устроен fan-out к GDS-провайдерам? Что происходит, если 3 из 10 не ответили за таймаут?
- Зачем нужен price revalidation перед оплатой, если цена уже показана пользователю?
- Как работает Optimistic Locking для предотвращения двойной продажи мест?
- Какую роль играет Адаптер-паттерн в интеграции с разными GDS?
Уровень компромиссов
- Почему TTL кэша поиска не должен быть ни слишком коротким, ни слишком длинным?
- Объясните компромисс между overbooking (продажей 105% мест) и риском bump пассажиров.
- Почему для инвентаря выбираем CP по CAP, а для поиска — AP?
- Какие проблемы возникают при переходе с GDS (XML/SOAP) на NDC (REST/JSON)?
- Зачем нужна Dead Letter Queue в Saga Pattern и что в неё попадает?
Уровень проектирования
- Спроектируйте ключ кэша Redis для результатов поиска. Какие поля включить? Что будет, если забыть поле
cabin_class? - Как бы вы реализовали функцию «отслеживание цены» (уведомление, когда цена на маршрут упала)? Опишите архитектуру.
- Как масштабировать систему для обработки распродажи авиакомпании, которая даёт ×100 нагрузки за 5 минут?
- Опишите схему таблицы
bookingsс учётом партиционирования для хранения 5-летней истории. - Как обеспечить data residency (хранение данных в стране пассажира) при мультирегиональной архитектуре?
Ответ на вопрос 7: Fan-out к GDS и частичные результаты
Fan-out реализуется через асинхронные запросы (asyncio/goroutines) ко всем провайдерам одновременно с общим таймаутом 1.5–2 секунды. Каждый провайдер оборачивается в отдельную корутину; результаты собираются через asyncio.gather() или аналог.
Если 3 из 10 провайдеров не ответили:
- Их результаты просто не включаются в итоговую выборку
- Пользователь видит результаты от 7 провайдеров с опциональным предупреждением «Не все авиакомпании отображены»
- Метрики фиксируют таймаут каждого провайдера
- При систематических таймаутах Circuit Breaker переводит провайдера в OPEN-состояние на 60 секунд, прекращая к нему запросы
Главный принцип: partial result лучше, чем полная ошибка. Пользователь увидит 40 маршрутов вместо 60 — это приемлемо. Пользователь увидит 500 Internal Server Error — это недопустимо.
Ответ на вопрос 11: Компромисс TTL кэша поиска
Слишком короткий TTL (< 2 мин):
- Почти все запросы — cache miss
- Каждый поиск → 10 запросов к GDS
- При 50 000 QPS: 500 000 GDS-транзакций/с
- GDS лимитирует: 40 TPS/ключ → нужно 12 500 ключей
- Стоимость: 2 500/с
Слишком длинный TTL (> 30 мин):
- Высокий процент «ghost availability» (место показано, но уже продано)
- Пользователи разочарованы расхождением цены при бронировании
- Репутационный ущерб
Оптимум 5–15 мин: цена меняется реже — TTL длиннее. Cache hit rate > 90% снижает нагрузку на GDS в 10 раз. Price revalidation при бронировании компенсирует возможное устаревание.
Адаптивный TTL: за 3 дня до вылета цены меняются каждые несколько минут → TTL = 3 мин. За 2 месяца до вылета цены стабильны → TTL = 15 мин.
Ответ на вопрос 17: Архитектура отслеживания цен
«Отслеживание цены» — пользователь подписывается на маршрут и получает уведомление, когда цена упала ниже порога.
Компоненты:
-
Price Alert Service — принимает подписки:
{user_id, origin, destination, dates, threshold_price}, сохраняет в PostgreSQL. -
Price Crawler — периодически (каждые 15–30 мин) делает поиск по всем активным подпискам. Приоритизирует: маршруты с наибольшим числом подписчиков — чаще. Для популярных направлений (SVO→JFK) достаточно одного запроса на тысячи подписчиков.
-
Price History — ClickHouse хранит временной ряд цен по каждому маршруту. Позволяет строить графики «история цен за 90 дней» и ML-модели предсказания.
-
Alert Dispatcher — при обнаружении цены ниже порога: публикует событие в Kafka → Notification Service отправляет email/push.
Оптимизация: вместо поиска по каждой подписке отдельно, группируем по направлению+дата → 1 запрос к GDS покрывает N подписчиков с разными порогами.
Схема Kafka: топик price.alerts с партиционированием по user_id — гарантирует порядок уведомлений для одного пользователя.
Что читать дальше
Связанные главы этой книги:
- Глава 6: Кэширование — TTL, инвалидация, write-through/write-behind, Redis Cluster
- Глава 12: Согласованность в распределённых системах — CAP теорема, Optimistic Locking, распределённые транзакции
- Глава 13: Шардирование и партиционирование — партиционирование PostgreSQL по дате
- Глава 14: Репликация данных — Active-Passive для Booking Service, Read Replicas
- Глава 17: Безопасность — PCI DSS, токенизация карт, шифрование PII
- Глава 37: Проектирование Booking.com — ближайший аналог: агрегатор с динамическим инвентарём от партнёров
- Глава 41: Uber — Circuit Breaker, distributed locking
- Глава 42: Uber Eats — Saga Pattern, идемпотентные платежи
- Глава 44: FedEx/UPS — конечные автоматы, Dead Letter Queue
Внешние ресурсы:
- IATA NDC Standard Documentation: iata.org/NDC
- Amadeus for Developers: developers.amadeus.com
- «Building Microservices» — Sam Newman (паттерны интеграции)
- «Designing Data-Intensive Applications» — Martin Kleppmann (Глава 9: Consistency and Consensus)
- Kiwi.com Engineering Blog — реальные истории о виртуальных маршрутах и self-transfer
В следующей главе мы спроектируем систему онлайн-обучения (Coursera / Stepik) — платформу с миллионами студентов, видеолекциями, системой проверки заданий и персонализированными рекомендациями курсов.