Автор: Ник Спрагг и Ник Рахмел
Введение
За последний год мы начали персонализировать домашнюю страницу iPlayer в гораздо большей степени, чем раньше - вместе с этим произошла реорганизация API.
Все данные, которые требуются любому из клиентов iPlayer для отображения полной домашней страницы, будь то в браузере, на телевизоре или в мобильном приложении, можно запросить одним запросом к базовому API GraphQL.
Каждый ответ персонализирован для пользователя - он включает в себя их текущие просмотренные передачи, их «добавленный» список и рекомендации, адаптированные к их поведению при просмотре.
Проблема
Вся эта персонализация делает каждый ответ уникальным и трудным для кэширования на граничном уровне кэширования.
Если раньше мы могли использовать простой слой кэша, созданный с использованием Varnish, для нескольких сотен различных вариантов домашней страницы, то с новой архитектурой это стало невозможным.
Вычислительные накладные расходы для каждого запроса значительно увеличились, что требует от нас сделать наш код как можно более эффективным и оптимизировать любые задачи, интенсивно использующие ЦП. На начальном этапе разработки мы не достигли ожидаемого уровня производительности прототипа для обработки 3000 запросов в секунду без ущерба для банка, поэтому мы начали выяснять, в чем заключаются наши узкие места.
Благодаря отличным функциям отладки, предоставляемым в текущих версиях Node.js (мы запускаем проект TypeScript на Node.js 8) и инструментам отладки Javascript в Chrome, простое профилирование определило задачу, которая в среднем занимает больше всего процессорного времени. Запросы: Переводы.
Переводы уже давно являются частью iPlayer - веб-сайт локализован на английский, валлийский, шотландский и ирландский гэльский, с сотнями предустановленных строк перевода, и все они применяются в зависимости от пользователя в модуле переводов. этого API.
Модуль переводов
В iPlayer API мы используем модуль перевода для преобразования текста (или частей текста) на указанный язык. Это достигается с помощью шаблонных строк. Например, шаблонная строка типа «# {available-for} 26 # {days}» станет «Доступен в течение 26 дней» на английском языке или «Ar gael am 26 o ddyddiau» на валлийском.
Модуль для этого относительно прост. Он экспортирует функцию translate, которая принимает шаблонную строку и код языкового стандарта. Translate анализирует строку на наличие ключей перевода и ищет каждый ключ в соответствующем поиске перевода. Например, уэльский перевод (cy), «available-for» соответствует «Ar gael am». Есть примерно 100 переводов на каждый язык.
Ключи перевода в строке представлены с использованием следующего синтаксиса: $ {example-translation-key}.
Вот пример английского и валлийского:
const templatedString = '#{available-for} 26 #{days}'; const toEnglish = translate(template, 'en'); console.log(toEnglish) // "Available for 26 days" const toWelsh = translate(template, 'cy'); console.log(toWelsh) // "Ar gael am 26 o ddyddiau"
Учитывая уровни кэширования в архитектуре iPlayer, такие модули часто писались с учетом простоты, тестируемости и точности, а не производительности.
Вот исходная функция перевода:
const TRANSLATIONS = loadTranslations(); const KEY_REGEX = /#{(.*?)}/g; function translate(value, language = 'en') { let translated = value; Object.keys(TRANSLATIONS[language]).forEach((key) => { const translation = TRANSLATIONS[language][key]; const re = new RegExp(`#{${key}}`, 'g'); translated = translated.replace(re, translation); }); return translated; }
Эта функция переведет ноль или более шаблонов переводов в заданную строку. Это работает, и это очень просто, но неэффективно. Любые идеи? Что ж, для указанного языка он пытается перевести каждый ключ в поиске перевода. В зависимости от того, как этот модуль в настоящее время используется в iPlayer, каждый вызов будет переводить от 0 до 3 переводов. В худшем случае он попытается сделать примерно 100 переводов шаблонов, даже если в строке их нет.
Потенциальная оптимизация состоит в том, чтобы выполнить необходимое количество переводов только для данной строки:
const TRANSLATIONS = loadTranslations(); const KEY_REGEX = /#{(.*?)}/g; function translate(value, language = 'en') { if (!value) { return value; } let translated = value; let match; while ((match = (KEY_REGEX.exec(value)))) { if (match) { const translation = TRANSLATIONS[language][match[1]]; if (translation === undefined) { continue; } const key = "#{" + match[1] + "}"; translated = translated.replace(key, translation); } } return translated; }
Из строк, содержащих от 0 до 3 переводов, тесты показали, что выборочная замена ключей перевода оказалась значительно быстрее. Конечно, у него есть пара недостатков. Во-первых, хотя это и не ракетостроение, оно добавляет дополнительную сложность. Во-вторых, из-за цикла while производительность падает, когда строка имеет 4 или более ключей.
Дальнейшая проверка сигнатуры метода замены строки Javascript показала, что в качестве второго параметра может быть указана функция замены. Эта функция замены будет вызываться после того, как будет найден каждый перевод.
Вот вариант функции перевода с использованием «string.replace» с предоставленной функцией замены:
const TRANSLATIONS = loadTranslations(); const KEY_REGEX = /#{(.*?)}/g; function translate(value, language = 'en') { if (!value) { return value; } return value.replace(KEY_REGEX, (a, b) => { const translation = TRANSLATIONS[b]; if (translation === undefined) { return a; } return translation; }); }
Возможно более элегантное решение. Как показывает практика, многократное использование стандартных библиотек или надежных сторонних решений часто приносит свои плоды, а не развертывание собственных решений. Талантливые инженеры уже потратили время на оптимизацию и повышение надежности кода. Кроме того, стандартные библиотеки официально поддерживаются и будут хорошо протестированы.
Это было очевидно по тестам; это решение оказалось таким же быстрым (приблизительно), как первая оптимизация для 0–3 переводов, но значительно быстрее для 4 или более.
Проверка
Когда дело доходит до такой оптимизации, одной из основных целей является удобство работы пользователей и максимально быстрое обслуживание большинства запросов.
Чтобы проверить наши изменения, мы запускаем нагрузочные тесты в контролируемой тестовой среде. Наряду с описанным выше профилированием, это информирует нас о приоритетах потенциальных оптимизаций.
Первая итерация
Для нашей первоначальной проверки нам нужно было найти текущий предел. Мы используем G atling в качестве инструмента нагрузочного тестирования со сценарием, который может делать персонализированные запросы для любого количества профилей пользователей и конфигураций клиентов. Мы можем выбирать для тестирования с репрезентативным разделением аудитории, в основном предпочитающим английский язык, или даже с разделением всех поддерживаемых нами языков.
Поскольку наша цель заключалась в улучшении затрат ЦП на переводы, а не в том, как мы храним или кэшируем результаты, это в значительной степени не имеет значения, и мы тестировали с равномерным разделением языков. Мы выбрали 2 экземпляра относительно небольшого типа машины и относительно небольшого количества пользователей, 30; каждый делает до 1 запроса в секунду.
Глядя на время отклика с течением времени, мы получаем следующий график:
Здесь мы видим, что время отклика вначале стабильное и с небольшими вариациями времени отклика, но к тому времени, когда мы достигаем 20 или около того пользователей, они достигают более одной секунды, что приводит к тому, что мы не достигаем нашей цели 30 оборотов в секунду, поскольку каждый пользователь должен ждать их просьбу завершить, прежде чем они смогут сделать новую. Однако полностью API не вываливается - в районе 12:00:20 наблюдается гораздо более заметное замедление, но оно само восстанавливается.
Улучшения
С нашей первой итерацией улучшений алгоритма мы получаем гораздо более стабильный график с той же тестовой настройкой:
Есть мгновенные всплески времени отклика для верхних процентилей, которые связаны с некоторыми обновлениями кеша и не имеют отношения к этому. Мы внесли улучшения! Но каков наш новый предел?
Для нашего второго раунда тестирования мы меняем настройку теста: сейчас мы используем только одну машину для запуска API, и мы увеличиваем количество пользователей итеративно, пока не найдем новый предел. Это график для 50 пользователей:
Мы можем снова игнорировать минутные всплески, как и раньше.
Как и раньше, когда мы набираем около 40 пользователей, замедление становится очень заметным, и, глядя на профилирование, кажется, что мы можем внести еще несколько улучшений - translate по-прежнему занимает большую часть процессорного времени для наших ответов, и мы бы хотелось бы выжать из этого каждую каплю производительности.
Окончательные результаты
После второго раунда улучшений с той же настройкой теста наш график выглядит следующим образом:
Похоже, что он снова начинает замедляться у 20 пользователей, но если мы посмотрим на левую ось Y, мы увидим, что диапазон замедлений намного ниже и находится в допустимом диапазоне для этого нагрузочного теста.
Профилирование теперь показывает нам, что перевод - не самая большая нагрузка на ЦП, и в сочетании с приведенным выше графиком пора посмотреть, откуда берутся некоторые закономерности в пиках. Но это уже для другого поста!