Задача
Нужно сделать дизайн Ticketmaster. Ticketmaster — это онлайн-платформа, которая позволяет пользователям приобретать билеты на концерты, спортивные мероприятия, театральные постановки и другие live ивенты. Число ежедневных активных пользователей ~100M
Решение
Уточняем требования
Функциональные требования
- Пользователь должен быть способен искать различные события, концерты и т.д.
- Пользователь должен быть способен смотреть информацию о событии
- Пользователь должен быть спообен забронировать и оплатить билеты
Нефункциональные требования
- Строгая консистентность для бронирования билетов. Два разных пользователя не могут забронировать один и тот же билет.
- High Availability для поиска и просмотра событий.
- Число запросов на чтение сильно больше, чем на запись
- Масштабируемость, чтобы поддержать бронирования на очень популярные события.
Database Design
Ключевые сущности:
Event- информация о событии
Venue - площадка, где будет проводится мероприятие
Ticket - билеты
Performer - информация об исполнителе
API
Поиск события:
GET /search?keyword={keyword}&location={location}&type={type}&start_date={start_date}&end_date={end_date}.
Возвращает список событий Event[].
keyword - текст поиска,
location - населенный пункт, координаты центра и радиус поиска и т.д.
start_date, end_date - даты события
type - тип события
Просмотр события:
GET /event/eventId
возвращает Event, Venue, Performer, Tickets[]
eventId - id события
Бронирование:
Бронирование проходит в два этапа. Сначала мы резервируем билеты, после чего у нас есть ограниченное время (например, пять минут) на их оплату. Если мы не оплатим билеты в течение этого срока, они снова станут доступны другим пользователям.
API для резервирования:
POST /booking/reserve
header JWT | sessionToken
body:{
ticketIds
}
Для оплаты:
POST /booking/confirm
header JWT | sessionToken
body:{
ticketIds
paymentDetails
}
High Level Design
Опишем более детально основные таблицы:
Из интересного - Ticket должен иметь статус. Статус может быть available, booked, reserved. reserved - означает, что билет зарезервирован, но еще не оплачен. Он может перейти в статус booked, если его оплатили или в available если не оплатили за 5 минут.
High Level Architecture:
API Gateway отвечает за роутинг запросов от клиента к различным бэкенд сервисам, а также за аутентификацию и rate limiting.
Search сервис отвечает за поиск событий по различным критериям. Для начала он может просто делать SQL-запросы в базу.
Event сервис отвечает за чтение и запись событий в базу данных.
Booking сервис отвечает за резервирование и покупку билетов.
В качестве базы данных следует использовать базу с поддержкой ACID-транзакций, чтобы обеспечить строгую консистентность и исключить возможность бронирования одних и тех же билетов несколькими пользователями. Например, это может быть postgresql или MySQL.
Ticket Reserve - in-memory cache, например, Redis/Memcached. Там мы будем хранить билеты, которые зарезервировали, но еще не оплатили. Если их не оплатят в течении TTL, то они будут удалены из кэша.
Статус reserved не сохраняем в основной базе — остаются только available и booked. При выводе доступных билетов для события выбираем записи со статусом available и отсекаем те, что временно находятся в Ticket Reserve.
При бронировании будет вызван API-метод reserve, который добавит билеты в Ticket Reserve (кэш) с TTL 5 минут. Если пользователь не купит билеты за 5 минут - они будут удалены из Ticket Reserve и станут доступными для других пользователей. Если пользователь купит билеты (confirm API), то они также будут удалены из Ticket Reserve и будет выполнена запись в базу с изменение статуса билетов на booked.
Detailed Design/Deep Dives
Для того, чтобы удовлетворить нефункциональные требования по масштабируемости и high availability нам надо ускорить поиск и Event сервисы.
Для этого вместо SQL-запросов в сервисе Search используем ElasticSearch или Amazon OpenSearch. Данные для индексации будем передавать из основной базы по стриму, например через AWS Kinesis или Kafka. При каждом изменении в базе сразу отправляем update в ElasticSearch, чтобы оперативно переиндексировать записи. Это схема назымается CDC(Change Data Capture).
Также добавим кэш, который будет содержать популярные события.
Добавим еще один полезный функционал. Например, пользователь зашел на сайт и нашел интересующий его концерт. Однако доступных билетов нет, поскольку все они находятся в процессе бронирования другими пользователями. Вместо того чтобы просто сообщить, что билеты недоступны, мы можем поставить пользователя в очередь ожидания.
Такие пользователи будут автоматически нотифицироваться в случае, если кто-то из тех, кто бронировал, не завершил покупку. Это особенно актуально для очень популярных ивентов, билеты на которые раскупаются практически мгновенно.
Для соблюдения честности и порядка, очередь должна храниться в структуре данных типа LinkedHashMap. В качестве ключа — userId, в качестве значения — eventId и timestamp, отражающий момент, когда пользователь встал в очередь. Таким образом, первым в очереди будет пользователь, который подал запрос раньше остальных, и именно он получит приоритет на освободившиеся билеты.
Т.е. можно использовать следующую структуру данных для Waiting Users:
HashMap(key->EventId, value->LinkedHashMap(key->userId, value->timestamp)).
Аналогично для Ticket Reserve:
HashMap(key->EventId, value->LinkedHashMap(key->TicketId, value->timestamp))
Поскольку ожидание может занять продолжительное время, необходимо обеспечить устойчивый канал связи с такими ожидающими клиентами. Это можно реализовать с помощью WebSocket-ов или LongPolling.
Если число ивентов в базе станет большим, то можно партиционировать данные по EventId.
Waiting Users - in-memory cache. Там мы будем хранить пользователей, которые ожидают появление билетов, если нет доступных билетов.
Top comments (2)
Спасибо за статью!
Есть пару вопросов:
eventId
, кажется, что лучше поvendorId
, иначе для получения событий какой-то площадки нужно будет ходить по разным партициям. Хотелось бы узнать мнение, какие плюсы и минусыreserved_tickets: set()
, когда пользователь делает запрос, получаем из кеша все id тикетов, далее в базе ищем черезid NOT IN(...)
, да?