company_banner

Как мы фрод из избы выносили

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


    Для начала немного расскажу о нашем сервисе.


    Антифрод 101


    Наш антифрод — это набор правил для выявления заказов, содержащих признаки мошенничества, фродовые паттерны.


    Пример водительского мошенничества

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


    Пример клиентского мошенничества

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


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


    Проверки можно разделить на несколько типов:


    1. Проверка клиента/водителя при каком-то изменении (например, добавили новую кредитную карту).
    2. Проверка 1..n последних заказов.
    3. Специальные: проверка корректности работы водителей, участвующих в специфических акциях.

    Для настройки правил есть web-интерфейс — «админка». И для визуального контроля за сработавшими правилами мы создали web-страницу с разными отчетами с большим набором фильтров.


    Добавление новой проверки происходит следующим образом: описали фродовый паттерн, закодировали его в сервисе, запустили новое правило в тестовом режиме, и наблюдаем. При необходимости корректируем правило и включаем.


    Проблемы прежней архитектуры


    Раньше компания-партнер могла получить деньги только при проверке всех своих водителей на фрод.


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


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


    Отчёты строились медленно. Чтобы посмотреть, казалось бы, простые вещи руками в базе, иногда приходилось JOIN-ить по пять и более таблиц, не говоря уже о более сложных вещах.


    Бизнес рос, и эти проблемы требовали скорого решения. Также хотелось проверять водителей на фрод «на лету» (после каждой поездки).


    Какие у нас были варианты:


    • Доводить до ума то, что есть. Переделывать модель данных на новую.
    • Писать сервис с нуля, с возможностью масштабироваться из коробки.

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


    Решено было переезжать в два этапа.


    Первый этап: перенос на новый язык


    Остались на старой модели данных (да, скрипит, но пока работает). Стали делать сервис с нуля, за пару месяцев перенесли основной функционал и большинство проверок. Добились полной работоспособности сервиса.


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


    Сравнительные характеристики по скорости обработки: ранее на анализ всех водителей уходило 6 часов, теперь 25 минут.


    Второй этап: выбор модели хранения


    Для текущей работы нужна была как OLTP-подобная база данных для анализа на фрод, так и OLAP-база для построения отчетов. Текущая схема данных не поддерживала сценарии антифрода, от слова «никак».


    Выбор стоял между:


    1. Новой моделью на SQL (правильно денормализованной) для текущей работы, а также ClickHouse’ом для отчетов.
    2. Elastic’ом.

    Мы выбрали Elastic. Он легко масштабируется, в нём «из коробки» есть индексы по любому полю, что позволяет настраивать фильтры в отчетах как душе угодно. Денормализовали модель, чтобы не приходилось делать JOIN’ы между индексами Elastic’а.


    Предостережение

    Если вы тоже решили выбрать Elastic в качестве базы данных, то будьте бдительны. При настройках по умолчанию Elastic под нагрузкой может начинать отдавать частичный результат поиска. Например запрос стаймаутится на нескольких шардах при этом код ответа будет 2xx. Если вас не устраивает такое поведение и вам лучше получить ошибку поиска (например, чтобы потом заретраить), то вы можете отрегулировать это поведение через параметр allow_partial_search_results.


    Текущая схема работы антифрода


    Напомню, что основная логика, связанная с поездками, живет в монолите, а вся информация по заказам всё еще лежит в MySQL. При завершении поездки монолит переносит заказ из таблицы активных заказов в таблицу закрытых и отправляет сообщение в наш сервис через RabbitMQ, чтобы мы проверили конкретный заказ.


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


    Приступая к обработке сообщения, сервис антифрода идет на слейв MySQL, считывает все нужные нам данные по заказу из разных таблиц, записывает их к себе в Elastic, а потом посылает сам себе сообщение проверить этот же заказ. При проверке захватывает распределенный lock на Redise, чтобы предотвратить параллельную обработку объекта при особо интенсивных запросах, например, при частом обновлении водителя или клиента. В случае нахождения фрода сервис отправляет сообщение об этом монолиту.



    При построении отчетов в админке сервис вызывается через REST API.


    Всё это позволяет оказывать на монолит минимальное влияние.


    Теперь подробнее


    Читатель мог заметить парочку проблем:


    1. Elastic не гарантирует, что записанные данные сразу будут доступны для поиска, а только после refreshа индекса, который сам эластик делает в фоновом режиме с некоторой периодичностью. Как тогда проверить заказ, который мы только что добавили?
    2. Что если слейв MySQL отстает и заказа там еще нет?

    Начнем с решения второй проблемы.

    Наш RabbitMQ внутри состоит не просто из двух очередей (входящей и исходящей), но еще и третьей — очереди повторных попыток (retry).


    У этой очереди есть producer, но нет consumer’а. На ней настроена политика dead-letter: по истечении своего TTL сообщение попадает обратно во входящую очередь, и мы обработаем это сообщение.


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


    А теперь вернемся к первой проблеме.

    Самый быстрый и самый плохой вариант — делать refresh индекса при любой операции записи. Разработчики Elasticsearch советуют быть крайне осторожными с таким подходом, это может привести к снижению производительности.


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


    Может быть, проверять заказ сразу, как только прочитали его со слейва? Можно, только вот большинство наших проверок всё-таки делают вывод на основе нескольких заказов, то есть в базу лезть всё равно придется за остальными заказами. Зачем усложнять логику, если можно воспользоваться тем же механизмом retry-очереди?


    Выставляя TTL сообщений в retry-очереди больше интервала обновления индекса Elastic, мы забудем о первой проблеме раз и навсегда.


    Подробнее про механизм dead-letter можете почитать например тут.


    Немного про наши тесты


    Ошибки в логике правил антифрода совершать опасно: это может привести к массовым денежным списаниям. Именно для этого мы стремимся к 100% покрытию важных участков кода. Для этого мы используем библиотеку testify, mock’ая внешние зависимости и проверяя правила на работоспособность. Также у нас есть функциональные тесты, проверяющие основной флоу обработки и проверки заказа.


    Вместо выводов


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


    Безусловно, некоторые задачи, которые выполняет наш сервис, остались за занавесом NDA. А некоторые задачи просто не влезли бы в одну статью.


    Быть может, в следующий раз я вернусь с рассказом о том, как мы анализируем на фрод действия пользователей, где нагрузки на порядки выше.

    Ситимобил
    Компания

    Комментарии 14

      +1
      Забыли указать язык на который переписали сервис.
      Но исходя из упоминания горутин можно предположить что так тщательно скрывали Go/Golang.
        0

        Антифрод, конечно, много что скрывает, но не в этот раз :)


        В качестве основного инструмента распараллеливания был выбран Golang, по которому в компании есть хорошая экспертиза.
      0
      Почему не удалось запускать несколько php процессов?
      Как я понял все правила применяются в рамках одного водителя/клиента и это должно хорошо решаться запуском нескольких consumer
        0

        Этот вариант, безусловно, рассматривали, но аргументы были простыми в нашем случае.
        1) Пока не перепишем модель запускать несколько consumer'ов опасно — быстрее положим базу.
        2) На самой модели завязано порядка 70% кода.
        3) Невозможно нормально параллелить независимые части одной проверки водителя/клиента или организовать какие то более сложные потенциальные зависимости.


        То есть в итоге переписывание модели было неизбежно. Раз уж переписывать 70% кода, тогда можно переписать все сразу на Go и иметь возможности лучшего масштабирования в будущем.

        0
        Почему выбрали ElasicSearch? У нас стоял примерно такой же выбор — между денормализованной RDBMS и ES. Остановились на Postgres'e. У ES'a преимущество было только в оптимизированном full-text поиске.
          0

          Для построения отчетов по пойманным событиям нам нужна была OLAP подобная база в нашем случае. OLTP базу настраивать для аналитических целей всегда непросто: когда перед тобой десятки полей по которым хотят искать и агрегировать, то тяжело настроить правильно индексы. При необходимости быстрого поиска по новому полю, нужно делать альтеры.
          Мы выбрали ElasticSearch как компромисс между OLTP и OLAP.
          В эластике удобно: индекс по каждому полю, возможность агрегации и, как вы упомянули, full-text поиск, если он понадобится.
          У меня нет опыта в администрировании Postgres, но кажется что маcштабировать ElasticSearch легче — так как его разработчики на старте продукта закладывали туда шардирование, реплицирование, алиасы и прочее.

            0
            А разве не MySQL у вас выступает в качестве OLTP? У нас четкое разделение: OLTP-операции проходят на разнородных базах и сервисах (обычно через 2PC), а в специальную базу для OLAP операций мы пишем только при синхронизации с основными базами (сразу прогоняем обновленную часть записи через очередь, обычно там не так много изменений за 1 раз).
              0

              MySQL конечно и есть OLTP, но он база монолита в первую очередь. Этот MySQL у нас источник данных — множество баз и таблиц. И работать по ним напрямую антифрод не может без риска положить их. Соответственно мы в начале переносим данные в ElasticSearch, а потом их же используем для анализа на фрод.

          0
          Сколько у вас на данный момент правил для антифрода?
            +1

            Число приближается к сотне.

            0
            Есть значения текущей нагрузки?
              0

              Если брать проверку водителя/клиента, то порядка 50к rpm. Если учитывать анализ действия пользователей, то в пиковые часы доcтигало 3к rpS.

              0
              Ваша задача в теории* хорошо ложится на хранимые процедуры — все данные изначально лежат в базе, можно создать очередь задач и СУБД сама решит как их выполнять, в каком потоке, на каком ядре или ноде кластера, сама разберется с блокировками.

              Скрытый текст
              * К сожалению, теория от практики существенно отличается и мы 15 лет назад в похожей ситуации обнаружили вагон и маленькую тележку проблем:
              — сама СУБД должна быть достаточно продвинутая, с хорошим языком запросов (T-SQL, PL/SQL), ей должно быть выделено достаточно ресурсов для выполнения ХП. При этом надо надеяться, что процедуры таки выполняться именно параллельно, а не по одной в одном потоке, блокируя все намертво;
              — в общем случае набор правил быстрее и удобнее всего писать прямо в виде кода процедуры (или нескольких). За пару месяцев эта процедура (или сотня процедур) становятся страшным монстром, в котором мало кто разбирается, а покрыть это все тестами совсем не просто;
              — заказчик не может сам писать правила и должен все время дёргать программиста. Если же таки заказчику нужен интерфейс и логика для работы с правилами, то мы падаем в бездонную кроличью нору, разрабатывая либо собственный интерпретатор правил в хранимой процедуре, либо компилируя правила в SQL код, который потом включается в процедуру. Оба варианта съедают жуткое количество времени на разработку и отладку.

              В итоге мы сидели над этими хранимками долгие месяцы и мечтали все переписать на людский язык программирования, пока проект благополучно не закрылся :)

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое