Всем привет! Меня зовут Иван Привалов, я разработчик в команде 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 на миллионах строк без курсоров и процедурного кода.

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

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

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

Комментарии (6)


  1. Akina
    15.06.2026 09:15

    Ох уж эта любовь к внутреннему слэнгу. Нет бы описывать принцип словами, понятными любому. Всегда думал, что есть два направления движения - так нет, оказывается, есть начисления, списания и платежи. Ну и кто из них NULL? Пока через всё это продерёшься - три раза вспотеешь.

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


    1. IvanPrivalov Автор
      15.06.2026 09:15

      Спасибо за комментарий!

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

      Если совсем упростить, у нас есть три события: бонусы начислили, бонусы потратили, либо бонусы сгорели по сроку жизни. Поэтому это не совсем два направления движения. Сгорание для нас тоже отдельное событие, потому что его нужно корректно отразить в учёте.

      По второй части вы в целом правы: бонусы универсальны по расходованию. Пользователь не обязан тратить бонус, начисленный за одно действие, строго на ту же категорию действий. Но для финансового учёта нам всё равно нужно понимать, из какого начисления и в какой доле была закрыта каждая трата. Поэтому и появляется FIFO-сопоставление между начислениями, списаниями и сгораниями.


      1. Akina
        15.06.2026 09:15

        Если совсем упростить, у нас есть три события: бонусы начислили, бонусы потратили, либо бонусы сгорели по сроку жизни. Поэтому это не совсем два направления движения. Сгорание для нас тоже отдельное событие, потому что его нужно корректно отразить в учёте.

        Вот этого я не понимаю - от слова совсем. У вас есть событие, в результате которого количество бонусов увеличивается - это начисление, приход. И у вас есть событие, в результате которого количество бонусов уменьшается - это списание, расход. Сгорание - это всего лишь разновидность расхода, когда назначение расхода - не существует, NULL, сам расход выполняется не по событию от клиента, а по событию от таймера, а сумма расхода определяется не фиксированной суммой или процентом от внешней операции, а текущим остатком бонуса. И я в упор не вижу "не совсем два направления". Даже хочется предположить, что этим созданием третьего направления вы взяли да усложнили себе жизнь. Хотя расчёт остатка - это тоже усложнение, конечно, и сравнивать сложности я не берусь. Тут всё зависит от организации этого таймера-сжигателя.

        Поэтому и появляется FIFO-сопоставление между начислениями, списаниями и сгораниями.

        Скорее, появляется сама возможность использования именно единого FIFO, а не нескольких, да ещё и пересекающихся.


  1. AngryEvilCookie
    15.06.2026 09:15

    "Сырые события в DWH прилетают полуструктурированно, в формате JSON"

    Миллионы строк, хай лоад и т.д. и т.п.. Почему события в json прилетают, еще и со строками вместо идентификаторов, учитывая что эти данные внутри одной системы?


    1. IvanPrivalov Автор
      15.06.2026 09:15

      Спасибо за комментарий!

      Это исторически сложившаяся событийная модель: источник отдаёт события в JSON, чтобы схема могла гибко расширяться без частых изменений контрактов и жёсткой табличной структуры. Со временем может потребоваться больше аналитического контекста, и добавить такие данные в JSON обычно быстрее и проще. Да, для DWH это не самый оптимальный формат, поэтому дальше мы нормализуем события и приводим их к аналитической модели.


  1. poruckayastya
    15.06.2026 09:15

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