Вектор енумов. С чем мы сталкиваемся в разработке

nwa3-gc57mh2seggmdnnmr-iorm.jpeg

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

Я делаю баннерную крутилку, делаю давно и уже оброс такими историями со всех сторон. Мне удалось уговорить команду рассказать на камеру хотя бы часть историй — с контекстом, чтобы зритель не чувствовал себя тем самым сторонним человеком. Надеюсь, читателям Хабра наши грабли покажутся интересными. Всего будет семь историй.

Высвобождение ресурсов


Периодически мы решаем задачи экономии разных ресурсов: памяти, процессора и так далее. Во время очередного подхода мы обнаружили, что один из наших процессингов употребляет неразумное количество процессорных ядер. Суть этого процессинга в том, что он читает поступающие с фронтовых серверов данные маленькими пачками, группирует их и передаёт в основной конвейер, где они варятся большими блоками по 100–200 тысяч строчек за раз.
Когда мы начали разбираться, почему так происходит, то поняли, что упираемся в деаллокацию данных — и это крайне странно. Казалось бы, самый простой путь — начать играть с аллокаторами, но мы пошли чуть глубже. Мы поняли, что, перекладывая данные в представление конвейера, мы перекладываем их в кликхаусные блобы. Это структура данных, где вместо строчек используются колонки (ClickHouse — колоночная база данных). Там есть некая матрица, внутри которой умные указатели по числу колонок, а их у нас 350–400 штук. Из-за того, что на входе данные берутся маленькими пачками, получается очень большой мультипликатор и очень большое количество умных указателей, которые создаются впустую. Потом мы ещё дополнительно их разбиваем, создаём много разных потоков на выходе и получаем ещё большее умножение и ещё больше умных указателей, которые потом приходится удалять в разных потоках записи. Заменили колонки на строки — получили экономию в 10 тысяч ядер, это примерно 15–20% потребления ядер всей Метрикой.

Инсёрт


Мне нужно было сделать INSERT в табличку в базе данных. Я выполнил INSERT (не INSERT IGNORE), и запись совпала по ключу с уже существующей записью, выделился новый автоинкрементный айдишник — соответственно, старая запись получила новый ID, и эти данные поехали в продакшен. Загорелся алерт, что что-то с продакшеном происходит. Его график пошёл вниз, а с рекламой стали происходить страшные вещи: где-то картинки перестали показываться, где-то всё поменялось местами — мы смотрим и не понимаем, как такое может быть.

Первая реакция — откатить. Откатываем — график возвращается обратно наверх, мы выдыхаем. Где-то через полчаса продакшен снова стал ломаться, график пошёл вниз, но всё уже выглядело по-другому: всё ломалось на других сайтах, ломалось по-другому, но указывало примерно на ту же самую конфигурационную табличку. Тогда мы поняли, что эти данные едут разными способами. Они едут и в быстром контуре, который сломался первым, и в медленном контуре, который сломался попозже. Так мы стали откатывать и медленный контур тоже. Мы балансировали туда-сюда, не очень понимая, что происходит. Возможно, мы даже раза три что-то откатывали. Конечно, было неприятно, к тому же эти индексы откатываются долго. Самое дурацкое в этой ситуации то, что можно было просто подождать — проблема была в рассинхроне быстрого и медленного индексов. Можно было потерпеть, и всё раскатилось бы, а мы откатами сделали только хуже.

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

Треснувший шард


Мы проектировали очередную базу данных. На старте нам сказали, что в ней будет храниться примерно миллиард записей, для быстрого доступа решили разделить данные на 300 шардов. Мы вычисляем хэш от первичного ключа, делим его на 300 и кладём в первую колонку. Всё легко и понятно, работает идеально. Мы запустились, всё летает — казалось бы, всё прекрасно.

Через пару месяцев происходит что-то невообразимое. У нас деградировали запросы по времени выполнения, взвинтились тайминги, загорелись мониторинги — в общем, всё в огне, и мы не понимаем, что происходит. Всё стало работать супермедленно. «И треснул шард напополам, дымит разлом…» — мы с ребятами в шутку напеваем эту песню, когда вспоминаем историю про треснувший шард. Оказалось, что YDB работает быстро тогда, когда запрос улетает в конкретный шард. Хорошо, мы считаем хэш от ключа, берём остаток от деления и точно знаем, в какой шард нам пойти.

Почему же всё стало работать медленно? Оказывается, наши данные распределились по шардам неравномерно — они все попали в первый шард. YDB разделила диапазон чисел от 0 до 232 на 300 частей и запросы от 0 до 14 млн отправляла в первый шард, от 14 до 28 млн — во второй и так далее. Напомню, что мы просто брали остаток от деления на 300 — то есть числа были от 0 до 299. Несложно догадаться, что все они улетали в первый шард. Когда мы это чинили, постоянно горели алерты, и за это время робот мониторинга, который звонит ответственным, позвонил мне ровно 128 раз — круглое для программиста число.

Этот код писал разработчик по имени Антон, вот и получилось, как в той песне: «И понял Антоха, что поступил плохо…»

Откусывание байта


Однажды клиент, рекламодатель, решил добавить в объявление символ рубля — как раз примерно тогда в Юникоде добавили символ рубля, его стало можно использовать, и браузеры научились его отрисовывать. В Юникоде символ рубля представлен тремя байтами, и вот один байт потерялся, судя по результату. Мы подумали, что дебажить и тем более чинить это будет очень трудно, лучше попробуем временно поправить данные в базе. Но это действие тоже нетривиальное и опасное.

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

Оказалось, символ рубля был настолько уникальным, что, когда нужно было добавить байт в начало строки, он не дописывался, а когда нужно было откусить — откусывался. В итоге мы откусывали настоящий первый байт из символа рубля и получали два непонятных байта, которые потом ломали вёрстку. После этого я решил внимательно проверить условия, по которым эти байты добавляются и удаляются. Выяснилось, что для служебных слов, предлогов и союзов (которые не нужно обрабатывать с точки зрения соответствия поисковому запросу) эти манипуляции не делаются. Тогда я придумал добавить символ рубля в список этих слов — это можно простым патчем накатить. Добавил, быстро протестировал — все на меня смотрели с надеждой, что это сработает. Я это выкатил — и оно работало. Это был восторг, когда мы довольно сложный баг починили буквально одним инсёртом в базу данных и избежали сложной миграции.

Пока vs поке


Мы живём в рамках одного большого монорепозитория, в который коммитит весь Яндекс. Это значит, что туда кто-то что-то коммитит 24/7. Монорепозиторий даёт нам отличную возможность переиспользовать компоненты из совершенно разных сервисов большого Яндекса. Это огромная гибкость, но есть сложность — мы не можем анализировать все коммиты, поэтому diff, который получается в тестовой системе, может быть спровоцирован коммитом вне скоупа рекламной системы. Именно так и произошло в нашем случае.

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

Вектор енумов


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

Решил начать с простого — написал в довольно понятном месте цикл for по вариантам отрисовки. Есть enum, который обозначает способ отрисовки объявления. Там в тот момент было два значения: стандартный дизайн и новый дизайн, который я реализовывал. Выбрал функцию, которая принимает объявления и возвращает набор способов отрисовки, которые для него допустимы. Дальше я написал цикл for по этому контейнеру и получил ещё один этап размножения. Затем всё это отправил в ранжирование, и оно работало как задумано. Но в процессе возник вопрос: как вернуть набор енумов из функции? И я по неопытности пошёл по простому пути — вернул вектор. Просто в нём не могло оказаться больше двух значений.

Казалось бы, я это реализовал, проверил на функциональных тестах, всё замечательно. Но на выкладке в препродакшен увидели, что движку стало очень плохо: он медленно обрабатывал запросы, ни за чем не успевал — в общем, была проблема с производительностью. В дебаге я увидел очень много аллокаций и деаллокаций памяти: память постоянно выделялась и освобождалась. Действительно, вектор хранит данные в куче, а не на стеке. Если я в каком-то нагруженном месте регулярно создаю вектор из одного-двух элементов, то, наверное, я что-то делаю неправильно. В тот момент я понял, что это всё-таки то место, где вектор использовать не стоит. С++-программисты нечасто сталкиваются с ситуациями, когда вектор неэффективен и нужно что-то улучшить, но здесь мне пришлось перейти на создание этого массива на стеке с помощью std::array, в который функция сохраняла данные с помощью выходного итератора.

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

Чуть-чуть другие настройки


Однажды вечером к нам прибежали коллеги, говорят — у нас пропали данные в одном из наших конвейеров обработки. В этот конвейер поступали все данные от внешних пользователей и игнорировались данные из сетей Яндекса, что вполне разумно.

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

Из-за этого полились данные с IP-адресами балансеров Яндекса, и коллеги начали все эти данные в одном месте отрубать, а в другом — залили весь свой процессинг (получили очень много данных). Конечно, мы всё откатили назад, но потом обнаружили ещё один интересный спецэффект — из этих данных высчитывалась одна из пользовательских метрик в статистике — регион. И у отдельных пользователей, и у отдельных приложений в этот промежуток времени почти вся аудитория была из Владимира, где находится один из наших дата-центров.

Мы восстановили данные, но сделали вывод: помимо проверки тестами нужно подвергать сомнениям продакшен-конфигурацию, идти к аналитикам и вместе смотреть, что происходит с данными.

© Habrahabr.ru