Демонстрация дизайна системы

Аудитория

Эта статья является следующей в моей серии статей о том, как я буду разрабатывать популярные приложения. Рекомендуется (хотя и не совсем обязательно) прочитать предыдущие посты, которые я услужливо собрал в список здесь. Мы ожидаем базового знакомства с принципами архитектуры и AWS, но надеемся, что этот пост будет доступен большинству инженеров.

Аргумент

Сначала давайте посмотрим на нашу постановку задачи.

Система для проектирования

Мы хотели бы разработать систему, аналогичную TicketMaster. Для тех из вас, кто не знаком, это система бронирования билетов на прямые трансляции. Это могут быть концерты, театр, комедия, что угодно под общим зонтиком.

Точные требования нашей системы заключаются в том, что она должна поддерживать несколько городов, и когда пользователь выбирает город, он должен видеть доступные места в этом месте. Когда пользователь выбирает место, он может увидеть выступления и даты для этого места. Они могут выбрать спектакль, дату и количество билетов, чтобы добавить их в корзину.

Затем пользователь может использовать стороннего поставщика для оплаты своих билетов, после чего он получит электронное письмо и уведомление по телефону, содержащее их квитанцию.

Наши обычные нефункциональные требования остаются в силе. Он должен быть надежным, масштабируемым и доступным.

Подход

У нас стандартный подход к проектированию системы, который более подробно описан в статье здесь. Однако шаги кратко изложены ниже:

  1. Уточнение требований: убедитесь, что у нас есть вся информация, прежде чем начать. Это может включать в себя ожидаемое количество запросов или пользователей.
  2. Задняя часть оценки конверта:Выполнение некоторых быстрых расчетов для оценки необходимой производительности системы. Например, сколько памяти или пропускной способности нам нужно?
  3. Дизайн интерфейса системы: как наша система будет выглядеть снаружи, как люди будут с ней взаимодействовать? Как правило, это контракт API.
  4. Дизайн модели данных. Как будут выглядеть наши данные при их хранении. На этом этапе мы могли бы подумать о реляционных и нереляционных моделях.
  5. Логический дизайн:собираем все вместе в грубую систему! В этот момент я думаю на уровне «как бы я объяснил свою идею тому, кто ничего не знает о технологиях?»
  6. Физический дизайн. Теперь мы начинаем думать о серверах, языках программирования и деталях реализации. Мы можем наложить их поверх логического дизайна.
  7. Выявление и устранение узких мест: На этом этапе у нас будет работающая система! Теперь дорабатываем дизайн.

С учетом сказанного, давайте застрянем!

Уточнение требований

Вопросы, которые я хотел бы задать, включают в себя, сколько городов мы ожидаем, сколько площадок в каждом городе, сколько выступлений в каждой площадке в год и вместимость каждой площадки!

Задняя часть оценки конверта

Допустим, у нас есть 100 городов, в каждом по 10 площадок, по 10 спектаклей в день, в среднем по 1000 билетов на площадку. Давайте также предположим, что все распродано (надеюсь). Это дает нам:

100 cities * 10 venues * 10 performance * 1000 tickets = 10,000,000 tickets transactions a day!

Это соответствует примерно 15 продажам в секунду (равномерно).

Если мы оценим, что хранилище города/места проведения/представления/билетов занимает примерно 50 Б на строку, то наши общие (приблизительные) требования к хранилищу будут следующими:

  1. 100 * 50 = 5000B = 5KB для городов
  2. 100 * 10 * 50 = 50,000B = 50KB для площадок
  3. 100 * 10 * 50 * 10 = 500,000B = 500KB для спектаклей
  4. 10,000,000 * 50 = 5,000,000,000B = 500MB на билеты

Таким образом, общее количество становится:

500MB + 500KB + 50KB + 5KB = 500.555MB (for the first day)

Если мы будем сохранять данные билетов каждый день, то объем памяти будет увеличиваться на 500 МБ в день, и к концу года нам потребуется 182,5 ГБ дискового пространства.

Для оценки трафика нам потребуется примерное количество раз, когда человек будет обращаться к страницам/объектам на сайте. Это возможно сделать, но, вероятно, не стоит прикрывать упражнение.

Дизайн системного интерфейса

Теперь у нас есть приблизительные оценки того, сколько хранилища мы хотели бы использовать, давайте подумаем о доступе к данным. Изначально нам нужно будет получить доступ ко всем городам, площадкам, представлениям и билетам на выбор.

Мы хотим быть немного прагматичными в том, как мы загружаем данные. У нас есть ряд вложенных объектов: города содержат места проведения концерты содержат билеты. Потенциально у нас может быть одна конечная точка /cities, которая возвращает все вложенные данные. Однако это кажется излишеством — мы каждый раз будем получать все билеты!

Вместо этого у нас есть одна конечная точка для каждого объекта данных.

  • Города:/cities
  • Места:/cities/{id}/venues
  • Выступления:/cities/{id}/venues/{id}/performances
  • Билеты:/cities/{id}/venues/{id}/performances/{id}/tickets

Мы получаем список объектов данных на запрос, с ответом 200 и обычными кодами 4XX, 5XX.

Еще одна вещь, которую нам нужно сделать, это иметь возможность бронировать и покупать билеты. Допустим, у нас есть объект билета, подобный показанному ниже.

{
  "id": "<Id of the ticket object>",
  "state": "<AVAILABLE/ RESERVED/ PURCHASED>"
  ...
}

Чтобы зарезервировать билет, мы можем отправить POST запрос на /user/{id}/tickets. Затем объект билета будет связан с пользователем, а состояние изменится на зарезервировано. Для покупки билета к той же конечной точке будет отправлен запрос PUT (или PATCH), обновляющий состояние на «Куплен».

Это очень полезно, так как билет останется привязанным к представлению, но его состояние изменится, показывая его как купленное или зарезервированное.

Другой подход, который мы могли бы попробовать использовать, — GraphQL. Используя традиционные принципы REST, мы иногда можем быть вовлечены в множество различных конечных точек, множественные вызовы и ненужную загрузку данных. GraphQL — это гибкий язык запросов для наших API.

Сначала мы определяем схему для наших городов:

type City {
  id: ID!
  name: String!
  venues: [Venue!]!
}

У каждого города есть идентификатор (который не может быть нулевым, отсюда и восклицательный знак), имя и список мест проведения. Затем мы можем запросить это, используя что-то в формате.

query
    { 
      city(id: 1) { 
        venues {
           name
        }
    }

Обратите внимание, это скорее отступление. Мы пока будем придерживаться REST!

Дизайн модели данных

Теперь нам нужно спроектировать нашу модель данных. Из описания до сих пор мы можем видеть появление реляционного паттерна. Еще нам нужны транзакции со свойствами ACID для обработки покупки билетов.

Давайте углубимся в определение транзакции. Транзакция — это единица работы для базы данных. Например, покупка билета может быть транзакцией. Есть два основных направления:

  1. Предоставить единицы работы, которые обеспечивают согласованное состояние и восстановление в случае сбоев на полпути.
  2. Чтобы обеспечить изоляцию между программами, одновременно обращающимися к базе данных.

Мы можем поместить их в контекст покупки билетов. Допустим, билет стоит 10 фунтов стерлингов, и у пользователя есть 10 фунтов стерлингов на банковском счете. В контексте покупки мы хотели бы:

  1. Возьмите 10 фунтов стерлингов с пользователя.
  2. Отметьте билет как купленный.
  3. Назначьте билет пользователю.

Если наша база данных падает (как они обычно делают), мы не хотим попасть в состояние, когда у пользователя было вычтено 10 фунтов стерлингов, но билет все еще доступен и не назначен (пункт 1)! Точно так же, если у нас есть два пользователя, покупающие один и тот же билет, мы не хотим вычитать 10 фунтов стерлингов с каждого из них, помечать билет как купленный, а затем иметь возможность назначить его только одному из них (пункт 2)!

В Spring мы используем аннотацию @Transactional для объявления транзакций. На самом уровне базы данных мы используем синтаксис, зависящий от СУБД. Для MySQL это START TRANSACTION; и COMMIT;.

Еще одна важная концепция, которую необходимо понять, — это ACID-свойства транзакций. КИСЛОТА означает:

  1. Атомарность. Вся транзакция представляет собой одну единицу работы. Либо все завершается, либо ни одно из них.
  2. Последовательность. Наша база данных будет иметь ряд ограничений (внешние ключи, уникальные ключи и т. д.). Это свойство гарантирует, что после транзакции у нас останется действительный набор данных с соблюдением ограничений в нашей базе данных.
  3. Изоляция. Это возможность обрабатывать несколько одновременных транзакций таким образом, чтобы они не влияли друг на друга. Если вы и другой человек одновременно пытаетесь купить билет, сначала должна произойти одна транзакция.
  4. Долговечность.После завершения транзакции результаты становятся постоянными. Например, если мы покупаем билет, то происходит отключение электричества, наш билет все равно будет куплен.

После всего этого давайте займемся дизайном нашей таблицы.

Город

  • id BIGINT PRIMARY KEY
  • name VARCHAR

Место проведения

  • id BIGINT PRIMARY KEY
  • name VARCHAR
  • city BIGINT FOREIGN KEY REFERENCES city(ID)

Производительность

  • id BIGINT PRIMARY KEY
  • name VARCHAR
  • venue BIGINT FOREIGN KEY REFERENCES venue(ID)

Билет

  • id BIGINT PRIMARY KEY
  • name VARCHAR
  • performance BIGINT FOREIGN KEY REFERENCES performance(ID)
  • user BIGINT FOREIGN KEY REFERENCES user(ID)
  • reserved_until TIMESTAMP

Пользователь

  • id BIGINT PRIMARY KEY
  • name VARCHAR
  • phone_number INT
  • email_address VARCHAR

Логический дизайн

Основная логическая конструкция достаточно проста.

Наш пользователь получает доступ к информации о билетах через наш API билетов. Когда пользователь добавляет билет в свою корзину, мы резервируем его, изменяя его состояние в базе данных. Мы также устанавливаем время через x минут, когда билеты будут удалены из корзины.

Когда пользователь покупает свой билет, он будет перенаправлен к стороннему платежному провайдеру, а в случае успеха он будет перенаправлен обратно в наш API билетов, чтобы отобразить страницу успеха.

API билетов поместит сообщение в тему продаж, на которую подписаны две очереди. Затем эти две очереди передаются двум рабочим процессам, которые отвечают за отправку электронных писем и текстовых сообщений.

Заключительная часть головоломки заключается в том, как мы освобождаем зарезервированные билеты. За это отвечает другой сервис, который каждые несколько минут опрашивает базу данных в поисках резервирований с истекшим сроком действия, освобождая их.

Это наш базовый логический проект, давайте посмотрим, как мы можем его построить.

Физический дизайн

Вот наш основной физический дизайн. Наш клиент находится в React, который предоставляется сервером Node, расположенным в кластере AWS ECS. Эта серверная служба отвечает за связь с базой данных MySQL, находящейся на Авроре.

Очистка резервирования выполняется путем запуска Лямбды, которая работает по таймеру, опрашивает БД и удаляет все устаревшие резервирования.

Наконец, при успешной продаже мы публикуем в теме SNS, на которую подписаны две очереди SQS. Оба воркера — это Lambdas, которые используют либо SES для отправки электронных писем, либо публикуют в теме, обрабатывающей SMS.

Еще одна интересная вещь, которую нужно изучить, пока мы здесь, — это то, как мы можем интегрировать нашу стороннюю платежную платформу. В качестве примера мы использовали PayPal (доступны другие платформы).

Действительно хорошая статья — здесь, а демонстрационный клиент/серверный код — здесь. Идея состоит в том, что вы встраиваете кнопку на свой сайт двумя способами: createOrder и onApprove.

Метод createOrder используется для создания заказа, сообщая PayPal, что покупается. Когда вы нажимаете кнопку, мы запускаем кассу PayPal. После успешного завершения проверки мы вызываем функцию onApprove, отображая сообщение для нашего пользователя. Средства будут безопасно переведены на наш счет PayPal.

Выявление и устранение узких мест

В нашей конструкции есть несколько узких мест. Первоначально мы могли бы ввести такие вещи, как CDN для уровня защиты/кэширования. Мы также могли бы добавить кеширующий слой для данных, которые редко меняются (список городов, площадок и выступлений — хороший вариант).

Еще одной оптимизацией будет разделение внутренних API-интерфейсов: одна служба Node для обслуживания приложения React и одна в качестве внутренней службы для работы с базой данных/платежами третьих лиц.

Основная оптимизация, которую мы можем сделать, связана с базой данных. Для этого давайте определимся с некоторыми терминами.

Секционирование – это процесс разбиения больших таблиц на более мелкие фрагменты. Делая это, мы сокращаем время запроса, так как одновременно запрашивается меньше данных. Существует два основных типа: горизонтальный и вертикальный.

Вертикальное масштабирование — это то, где мы разбиваем таблицу в зависимости от столбцов. Возможно, у нас есть столбцы, содержащие большие объемы данных. Имеет смысл хранить их отдельно, чтобы не запрашивать их каждый раз. Это похоже на нормализацию, когда мы разбиваем таблицы, чтобы уменьшить зависимость и избыточность.

Горизонтальное разбиение — это то, где мы разбиваем таблицу в зависимости от строк. Каждая строка будет иметь одинаковое количество столбцов, однако теперь будет несколько таблиц. Обычно мы горизонтально разбиваем на основе определенного столбца.

В приведенном выше примере мы разделили большой столбец по вертикали и разделили столбец по горизонтали.

Сегментирование немного отличается и представляет собой подмножество горизонтального секционирования. Горизонтальное разбиение — это логическое разделение данных, тогда как сегментирование обычно предполагает размещение этих логически отдельных блоков на разных физических серверах, определяя, на каком сервере должны находиться данные, с помощью ключа сегмента.

Но как это связано с индексированием? Я (возможно) слышу, как вы плачете? Чтобы ответить на этот вопрос, давайте разберемся, что такое индекс.

Когда мы сохраняем данные для таблицы, мы сохраняем их в блоке с указателем на следующий блок (подумайте о связном списке). Чтобы найти конкретный блок данных, нам нужно просмотреть их все, один за другим. Как вы можете себе представить, если то, что нам нужно, находится в самом конце этого списка, это может быть очень медленным.

Чтобы ускорить его, у нас может быть структура данных, которая сопоставляет искомый столбец со списком идентификаторов с этим значением. Мы демонстрируем ниже.

Это небольшое упрощение, на самом деле они используют структуру B+ Tree.

Другой способ думать об индексах — это колода карт. Если бы я попросил вас найти пикового туза, вы бы перелистывали всю колоду, пока не нашли бы его. Однако, если бы я разделил их на масти (эквивалентно индексации мастей), найти их было бы намного проще!

Итак, когда включать индекс и когда включать раздел? Индекс хорош, если вы хотите получить доступ только к небольшой части данных. Разделы хороши, если вы хотите получить доступ к большим частям данных, которые, как вы знаете, будут сгруппированы вместе.

Собрав все это вместе, мы могли бы разбить/осколок по городам! Это имеет смысл, так как все места, выступления и билеты будут расположены по городам.

Другая проблема, которую мы, возможно, захотим решить, — это устойчивость за счет реплик. У нас может быть реплика для каждого сегмента с механизмом аварийного переключения на случай, если основной экземпляр выйдет из строя.

Наша окончательная диаграмма выглядит следующим образом.

Заключение

В заключение, мы обсудили, разработали и подвергли критике наш собственный план сервиса, подобного TicketMaster. Надеюсь, вам понравилось!