The post has been translated automatically. Original language: Russian
I came across an excellent Stripe engineering post from Reed Trevelyan of the Revenue Intelligence team, who explains how they converted Stripe Billing from a patch to an honest real-time one. This is a rare case when "how we built it" is written without water and with specific numbers, so I'll try to sort it out. I'll leave a link to the source below.
The starting point is a survey where 84% of business leaders believe that the ability to quickly change prices will be a key competitive advantage in the next year or two. And in order to change prices quickly, you need to see how customers behave just as quickly, and then the old pain came out, namely, the old analytics were spinning on the batch with an average lag of 24 hours. A day's delay in a world where you have to catch a trend on the fly is a verdict.
Stripe has divided the task into three blocks
- rebuild the data architecture for real-time subscription updates
- redo the aggregation so that it reaches the dashboard at the same pace
- enable clients to change metric definitions without breaking real-time and history. Further details on each of the blocks.
Why is it so hard to count a subscription in real time?
Here the author has an important idea that is often underestimated. A subscription is an easy way to pay for a service, but it's a nasty thing for a data structure, because the current state of a subscription depends on the past no less than on the present. By itself, the fact that "the client paid $20 in June" means almost nothing to the business, it is also important that he pays on time every month since January.
The most straightforward approach, which was used by the old Stripe system, was to recalculate the current subscription status, each time re-running its entire history from the beginning of time. And this automatically means a scheduled batch, and it was impossible to race it often enough for something close to real-time due to architectural limits. This is the root of the problem — not "slow servers", but the way to count.
The heart of the solution is an event-driven pipeline on Flink, initialized via Spark
To avoid recalculating everything, Stripe has built a pipeline that turns updates of subscription and invoice objects into analytical events. Apache Flink does this, and the key idea is that Flink stores a highly compressed version of the subscription history as a "state" and incrementally adds this state as new events arrive. That is, the same June payment for $20 is no longer overestimated along with all payments since January, but is simply added to the current ledger inside the Flink state. The difference is like between "recalculate the entire statement from scratch" and "add one line to the journal."
But there was an unpleasant nuance, and the author honestly says it, namely, generating the initial Flink state for long-time clients is difficult in itself, because it would have to replay billions of historical events in order. We decided this with a separate tool that uses the same streaming transformation logic as data-job in Apache Spark, and Spark is able to process huge amounts of historical events in parallel and return the result in verifiable flat files. This job effectively generates the starting state for Flink and at the same time feeds the backup offline pipeline for validation and export. The bundle turned out to be honest - Flink for continuous flow, Spark for one-time heavy initialization and backup.
The result for this block is that the delay on subscription updates has dropped from daily to just 15 minutes.
Real—time aggregation - saved by the appearance of the new Pinot v2 engine
Then the second wall. The Stripe dashboard should respond to queries flexibly and instantly — the user filters, groups and falls into the data without waiting, and it should feel as if a window into your data has just been opened to you. But under the hood, aggregating subscriptions for such queries is a serious computational task, because to show how the MRR changes over time, you need to analyze the historical status of each subscription at each moment of the selected period. At the start of billing analytics, Stripe used Apache Pinot as an OLAP database, and the best solution available at that time was to pre-aggregate data offline in a scheduled batch job.
And for real-time, this pre—aggregation had to be removed - and at the same time, at the time of the request, be able to analyze the historical and current status of all subscriptions in order to catch those that had just added data to their ledger, but without losing the very over-responsiveness for which Pinot was chosen. The vicious circle broke when the maintainers of the open source Pinot released a new v2 engine that can "windowed" aggregating queries, namely, cutting data into several "windows" or date ranges and performing operations on them such as summation and averaging, which allows you to calculate MRR in time without offline pre-aggregation. At the same time, the new engine provided more complex joins, which also opened up data gaps on the dashboard, currency conversion, and custom query measurements.
By the way, there is a beautiful detail about working with open source - Stripe worked closely with Pinot maintainers to test and bring the engine to production, because in a user context, no one had deployed it on the scale of Stripe before. The output figures speak for themselves - most updates are processed in noticeably less than a minute, almost all reach the user within 15 minutes, and the delay in the requests themselves is kept below 300 milliseconds, so the dashboard remains just as nimble.
Custom definitions of metrics without breaking history are the most subtle point
I would call the third piece the most underrated, because it is on such things that beautiful real-time systems usually crumble. The definition of a seemingly simple MRR metric varies from business to business, so Stripe allows customers to tweak the formulas for calculating MRR and other metrics. And the switch to streaming has created a conflict here, namely how to maintain this flexibility, but not disperse in the data against the background of constant real-time updates.
The example from the text is indicative - if a client decides to exclude one-time coupons from the MRR, this change must be consistently dragged through all historical and incoming data, and for a client who has been with Stripe since 2017, these are hours of recalculation of long-term history, and without stopping the processing of new events. The solution is a neat workflow that balances the recalculation of history and the flow in real time, and the logic there is as follows: when the definition is changed, a batch recalculation of history is started for a new formula; in parallel, new events continue to flow and be counted according to the old definition; at the same time, these incoming events are temporarily buffered in the memory of the Flink application; when the recalculation of history is completed, the state of Flink patches with recalculated historical data, and Flink replays the accumulated events on top of the updated history; and only then does the dashboard switch to fully updated data, and processing stops according to the old definition.
The point of all this dancing is one thing, namely, the dashboard remains alive and useful all the time, not gray and not showing contradictory figures, so that the client always sees a consistent picture from the beginning of his story to the current moment, even when he is changing the definition right now and at the same time receiving real-time updates.
What's next for Stripe themselves
There are two plans, according to the text, to further reduce data latency without losing reliability and accuracy, and to attach more data and measurements to the dashboard, including usage-based metrics and filters by geography and customer cohorts.
My conclusion
What I like about this analysis as a practice is that it's about the correct diagnosis, not about the fashionable stack. The root of the problem was not the "slow infra", but the way to calculate the state through a complete recalculation of history, and until this was changed to an incremental event-driven approach, no servers would have saved. Hence, there are three lessons that fall on any similar system - to keep the state incrementally instead of recalculating from scratch (Flink), and to put heavy one-time initialization into a parallel batch (Spark); not to fight with open source alone, but to move the necessary feature together with the maintainers (the story of Pinot v2); and to lay transitional states (changing the definition of metrics) as part of the architecture, not as an annoying edge. By the way, please note that both pains — streaming the state and switching to a new definition — are solved through the same idea of a buffer and a state patch, and not through two different crutches.
Source: Reed Trevelyan, "How we built it: Real-time analytics for Stripe Billing",
https://stripe.dev/blog/how-we-built-it-real-time-analytics-for-stripe-billing
Наткнулся на отличный инженерный пост Stripe от Рида Тревельяна из команды Revenue Intelligence он рассказывает, как они переделали аналитику(Stripe Billing) с батча на честный real-time. Это тот редкий случай, когда «как мы это построили» написано без воды и с конкретными цифрами, поэтому попробую разбрать всепо полочкам. Ссылку на источник оставлю ниже.
Стартовая точка это опрос где 84% бизнес-лидеров считают, что умение быстро менять цены станет ключевым конкурентным преимуществом в горизонте года-двух. А чтобы менять цены быстро, надо так же быстро видеть, как ведут себя клиенты, и тут вылезала старая боль а именно прежняя аналитика крутилась на батче со средним отставанием в 24 часа. Сутки задержки в мире, где тренд надо ловить на лету, это приговор.
Stripe разбил задачу на три блока
- перестроить архитектуру данных под real-time апдейты подписок
- переделать агрегацию, чтобы это доезжало до дашборда в том же темпе
- дать клиентам менять определения метрик без поломки real-time и истории. Дальше подробнее по каждому из блоков.
Почему подписку так тяжело считать в реальном времени
Тут у автора важная мысль, которую часто недооценивают. Подписка — простой способ платить за сервис, но мерзкая штука для структуры данных, потому что актуальное состояние подписки зависит от прошлого не меньше, чем от настоящего. Сам по себе факт «клиент заплатил $20 в июне» бизнесу почти ничего не говорит, важно ещё и то, что он платит вовремя каждый месяц с января.
Самый прямолинейный подход, на котором и сидела старая система Stripe, это пересчитывать текущее состояние подписки, каждый раз заново прогоняя всю её историю с начала времён. А это автоматически означает батч по расписанию, и гонять его достаточно часто для чего-то близкого к real-time было невозможно из-за архитектурных лимитов. Вот это и есть корень проблемы — не «медленные серверы», а сам способ считать.
Сердце решения — event-driven пайплайн на Flink, инициализированный через Spark
Чтобы уйти от пересчёта всего и вся, Stripe построил пайплайн, который превращает апдейты объектов подписок и инвойсов в аналитические события. Делает это Apache Flink, и ключевая идея в том, что Flink хранит сильно сжатую версию истории подписки как «состояние»(state) и инкрементально дописывает это состояние по мере прихода новых событий. То есть тот самый июньский платёж на $20 больше не переоценивается вместе со всеми платежами с января, а просто добавляется в текущий «леджер» внутри Flink-состояния. Разница как между «пересчитать всю выписку с нуля» и «дописать одну строчку в журнал».
Но был неприятный нюанс, и автор его честно проговаривает а именно сгенерировать начальное Flink-состояние для давних клиентов само по себе тяжело, потому что пришлось бы по порядку переиграть миллиарды исторических событий. Решили это отдельным инструментом, который гоняет ту же стриминговую логику трансформации как data-job в Apache Spark, а Spark умеет обрабатывать огромные объёмы исторических событий параллельно и отдавать результат проверяемыми плоскими файлами. Этот джоб эффективно генерит стартовое состояние для Flink и заодно кормит резервный оффлайн-пайплайн для валидации и экспорта. Связка получилась честная - Flink для непрерывного потока, Spark для разовой тяжёлой инициализации и бэкапа.
Итог по этому блоку - задержка на апдейтах подписок упала с суточной до всего лишь 15 минут.
Агрегация в реальном времени — спасло появление нового движка Pinot v2
Дальше вторая стена. Дашборд Stripe должен отвечать на запросы гибко и мгновенно — пользователь фильтрует, группирует и проваливается в данные без ожидания, и ощущение должно быть как будто тебе просто открыли окно в твои данные. Но под капотом агрегация подписок под такие запросы это серьёзная вычислительная задача, потому что чтобы показать, как меняется MRR во времени, надо проанализировать историческое состояние каждой подписки в каждый момент выбранного периода. На старте billing-аналитики Stripe использовал Apache Pinot как OLAP-базу, и лучшим из доступного тогда решением было предагрегировать данные оффлайн в плановом батч-джобе.
А для real-time эту предагрегацию надо было убрать — и при этом в момент запроса уметь анализировать историческое и текущее состояние всех подписок, чтобы ловить те, что только что дописали данные в свой леджер, но не потеряв ту самую сверхотзывчивость, ради которой Pinot и выбирали. Замкнутый круг разорвался, когда мейнтейнеры опенсорсного Pinot выпустили новый движок v2, умеющий «оконные»(windowed) агрегирующие запросы а именно режущие данные на несколько «окон» или диапазонов дат и выполняющие по ним операции вроде суммирования и усреднения, что и позволяет считать MRR во времени без оффлайн-предагрегации. Заодно новый движок дал более сложные джойны, а это открыло на дашборде ещё и заполнение пробелов в данных, конвертацию валют и кастомные измерения запросов.
К слову, тут красивая деталь про работу с опенсорсом - Stripe плотно работал с мейнтейнерами Pinot, чтобы протестировать и довести движок до прода, потому что в пользовательском контексте на масштабах Stripe его до этого никто не разворачивал. Цифры на выходе говорят сами за себя - большинство апдейтов обрабатывается заметно меньше чем за минуту, почти все доезжают до пользователя в пределах 15 минут, а задержка самих запросов в проде держится ниже 300 миллисекунд, так что дашборд остаётся таким же шустрым.
Кастомные определения метрик без поломки истории — самый тонкий момент
Третий кусок я бы назвал самым недооценённым, потому что именно на таких вещах обычно и рассыпаются красивые real-time системы. Определение вроде бы простой метрики MRR у разных бизнесов разное, поэтому Stripe позволяет клиентам подкручивать формулы расчёта MRR и других метрик. И переход на стриминг породил тут конфликт а именно как сохранить эту гибкость, но не разъехаться в данных на фоне постоянных real-time апдейтов.
Пример из текста показателен - если клиент решает исключить из MRR разовые купоны, это изменение надо консистентно протащить через все исторические и входящие данные, а для клиента, который со Stripe с 2017 года, это часы пересчёта многолетней истории, причём не останавливая обработку новых событий. Решение — аккуратный воркфлоу, балансирующий пересчёт истории и поток в реальном времени, и логика там такая: при смене определения запускается батч-пересчёт истории под новую формулу; параллельно новые события продолжают литься и считаться по старому определению; одновременно эти входящие события временно буферизуются в памяти Flink-приложения; когда пересчёт истории закончен, состояние Flink патчится пересчитанными историческими данными, и Flink переигрывает накопленные события поверх обновлённой истории; и только потом дашборд переключается на полностью обновлённые данные, а обработка по старому определению останавливается.
Смысл всей этой пляски в одном а именно дашборд всё это время остаётся живым и полезным, не серым и не показывающим противоречивые цифры, так что клиент всегда видит консистентную картину от начала своей истории до текущего момента, даже когда прямо сейчас меняет определение и одновременно получает real-time апдейты.
Что дальше у самих Stripe
В планах, по тексту, две вещи - дальше снижать задержку данных, не теряя надёжности и точности, и навешивать на дашборд больше данных и измерений, включая usage-based метрики и фильтры по географии и когортам клиентов.
Мой вывод
Что мне в этом разборе нравится как практику — он про правильный диагноз, а не про модный стек. Корнем проблемы была не «медленная инфра», а сам способ считать состояние через полный пересчёт истории, и пока это не поменяли на инкрементальный event-driven подход, никакие серверы бы не спасли. Отсюда же три урока, которые ложатся на любую похожую систему - держать состояние инкрементально вместо пересчёта с нуля (Flink), а тяжёлую разовую инициализацию выносить в параллельный батч (Spark); не воевать с опенсорсом в одиночку, а двигать нужную фичу вместе с мейнтейнерами (история с Pinot v2); и закладывать переходные состояния (смена определения метрики) как часть архитектуры, а не как досадный край. Кстати, обратите внимание, что обе боли — стриминг состояния и переход на новое определение — решены через одну и ту же идею буфера и патча состояния, а не через два разных костыля.
Источник: Reed Trevelyan, «How we built it: Real-time analytics for Stripe Billing»,
https://stripe.dev/blog/how-we-built-it-real-time-analytics-for-stripe-billing