Привет! Меня зовут Владимир, я руководитель управления разработки и тестирования в СИГМЕ. Сегодня хочу рассказать, как наша команда дорабатывала CRM-систему заказчика. Она используется для контроля всевозможных коммуникаций с клиентами — от звонков на горячую линию и переписки в мессенджерах до визитов в офисы и почтовых рассылок. Архитектурно CRM спроектирована так, что способна сопровождать оказание практически любых услуг, но исторически сосредоточена на взаимодействии с клиентами энергосбытовых компаний.
Перед нами стояла задача написать подсистему, которая позволит настраивать условия и в соответствии с ними сегментировать клиентскую базу. Клиенты, соответствующие заданным условиям, будут попадать в определенный сегмент. Эта функция нужна заказчику, чтобы выстраивать диалог с клиентами с учетом их психологического профиля и предпочтений, а также адресно предлагать услуги.
Нам необходимо было реализовать возможность классифицировать основные бизнес-объекты без детального анализа данных. Недолго думая, мы усложнили задачу до формулировки: «любые бизнес-объекты, соответствующие условиям, будут попадать в определенный сегмент».
Способов решения задачи (по сути задачи классификации) известно множество. Вплоть до градиентных моделей обучения без учителя, когда мы заранее не знаем что ищем. Наша реализация не столь сложна, она оперирует конкретными известными аналитику категориями и только выявляет соответствие этим категориям среди рассматриваемых объектов.
Цель данной статьи — показать, что при правильном подходе есть возможность легко масштабировать решение, а также переиспользовать отдельные части подсистемы под другие нужды. А если говорить человеческим языком, это история о том, какой кайф — писать системы, состоящие из функциональных кубиков, которые как Лего можно перещелкивать с места на место и получать в итоге сложную рабочую конструкцию.
Первая итерация
Из данной постановки сразу следует, что у нас должно появиться как минимум две большие сущности — сегменты и условия.
Для начала представим простейший набор сущностей в блоке «Сегментирование». Сразу учтем, что объектом для сегмента может выступать любая сущность — не только клиент, как в изначальной задаче.
Обратите внимание, что не требуется поддерживать историю изменений состава сегмента. Достаточно периодически перерассчитывать этот состав, удаляя объекты, переставшие соответствовать условию, и добавляя те, которые пришли в соответствие с момента последнего расчета. А чтобы знать, когда сегмент был рассчитан последний раз, введем еще одну таблицу — журнал сегмента. По ней мы также определим, что сегмент уже в процессе расчета и его не надо трогать.
Здесь и далее я опускаю подробности про:
вспомогательные таблицы типа доступности для систем, логи, справочники и т.д.
технические поля, аудитные, логическое удаление и т.п.
безумное количество функциональных проверок, которые не позволяют сохранить что-то в неправильном формате, составе или порядке.
элементы отказоустойчивости, позволяющие не прерывать процесс расчета всей партии, если попался один «кривой» объект, и в то же время не потерять из виду ошибки, с которыми процесс столкнулся.
Теперь к условиям. На момент появления задачи сегментирования в CRM-системе заказчика уже был реализован конструктор условий. Он применялся для построения ветвлений бизнес-процессов. Единственное, чем он нам не подходил, — расчет производился для 1 объекта за раз. Необходимо было переделать его для поддержки массового расчета. Тем не менее здесь я буду описывать архитектуру конструктора условий, как если бы мы делали его с нуля, от простого к сложному. Итак, простейший набор сущностей по условиям.
Расшифруем значение полей.
Операнды слева и справа — новая сущность, которую мы вводим. Понятно, что для расчета условий нам нужны данные, а их надо как-то получать из источников. Это нетривиальный процесс, к тому же потенциально полезный и для других задач, поэтому он достоин независимой реализации.
Оператор — это булевая функция. Мы использовали ==, !=, >, <, >=, <=, is null, is not null, like и прочие стандартные операции, которые вы видите в любом более-менее приличном фильтре грида. Заметьте, не все операции нуждаются в правой части.
Справа может быть:
константа, что чаще всего и происходит. У данного поля лучше всего сделать тип jsonb или text, так как она может принимать очень разные значения.
другой операнд. Это может быть полезно, если нам нужно сравнить два атрибута за разные периоды. Пример готового условия с операндом справа — «Прибыль за прошлый месяц»-(операнд слева) «меньше»-(оператор) «прибыли за текущий месяц»-(операнд справа).
Вводим новую сущность — тэг. Она будет выступать у нас операндом. Как и условие, тэг уже был реализован в системе еще раньше и использовался для разных целей. Например, заказчик использовал тэги в конструкторе сообщений, чтобы отправлять персонализированные смски. Но я также буду здесь описывать эту сущность, как если бы мы ее изобретали с нуля, от простого к сложному. Итак, простая модель тэгов.
Теперь мы сможем получить значение любой колонки любой таблицы БД по ее ИД. ИД должен прилетать среди входящих параметров запущенного процесса. Для этого нам пришлось расширить справочник системных сущностей данными о их физическом адресе.
Теперь, когда у нас есть условие, подключаем ссылку на него в сегментировании. Конечно, ссылаться на условие должен сам сегмент. То есть каждый сегмент рассчитывается по одному условию.
Модель готова. На такой модели мы уже можем рассчитать самый примитивный сегмент, когда всего один прямой атрибут нашего объекта соответствует простому булевому условию. Функционально это будет работать так:
Начинаем расчет сегмента.
По ссылке на тип объекта находим ИД всех экземпляров системной сущности.
По каждому объекту передаем ИД в расчет условия.
Условие вызывает вычисление тэга/тэгов, передавая этот же ИД.
Условие производит операцию над результатами и возвращает булевое значение в сегмент.
Если вернулось true, помещаем объект в сегмент, false – удаляем из сегмента.
Изобразим в виде диаграммы Венна.
Что не так с этим функционалом?
-
Основная претензия — скудные возможности, которые он предоставляет. Никому не нужен функционал, который может только сравнивать какое-то одно поле из той же таблицы, где лежат объекты для сегмента.
1.1. Нужно сделать возможным оперировать не только плоскими данными таблиц, но и получать произвольные срезы данных в плоскости нашего объекта.
1.2. Одного условия мало, нужно сделать возможным построение составных условий из нескольких операций.
-
Вторая претензия касается быстродействия.
2.1. Перебирать многомиллионные таблицы по одной строке за раз — непозволительная роскошь, поэтому нужно организовать возможность массового расчета условий и массового получения значений тэгов.
2.2.Желательно иметь возможность как-то сузить ареал возможных объектов на входе в сегмент, а не брать просто все данные из таблицы как сейчас.
Вторая итерация
Движемся по порядку. Для кастомизации срезов данных (1.1.) вводим в тэги новую их разновидность — функциональные тэги. А те, что были раньше, назовем системными тэгами. Функциональные тэги для получения данных не просто обращаются к системной сущности, а вызывают некую функцию. Расширим нашу модель данных.
Теперь, если есть ссылка на системную сущность, это системный тэг, а если на функцию — функциональный. Организовать другой способ ссылаться на источник, чтобы избежать «шахматного» хранения данных можно, но мы решили не жертвовать FK в угоду эстетике.
Тип параметра — это не про тип данных. Это поле может принимать одно из значений:
Константа. Тогда в поле «Значение» мы запишем то значение, которое хотим отправить в функцию.
Пользовательский. Это параметр, который мы ожидаем извне. Например, если потребителем функционала является подсистема «Сегментирование», мы ожидаем этот параметр либо из настройки условия, либо из настройки сегмента. Там пока нет таких опций, но мы до них дойдем.
Тэг. В поле «Значение» мы пропишем ИД тэга. Такой параметр тоже приходит извне, но он не является настраиваемым/пользовательским. Почему мы ссылаемся на тэг? Это довольно удобно — в таблице тэгов уже есть описание, как называется поле и какого оно типа данных. К тому же зачастую для системных и функциональных тэгов требуются примерно одни и те же поля на вход. В разрезе сегментирования с таким типом будет передаваться как раз параметр ИД объекта.
У функций, которые мы можем использовать для сегментирования, есть следующие ограничения:
Обязателен входящий параметр — массив ИД объекта. Так мы заодно решаем проблему производительности (2.1.), передавая данные сразу чанками.
Функция возвращает таблицу данных.
В исходящей таблице обязательно поле «ИД объекта» с таким же именем, как у входящего параметра.
Таблица должна быть уникальна по этому полю. То есть мы получаем срез произвольных данных, но строго в плоскости исследуемого объекта.
Теперь мы даже можем предварительно фильтровать, какие условия доступны для сегмента. ИД системной сущности всех тэгов, участвующих в условии, должен строго совпадать с ИД типа объекта самого сегмента.
На стороне условий мы тоже привносим некоторые изменения модели для этого пункта.
Поле «Правый операнд» булевое, позволяет разделить параметры левого и правого операнда.
Такой подход позволяет передавать в тэги Пользовательские параметры. Так мы можем вызвать для разных условий один и тот же функциональный тэг, но с разными параметрами. Например, тэг рассчитывает количество контактов с клиентом за определенный период. Тогда можно настроить с этим тэгом условия «количество контактов за месяц > 0» и «количество контактов за год > 6», передавая количество дней как пользовательский параметр.
Теперь разберемся с пунктом 1.2. — а именно «Составные условия». Сделать их не так уж сложно. Нужно просто объединять простые условия в группы, используя оператор И или ИЛИ. Добавим возможность в модель.
Добавили поле «Группа условий», которое будет содержать массив ИД условий, объединенных в группу. А оператор будет прописан в поле «Оператор». Почему мы опять выбрали «шахматное» хранение данных, а не выделили развязочную таблицу? И составное, и простое условие в равной степени нас интересуют именно в качестве условия: по сути, для процессов они неотличимы и выполняют одну и ту же функцию — возвращают булевый результат расчета. По итогу у нас получается что-то максимально близкое к фильтру грида типа такого:
Теперь займемся оптимизацией. По пункту 2.1. мы уже организовали пакетную передачу ИД объекта в условия. Отмечу только, что над этим пунктом нам пришлось поломать голову. Дело в том, что единичные условия могут рассчитываться на основе нескольких типов системных сущностей, хоть сразу десятка. Может быть вычурное условие и по атрибутам клиента, и по каким-нибудь платежным данным. Главное, чтобы на вход в расчет передали все необходимые параметры. Но для пакетного подхода такое усложнение труднореализуемо, а для сегментирования и вовсе избыточно. Поэтому мы ввели специализацию и ограничения на условия. Теперь можно присылать либо пакет ИД, но условие должно целиком зависеть от одной сущности, либо по одному ИД для произвольного количества сущностей. Если условие зависит от одной сущности, его можно использовать для любой функциональности — хоть пакетной, хоть единичной.
В плане процесса, мы стали делить данные из источника на чанки по 10 000 записей и рассчитывать условия для всего чанка. Затем сразу сохранять его в сегмент и переходить к следующему. Так при отказе системы мы недосчитаем только часть сегмента, и эту часть можно просто досчитать, запустив процесс заново.
Для пункта 2.2. мы написали ряд функций ареала, которые можно подключать к сегменту. Идея была обозначить часто используемые срезы данных и максимально сузить выборку перед началом расчета. Например: «все клиенты ФЛ», «все клиенты женского пола» или «все клиенты, по которым за последнюю неделю происходили любые изменения». Эти функции не имеют входящих параметров кроме ИД сегмента, как раз чтобы игнорировать свежепосчитанные объекты. А на выход они отдают только массив ИД объекта. Схема такая:
ИД системной сущности в новой таблице нам нужен, чтобы для сегмента по слоникам мы могли выбрать только функцию ареала слоников и не посчитали случайно бегемотов :)
Рассмотрим, что у нас получилось. На текущий момент мы можем:
Создавать любые сегменты по любым объектам, до которых сможем дотянуться и однозначно связать их с окружением.
Заранее обозначать ареал объектов сегментирования, чтобы исключить из обработки априори неподходящие элементы.
В этих сегментах мы используем древовидные условия произвольной сложности и уровня вложенности.
Условия в свою очередь могут получать для себя произвольные данные из любых доступных источников. Это могут быть в том числе Rest API сервисы.
Как бонус, параметры этих функций можно передавать несколькими разными способами из разных этапов расчета сегмента.
Я не останавливался на этом отдельно, но это может быть неочевидно. Так как теперь условия могут быть сложными, операндами в них могут выступать данные из одинаковых источников с одинаковыми параметрами. Мы оптимизировали процесс так, чтобы при расчете сегмента каждая функция с уникальным набором значений входящих параметров вызывалась только один раз.
Это уже сойдет за MVP, но для полного счастья не хватает еще пары деталей:
-
Сейчас все сегменты независимы друг от друга, а иногда появляется потребность как-то их группировать. Самый простой пример, это когда у нас есть несколько сегментов по определенным условиям и еще один с пометкой «остальные». То есть в него должны попасть все оставшиеся объекты из ареала, которые не попали в другие сегменты группы. Даже сейчас можно было бы просто сделать условие, которое проверяло бы нахождение объекта в сегменте, но мы посчитали это неудобным.
-
Все еще есть вопросики к производительности, в частности к расчету условий по похожим сегментам, использующим данные из одинаковых источников. При расчете в один день все равно независимо запрашиваются эти данные, а не переиспользуются от предыдущего запроса. Когда сегментируются десятки миллионов объектов, даже при отработке каждого чанка на 10 000 за пару секунд, весь сегмент все равно рассчитывается за несколько часов. И использование уже посчитанных тэгов было бы весьма кстати.
Третья итерация
Для решения первой проблемы мы добавили возможность включения/исключения одних сегментов в другие.
Тип взаимодействия может принимать значения «включен» и «исключен». Если «включен», в сегменте-потомке могут содержаться только объекты родителя. Если «исключен» — наоборот, потомок не может содержать объекты родителя.
Скажу сразу — как видно из модели, мы не стали конструировать многоэтажные «деревья» с И/ИЛИ взаимоотношениями. Если сегмент включен в несколько родителей, его объекты должны быть представлены в каждом из них. И наоборот, если сегмент исключен из нескольких родителей, ни один объект любого из родителей не должен быть в нем.
Это, казалось бы, небольшое нововведение породило довольно много сложностей и концептуальных решений:
Дело в том, что эти взаимодействия по сути являются условиями сегмента, значит, сами условия теперь становятся опциональными. Можно весь сегмент построить только на его взаимоотношении с другими.
Менее очевидным является, что «включенность» сегмента в другие сегменты — это также и функция ареала, значит, ареал тоже теперь опционален. Более того, логика отличается при разных ситуациях. Если у нас есть только функция ареала или включенность, мы очерчиваем ареал этим имеющимся множеством. Если же у нас есть оба этих механизма, мы вынуждены взять только то, что находится на их пересечении.
Возникла необходимость учитывать очередность расчета сегментов, чтобы потомки всегда рассчитывались после родителей. Да еще и теперь нужно следить, чтобы связи не закольцовывались.
Диаграмма Венна по различным множествам стала интереснее. Рассмотрим основные действия над множествами.
Теперь работа с сегментом происходит в два этапа:
Сначала высчитывается итоговый ареал (на схеме это совокупность секторов 1-4), и первым этапом сразу удаляем из сегмента все объекты, не вошедшие в него (на схеме это весь оранжевый сектор, условно помеченный цифрой 5).
Затем просчитываются условия для всего ареала 1-4. Объекты сектора 2 добавляются в сегмент, а сектор 4 удаляется из сегмента.
Теперь решим задачу повторного использования значения тэгов. Что интересно, мы ее решили даже немного эффективнее, чем планировали изначально. По первоначальной задумке мы хотели сделать что-то такое:
Но такое решение было бы неполноценным, потому что нам интересно минимизировать вызовы источников как таковые. Поэтому правильнее было бы ориентироваться на функции тэга, а не на сами тэги. Но у функции могут быть различные параметры для ее вызова, значит, фиксировать надо уникальные вызовы, а не саму функцию. В итоге пришли к такому решению:
Таблица не привязана к какой-либо бизнес-сущности. Привязкой служит уникальный для каждого процесса текстовый код. Чтобы он был уникален, в него входит имя функции и значения кастомизируемых параметров. Например, есть у нас функция, которая получает в разрезе клиента количественные данные по контактам за период. На вход мы подаем чанк из 10 000 клиентов и количество дней периода. Чанк — обязательный параметр и не кастомизируемый, поэтому его игнорируем, а вот период может меняться. Тогда код процесса построится из <Имя функции>_<количество дней> (например, cnt_contact_365 или cnt_contact_30).
Для всех тэговых функций мы добавили опциональную возможность пользоваться специальной прослойкой. Алгоритм такой:
Функция формирует код процесса.
Отбирает объекты чанка, по которым в новой таблице НЕ нашлась пара «код процесса – ИД объекта» с датой получения не ранее сегодняшней (можно менять, если данные не устаревают дольше).
По выбранным объектам вызывает функцию тэга.
Сохраняет полученные значения в таблицу. Причем все значения, не только то, что нужно для текущего тэга. Поле «значение тэга» имеет формат jsonb под любые наборы исходящих данных.
Запросом получаем уже из нашей таблицы значения именно нашего тэга по каждому объекту и возвращаем их в расчет условий.
Благодаря такому подходу если мы для другого тэга или из другого сегмента вызовем сегодня эту же функцию, в пункте 2 мы не найдем неподсчитанных объектов и сразу перейдем к пункту 5, минуя энергозатратный пункт 3.
Подведем итог:
1. Мы можем:
сегментировать произвольные бизнес-объекты;
настраивать элементарные зависимости сегментов друг от друга;
использовать произвольные многоэтажные условия по произвольным данным из доступных источников.
2. Весь процесс алгоритмически оптимизирован, чтобы по возможности не выполнять дважды какие бы то ни было действия.
3. Как бонус, практически все элементы системы могут быть использованы и используются для решения других задач — от расчета условий маркетинговых акций до рассылки сообщений. Даже у самих сегментов есть «ручная» версия, которую я оставил за скобками. Она позволяет заполнять его напрямую и через API. Это позволяет снабжать объекты субъективными метками.
Спасибо за внимание! Буду рад ответить на ваши вопросы.
Автор: Владимир Бурба