Всем привет! Меня зовут Иван Привалов, я разработчик в команде BI Авито Финтеха и в этой статье расскажу, как мы сделали FIFO-сопоставление между N начислений и M списаний для бонусов. Заодно покажу подвох, без которого SQL быстро превращался в тыкву.

Статья будет полезна аналитикам и data-инженерам уровней мидл+, которые работают с финансовыми данными в Trino, Presto и Spark SQL.

В этой статье

Что такое бонусы и зачем им FIFO

Бонусы в Авито — виртуальные средства, которыми пользователи оплачивают услуги площадки: размещение и выделение объявлений, продвижение, доставку. Бонусы выпускаются по разным поводам: за активный тариф, в рамках маркетинговой акции, как компенсация за неудобства, по программам CPA. У каждого начисления есть срок жизни, и если за это время пользователь не успел воспользоваться бонусами, остаток сгорает.

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

— лента начислений, по которой видно кому, когда и сколько начислили, а так же до какой даты эти бонусы живут;

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

В обычном бухгалтерском смысле начисления и траты — это два числа. Но этих данных недостаточно, поскольку нам нужно знать, что случилось с каждым бонусом, чтобы отразить это в налоговой и МСФО-отчётности, показать на аудите и корректно выгрузить в управленческие системы в конце концов. Бонус, выпущенный из тарифа, и из маркетинговой акции ложатся в строки P&L по-разному: первый закрывает выручку, второй идёт на расходы маркетинга. Чтобы их различить, нужно построить поимённое сопоставление между лентами начислений и списаний.

Это классический FIFO: первыми списываются самые ранние начисления, что создаёт большие проблемы для SQL, если надо обработать миллионы строк.

Тут еще больше контента

Почему на один платёж приходятся N строк начисления и M трат

Сырые события в DWH прилетают полуструктурированно, в формате JSON. И уже на этапе разбора одно начисление разворачивается в N строк с весами по подкатегориям объявлений и типам выручки:

accrual_id    microcat              revenue_type     amount
ACC-1         детская одежда        tariff           600
ACC-1         бытовая техника       vas              400

Это намеренная декомпозиция, чтобы бухгалтерии понимала, в каких долях и за что именно начислены бонусы. Сумма строк равна полной сумме начисления, но каждая доля несёт свою бухгалтерскую этикетку из микрокатегории и типа выручки, которую нельзя терять.

Следствие №1: уникальный id в исходниках присваивается паре из id начисления и микрокатегории. Это бакет для FIFO.

Списания, наоборот, атомарны. Одна трата равна одной строке с собственным набором атрибутов: дата, услуга, вертикаль, микрокатегория и тип выручки. Никакого внутреннего разворота у списаний нет.

Следствие №2: на стороне списаний M возникает по другой причине. За время жизни одного начисления пользователь тратит бонусы по частям: на разные услуги, в разные дни, по разным подкатегориям объявлений. Поэтому на одном платеже встречается M отдельных трат, иногда десятки. К ним добавляется событие сгорания остатка, если бонус не успел израсходоваться.

Одно начисление в источнике разворачивается в N бакетов с весами, а за время его жизни на платеже встречается M отдельных трат. Граф FIFO между ними асимметричный
Одно начисление в источнике разворачивается в N бакетов с весами, а за время его жизни на платеже встречается M отдельных трат. Граф FIFO между ними асимметричный

Сложность задачи в том, что слои асимметричны. Сторона начисления разнесена по подкатегориям для бухгалтерии, а списания по времени и услугам для поведенческой детализации. FIFO нужно делать по графу N×M аллокаций на каждом платеже.

Постановка задачи

На входе для каждого платежа мы имеем:

— N бакетов начислений с собственной суммой и атрибутами.

— M событий списаний и сгораний с ключом из даты, услуги, вертикали, микрокатегории и типа выручки. У каждого также своя сумма и атрибуты.

На выходе для каждой пары из бакета начисления и события списания рассчитывается точная сумма аллокации. Получаем строки вида: трата 150 ₽ на платное размещение потратила 80 ₽ из начисления ACC-1 (детская одежда, тариф) и 70 ₽ из начисления ACC-2 (бытовая техника, продвижение).

Правило аллокации, FIFO: списания съедают бакеты в порядке поступления, целыми и без округлений.

Как мы переформулировали FIFO

В открытых источниках тему FIFO в SQL обычно разбирают на простой модели «1 начисление ↔ 2–3 списания». Реальные финансовые данные устроены сложнее, и наивные подходы здесь не подойдут.

📌 Trino не поддерживает процедурный SQL, рекурсия в его CTE ограничена и плохо параллелится. На N×M-графе на каждом из миллионов платежей задача не помещается в память.

📌 Простой JOIN по сумме или дате не моделирует «поедание» бонусов в порядке поступления, а даёт двойной счёт: одно списание матчится сразу с несколькими начислениями без правильного распределения долей. Получается, списание учтётся полностью столько раз, сколько существует строк для начислений.

📌 Текущая логика агрегации схлопывает строки до уровня id начисления, из-за чего теряется бухгалтерская детализация, ради которой источник развернул одно начисление в N строк. После такой агрегации восстановить детализацию невозможно.

📌 GROUP BY с MAX/SUM не учитывает гранулярность витрины и молча задваивает или утраивает суммы. Это вообще главная ошибка при потреблении подобных витрин, в разделе про гранулярность я к ней вернусь.

Мы решили переформулировать FIFO через геометрию и сделали это в три шага.

Шаг 1. На стороне начислений взяли бакеты с ключом (id начисления, микрокатегория). На стороне списаний берём атомарные траты плюс события сгораний.

Шаг 2. В рамках одного платежа вычисляем накопительную сумму по каждой ленте:

SUM(amount) OVER (
  PARTITION BY payin_ext
  ORDER BY ...
  ROWS UNBOUNDED PRECEDING
)

После этого каждый бакет начисления превращается в отрезок [acc_cum_start, acc_cum_end] на оси «накопленная сумма платежа». Каждая трата превращается в отрезок [w_cum_start, w_cum_end] на той же оси.

Шаг 3. FIFO эквивалентен пересечению отрезков. Если бакет начисления и трата накладываются на одну и ту же часть оси сумм, значит, эта трата частично или целиком съела этот бакет. Сумма аллокации равна длине пересечения.

FIFO как пересечение интервалов: бакеты начисления сверху, события трат снизу, на одной оси накопленной суммы платежа. Длина пересечения равна сумме аллокации

Этот трюк широко известен в задачах interval scheduling и timeline merging, но в финансовых задачах FIFO он почему-то редко используется явно. А именно здесь от него больше всего пользы: один и тот же SQL обслуживает вырожденные платежи из 1 бакета и 1 траты, а также сложные из десятков бакетов и десятков трат, без if/else.

Полный SQL-запрос в упрощённом виде, без технических полей витрины:

-- Шаг 0: подготовка стороны начислений, собираем бакеты
WITH accrual_buckets AS (
  SELECT
    payin_ext, accrual_id, accrual_microcat,
    SUM(accrual_amount) AS bucket_amount
  FROM raw_accruals
  GROUP BY payin_ext, accrual_id, accrual_microcat
),
 
-- Накопительная сумма на стороне начислений
accruals_cum AS (
  SELECT
    a.*,
    SUM(bucket_amount) OVER (
      PARTITION BY payin_ext
      ORDER BY accrual_id, accrual_microcat
      ROWS UNBOUNDED PRECEDING
    ) AS acc_cum_end,
    SUM(bucket_amount) OVER (
      PARTITION BY payin_ext
      ORDER BY accrual_id, accrual_microcat
      ROWS UNBOUNDED PRECEDING
    ) - bucket_amount AS acc_cum_start
  FROM accrual_buckets a
),
 
-- Шаг 0b: подготовка стороны списаний через UNION ALL трат и сгораний
consumption AS (
  SELECT payin_ext, writeoff_date, writeoff_purchase, ...,
         writeoff_amount, FALSE AS is_burn
  FROM writeoffs
  UNION ALL
  SELECT payin_ext, burn_date AS writeoff_date,
         'burn' AS writeoff_purchase, ...,
         burn_amount AS writeoff_amount, TRUE AS is_burn
  FROM burns
),
 
-- Накопительная сумма на стороне списаний
consumption_cum AS (
  SELECT
    c.*,
    SUM(writeoff_amount) OVER (
      PARTITION BY payin_ext
      ORDER BY writeoff_date, writeoff_purchase, ...
      ROWS UNBOUNDED PRECEDING
    ) AS w_cum_end,
    SUM(writeoff_amount) OVER (
      PARTITION BY payin_ext
      ORDER BY writeoff_date, writeoff_purchase, ...
      ROWS UNBOUNDED PRECEDING
    ) - writeoff_amount AS w_cum_start
  FROM consumption c
)
 
-- Сердце алгоритма: interval overlap join + длина пересечения
SELECT
  a.payin_ext,
  a.accrual_id, a.accrual_microcat,
  c.writeoff_date, c.writeoff_purchase, ...,
  GREATEST(0,
    LEAST(a.acc_cum_end, c.w_cum_end)
    - GREATEST(a.acc_cum_start, c.w_cum_start)
  ) AS allocated_amount
FROM accruals_cum a
JOIN consumption_cum c
  ON c.payin_ext = a.payin_ext
 AND c.w_cum_start < a.acc_cum_end
 AND c.w_cum_end   > a.acc_cum_start;

Несмотря на формальный вид «N×M», декартов взрыв не случается: условие w_cum_start < acc_cum_end AND w_cum_end > acc_cum_start чрезвычайно селективно на отсортированных интервалах. Каждая трата пересекается обычно с одним или двумя бакетами начисления, и движок это учитывает.

Мы обошлись без процедурного кода и курсора, поскольку сложность алгоритма линейная по числу пар (на практике O(N log N) с учётом сортировок window-функций).

Где ещё можно использовать подход

Cumulative sum + interval overlap join — это общая идея, не привязанная к бонусам. Например, я уже видел несколько подобных задач:

— Распределение оплат по нескольким счетам: те же интервалы, тот же overlap join.

— LIFO-партии: те же интервалы, обратный порядок ORDER BY в накопительной сумме.

— Налоговые партии (FIFO inventory) в учёте товаров: буквально из учебников бухучёта, но впервые сформулированные как чистый SQL и сразу с поддержкой внутренней разбивки партии.

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

Любая задача формата «M входящих событий закрыли N исходящих с разделением на доли», в которой сама сущность тоже разбита на подкатегории, это кандидат на такой паттерн.

Жми сюда!

Где ломается наивный SUM в гранулярной витрине

Витрина-результат имеет составной ключ:

payin_ext × accrual_microcat × writeoff_microcat × flow_type

Расшифровка: id платежа, микрокатегория со стороны начисления, микрокатегория со стороны траты, тип потока (writeoff, burn, accrual). Каждая строка — одна пара из бакета начисления и траты с аллокацией. Всё аккуратно и разнесено по бухгалтерским разрезам.

И здесь начинается подвох. Каждый бакет начисления повторяется в нескольких строках одного платежа по числу трат, которые его съели. Поле accrual_amount (сумма бакета) в этих строках одинаковое. Любой потребитель, пишущий SUM(accrual_amount) GROUP BY payin_ext, получит сумму, кратную числу строк. Цифры в отчёте растут в разы, при этом никакой ошибки в SQL формально нет, отчего баг очень тяжело заметить.

Поэтому витрина защищается от наивного потребителя: в каждой строке есть предсчитанные tot_accrual_by_payin, tot_writeoff_by_payin, tot_burn_by_payin. Это настоящие суммы платежа, одинаковые во всех строках. Правильный паттерн потребителя — это any_value:

SELECT
  payin_ext,
  any_value(tot_accrual_by_payin)  AS accrued,
  any_value(tot_writeoff_by_payin) AS spent,
  any_value(tot_burn_by_payin)     AS burned
FROM bonus_flows_fifo_subcat
WHERE constant = 1
GROUP BY payin_ext;

Если потребителю нужна разбивка по выручке или подкатегориям, он использует amount_flow. Это сумма конкретной аллокации, которая привязана к паре бакет ↔ траты и не удваивается.

Один и тот же бакет повторяется в N строках витрины. Наивный SUM даёт ×N от настоящей суммы. Правильно использовать предсчитанные tot_*_by_payin через any_value
Один и тот же бакет повторяется в N строках витрины. Наивный SUM даёт ×N от настоящей суммы. Правильно использовать предсчитанные tot_*_by_payin через any_value

Главное правило проектирования FIFO-витрин: правильные суммы должно быть невозможно посчитать неправильно. Предсчитанные tot_*_by_payin + any_value проще и безопаснее, чем DISTINCT-фильтрация или сложные оконные функции на стороне потребителя.

Как защищаем витрину от балансового расхождения из-за лагов

Бонус закрыт, когда он целиком исчерпан: accrued = spent + burned. Казалось бы, это можно проверять по дате expire: срок истёк, бонус закрыт, но практика показала, что так делать нельзя.

У любой витрины есть лаг, когда срок жизни закончился, но событие сгорания ещё не доехало в DWH. Такой бонус по дате формально закрыт, но баланс не сошёлся и если строить отчёт по expire_date < today, мы будем считать живые бонусы закрытыми и получим расхождения.

Мы решили эту задачу с помощью балансового фильтра одной строкой:

WHERE abs(accrued - spent - burned) < 0.01

Этот фильтр одновременно:

— отсекает живые бонусы (для них spent + burned < accrued);

— защищает от лагов: если для запоздавших burn-событий баланс не сошёлся, они тоже отсеются.

В наших данных это даёт около 98% сходимости на бонусах, закрытых по сроку, и 100% на досрочно потраченных. Потерянные 2% это те самые, у которых сгорание ещё не доехало. Они автоматически вернутся в выборку, как только догрузится источник.

Балансовое уравнение работает универсальным фильтром закрытости: одной строкой отсеивает живые бонусы и защищает витрину от лагов
Балансовое уравнение работает универсальным фильтром закрытости: одной строкой отсеивает живые бонусы и защищает витрину от лагов

Граничные случаи и шрамы

📌 Смена источника на середине истории. В день, когда мы переезжали со старой модели на новую, исходные таблицы расщепились, и появился риск потерять или задвоить данные. Проблему решили оператором UNION ALL с фильтрами по дате: cut date разделяет периоды, после неё используется только новая модель.

📌 Поле «мигрированный флаг». Часть записей перенеслась из старой модели в новую. Их отсекаем фильтром, чтобы не задвоить с pre-cut периодом.

📌 Семантика поля hold_operation. До cut date это числовые id, а после — текстовые лейблы. Переписывать витрину ради унификации не стали, поэтому появилась публичная особенность модели — потребители разрешают её через справочник.

Что мы из этого вынесли

👉 У нас была задача учитывать бонусы строго в соответствии с историей начислений — раньше пришло → раньше ушло. Проблема в том, что на N начислений приходится M строк списаний, поэтому наивные способы подсчёта не сработали.

👉 Мы разобрались с задачей при помощи бакетирования. За единицу начисления взяли бакет из id начисления и микрокатегории, а траты оставили атомарными по одной строке.

👉 По дороге встретились с проблемой неверных подсчётов. Наивный SUM многократно умножал траты, поскольку бакет начислений списывался с каждой строки трат.

👉 Вторая проблема — алгоритм неверно подсчитывал пограничные бонусы, срок которых формально истёк, но данные до витрины ещё не добрались. Задачу решили с помощью балансового уравнения: WHERE abs(accrued - spent - burned) < 0.01. Также решили и другие проблемы: отсеяли живые бонусы и защитились от лагов.

👉 Итоговый паттерн — cumulative sum + interval overlap join — позволяет решать FIFO на миллионах строк без курсоров и процедурного кода.

Теперь мы правильно подсчитываем каждый бонус.

Аналитики Авито пишут много статей, а ещё ведут недушный Телеграм-канал «Коммуналка аналитиков» Подписывайтесь!

Кликни здесь и узнаешь