Что есть реальность? И как определить её? Если говорить про то, что можно почувствовать, понюхать, попробовать на вкус или увидеть, тогда реальность — это электрические импульсы, которые обрабатывает мозг. Сказал Морфеус в фильме Матрица, чтобы объяснить, что такое симуляция. А у нас как раз были проблемы, которые могла решить симуляция!
С самого начала разработки курьерки мы генерировали много идей по улучшению алгоритмов. Мы не стеснялись выкатывать кучу фичей и проверять их работоспособность А/Б тестами. Не было ни дня, когда у нас не крутились эксперименты в продакшене. Что-то мы выкидывали, что-то оставляли, но не хардкодили, чтобы иметь гибкость. Страна большая и у каждого региона свои особенности, которые влияют на плотность заказов, логистику и потребность в курьерах. Поэтому наборы фичей и их значения различаются. Мы поняли, как отдельная фича влияет на продуктовые метрики, но как набор фичей в совокупности влияет на итоговый результат?
Запускать А/Б тесты для каждого набора фичей слишком долго и затратно, ведь команде надо создавать и тестировать новые фичи. Но найти наилучшие комбинации фичей для разных регионов и городов быстро с наименьшими затратами ресурсов команды — было жизненно необходимо. Поэтому мы создали копию реального мира, чтобы внутри неё пробовать самые смелые идеи.
Введение
Давайте знакомиться! Меня зовут Александр Мировов. Я – Главный Архитектор. Я создал Матрицу. Шутка! Я технический руководитель направления курьерской доставки в X5 Digital. Наша цель — улучшать качество доставки и позволять ей активно расти. Сейчас мы доставляем более 100 млн заказов в год. Каждый день у нас выходит на смены более 25000 курьеров-партнёров в 200+ городах России!
Моя команда разрабатывает приложение для курьеров и CRM‑систему для диспетчеров. Там они могут следить за заказами и решать какие‑то проблемы. Сердце нашей системы — это сервис распределения заказов. Там решаются вопросы: какому курьеру какие заказы назначить. Также мы разрабатываем сервис управления курьерами. Он решает задачи смен и взаиморасчётов с курьерами.
Мы пользуемся следующими технологиями:
Основная база данных у нас MongoDB, межсервисное взаимодействие происходит с помощью Kafka. Radis используется для хранения кэша. Android приложение для курьеров у нас нативное, написано на Kotlin, crm‑система — на Vue.js, а все сервисы написаны на Node.js.
Чтобы ответить на главный вопрос: «Нужна ли вам симуляция?», давайте начнём издалека — с проблемы тестирования продуктовых гипотез.
Почему мы написали симуляцию?
Цель любой продуктовой команды — выявлять потребности и улучшать метрики. Flow работы команды такой: сначала команда выявляет потребность пользователей, затем формирует гипотезы, потом ставятся задачи, всё это выкатывается на продакшен и потом тестируется обычно с помощью A/B тестов. Если фича хорошо показала себя на продакшене и повлияла на метрики, её оставляют. В ином случае — выкидывают и продолжают генерировать новые гипотезы.
В целом этот процесс даёт результаты, но занимает слишком много времени. В нашем случае выкатка одной фичи могла идти около месяца. А то и больше. К тому же комбинации фичей могут давать непредсказуемый результат. Например, у нас таких фичей более 80, но мы запускаем A/B тесты на какую-то одну фичу. А на какие-то комбинации фичей вообще не запускаем, потому что это занимает слишком много времени. Поэтому мы решили влезть в авантюру и разработать симуляцию работы курьеров, чтобы проводить продуктовые тесты без релиза в продакшн!
Есть определённая, судьбоносная точка — рождение. Это фатально и неизбежно. Всё остальное можно изменить! Будущее — это хаос. А в хаосе много вариантов. Вы принимаете то или иное решение, и тут же возникает множество вариантов дальнейшего развития событий. (Пифия)
Как мы её проектировали
Мы продуктовая команда и работы у нас много. Поэтому хотелось минимальными усилиями реализовать простейший вариант симуляции. Одним из важнейших критериев была скорость разработки симуляции, чтобы скорее проверить гипотезу. А ещё простота в обращении. Никаких консольных утилит. Чтобы ей могли пользоваться и продакты, и аналитики.
Первым этапом проектирования стало решение следующих вопросов:
Как создавать заказы, чтобы интенсивность создания и данные по ним были похожи на продакшен?
Как симулировать передвижение курьера от магазина до заказа?
Как сделать симуляцию быстрой?
Как собирать продуктовые метрики после выполнения симуляции?
Создание заказов
Когда мы начали думать о том, как создавать заказы, то пришли к двум вариантам: делать генератор или тянуть данные из продакшена. И у того, и другого подхода были свои плюсы и минусы. Поэтому выбирали такой, который будет в наибольшей степени соответствовать нашим критериям.
Генерация заказов помогла бы создавать абсолютно любые ситуации и проверять различные корнер-кейсы. Например, создать заказов в 2 раза больше, чем обычно. Но при этом пришлось бы писать генератор. Да и создать с его помощью реалистичную ситуацию, когда количество заказов такое, как бывает на самом деле, с учётом таймингов — довольно сложно и долго.
Если тянуть данные с продакшена, то всё будет быстро, и для этого фактически ничего не нужно делать. Симуляция будет работать с реальными данными. Но найти исключительные ситуации на продакшене сложно, поэтому воспроизвести корнер-кейсы для нашего сервиса не получится.
Так как одним из важнейших критериев работы была скорость разработки, решили тянуть данные с продакшена.
Flow получился такой: берём данные по заказам с продакшена по нескольким магазинам за определённый день, очищаем заказы от персональных данных и сохраняем в файл.
Передвижение курьеров
У этого вопроса тоже было два варианта решения. Смоделировать передвижение прямо в самой симуляции или использовать тайминги с продакшена.
Плюсы первого варианта в том, что можно посчитать тайминги абсолютно для любых ситуаций. То есть сама модель будет определять, сколько времени ехать курьеру. Только такие тайминги будут не очень реалистичными. Да ещё нужно писать и сам алгоритм передвижения курьеров.
Во втором варианте у каждого заказа, доставленного на продакшен, реальный тайминг довоза. Поэтому мы точно знаем, в какое время курьер вывез заказ из магазина и когда доставил его клиенту. Конечно, реальные тайминги продакшена могут улучшить качество симуляции, но маршруты в симуляции не всегда будут составляться так же, как на продакшене. Соответственно, может получиться, что нужных таймингов не будет. Например, на продакшене заказ был первым в маршруте, тогда легко посчитать, сколько курьер его вёз. Но в симуляции заказ может быть третьим в маршруте, а не первым. Тогда нам уже нужно считать со второго до третьего заказа, а такого тайминга на продакшене нет.
Второй вариант получался не самым реалистичным, поэтому выбрали моделирование передвижения, и для простоты реализации решили двигать курьеров по прямой. То есть между точками А и Б не учитываются городская логистика и инфраструктура. Правда, чтобы передвигать курьера таким образом, перед стартом симуляции нам пришлось указывать его среднюю скорость, чтобы можно было приблизить скорость его передвижения к более реальным цифрам.
Скорость симуляции
Тут вариантов не было. У нашей симуляции своё виртуальное время. Курьеры в среднем работают по 12 часов — это 720 минут. Значит, всего в симуляции около 720 тиков, и каждый тик выполняется в районе 200–300 миллисекунд. Следовательно, в среднем симуляция отрабатывает за 180 секунд. Каждый тик симуляция добавляет к своему виртуальному времени одну минуту и время отправляется в сервис распределения заказов. Соответственно, все бизнес-процессы внутри сервиса подчиняются виртуальному времени.
Сбор продуктовых метрик
Для этого вопроса вариантов снова было два: писать в систему аналитики и считать метрики прямо в симуляции.
Если писать в систему аналитики, то можно было использовать уже готовые методы подсчёта метрик. Правда, для этого пришлось бы подготовить отдельный стенд и напрячь команду аналитиков, чтобы они это сделали.
У второго варианта подсчёта метрик в симуляции больше плюсов. Нам не нужно было согласовывать задачи между командами. Мы могли создавать новые метрики и тут же проверять симуляции, а не ждать другую команду. Минусом была необходимость повторять метод подсчёта метрик внутри симуляции. Но поскольку это не очень сложно и не долго — достаточно один раз сделать и всё будет работать, мы решили собирать метрики в симуляции.
Каждое событие, которое происходит в симуляции, добавляется в некий массив аналитики. Туда записываются тайминги из виртуального времени. По окончанию симуляции мы просто собираем весь массив, прогоняем и считаем метрики. После этого сохраняем файл и выгружаем.
Общий Flow работы с симуляцией
Получился следующий Flow: на продакшене создаём слепок, затем скачиваем файл и идём на тестовый стенд. Заходим в симуляцию, загружаем слепок, устанавливаем количество курьеров, их среднюю скорость и те фичи, которые хотим протестировать. Запускаем симуляцию, ждём и получаем файл с конечными метриками.
Никогда не посылайте человека делать работу машины. (Агент Смит)
Коротко о сервисе распределения заказов
Прежде, чем начнём говорить про саму симуляцию, давайте разберёмся с архитектурой нашего текущего сервиса распределения заказов.
Это упрощённая модель нашего сервиса. Здесь показаны данные, которые необходимы для понимания того, как работает симуляция. Справа вы можете увидеть обработчики заказа. Это http ручки, которые могут дёргать клиенты. Самые важные — это создание и отмена заказа. Слева — http обработчики для курьеров. Это http ручки, которые курьеры дёргают из своего приложения, когда доставляют заказы или берут заказы на доставку. Снизу — один единственный обработчик для crm-системы: установка и изменение фичей. А самое главное, конечно, по центру. Это диспатчер, который распределяет заказы на курьеров. Он отрабатывает раз в минуту: берёт заказы, берёт курьеров, берёт фичи и на основе этих данных определяет, на какого курьера какие заказы назначить.
Как мы её реализовали
Сначала мы реализовали сервис симуляции и в нём в отдельном потоке запустили наш основной сервис распределения заказов. Симуляция просто должна была дёргать ручки в нужном порядке, так, как это происходит в реальной жизни.
Симуляция
У симуляции есть свои обработчики. Это запуск симуляции и остановка симуляции. При запуске необходимо сделать некоторые инициализации:
Инициализируем репозиторий распределения заказов;
Поднимаем MongoInMemory прямо внутри, чтобы не создавать отдельный стенд;
Запускаем наш сервис распределения заказов в отдельном потоке (выделено зелёной рамкой);
Создаём виртуальных курьеров.
Далее начинается цикл симуляции:
Установка виртуального времени;
Запускается диспатчер в сервисе распределения заказов и туда передаётся виртуальное время, которое устанавливается внутри самого сервиса;
Создание заказов.
Давайте подробнее рассмотрим этот этап. После того, как в симуляцию загружен слепок с продакшена, создаётся экземпляр класса для каждого заказа и определяется время создания самого первого заказа в списке из слепка. Это нужно для того, чтобы начать симуляцию ровно с этого момента и не делать итерации симуляции, в которых ничего не происходит. Далее на каждой итерации симуляции сверяется время создания заказов в списке и текущее виртуальное время. Если пришло время создавать какой-либо заказ — заказ создаётся.
Ещё один важный момент в жизни заказа — сборка. Из слепка данных мы знаем, в какое время заказ был собран. Виртуальный курьер не должен забирать заказ, пока он не готов. Готовка заказа происходит по тому же принципу, что и создание. Информация о времени готовки всех созданных заказов на каждой итерации сверяется с виртуальным временем. Как только наступает время подготовки какого-либо заказа, заказ переводится по статусу. Знаю, что лучше один раз увидеть, чем сто раз услышать. Поэтому вот кусок кода, который обрабатывает заказы:
Теперь вернёмся к нашей схеме и продолжим рассматривать этапы симуляции:
Получение текущих активных заказов. По их статусам и данным можно понять, что делать с курьерами.
Ниже идет блок, который относится непосредственно к курьерам. Мы получили заказы, смапили их статусы и действия курьеров и выполнили определённые действия по подтверждению заказа.
Подтверждение заказа.
Действия виртуальных курьеров обусловлены текущими заказами. Если на курьера назначен заказ, то он должен начать двигаться к магазину. Если координаты курьера совпадают с координатами магазина, то курьер должен дёрнуть соответствующую ручку. Каждый виртуальный курьер — это экземпляр класса курьера. У курьера есть несколько методов, которые позволяют реагировать на внешние обстоятельства. А внешние обстоятельства зависят от статуса заказа, который назначен на курьера.
Мы сделали маппинг статусов заказов и действий курьеров. Выглядит это примерно так:
Отдельно хочется прокомментировать метод .move() у объекта курьера. Этот метод в первом аргументе принимает координату, в которую курьеру нужно приехать, а во втором аргументе принимает колбэк, который выполнится по достижении координаты из первого аргумента. Движение курьера линейно, поэтому нам достаточно определить вектор направления от текущего положения курьера к целевой точке и перемещать его по этому вектору на каждой итерации на определённое расстояние, которое зависит от установленной скорости курьера. Как только координаты курьера станут равными целевой координате, либо вектор направления изменится в противоположную сторону (когда курьер перешагнул целевую точку), вызывается колбек. В колбэке определено следующее действие, которое должен сделать курьер по достижении точки.
На схеме это выглядит так:
Курьер приехал в магазин.
Курьер взял доставку.
Курьер доставил заказ.
На конечном этапе мы проверяем, есть ли у нас заказы, которые необходимо доставить. Если такие заказы есть, мы идём на следующую итерацию, если таких заказов нет — мы завершаем цикл и начинаем формировать метрики. Они сохранятся в файл, и их можно будет загрузить.
Фронтенд
Одним из важных критериев была простота в обращении. Поэтому мы реализовали фронтенд. Когда мы загружаем файл со слепком из продакшена, у нас открывается такая страница:
На ней можно установить количество курьеров. Причём, как «Пеших», так и «Автокурьеров». Можно проверять гипотезы. Например, только с пешими или только с автокурьерами. Здесь же можно установить «Среднюю скорость» курьера и «Скорость симуляции». Скорость симуляции — это одна из фичей, которую мы добавили во время разработки. Она пригодилась для отладки. Эта цифра означает минимальное время отработки цикла симуляции. Например, если выставить 1000 миллисекунд, то один цикл симуляции будет проходить не меньше, чем за 1 секунду.
Ещё одна фишка — «Ветка», на которой мы будем запускать сервис автоматического распределения заказов. Это необходимо для того, чтобы во время разработки новой фичи не мёржить ветку в master, а проверять гипотезу до мёржа, чтобы можно было поправить код при необходимости, если симуляция показывает плохой результат. Либо просто запускать на master ветке, чтобы просто провести тесты на стабильном коде.
В «Настройках полигона» хранятся различные фичи. Перед запуском мы можем установить любую фичу, которая нам необходима, или ряд фичей, и запускать симуляцию.
В «Заказах» выведен список, чтобы понимать, сколько у нас заказов, что это за заказы и в какое время они были созданы.
Сверху справа есть функциональные кнопки: «Запустить», «Карта» и «Консоль». Кнопка «Запустить», естественно, запускает симуляцию. Кнопка «Консоль» нужна для отладки. Туда по WebSocket с бэкенда прилетают различные события. Например, действия виртуальных курьеров, показывающих, что они делают.
Давайте подробнее остановимся на кнопке «Карта». Изначально мы не собирались её делать, но потом начали сталкиваться с разными проблемами. Например, курьер не доставил заказ или происходило что-то странное и непонятное. Чтобы наглядно всё это увидеть, мы реализовали карту:
Матрица повсюду. Она окружает нас. Даже сейчас она с нами рядом. (Морфеус)
Карта позволяет в realtime смотреть, что происходит с симуляцией. Курьеры передвигаются очень просто: по WebSocket прилетают события передвижения курьера с координатами курьера, фронтенд их подхватывает и отображает. Плюс у нас есть логи в консоли. Всё это вместе позволяет лучше понимать, как работает симуляция.
Что по итогу?
Когда мы начали запускать симуляцию на реальных слепках с таким же количеством курьеров, как и в продакшене, мы ожидали увидеть более-менее похожие на продакшен продуктовые метрики. По факту в симуляции мы увидели довольно оптимистичные метрики, которые отличались от продакшена. Это означало, что отказаться от проведения А/Б мы не могли, по крайней мере, пока мы не доработаем симуляцию таким образом, чтобы она выдавала похожие на продакшен результаты.
Но есть два важных пункта, по которым симуляция нам помогла. Во-первых, мы начали находить новые комбинации фичей, которые в продакшене показали статистическую значимость. Мы запускали различные варианты фичей, смотрели на результат симуляции и видели какие-то изменения. После этого мы шли на продакшен, запускали А/Б тест на этих группах фичей и получали примерно такой же результат.
Во-вторых, симуляция помогла нам готовиться к А/Б тестам. Например, когда для тестовых групп нам необходимо выбрать какие-то значения. Чтобы не брать их с потолка, мы запускали симуляцию, смотрели, на каких значениях симуляция себя лучше всего показывала, и уже эти значения использовали в дальнейших А/Б тестах.
Симуляция открыла перед нами новые возможности в тестировании продуктовых гипотез, хотя пока она и не заменяет А/Б тесты, но очень хорошо их дополняет.
Сколько времени заняла разработка?
Честно говоря, изначально мы думали, что это займёт у нас не менее полугода, но в итоге справились за два месяца. Плюс ещё месяц на отладку и поиск багов. Довольно быстро получилось, потому что мы не стали сразу делать фишки, которые могли бы реализовать. А делали всё максимально просто, чтобы скорее получить первый результат.
Морфеус протянул руку и раскрыл кулак. На ладони лежали две таблетки — голубая и красная.
Заключение
Профит от симуляции я уже перечислил, но есть и определённые нюансы.
Пока что наша симуляция показывает не очень достоверные цифры по метрикам. Они не совсем похожи на продакшен, и их нельзя напрямую сравнивать с ним. Вероятнее всего, это связано с прямолинейным передвижением курьеров.
Также симуляция не умеет работать с большой выборкой заказов. Если в неё загрузить несколько тысяч, а тем более десятков тысяч заказов, она будет работать очень медленно.
Конечно, мы планируем это исправить. Сделать передвижение курьеров более достоверным, пускать их по улицам с учётом логистики, используя OSM для построения маршрута передвижения. Когда сделаем симуляцию более точной, можно с помощью симуляции сгенерировать большой набор данных с различными вариациями настроек и натренировать нейросетевую модель, которая найдёт взаимосвязи между фичами и метриками. Возможно, для некоторых кейсов можно полностью заменить A/B тесты. А ещё сделать ферму симуляций! Запускать сразу десяток симуляций и на каждую задавать набор фичей — так можно получить результат намного быстрее.
В общем, с симуляцией открываются новые горизонты, которые позволят нам более эффективно разрабатывать и поставлять новые фичи, а вариантов дальнейшего развития у нас очень много.
Отвечая на вопрос “Нужна ли вам симуляция?”, отвечу — да! Особенно если ваш продукт построен вокруг сложных и нелинейных операционных процессов, где операционные процессы влияют на алгоритмы, и алгоритмы, в свою очередь, влияют на операционные процессы. Симулировать можно не только работу курьеров, но и работу сборщиков на складе, работников на производстве и работу любых пользователей в комплексных системах. Симуляция позволит вам быстрее проверять гипотезы и экономить время.
Не пытайся согнуть ложку. Это невозможно. Для начала нужно понять главное — ложки не существует!
Ornatrix
Впервые за долгое время интересная статья. Лайк)