Меня зовут Никита, я backend-разработчик из команды антифрода в Ситимобил. Сегодня я поделюсь с вами историей о том, как мы выносили наш сервис из монолита в отдельный сервис, как вообще пришли к этому решению и с какими проблемами столкнулись.
Для начала немного расскажу о нашем сервисе.
Антифрод 101
Наш антифрод — это набор правил для выявления заказов, содержащих признаки мошенничества, фродовые паттерны.
У многих агрегаторов такси есть доплаты водителю за короткие поездки, на чём водители-мошенники пытаются нечестно заработать. Например, мы видим у одного водителя n заказов подряд с одним и тем же клиентом. Чем они там занимались, нам не ясно, но с большой уверенностью можно заявить, что это фрод и аннулировать эти заказы.
Клиентам начисляются бонусы в случае приглашения ими новых клиентов в приложение. Клиенты-фродеры регистрируют несколько новых клиентов на своем девайсе, за что могут потерять все начисленные бонусы.
Чтобы проверить водителя на мошенничество, мы запускаем все проверки и в результате получаем события, каждое из которых говорит о наличии искомого паттерна в поданных на вход заказах.
Проверки можно разделить на несколько типов:
- Проверка клиента/водителя при каком-то изменении (например, добавили новую кредитную карту).
- Проверка 1..n последних заказов.
- Специальные: проверка корректности работы водителей, участвующих в специфических акциях.
Для настройки правил есть web-интерфейс — «админка». И для визуального контроля за сработавшими правилами мы создали web-страницу с разными отчетами с большим набором фильтров.
Добавление новой проверки происходит следующим образом: описали фродовый паттерн, закодировали его в сервисе, запустили новое правило в тестовом режиме, и наблюдаем. При необходимости корректируем правило и включаем.
Проблемы прежней архитектуры
Раньше водители могли выводить деньги со счета только при успешном прохождении проверки на фрод. Компания-партнер могла вывести деньги только при проверке всех своих водителей.
Антифрод работал внутри PHP на одном потоке. Не масштабировался без костылей, в пиковые часы возникали очереди на проверку. Сами проверки никак не распараллеливались, и добавление каждого нового правила неизбежно увеличивало время обработки.
Старый антифрод «перерос» свою модель БД, работать с базой стало невозможно: периодически клали базу при работе, что в условиях монолитной архитектуры приводило не только к проблемам антифрода, но и всего бизнеса в целом.
Отчёты строились медленно. Чтобы посмотреть, казалось бы, простые вещи руками в базе, иногда приходилось JOIN-ить по пять и более таблиц, не говоря уже о более сложных вещах.
Бизнес рос, и эти проблемы требовали скорого решения. Также хотелось проверять водителей на фрод «на лету» (после каждой поездки), что позволило бы им выводить деньги сразу, как только им этого захотелось.
Какие у нас были варианты:
- Доводить до ума то, что есть. Переделывать модель данных на новую.
- Писать сервис с нуля, с возможностью масштабироваться из коробки.
Выбор пал на уход антифрода в отдельный сервис. В качестве основного инструмента распараллеливания был выбран Golang, по которому в компании есть хорошая экспертиза.
Решено было переезжать в два этапа.
Первый этап: перенос на новый язык
Остались на старой модели данных (да, скрипит, но пока работает). Стали делать сервис с нуля, за пару месяцев перенесли основной функционал и большинство проверок. Добились полной работоспособности сервиса.
Теперь каждая проверка по одному заказу может запускаться параллельно, что значительно уменьшает время обработки.
Сравнительные характеристики по скорости обработки: ранее на анализ всех водителей уходило 6 часов, теперь 25 минут.
Второй этап: выбор модели хранения
Для текущей работы нужна была как OLTP-подобная база данных для анализа на фрод, так и OLAP-база для построения отчетов. Текущая схема данных не поддерживала сценарии антифрода, от слова «никак».
Выбор стоял между:
- Новой моделью на SQL (правильно денормализованной) для текущей работы, а также ClickHouse’ом для отчетов.
- Elastic’ом.
Мы выбрали Elastic. Он легко масштабируется, в нём «из коробки» есть индексы по любому полю, что позволяет настраивать фильтры в отчетах как душе угодно. Денормализовали модель, чтобы не приходилось делать JOIN’ы между индексами Elastic’а.
Если вы тоже решили выбрать Elastic в качестве базы данных, то будьте бдительны. При настройках по умолчанию Elastic под нагрузкой может начинать отдавать частичный результат поиска. Например запрос стаймаутится на нескольких шардах при этом код ответа будет 2xx. Если вас не устраивает такое поведение и вам лучше получить ошибку поиска (например, чтобы потом заретраить), то вы можете отрегулировать это поведение через параметр allow_partial_search_results.
Текущая схема работы антифрода
Напомню, что основная логика, связанная с поездками, живет в монолите, а вся информация по заказам всё еще лежит в MySQL. При завершении поездки монолит переносит заказ из таблицы активных заказов в таблицу закрытых и отправляет сообщение в наш сервис через RabbitMQ, чтобы мы проверили конкретный заказ.
Получая сообщение из RabbitMQ, можно было бы сразу порождать горутину для обработки сообщения и переходить к получению следующего сообщения, но при таком подходе никак не контролируется количество горутин. Поэтому в сервисе количество обработчиков регулируется динамически с помощью пула воркеров.
Приступая к обработке сообщения, сервис антифрода идет на слейв MySQL, считывает все нужные нам данные по заказу из разных таблиц, записывает их к себе в Elastic, а потом посылает сам себе сообщение проверить этот же заказ. При проверке захватывает распределенный lock на Redise, чтобы предотвратить параллельную обработку объекта при особо интенсивных запросах, например, при частом обновлении водителя или клиента. В случае нахождения фрода сервис отправляет сообщение об этом монолиту.
При построении отчетов в админке сервис вызывается через REST API.
Всё это позволяет оказывать на монолит минимальное влияние.
Теперь подробнее
Читатель мог заметить парочку проблем:
- Elastic не гарантирует, что записанные данные сразу будут доступны для поиска, а только после refreshа индекса, который сам эластик делает в фоновом режиме с некоторой периодичностью. Как тогда проверить заказ, который мы только что добавили?
- Что если слейв MySQL отстает и заказа там еще нет?
Начнем с решения второй проблемы.
Наш RabbitMQ внутри состоит не просто из двух очередей (входящей и исходящей), но еще и третьей — очереди повторных попыток (retry).
У этой очереди есть producer, но нет consumer’а. На ней настроена политика dead-letter: по истечении своего TTL сообщение попадает обратно во входящую очередь, и мы обработаем это сообщение.
Иными словами, если мы получили сообщение на проверку заказа, но на слейве этого заказа еще нет, то мы просто будем помещать это сообщение каждый раз в retry-очередь, пока заказ не появится. С помощью этого подхода можно ретраить временные ошибки, а при превышении количества попыток на обработку этого сообщения отбрасывать его с записью об ошибке в лог.
А теперь вернемся к первой проблеме.
Самый быстрый и самый плохой вариант — делать refresh индекса при любой операции записи. Разработчики Elasticsearch советуют быть крайне осторожными с таким подходом, это может привести к снижению производительности.
Есть другой вариант: сразу передавать в сообщении всю информацию о заказе, а не считывать его со слейва. Но тогда размер сообщения вырастет на несколько порядков, что увеличит нагрузку на наш кролик, а мы его стараемся беречь. К тому же структура считываемых данных меняется достаточно часто, и изменения модели как в монолите, так и в сервисе хотелось бы избежать.
Может быть, проверять заказ сразу, как только прочитали его со слейва? Можно, только вот большинство наших проверок всё-таки делают вывод на основе нескольких заказов, то есть в базу лезть всё равно придется за остальными заказами. Зачем усложнять логику, если можно воспользоваться тем же механизмом retry-очереди?
Выставляя TTL сообщений в retry-очереди больше интервала обновления индекса Elastic, мы забудем о первой проблеме раз и навсегда.
Подробнее про механизм dead-letter можете почитать например тут.
Немного про наши тесты
Ошибки в логике правил антифрода совершать опасно: это может привести к массовым денежным списаниям. Именно для этого мы стремимся к 100% покрытию важных участков кода. Для этого мы используем библиотеку testify, mock’ая внешние зависимости и проверяя правила на работоспособность. Также у нас есть функциональные тесты, проверяющие основной флоу обработки и проверки заказа.
Вместо выводов
Переписав весь антифрод, на выходе мы обеспечили себе уверенность в нашем сервисе при дальнейшем росте бизнеса в течение следующих нескольких лет. Решили важную бизнес-задачу, благодаря которой честный водитель уверен в получении своих честных денег сразу после выполненной поездки.
Безусловно, некоторые задачи, которые выполняет наш сервис, остались за занавесом NDA. А некоторые задачи просто не влезли бы в одну статью.
Быть может, в следующий раз я вернусь с рассказом о том, как мы анализируем на фрод действия пользователей, где нагрузки на порядки выше.
Автор: Никита Серенко