Кеширование. Часть 2: 60 дней до релиза

Привет! Я уже писал вам о том, как, бывает, удается продвигать инициативы в корпорации, какие сложности при этом могут возникнуть: здесь, и здесь.

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

  1. именно проблемные места – это точки роста
  2. наибольшие проблемы «прилетают» именно оттуда, откуда не ждешь.


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

sa3o8kskmgz_ptdsflpadmiycra.png

Итак, кажется, достаточно вступления, если готовы – добро пожаловать под кат.
2017 год, июнь. Мы дорабатываем админку. Админка – это не только набор формочек и таблиц в web-интерфейсе – введенные значения нужно склеить с десятками других данных, которые получаем из сторонних систем. Плюс каким-то образом преобразовать и в итоге отправить потребителям (главный из которых – ElasticSearch сайта Спортмастер).

Основная сложность как раз в том, чтобы преобразовать и отправить. А именно:

  1. поставлять нужно данные в виде json, которые весят и по 100Кб, а отдельные выскакивают за 10Мб (развертка по наличию и критериям доставки товара по магазинам)
  2. встречаются json со структурой, которая имеет рекурсивные вложения любого уровня вложенности (например, меню внутри пункта меню, в котором опять пункты с меню и прочее)
  3. итоговая постановка не утверждена и постоянно меняется (например, работа с товарами по Моделям сменяется подходом, когда работаем по Цвето-Моделям). Постоянно – это несколько раз в неделю, с пиковым показателем 2 раза в день в течение недели.


Если первые 2 пункта – чисто технические, и продиктованы самой задачей, то вот с 3-м пунктом, конечно же, надо разбираться организационно. Но реальный мир далек от идеального, так что работаем с тем, что есть. А именно – разобрались с тем, как быстро клепать web-формы и их объекты на стороне сервера. Один человек из команды выделялся на роль профессионального «формо-шлёпа» и с помощью подготовленных web-компонент выкатывал демку для ui быстрее, чем аналитики правили рисунки. А вот с тем, чтобы поменять схемы трансформаций, возникала сложность.

Сначала мы пошли привычным путем – проводить трансформацию в sql-запросе к Oracle. В команде был специалист по DB. Он продержался до момента, когда запрос представлял из себя 2 страницы сплошного sql-текста. Мог бы продолжать и дальше, но когда приходили изменения от аналитиков – объективно, самое сложное было – найти то место, в которое внести правки.
Аналитики выражали правила на схемах, которые хоть и были нарисованы в чем-то отстраненном от программного кода, но были так похожи на квадратики и стрелочки в ETL-системах (например, Pentaho Kettle, который в то время как раз использовали для поставки данных на сайт Спортмастер). Вот если бы у нас был не SQL-запрос, а ETL-схема! Тогда постановка и решение были бы топологически одинаково выражены, а значит – правка кода занимала бы времени столько же, что и правка постановки!

Но с ETL-системами есть другая сложность. Тот же Pentaho Kettle отлично подходит, когда требуется создать новый индекс в ElasticSearch, в который записать все данные, склеенные из нескольких источников (remark: на самом деле, именно Pentaho Kettle – подходит не очень, так как в трансформациях использует javascript, не связанный с java-классами, через которые к данным обращается потребитель, из-за этого можно записать то, что потом не получится считать. Но это отдельная тема, в стороне от основного хода статьи).

Но что делать, когда в админке пользователь поправил одно поле в одном документе? Для поставки этого изменения в ElasticSearch сайта Спортмастер не создавать же новый индекс, в который залить все такие документы и, в том числе обновленный!

Хотелось, чтобы, когда изменился один объект во входных данных, то в ElasticSearch сайта отправить обновление только для соответствующего выходного документа.

Ладно, сам входной документ, но ведь он по схеме трансформаций мог через join быть прикреплен к документам другого типа! А значит, надо анализировать схему трансформаций и вычислять, какие именно выходные документы будут задеты изменением данных в источниках.
Поиск коробочных продуктов для решения такой задачи не привел ни к чему. Не нашли.
А когда отчаялись найти – прикинули, а как это должно работать внутри, а как это можно сделать?

Идея возникла буквально сразу. Если итоговую ETL можно разбить на составные части, каждая из которых имеет определенный тип из конечного набора (например, filter, join и прочее), тогда, возможно, будет достаточно создать такой же конечный набор специальных узлов, которые соответствуют исходным, но с тем отличием, что работают не с самими данными, а с их изменением?

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

Таким образом, наше решение обладало сильной стороной в отношении всех вводных сложностей:

  1. поставлять нужно данные в виде json – мы работаем с pojo-объектами (plain old java object, если кто не застал времена, когда такое обозначение было в ходу), которые легко перегнать в json
  2. встречаются json со структурой, которая имеет рекурсивные вложения любого уровня вложенности – опять же, pojo (главное, что нет циклов, а сколько ровней вложенности – это так же, рекурсивно в java и обрабатываем)
  3. итоговая постановка постоянно меняется – отлично, тк мы меняем схему трансформации быстрее, чем аналитики оформляют (в схемах) пожелания к экспериментам


Из рискованных моментов, только один – решение пишем с нуля, самостоятельно.
Собственно, ловушки не заставили себя ждать.

Особый момент N1. Ловушка. «Хорошо экстраполируем»


Еще один организационный сюрприз состоял в том, что одновременно с нашей разработкой шел переход основного мастер-хранилища на новую версию, в которой данные хранятся в другом формате. И было бы хорошо, чтобы наша система сразу работала с новым хранилищем, а не со старым. Вот только новое хранилище еще не готово. Но зато структуры данных известны и нам могут выдать демо-стенд, на который зальют небольшое количество связанных данных. Идет?

Вот в продуктовом подходе при работе с потоком поставки ценности однозначно всем оптимистам вколачивается предупреждение: есть блокер -> задачу в работу не-бе-рем, точка.
Но тогда, такая зависимость даже не вызвала подозрений. Действительно, мы были в эйфории от успехов с прототипом Дельта-процессора – системы для обработки данных по дельтам (реализация математической модели, когда по схеме трансформации вычисляются изменения в выходных данных как ответ на изменение данных на входе).

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

Так, трансформация должна выполняться 15 минут и ни секундой дольше. Входные данные – таблица с 5,5 млн записей.

На этапе разработки, таблица еще не заполнена. Точнее, заполнена небольшим, тестовым набором данных в количестве 10 тысяч записей.

Что ж, приступаем. В первой реализации Дельта-процессор работал на HashMap в роли Key-Value хранилища (напомню, нам требуется очень много считывать и записывать объекты по ключам). Разумеется, что на продакшн-объемах, в памяти все промежуточные объекты не поместятся – поэтому вместо HashMap мы переходим на Hazelcast.

Почему именно Hazelcast – так потому, что этот продукт был знаком, использовался в backend к сайту Спортмастера. Плюс, это распределенная система и, как нам казалось – если вдруг что-то будет не так с производительностью, добавим еще инстансов на парочку машин и вопрос решен. В крайнем случае – на десяток машин. Горизонтальное масштабирование и все дела.

И вот мы запускаем наш Дельта-процессор для целевой трансформации. Отрабатывает практически моментально. Это и понятно – данных-то всего 10 тысяч вместо 5,5 млн. Поэтому измеренное время умножаем на 550, и получаем результат: что-то около 2 минут. Отлично! Фактически – победа!

Это было практически в самом начале работы над проектом – как раз тогда, когда надо было определиться с архитектурой, провести подтверждающие тесты, интегрировать пилотное решение по вертикали и дальше приступать к кодированию – наполнению «скелета мясом». Чем мы успешно и бодро занимались. До того прекрасного дня, когда в мастер-хранилище залили полный набор данных.

Запустили тест на полном наборе данных. Через 2 минуты не отработал. Не отработал и через 5, 10, 15 минут. То есть, в нужные рамки не поместились. Но с кем не бывает, надо будет подкрутить что-нибудь по мелочам, и поместимся. Но тест не отработал и через час. И даже через 2 часа оставалась надежда, что вот он отработает, и мы поищем, что надо подкрутить. Остатки надежды были даже через 5 часов. Но через 10 часов, когда уходили домой, а тест все еще не отработал – надежды уже не было. Беда была в том, что и на следующий день, когда пришли в офис, тест все еще старательно продолжал работать. В итоге прокрутился 30 часов, не стали дожидаться, выключили. Катастрофа!

Проблему локализовали достаточно быстро. Hazelcast, когда работал на небольшом объеме данных, на самом деле все прокручивал в памяти. А вот когда потребовалось скидывать данные на диск, производительность просела в тысячи раз.

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

Вот это очень серьезный и сложный выбор:

  1. сказать «как есть» = отказаться от проекта
  2. сказать «как хотелось бы» = рисковать, тк, не известно, сможем ли проблему исправить


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

В общем, мы решили, что есть еще очень и очень много разных систем, которые можно использовать как Key-Value хранилище, и если Hazelcast не подошел, то уж что-нибудь точно подойдет. То есть, приняли решение рискнуть. К нашему оправданию можно сказать, что это был еще не «кровавый дедлайн» — в целом, еще оставался запас по времени, чтобы «съехать» на запасное решение.

На той встрече с начальством наш менеджер обозначил, что «тест показал, что на продакшн объемах система работает стабильно, не падает». Действительно, система работала стабильно.
До релиза 60 дней.

Особый момент N2. Не ловушка, но и не открытие. «Меньше – значить больше»


Чтобы найти замену для Hazelcast на роль Key-Value хранилища данных, мы составили список всех кандидатов – получился список из 31 продукта. Это все что удалось нагуглить и узнать у знакомых. Дальше гугл выдавал какие-то уж совсем непристойные варианты, вроде курсовой работы какого-то студента. Чтобы проверять кандидатов быстрее – подготовили небольшой тест, который быстро показывал производительность на нужных объемах. И работу распараллелили – каждый брал следующую систему из списка, настраивал, запускал тест, брал следующую.

Работали быстро, отщелкивали по несколько систем в день.

На 18-й системе стало понятно, что это бессмысленно. Под наш профиль нагрузки – ни одна из этих систем не заточена. Много рюшек и реверансов, чтобы было удобно использовать, много красивых подходов к горизонтальному масштабированию – но нам это профита не дает никакого.

Нам нужна система, которая _быстро_ сохраняет по ключу объект на диск и быстро считывает.
Раз так – набрасываем алгоритм, как это можно реализовать. В целом, кажется достаточно реализуемым – если одновременно: а) пожертвовать объемом, который будут данные занимать на диске, б) иметь приблизительные оценки по объему и характерным размерам данных в каждой таблице. Что-то в стиле, выделять под объекты память (на диске) с запасом, кусками фиксированного максимального объема. Тогда с помощью таблиц указателей… и тд … Повезло, что до этого не дошло.

Спасение пришло в виде RocksDB. Это продукт от Facebook, который заточен под быстрое считывание и сохранение массивов байт на диск. При этом, доступ к файлам предоставляет через интерфейс, который похож на Key-Value хранилище. Фактически, в качестве ключа – массив байт, в качестве значения – массив байт. Оптимизирован, чтобы эту работу делать быстро и надежно. Все. Если надо что-то более красивое и высокоуровневое – прикручивайте сверху сами. Ровно то, что нам надо.

RocksDB, прикрученный в роли Key-Value хранилища, вывел показатель целевого теста на уровень 5 часов. Это было далеко от 15 минут, но было сделано главное. Главное – было понимание, что происходит, понимание, что запись на диск идет максимально быстро, быстрее невозможно. На SSD, в рафинированных тестах, RocksDB выжимал 400Мб/сек, а этого было достаточно для нашей задачи. Задержки – где-то в обвязочном коде.

В нашем коде, а значит – справимся. Разберем на кусочки, но справимся.

Особый момент N3. Опора. «Теоретический расчет»


У нас есть алгоритм и входные данные. Снимаем спектр входных данных, проводим подсчет, сколько каких действий система должна выполнить. Как эти действия выражаются в run-time затратах JVM (присвоить значение переменной, войти в метод, создать объект, копировать массив байт и тд), плюс, сколько каких обращений к RocksDB следует провести.

По расчетам получается, что должны уложиться в 2 мин (примерно, как показывал тест для HashMap в самом начале, но это всего лишь совпадение – алгоритм с тех пор поменялся).

И все-таки тест работает 5 часов.

И вот, до релиза 30 дней.

Это особая дата – теперь свернуть будет нельзя – на запасной вариант перейти не успеем.
Конечно же, в этот день руководителя проекта вызывают к начальству. Вопрос тот же самый – успеваете, все в порядке?

ipcipst6tfldoo7vmigaxcmsmta.png

Вот самый лучший способ описать эту ситуацию – расширенная титульная картинка к этой статье. То есть, начальству показана та часть картинки, которая вынесена в титул. А в реальности – вот так.

Хотя, конечно же, в реальности – нам было совсем не смешно. И сказать, что «Все классно!» — это возможно только для человека с очень сильным навыком самообладания.

Большое, огромное уважение к менеджеру, за то, что он поверил, доверился разработчикам.
Действительно, реально имеющийся код – показывает 5 часов. А теоретический расчет – показывает 2 минуты. Как такому можно поверить?

А вот возможно, если модель сформулирована понятно, как считать – понятно, и какие значения подставлять – тоже понятно. То есть, то, что в реальности выполнение занимает больше времени – означает, что в реальности выполняется не совсем тот код, который мы рассчитываем там выполнять.

Центральная задача – найти в коде «балласт». То есть, какие-то действия выполняются в довесок к основному потоку создания итоговых данных. Юнит-тесты, функциональные композиции, дробление функций и локализация мест с непропорциональными затратами времени на выполнение. Много всего проделали.

Попутно сформулировали такие места, где можно серьезно подкрутить. Например, сериализация. Сначала использовали стандартную java.io, но если прикрутить Cryo, то в нашем кейсе получаем прирост в 2,5 раза по скорости сериализации и 3 раза сокращение объема сериализованных данных (а значит, в 3 раза меньше объем IO, который как раз и съедает основные ресурсы). Но, более подробно, в этой статье загружать не стоит – расскажу отдельно.
А вот ключевой момент, или «где спрятался слон» — попробую описать одним абзацем.

Особый момент 4. Прием для поиска решения. «Проблема = решение»


Когда делаем get/set по ключу – в расчетах это проходило как 1 операция, затрагивает IO в объеме равном ключ + объект-значение (в сериализованном виде, разумеется).
Но что, если сам объект, на котором вызываем get/set – это Map, который тоже получаем по get/set с диска. Сколько в таком случае будет выполнено IO?

В наших расчетах эта особенность не учитывалась. То есть, считали, как 1 IO для ключ + объект-значение. А на деле?

Например, в Key-Value хранилище, по ключу key-1 находится объект obj-1 с типом Map, в котором под ключом key-2 надо сохранить некоторый объект obj-2. Вот здесь мы и считали, что операция потребует IO для key-2 + obj-2. Но в реальности, потребуется считать obj-1, провести с ним манипуляцию и отправить в IO: key-1 + obj-1. И если это Map в которой 1000 объектов, то расход IO будет примерно в 1000 раз больше. А если 10 000 объектов, то … Вот так и получили «балласт».

Когда проблема обозначена – как правило, решение практически очевидно.

В нашем случае это стала особая структура для манипуляций внутри вложенных Map. То есть, такая Key-Value, которая для get/set принимает сразу два ключа, которые следует применить последовательно: key-1, key-2 – для первого уровня и вложенного. Как реализовать такую структуру – подробно расскажу с удовольствием, но в отдельной, технической статье.

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

Завершение


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

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

Об этом – в следующей статье.

А пока – Happy New Code!

© Habrahabr.ru