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

в 11:18, , рубрики: javascript, web-разработка, Блог компании Яндекс, Веб-разработка, Яндекс.Почта

Яндекс.Почта — большое и сложное веб-приложение. Для первоначальной загрузки ей необходимо более 1 МБ статических ресурсов (JS/CSS/Шаблонов). При этом Яндекс.Почта обновляется два раза в неделю, а иногда и чаще.

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

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

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

Мы подумали: «А что если хранить где-то старую версию файлов (например, в localStorage), а при выходе новой передавать только diff между ней и той, которая сохранена у пользователя?» В браузере же останется просто наложить патч на клиенте. О том, что из этого получилось и каким выводам мы пришли, читайте под катом.
КАТ
На самое деле эта идея не нова. Уже существуют стандарты для HTTP — например, RFC 3229 “Delta encoding in HTTP” и Google SDHC, — но по разным причинам они не получили должного распространения в браузерах и на серверах.

Мы же решили сделать свой аналог на JS. Чтобы реализовать этот метод обновления, начали искать реализации diff на JS. На популярных хостингах кода нашли библиотеки: VCDiff, google-diff-patch-match, jsdiff, Pretty Diff и jsdifflib.

Последние две библиотеки (jsdifflib и Pretty Diff) нам сразу не подошли, потому что не умеют накладывать патч, а показывают только изменения между строками. А jsdiff генерирует патч в формате, похожем на google diff patch match, но накладывает его в пять раз медленнее. В итоге у нас осталось два кандидата.

Для окончательного выбора библиотеки нам нужно сравнить их по двум ключевым для нас метрикам. Первая — размер генерируемого патча. Мы нагенерировали патчей для разных ресурсов разных версий и сравнили google diff patch match и vcdiff с разным размером блока.

Vcdiff (размер блока 3) Vcdiff (размер блока 10) Vcdiff (размер блока 20) google diff patch match
13957 3586 3431 9297
865 367 309 910
4615 1854 1736 6740

Как видно по таблице результатов, vcdiff с размером блока 20 байт имеет наименьший размер патча. Вторая ключевая метрика для нас — время наложения патча на клиенте.

Библиотека IE 9 Opera 12 Firefox 19 Chrome
vcdiff (размер блока 10) 8 5 5 3
google diff patch match 1363 76 43 35

Тут тоже vcdiff выигрывает c большим отрывом. В IE 9 и ниже google-diff-patch-match накладывает патч больше, чем за секунду.

У нас определился победитель — vcdiff. Этот алгоритм был предложен в 2002 году (RFC3284). Он достаточно популярен и имеет множество реализаций на разных языках, в том числе на C++, Java и JS.

После того как мы определились с библиотекой для диффа, нужно определиться с тем, где и как хранить статику на клиенте. Яндекс.Почта — современное веб-приложение, у нас нет старых браузеров, поэтому почти все поддерживают localStorage. Он является удобным местом для хранения статики. Никаких личных данных пользователя там нет, поэтому нет проблем с безопасностью. Каждый файл хранится в отдельном ключе. В этот ключ вшито не только название ресурса, но и его версия. Это позволяет избежать проблем, когда версия смогла записаться в localStorage, а сам файл — нет (или наоборот), в варианте, когда файл и его метаданные хранятся в разных ключах.

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

Формат файла с патчами для проекта выглядит так:
[ { "k": "jane.css", "p": [patch], "s": 4554 }, { “k”: “jane.js”, “p”: [patch], “s”: 4423 }, ...]

То есть это обычный массив из объектов. Каждый объект — отдельный ресурс. У каждого объекта есть три свойства. «k» — названия ключа в localStorage для этого ресурса. «p» — патч для ресурса, который сгенерировал vcdiff. «s» — чексумма для ресурса актуальной версии, чтобы потом можно было проверить правильность наложения патча на клиенте. Чексумма вычисляется по алгоритму Флетчера.

Почему именно алгоритм Флетчера, а не другие популярные алгоритмы вроде CRC16/32 или md5? Потому что он быстрый, компактный и легок в реализации.

Процесс обновления

В самом начале загрузчик смотрит в localStorage. Если у пользователя в нем ничего нет или версии настолько старые, что у нас для них нет патчей, то загружаются все файлы целиком.

Если же есть сохраненная старая версия в localStorage, посылаем запрос за файлом с патчем. Далее обновляем все ресурсы и проверяем чексумму. Если все чексуммы совпадают, обновляем содержимое ключей в localStorage.

На этом процесс обновления закончен. Дальше все необходимые ресурсы мы берем из localStorage. Код JS-модулей и скомпилированных шаблонов мы выполняем через new Function(), а CSS подключаем через динамическую вставку тега <style>.

Что получили

Фактически мы экономим 80-90% трафика. Размер загружаемой статитки в байтах:

Релиз С патчем Без патча
7.7.20 — 7.7.21 397 174 549
7.7.21 — 7.7.22 383 53 995
7.7.22 — 7.8 18 077 611 378
7.8 — 7.8.50 2 817 137 820
7.8.50 — 7.8.8000 14 868 443 159

Что же мы получили в реальности?

В реальности скорость загрузки выросла совсем немного. Почему так?

У нас сразу появилась проблема с проверкой чексуммы. Считать его для всего файла оказалось очень дорого. Даже в современных браузерах на мощных компьютерах на один файл уходит примерно 20мс, а в Opera 12 и IE9 — более 100мс. Для загрузки Почты надо минимум шесть файлов. С учетом того, что JavaScript в браузере — однопоточный, то получаем последовательный расчет хеша для каждого файла, то есть в лучшем случае это будет минимум 120мс, а в реальности — еще больше.

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

Проблема усугубляется еще и тем, что, скажем, валидность JS мы можем проверить просто исполнив его. Если полученный JS не запускается, то патч не прошел — надо все стирать и перезагружать. А вот с CSS проблема остается открытой. Невалидный CSS просто исполнится, не вызовет никаких ошибок, но пользователь увидит не то (у нас даже были такие случаи). Да, можно дописать валидатор CSS после его исполнения, но это опять же повлияет на скорость загрузки — по аналогии с хешсуммой.

Еще есть проблема со слишком большими патчами. Мы ограничили размер патча 30% от исходного файла. Если патч получается слишком большим, то браузер загружает весь файл целиком. И в итоге с этой схемой мы получили блокирующий HTTP-запрос. Рассказать заранее, с каких версий можно обновляться, а с каких — нет, мы не можем, потому что информация о кеше в браузере. Соответственно, наш загрузчик понимает, что есть кеш и идет за патчами. В этот момент ничего больше не грузится. Если в патчах сказано «грузи все полностью», то загрузчик начинает обычный процесс загрузки, как будто нет кешей, но время уже потеряно.

По сути патч VS. скачивание файла — это соревнование между скоростью браузера и скоростью подключения к интернету. Если смотреть на мировые тенденции в этом размере, то компьютеры не становятся быстрее. Наоборот, мощные стационарные компьютеры заменяются планшетами и ноутбуками, которые не обладают той же производительностью. В то же время интернет становится быстрее, причем обычно бесплатно. Обновляются тарифы провайдеров, которые строят сети нового поколения.

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

Итог

Мы поняли, что ни на какие 146% загрузку почты таким способом не ускорить. А лишнюю головную боль как разработчикам, так и тестировщикам получить легко — появляется еще один способ загрузки страницы, который надо проверять для каждого релиза. В то же время фризы и грамотное деление кода на модули дает понятный и абсолютно безопасный, с точки зрения тестирования и поддержки, способ ускорения.

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

Автор: doochik

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js