Привет! Я уже писал вам о том, как продвигать инициативы в корпорации. Точнее, как (иногда) это удается, и какие сложности могут возникнуть: Ретроспектива граблей. Как самописное решение оказалось круче платного и Как мы выбирали систему кеширования. Часть 1.

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

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

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

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

Итак, кажется, достаточно вступления, если готовы – добро пожаловать под кат.



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

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

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

Если первые 2 пункта – чисто технические, и продиктованы самой задачей, то вот с 3-м пунктом, конечно же, надо разбираться организационно. Но, реальный мир далек от идеального, так что работаем с тем что есть.

А именно – разобрались с тем, как быстро клепать web-формы и их объекты на стороне сервера.

Один человек из команды назначался на роль профессионального «формо-шлёпа» и с помощью подготовленных web-компонент выкатывал демку для ui быстрее, чем аналитики правили рисунки этого ui.

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

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

Аналитики выражали правила на схемах, которые хоть и были нарисованы в чем-то отстраненном от программного кода (что-то из visio/draw.io/gliffy), но были так похожи на квадратики и стрелочки в ETL-системах (например, Pentaho Kettle, который в то время как раз использовали для поставки данных на сайт Спортмастер). Вот если бы у нас был не SQL-запрос, а ETL-схема! Тогда постановка и решение были бы топологически одинаково выражены, а значит – правка кода могла бы занимать времени столько же, что и правка постановки!

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

Но что делать, когда в админке пользователь поправил одно поле в одном документе? Для поставки этого изменения в 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 дней.

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



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

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

Действительно, реально имеющийся код – показывает 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!