Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Приветствую! Меня зовут Михаил, я разработчик Oracle в ClubPro (Клубная программа, программа лояльности Спортмастера). В команде разработки моё основное направление связано с развитием Campaign Management.
Campaign Management (далее для краткости Campaign) предназначен для создания и управления многошаговыми маркетинговыми кампаниями. Другие задачи, которые решает Campaign — формирование омниканальных предложений клиенту, начисление бонусов в Клубной программе, верстка и создание email-рассылок.
В этой статье я сделал основной упор на то, как кампании работают в СУБД Oracle и какие технологии Oracle мы используем. Вначале предлагаю краткий обзор того, как создаются кампании и какой путь проходят от брифа до запуска.
Клиент
Приложение, через которое отдел маркетинга создает цепочки кампаний, написано на Delphi с использованием DevExpress и библиотеки TMS Diagram Studio. Delphi используется в ClubPro столько, сколько существует система лояльности Спортмастера. Учитывая скорость разработки и обилие компонентов, Delphi успешно подошла для Campaign.
Вот сам интерфейс с короткой цепочкой.
Интерфейс создания кампании мы разделили на несколько областей: слева панель выбора блоков, по центру поле для рисования цепочки, справа Миникарта и справочные данные о блоках. Пользователю предлагается создавать диаграмму из блоков согласно маркетинговой кампании. По этим блокам потекут потоки клиентов. Выше упрощенная версия кампании «День Рождения». В блоке SELECT произойдет выборка тех клиентов, у кого сегодня день рождения. Далее клиенты попадут в блок BONUS, где начисляются бонусы. В конце цепочки блок MESSAGE, который сформирует рассылку с поздравлением и напоминанием, что бонусы можно использовать.
Создание кампаний
Для создания кампаний доступно 23 типа блоков, разделенных на 4 группы: Источники данных, Разделение потоков, Соединение потоков, Блоки действия. Перечислю наиболее используемые:
Блоки Источники: SELECT — выбирает клиентов из Витрины или Запроса. TRIGGER — блок, через который клиенты попадают в кампанию по клиентскому событию, покупке,
прохождению опроса
Блоки Разделения потоков: FILTER – фильтрует поток клиентов, пропускает только тех, кто удовлетворяет бизнес-условию, заложенному в блоке. CONTROL GROUP – выделяет из потока заданное количество, процент клиентов, которые попадают в Контрольную группу.
Блоки Соединения потоков — это операции AND: проходят дальше те, кто во всех входных потоках, MINUS: проверочное вычитание из основного потока.
Блоки Действия: BONUS – блок начисления бонусов. MESSAGE – создание коммуникации в различные каналы (SMS, Email, Push) и другие.
Сохранение кампании
На этапе проектирования пользователь сохраняет кампанию без валидации в статусе «Черновик». Кампания в статусе «Черновик» хранится в БД в формате XML. Это позволяет прерывать работу над созданием кампании, откладывать разработку до уточнения требований, и позже возвращаться к доработке. Если бы кампания сразу сохранялась в реляционной таблице, от пользователя потребовалось бы создавать корректную цепочку со связями между блоками, с наполнением блоков. А черновик не предъявляет никаких требований к целостности.
В момент, когда бизнес-требования учтены и реализованы в редакторе кампаний, пользователь переводит кампанию в статус «Готово к запуску». Сначала на клиенте проводится бизнес-валидация кампании, например, корректно ли заполнены поля, правильно ли составлены формулы в блоках и прочее. Затем клиент пересылает в БД новый XML с указанием нового желаемого статуса. В БД происходит разбор XML в пакетные структуры, а затем блоки и смежные сущности сохраняются в свои таблицы. Здесь уже срабатывает проверка целостности на уровне БД.
XML был выбран как понятный, человекочитаемый формат, с которым СУБД Oracle через XML DB и поддержку SQL/XML хорошо и быстро работает. Ниже представлен фрагмент XML, который приходит с клиента при сохранении, и SQL для парсинга блока.
Фрагмент XML кампании
<campaign_ver campaign_code="" name="Select Bonus Message" remarks="" state="0" dat_begin="05.01.2022 00:00:00" dat_end="06.01.2022 00:00:00">
<cp_priorities>
<cp_priority cp_code="100" cp_name="КП Спортмастер РФ" priority="1"/>
</cp_priorities>
<block_list>
<block code="-3" mnemocode="BONUS0" block_type_mnemocode="BONUS" name="BONUS" remarks="" pos_value_x="397" pos_value_y="177">
<block_bonus code="-3" action_code="-2" ignore_global_cg="1" wait_for_export="0" >
</block>
...
</block_list>
<block_relationship>
<block_rel block_code="-3" parent_block_code="-1" state="1"/>
<block_rel block_code="-4" parent_block_code="-3" state="1"/>
</block_relationship>
</campaign_ver>
Запрос парсинга блока
select
x.code,
x.mnemocode,
bt.code,
x.name,
x.remarks
into
v_block_info.block_data.code,
v_block_info.block_data.mnemocode,
v_block_info.block_data.block_type,
v_block_info.block_data.name,
v_block_info.block_data.remarks
from
xmltable
(
'block'
passing p_block_xml
columns
code number(18) path '@code',
mnemocode varchar2(20 char) path '@mnemocode',
block_type_mnemocode varchar2(100 char) path '@block_type_mnemocode',
name varchar2(200 char) path '@name',
remarks varchar2(1000 char) path '@remarks'
) x,
tu_ref_cm_block_type bt
where
bt.mnemocode = x.block_type_mnemocode;
Запуск кампании
Создание кампаний происходит на отдельной БД — Маркетинг. А их исполнение уже на других базах – Процессинги. Теперь, когда цепочка успешно сохранена в БД, кампания пересылается на соответствующий Процессинг, сервер исполнения кампаний. Кампания готова к запуску. Бизнес, нажав кнопку Play, переводит кампанию в статус «Запланирована». В назначенное время планировщик посылает команду на сервер исполнения – «Запуск».
Процессинг, на котором исполняется кампания, получает команду «Запуск», создает сопутствующие исполнению сущности, передает блоки Источники данных на исполнение. Кампания заработала. Формируется ответ для Маркетинга – работаем – на клиенте кампания теперь отображается в статусе Работает.
Исполнение
Для исполнения кампаний существует отдельная сущность — Итерация. Итерацией мы назвали факт исполнения кампании. На начальном этапе разработки стало понятно, что Блоки в кампании исполняются в одной итерации. Отсюда вытекает архитектурное решение секционировать таблицы, связанные с исполнением Campaign, по Итерации. Начало разработки Campaign совпало с переходом на Oracle 12 Release 2, в котором внедрено Automatic List Partitioning. Создание основной таблицы (клиенты в блоке) с автоматическим секционированием выглядит так:
create table cx_exec_block_client
(
iteration_code number(18) not null, -- Код итерации
block_code number(18) not null, -- Код блока
client_code number(18) not null, -- Клиент
exec_code number(18) not null, -- Код попадания клиента в кампанию
state number(2) not null -- Статус клиента в блоке
)
partition by list(iteration_code) automatic
(
partition cx_part0 values(0)
);
И если до перехода на 12.2 придумывали костыли в виде предварительного создания секции, то теперь этот процесс отдан СУБД.
Бизнес-процесс исполнения в первом приближении выглядит таким образом.
С Маркетинга приходит команда: Запуск Кампании.
Обработчик команд создает Итерацию, находит блоки-источники и помещает в очередь блоков на исполнение.
Обработчик очереди блоков находит для блока-источника свободный слот на исполнение.
Блоки-источники выполняют выборку клиентов и передают в дочерние.
Блок обрабатывает поступивших клиентов, согласно внутренней логике, и отправляет далее в дочерние.
Так повторяется до тех пор, пока блоки не завершат работу.
Подробнее о работе блоков
Блоки задумывались как «черный ящик». Каждый блок вынесен в отдельный пакет, предоставляя внешним вызовам простой API, который сводится к Блок —> Выполнить.
Блок ничего не знает о том, кто его вызвал, и кто конкретно ждет от него результата. Выполнение каждого блока делится на этапы:
Обработка входного потока клиентов согласно логике блока.
Отправка далее
Чтобы начать работать над своими клиентами, блок занимает свободный слот на обработку. В слот он попадает из очереди блоков. Обработчик очереди выбирает следующий по дате исполнения блок, блокирует первый попавшийся пустой слот и помещает туда блок. Для обработчика очереди тоже особой разницы нет, какой блок в очереди, важно только, что время исполнения пришло.
Блок в слоте. Начало обработки. Подготовка списка клиентов
Блоки умеют обрабатывать входящий поток клиентов в параллели. Это необходимо, в первую очередь, потому что кампании по количеству клиентов достигают десятки миллионов, а вариант исполнения «Выбрал всех, обработал и отправил дальше» подходит для небольших групп. Ведь на больших объемах обработка может занимать много времени, в которое дочерние блоки простаивают.
Верным решением было разрешить блоку выполнять обработку входящего потока небольшими пачками, причем выбор размера пачки тоже зависит от типа блока, от 100 клиентов до 50 000. Выполнение небольшими пачками, но в параллели. Итак, блок в слоте, и он параллельный: слот резервирует под себя необходимое количество потоков, и блок начинает выполняться в этих потоках.
При параллельном выполнении первое, что решается — как не дать потокам обрабатывать одного и того же клиента. Это достигается опцией оператора SELECT .. FOR UPDATE SKIP LOCKED.
С этой опцией блокируются на изменение строки, кроме уже заблокированы в других транзакциях.
procedure execute_thread
(
p_iteration_code number,
p_block_code number,
p_client_state number
)
is
v_rowid_list t_rowid_list; -- table of char(18)
cursor cur_thread_rows is
select
rowidtochar(bl.rowid)
from
cx_exec_block_client bl
where
bl.iteration_code = p_iteration_code and
bl.block_code = p_block_code and
bl.state = p_client_state
for update skip locked;
begin
open cur_thread_rows;
fetch cur_thread_rows bulk collect into v_rowid_list limit 50000;
close cur_thread_rows;
execute_block(p_iteration_code, p_block_code, v_rowid_list);
end;
Использование запроса через явный курсор дает еще одно преимущество: блокируются только те строки, которые были выбраны fetch. В вышеуказанном примере выбрано до 50 000 строк, хотя под условие where может подпадать гораздо больше. Rowid заблокированных строк передаются на обработку блоку.
Блок по полученным rowid заполняет global temporary table (GTT) списком клиентов и необходимыми для обработки атрибутами. Атрибуты — это чек покупки, анкета, опрос и подобное.
В чем удобство использовать GTT для хранения списка клиентов? Во-первых, можно работать со списком клиентов в процедурах блока, без необходимости передачи списка как параметра. Во-вторых, изменять список, используя SQL-операторы, а также использовать список в других DML-операциях. В-третьих, нет необходимости её очищать после использования, Oracle сам позаботится об этом, то есть в конце обработки нет необходимости длинного delete.
При использовании GTT необходимо учесть следующее: для отката транзакции UNDO пишется в общее UNDO таблично пространство. Последнее защищено REDO (журналами повтора). Как видите,при такой схеме работы операции над GTT генерировали много логов. А кому нужны логи таких временных данных? Правильно, никому. В Oracle 12.1 добавлен параметр temp_undo_enabled=true, который для временных объектов пишет UNDO в TEMP табличное пространство. TEMP не записывается в REDO. Таким образом уменьшается количество данных в журналах логов. Важно только включить параметр до первого DML в сессии.
Обработка списка клиентов
Блок, пройдя через «skip locked и GTT», наконец-то готов заниматься своей бизнес-логикой. Например, блок Фильтр. В цикле по списку клиентов выполняет расчет формулы для каждого, и, получив положительный результат, отправляет клиента в выходное множество. Расчет формулы — это тема для отдельной статьи. Скажу в общих чертах, что существует внутренний язык, который поддерживает ветвления, циклы и пользовательские формулы. Это может быть условие для блока Фильтр или промомеханика, которая по позициям чека рассчитывает наилучшую скидку.
И вот наш блок Фильтр с условием КЛ_ДАТА_СГОР_БОНУС < ДАТА_ВЫПОЛНЕНИЯ + 7 проверяет, что у очередного клиента скоро сгорают бонусы, и, вероятно, после фильтра стоит блок MESSAGE, который оповестит клиента о сгорании.
Клиент во входном потоке в блок попадает в статус = 0 (необработанный). После проверки, если он прошел блок, ему присваивается статус = 2 (ушел дальше), в противном случае = 1 (обработан).
Далее блок перемещает клиентов со статусом «Ушел Дальше» в отдельную GTT. По этой GTT уже происходит рассылка клиентов в дочерние блоки. Дочерние блоки помещаются в очередь на исполнение и готовы вступить в работу.
Я писал выше, что размер пачки клиентов зависит от типа блока. Блок Фильтр работает пачками по 50 000 клиентов. Для их обработки Фильтр не вносит изменений в данные, кроме обновления статуса и отправки далее по цепочке, которое делается в самом конце выполнения блока. Небольшие пачки клиентов используются в блоках, где изменяются смежные бизнес-сущности. Так, блок BONUS создает начисления пачке в 100 клиентов. Если Фильтр упадет с ошибкой во время обработки, то не придется ничего откатывать, так как изменений еще не было, а в случае с Бонусом откат большой транзакции может затянуться.
Триггерное исполнение блоков
Для кампаний, настроенных на реакцию на действия клиента (авторизация в МП, покупка, заполнение анкеты и другое), предусмотрен отдельный режим исполнения блоков: Триггерный.
Такое исполнение требует повышенного быстродействия, оптимизации кода под обработку малого количества записей. Мы пишем стройный чистый код, но с опытом эксплуатации пришлось поступиться некоторыми принципами: как пример, одни и те же запросы дублируются, один оптимизирован для работы с большими пачками клиентов (обычное исполнение), другой оптимизирован под малое количество (триггерное исполнение). Опыт коммерческой разработки часто говорит, что не всегда нужен мультитул там, где в требованиях было захардкодить условие. Технический долг.
Как клиенты попадают в триггерную кампанию? Правильно, через блок Триггер. Каждый блок Триггер настраивается на обработку события, в настройках указываются дополнительные фильтры, например, ловить чеки только из города Челябинск, до 10 утра, в которых лыжи.
Когда приходит чек, запись о нем создается в таблице Триггерных объектов со статусом 0 (новая). Над этой таблицей крутится стая джобов, названных Watchdog’ами. Они и формируют данные для триггеров. Пришел чек по клиенту Михаилу, найдем триггеры на чеки и оповестим — обработайте чек.
Для вачдогов главное требование следующее: события по одному и тому же клиенту обрабатываются одним и тем же вачдогом. Это достигается следующим образом: в качестве параметра вачдог принимает свой номер и общее количество запущенных вачдогов. Далее, на основании функции ora_hash, он всегда получает одних и тех же клиентов.
procedure trigger_object_watchdog
(
p_chunk number, --Какую часть брать к обработке
p_max_chunk number --Сколько частей
)is
cursor cur_object is
select
o.rowid rwd,
o.*
from
cx_trigger_object o
where
o.state = 0 and
--Тут от 0 до max - 1
ora_hash(o.client_reg_code, p_max_chunk - 1) = p_chunk - 1
order by
o.code
begin
-- исполнение
End;
Ну а далее, если чек удовлетворяет условиям триггера, клиент попадает в кампанию, где, возможно, сразу получит бонусы или коммуникацию, а возможно, зависнет в таймере до будущей акции.
Опыт эксплуатации
Начну с того, что какой бы опыт ни был, пользователи всегда могут удивить и заставить Campaign попотеть.
Вот случай, когда пользователь решил проверить, сколько клиентов будет отобрано различными фильтрами из витрины в разрезе Спорта. Создал 30 блоков выборки клиентов, которые сгенерировали полмиллиарда строк. На тот момент это было 10% от всего объема данных Campaign.
Так это выглядело:
В такой день повышенной нагрузки OEM (Oracle Enterprise Manager) показывал высокие ожидания «library cache: mutex X». Причина оказалась в обновлении статусов клиентов (поле state), так как на этом поле висел foreign key на справочник статусов. А на таблице прохождения клиентов по блокам (cx_exec_block_client) ключей было 7 штук. Решили отключить внешние ключи на полях, подверженных обновлению.
Так как эту таблицу обновляют только внутренние процессы и пользовательский ввод исключен, считаем, что риск ошибки вставки некорректных данных минимален. Все ключи не стали отключать, но наличие foreign key при больших операциях вставки, как в кампании выше, показывает большое количество чтений: как раз проверка внешнего ключа.
Другой момент, о котором пришлось заботиться, когда вступили в «клуб миллиардеров» строк, это растущее потребление дискового пространства. С одной стороны, удалять данные нельзя, с другой — SSD недешевы, а данные по завершенным кампаниям лежат и пылятся.
Тут пригодился Oracle Advanced Row/Index Compression для сжатия данных.
Простой DDL:
alter table cx_exec_block_client
move partition sys_p222870 row store compress advanced
И секция, ранее занимающая 2GB, сжимается в 3 раза. А индексы:
alter index cx_exec_block_client_i1
rebuild partition sys_p222870 compress advanced high
— до 20 раз.
Чтение документации Oracle по сжатию (минимальный оверхед при DML, существенное улучшение чтения, экономия хранения данных) сподвигло нас на то, чтобы включить сжатие по умолчанию на таблицу и индексы. Но документация скрывала важный момент: сжатие хорошо на статичных данных, не подверженных изменению. При обновлении строк Oracle их распаковывает, а так как текущий блок уже сжат, то распаковывает в новый, соответственно, данные занимают уже больше места, чем до сжатия.
В работающей кампании клиент переходит из блока в блок (в одной секции таблицы) и постоянно обновляет статус. Таким образом, секция распухала десятикратно по отношению к состоянию до сжатия. Сжатие по умолчанию проработало не более пары дней на тестовом окружении и было отключено. Создали регулярный джоб, который сжимает отработавшие кампании, и тут компрессия себя показывает великолепно.
Послесловие
В статье сделан упор на применение возможностей СУБД Oracle в реализации Campaign Management в составе Системы Лояльности Спортмастера. Campaign всё ещё активно развивается: появляются новые блоки: блок соединения витрин, блок А/Б-тестирования и другие.
Сейчас мы занимаемся проработкой перехода на веб-интерфейс редактирования кампаний.
Если у вас есть какие-то вопросы, буду рад на них ответить.