Когда вы открываете Netflix, кажется, что всё уже готово — но на самом деле под капотом разворачивается сложная архитектура, которая адаптирует главную страницу в реальном времени. Команда Netflix перешла от статичной генерации страниц к умной системе, основанной на GraphQL-мутaциях, клиентской нормализации кэша и триггерах обновлений. Благодаря этому подходу, пользовательский опыт становится глубоко персонализированным, отзывчивым и масштабируемым.

В новом переводе от команды Spring АйО подробно рассказывается, как устроен API, как работают обновления, и почему Netflix выбрал именно мутации вместо подписок или обычных запросов.


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

Рисунок 1. Закулисная активность устройств и серверов
Рисунок 1. Закулисная активность устройств и серверов

Ранее, как только главная страница была создана, она оставалась статичной и неизменной на протяжении всей сессии пользователя, за исключением некоторых угловых кейсов, таких как разделы «Мой список» и «Продолжить просмотр». Несколько лет назад мы начали эксперименты по улучшению user experience для конкретного момента времени, обновляя страницу динамически в зависимости от поведения пользователя в рамках текущей сессии. Например, после просмотра нескольких трейлеров комедийных шоу, пользователь увидел бы обновлённую страницу с релевантными комедийными заголовками.

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

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

GraphQL API для обновлений страниц

Поддержка динамических обновлений страниц естественным образом потребовала внимания к GraphQL API, используемым устройствами Netflix. Наши технические решения в области GraphQL заключались в выборе подходящих операций и проектировании самой GraphQL схемы.

Наш подход был достаточно простой:

  1. Устройство отправляет GraphQL-запрос для получения начальных данных для отображения страницы.

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

  3. Устройство отправляет GraphQL mutation для обновления страницы.

Рисунок 2. Последовательность вызовов GraphQL
Рисунок 2. Последовательность вызовов GraphQL

Конкретно для шага 3 кандидатами в качестве альтернативных вариантов также были простые “запросы” (т.е. GraphQL queries) и “подписки” (GraphQL subscriptions). Однако, в итоге мы всё же выбрали мутации для операции с GraphQL по следующим причинам.

Мутации вместо запросов

Поскольку запросы обычно используются для операций чтения, а мутации — для изменения данных на сервере, мутации GraphQL оказались более подходящими концептуально. Кроме того, мы нашли вескую техническую причину в пользу мутаций, исследуя поведение кэширования на клиентской стороне GraphQL. Для queries запросов наши библиотеки GraphQД часто включают механизм кэширования данных локально и используют их как результат выполнения последующих запросов, некая рекомендуемая оптимизация. К сожалению, такое поведение кэширования мешало нам получать новые данные страницы с помощью запросов, и нам приходилось тщательно управлять запросами, чтобы избежать проблем. С мутациями клиентские библиотеки всегда получали данные с сервера без проблем, даже при повторных одинаковых GraphQL вызовах.

Мутации вместо подписок
В отличие от запросов, подписки GraphQL концептуально хорошо подходили для нашего случая. Однако мы решили отказаться от использования подписок по практическим соображениям, связанным с затратами на реализацию и рисками внедрения, связанными с общей инфраструктурой работы GraphQL подписок в Netflix в целом. Дело в том, что на тот момент подписки имели ограниченное распространение в Netflix, в основном в нескольких приложениях для студийного контента и нигде в пространстве подписчиков Netflix. Решение на основе подписок потребовало бы значительных вложений в инфраструктуру для поддержки огромного масштаба стриминга Netflix для подписчиков. Хотя мы считали эти инвестиции ненужными в тот момент, мы разработали наш подход с мутациями таким образом, чтобы он был совместим с подпискам, что обеспечивало бы плавный переход, если наша стратегия изменится в будущем.

Проектирование схемы
После того как мы определились с операциями GraphQL, мы переключили внимание на проектирование схемы. Здесь мы столкнулись с рядом технических проблем, которые будем иллюстрировать далее.

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

type Page {
  id: ID
  timestamp: Long
  sections: [Section]
}

type Section {
  id: ID
  title: String
}

Далее у нас есть запрос для получения начальной страницы и мутация для получения обновленной страницы.

type Query {
  getPage(pageType: String): Page // pageType = "home"
}

type Mutation {
  updatePage(id: ID!): UpdatePageResult
}

type UpdatePageResult {
  page: Page
}

Используя запрос для получения начальной страницы, устройство получает ответ, содержащий страницу с двумя разделами:

{
   "id": 1234,
   "timestamp": 1735763430000,
   "sections": [
     {
          "id": 1,
          "title": "first section"
     },
     {
          "id": 2,
          "title": "second section"
      }
   ]
}

Эти данные страницы сохраняются в локальном кэше устройства и передаются в соответствующие слои для рендеринга или других целей.

Позже происходит “триггерное событие”, например, пользователь запускает воспроизведение какого-либо видео, и устройству нужно получить обновление страницы с учетом новых преференций. Приложение использует мутацию и запрашивает обновленную страницу. На этот раз сервер возвращает обновленную страницу с новым timestamp`ом, а также новым разделом, помимо двух предыдущих.

Ответ мутации:

{
   "id": 1234,
   "timestamp": 1735770630000, // обновленное значение
   "sections": [
     {
        "id": 1,
        "title": "first section"
     },
     {
        "id": 3,
        "title": "new" // новый раздел
     },
     {
        "id": 2,
        "title": "second section"
     }
  ]
}

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

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

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

Чтобы понять это на более экстремальном примере, представьте, что у устройства есть список из 100 разделов, и один новый раздел добавляется во время обновления страницы. Получение нового списка из 100 + 1 раздела становится как непомерно дорогим, так и расточительным, поскольку 100 разделов остаются неизменными, а только 1 новый.

Основная проблема здесь заключается в том, как можно эффективно обновить список сложных типов в GraphQL, при этом минимизировав количество запросов на сервер.

Нормализация кэша для разделов

Чтобы решить проблему эффективности, мы работали с инженерами клиентских приложений Netflix для TV и мобильных устройств, исследуя нормализацию кэша клиента GraphQL для разделов. Вместо того чтобы хранить все Sections и соответствующие данные под одной записью Page в клиентском кэше, мы хранили отдельную запись для каждой Section, используя сгенерированные id в качестве ключа, и уже далее использовали ссылки на эти секции в записи кеша, которая указывает на саму Page. Вот визуальное представление этой разницы:

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

Возвращаясь к нашему примеру обновления страницы с добавлением нового раздела в середину списка, нам было необходимо:

  1. Получить полный набор данных для нового раздела.

  2. Пропустить получение полного набора данных для неизмененных разделов.

  3. Обновить список ссылок на разделы в Page.

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

type UpdatePageResult {
  page: Page
  insertedSections: [Section] // новое поле
}

Используя запрос мутации, устройство получает поле страницы (Page) из UpdatePageResult, но оно содержит лишь идентификаторы нормализации для разделов (ключи в кеше). Оно также получает поле insertedSections, и вот она поле уже содержит полный список полей только для нового раздела.

Вот пример мутации и ответа:

mutation updatePage(pageId: 1234) {

  page {
    sections {
      id // запрашиваем только идентификаторы нормализации
    }
  }

  insertedSections {
    id
    title // запрашиваем полные данные для новых разделов
  }
}

updatePageResult: {
  page: {
    sections: [
      {
         id: 1
      },
      {
         id: 3 // идентификатор нового раздела
      },
      {
         id: 2
      }
    ]
  },
  insertedSections: [
    {
       id: 3, // идентификатор нового раздела
       title: "new"
    }
  ]
}

После этого внутренний кэш GraphQL клиента устройства обновляется и выглядит следующим образом:

{
  "page:1234": {
    "timestamp": 1735770630000,
    "sections": [
      "${section:1}", // Ссылка
      "${section:3}", // Ссылка на новый раздел
      "${section:2}"  // Ссылка
    ]
  },
  "section:1": {
    "id": 1,
    "title": "first section"
  },
  "section:2": {
    "id": 2,
    "title": "second section"
  },
  "section:3": {
    "id": 3,
    "title": "new"
  }
}

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

Триггеры для обновлений страницы

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

Когда устройство впервые запрашивает главную страницу при запуске приложения, сервер возвращает набор триггеров для страницы в рамках тела ответа Page и Section. Ниже представлена схема, в которой теперь добавлены триггеры на уровне страницы и раздела.

type Page {
   id: ID
   timestamp: Long
   sections: [Section]
   triggers : [Trigger] // добавлено
}

type Section {
   id: ID
   title: String
   triggers : [Trigger] // добавлено
}

type PlaybackEndTrigger {
 id: ID
 action: Action
}

type ServerNotificationTrigger {
 id: ID
 serverNotificationMessage: String
 action: Action
}

type AddToMyListTrigger {
 id: ID
 action: Action
}

union Trigger = PlaybackEndTrigger | ServerNotificationTrigger | AddToMyListTrigger
union Action = UpdatePageAction | NewPageAction | ApplyPrefetchAction

Пока сервер решает, какие триггеры возвращать, клиент интерпретирует критерии для каждого триггера на основе его конкретного типа GraphQL. Например, при встрече с типом AddToMyListTrigger клиент интерпретирует это как инструкцию отслеживать добавление элементов в «Избранное» пользователями.

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

Например, когда пользователь начинает смотреть новый заголовок на телевизоре, мы хотим обновить раздел «Продолжить просмотр» на всех его активных устройствах, помимо самого телевизора. Серверные уведомления отправляются на эти устройства, которые затем обновляют страницы с соответствующими триггерами.

Действия триггера

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

  • NewPage: Указывает клиенту загрузить совершенно новую страницу.

  • UpdatePage: Говорит клиенту обновить определённые разделы без перезагрузки всей страницы.

  • ApplyPrefetchedData: Направляет клиента использовать данные, которые были заранее загружены в ожидании обновления, вместо того чтобы делать запрос к серверу.

  • NavigateToAppStore: Направляет клиента открыть App Store, что может быть необходимо для загрузки игр и других приложений.

  • OpenGame: Переводит клиента в игровой режим, если это необходимо.

Этот гибкий подход позволяет нам задавать разное поведение клиента в зависимости от контекста, а также изменять это поведение в будущем. Например, на главной странице «PlaybackEndTrigger» может привести к обновлению страницы, а на другой странице — к загрузке новой страницы с использованием другого действия.

Обработка типа UpdatePageAction

Теперь давайте более подробно рассмотрим тип UpdatePageAction, определённый ниже. Это действие может быть связано с любым триггером, и когда триггер происходит, клиент вызывает сервер с помощью мутации updatePage, передавая связанный с действием идентификатор как параметр мутации.

type UpdatePageAction {
   actionId: ID
}

mutation updatePage(pageId: String!, actionId: ID): UpdatePageResult // новый параметр actionId

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

  • Модификацию раздела для добавления или удаления элементов

  • Вставку нового раздела в определённое место

  • Удаление раздела со страницы

UpdatePageAction и мутация UpdatePage

Давайте свяжем это с примером.

Сначала устройство получает следующую полезную нагрузку страницы от сервера. Она содержит один раздел «Продолжить просмотр» с одним видео и PlaybackEndTrigger.

{// original page response
  "sections": [
      {
         "id": 1,
         "title": "Continue Watching",
         "videos": [
           {
              "id": "video1",
              "title": "Stranger Things",
              "duration": 50
           }
         ],
         "triggers": [
           {
              "__typename": "PlaybackEndTrigger",
              "id": "trigger1",
              "action": {
                 "__typename": "UpdatePageAction",
                 "id": "encodedCwPlaybackActionId1"
              }
           }
        ]
     }
  ]
}

Затем пользователь воспроизводит «Stranger Things» из раздела «Продолжить просмотр». Когда воспроизведение заканчивается, устройство активирует зарегистрированный триггер PlaybackEndTrigger и связанный с ним UpdatePageAction. В результате клиент вызывает мутацию updatePage и передает в качестве параметра идентификатор действия «encodedCwPlaybackActionId1».

С помощью этого идентификатора действия сервер понимает, что воспроизведение произошло из строки «Продолжить просмотр» на этой конкретной странице. Сервер определяет, что пользователю может быть интересен похожий контент. Он обращается к серверным сервисам, чтобы сообщить, что было воспроизведено «Stranger Things», и затем сервер вставляет новый раздел «Поскольку вы смотрели Stranger Things» на страницу. В ответе на мутацию updatePage сервер возвращает UpdatePageResult, с новым идентификатором раздела, вставленным в нужную позицию, и полностью заполненным новым разделом как модификация insertSection.

updatePageResult: {
  page: {
    sections: [
      {
         id: 1
      },
      {
         id: 2 // новый вставленный раздел
      }
    ]
  },
  insertedSections: [
    {
       id: 2, // новый вставленный раздел
       title: "Поскольку вы смотрели Stranger Things"
    }
  ]
}

После получения ответа устройство рендерит обновленную страницу, и пользователь видит новый раздел «Поскольку вы смотрели Stranger Things» с релевантными заголовками.

Заключение

Для простоты эта статья сосредоточена на модификации страницы путём вставки новых разделов. Однако на практике наша схема GraphQL более сложна и поддерживает различные типы изменений, при этом использует те же основные концепции, которые были представлены здесь. Помимо вставки разделов, она поддерживает их переупорядочивание, удаление и замену, с более широким набором триггеров и действий. В совокупности эта система предоставляет значительную гибкость в создании динамичного UX.

Стоит отметить, что ключевым аспектом этой разработки является server-driven подход, для управления триггерами, действиями и изменениями страниц. Это упростило бизнес-логику устройств, исключив необходимость в индивидуальных реализациях, которые ранее требовались для таких функций, как «Продолжить просмотр» и «Мой список». Более того, это позволяет нам изменять поведение динамичных страниц единообразно на всех устройствах без дополнительных изменений, специфичных для устройства, или обновлений приложений.

Наш переход от статичных страниц к динамичным в Netflix — это только начало гораздо более масштабного пути. Эта гибкая система мутаций GraphQL и триггеров является важной частью этого пути, предоставляя прочную основу для инноваций в будущем. Мы с нетерпением ждем возможности продолжать развивать нашу платформу, делая её ещё более отзывчивой и персонализированной для каждого пользователя Netflix.


Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано

Комментарии (1)


  1. gazkom
    27.09.2025 08:41

    Когда вы открываете Netflix, кажется, что всё уже готово — но на самом деле под капотом разворачивается сложная архитектура, которая адаптирует главную страницу в реальном времени.

    Как, оказывается, много нужно работать, чтобы сделать такую страницу