Написано Ником Спраггом и Ником Рамелем
За последний год мы начали персонализировать домашнюю страницу iPlayer в большей степени, чем раньше, — вместе с этим была изменена архитектура API.
Все данные, необходимые любому из клиентов iPlayer для отображения полной домашней страницы, будь то в браузере, на телевизоре или в мобильном приложении, можно запросить одним запросом к базовому API GraphQL.
Каждый ответ персонализирован для пользователя — он включает в себя его текущие просмотренные передачи, их «добавленный» список и рекомендации, адаптированные к их поведению при просмотре.
Вся эта персонализация делает каждый ответ уникальным и трудным для кэширования на уровне граничного кэширования.
Если раньше мы могли использовать простой слой кэша, созданный с помощью Varnish, для нескольких сотен различных вариантов домашней страницы, то с новой архитектурой это стало невозможным.
Вычислительные накладные расходы для каждого запроса значительно увеличились, что требует от нас сделать наш код максимально эффективным и оптимизировать любые задачи, интенсивно использующие ЦП. Во время первоначальной разработки мы не оправдали ожидания производительности нашего прототипа, чтобы обрабатывать 3000 запросов в секунду без ущерба для банка, поэтому мы начали исследовать наши узкие места.
Благодаря отличным функциям отладки, предоставляемым в текущих версиях Node.js (мы запускаем проект TypeScript на Node.js 8), и инструментам отладки Javascript в Chrome, простое профилирование выявило задачу, которая занимает больше всего времени процессора в среднем. запросы: Переводы.
Переводы уже давно являются частью iPlayer — веб-сайт локализован на английский, валлийский, шотландский гэльский и ирландский гэльский языки с сотнями предустановленных строк перевода, и все они применяются в зависимости от пользователя в модуле переводов. этого API.
В API iPlayer мы используем модуль перевода для преобразования текста (или частей текста) на указанный язык. Это достигается с помощью шаблонных строк. Например, шаблонная строка типа «#{доступно для} 26 #{дней}» станет «Доступно в течение 26 дней» на английском языке или «Ar gael am 26 o ddyddiau» на валлийском языке.
Модуль для этого относительно прост. Он экспортирует функцию translate, которая принимает шаблонную строку и код локали. Translate анализирует строку на наличие ключей перевода и ищет каждый ключ в соответствующем поиске перевода. Например, в валлийском переводе (cy) «доступно для» отображается как «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 переводов, тесты показали, что выборочная замена ключей перевода оказалась значительно быстрее. Конечно, у него есть пара недостатков. Во-первых, хотя это и не ракетостроение, но добавляет сложности. Во-вторых, производительность резко падает, когда строка содержит 4 и более ключей из-за цикла while.
Дальнейшая проверка сигнатуры метода замены строки 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 пользователей, замедление становится очень заметным, и, глядя на профилирование, кажется, что мы можем сделать еще несколько улучшений — перевод по-прежнему занимает большую часть процессорного времени для наших ответов, и мы бы хотел бы выжать из этого каждую частичку производительности.
После второго раунда улучшений с той же тестовой настройкой наш график выглядит так:
Похоже, что при 20 пользователях он снова начинает тормозить, но если мы посмотрим на левую ось Y, мы увидим, что диапазон замедлений намного ниже и находится в допустимом диапазоне для этого нагрузочного теста.
Теперь профилирование показывает нам, что преобразование сейчас не является самой большой затратой ресурсов ЦП, и в сочетании с приведенным выше графиком пришло время посмотреть, откуда берутся некоторые модели всплесков. Но это для другого поста!
Первоначально опубликовано на iplayer.engineering 7 декабря 2018 г.