Submind YouTube summaries
Thumbnail for How Razorpay refreshes its data warehouse 10x faster with graphs and indexes

How Razorpay refreshes its data warehouse 10x faster with graphs and indexes

Watch on YouTube

Video summary

Amit Prabhu, a staff engineer at Razorpay, introduces his team's mission to build a central data layer that ingests information from various sources and ensures it remains easily accessible for both internal operations and external users like merchants. The core challenge addressed in this talk revolves around generating transactional reports efficiently while managing scalability, cost, and data freshness constraints. As the company evolved from processing one million transactions daily with a monolithic MySQL setup to handling over ten million transactions across hundreds of microservices, their reporting architecture faced significant hurdles. Initially moving to TiDB for centralization and later adopting an S3-based data lakehouse with Delta Lake format helped reduce costs but introduced latency issues due to replication delays between the operational database and the storage layer, making real-time report generation difficult without expensive full table scans on massive datasets. To overcome these limitations, Razorpay explored several architectural patterns before settling on a hybrid approach that combines precomputed denormalized tables with incremental updates driven by dependency graphs and secondary indexes. The team realized that streaming solutions were inefficient due to stateful joins and high data mutability, leading them back to batch processing enhanced by intelligent change propagation strategies. A key innovation was the creation of in-memory dependency graphs that mapped relationships between entities like payments, orders, and settlements, allowing the system to traverse only affected rows rather than reprocessing entire datasets whenever a transaction status changed. Additionally, they implemented secondary indexes on S3 partitions to accelerate lookups for join keys without relying solely on TiDB, significantly reducing query times and avoiding costly full scans across terabytes of historical data stored in object storage. Further optimizations included bucketing high-cardinality dimension tables like payment links by merchant ID to enable predicate pushdown during runtime joins, which drastically reduced the amount of data scanned from 2TB down to manageable sizes. By adopting Apache Iceberg for new table formats, Razorpay leveraged its superior metadata pruning capabilities alongside their custom indexing and graph traversal logic to maintain full historical accuracy while keeping compute costs low. The final architecture consists of three distinct pipelines: one generating secondary indexes, another managing replication tables partitioned by merchant ID, and a third handling incremental batch updates to the denormalized facts. This evolution allowed them to cut daily computation time from over 30 hours to less than 3 hours—a ninety percent reduction—while maintaining an SLA of under five minutes for data freshness through intelligent merging of TiDB replicas with their optimized lakehouse facts. The presentation concludes with important lessons learned regarding the trade-offs between streaming and batch processing, the necessity of optimizing object storage for lookup-heavy workloads rather than just aggregation, and the importance of modeling facts based on domain-specific query patterns instead of creating monolithic super-tables. Data quality assurance was maintained by comparing incremental outputs against full refresh baselines using random sampling across dedicated data quality jobs for each pipeline stage. While some enterprise clients required custom SQL reports that bypassed precomputed tables due to unique nesting requirements, the majority benefited from a JSON-based DSL layer built on top of these optimized facts. Ultimately, Razorpay's strategy demonstrates how combining graph theory, secondary indexing, and smart bucketing can transform data warehouse performance without sacrificing accuracy or incurring prohibitive costs as transaction volumes continue to grow globally.
Read the full video transcript
Uh this is Amit Prabhu. Uh I work as a staff engineer in the core data platform team at Razer Pay. Sure. Slightly a mic problem. One second. Sure. So my team at uh Razer Pay uh we work on building a central data layer uh which ingests data from multiple sources and then uh our job is to make sure that the data is easily accessible by both internal and the external users. So today I'm here to talk about one such interesting problem. So this is about serving the transactional reports to our end users. And if you look at the problem, it sounds simple that you are just doing a report generation. But when you add the constraints of uh scalability uh you have data freshness, cost and other things. This becomes more and more challenging. So our we tried multiple solutions and we arrived at a final solution which was 10x faster, 10x cheaper and at the same time we were also able to give better experience to our users. So this is what I want to cover in this talk today and let's get started. So before I uh jump onto the problem statement, I want to give a glimpse about what Razer Pay does and what are what is the uh numbers that we operate in. So Razer Pay is a fullstack uh financial service company. So we uh primarily provide a payment gateway service to our businesses uh which merchants can use to collect money from the customers and they can also do disbburus. So we have a option of payouts. So where they can actually send out cash backs, refunds and other things. So now we have around uh 10 million plus merchants registered with Razer Way across the world and they make around 20 million plus transactions on a single day and they generate around 1 million plus reports uh in a given month and there are about 100 plus microservices. So we have one micros service for each entity that comes in your payment life cycle and then uh they make about 6 billion plus events. So these events include your application events and then uh change data capture events those which come from your database to uh Kafka and together they make around 50 plus GB of data that is ingested every day every single day uh on our data lakehouse. So before uh so now I want to go into the problem of report generation and in order to understand that journey we have to go back to the initial years of Razer Pay. So this is uh uh a couple of years back when Razer Pay was m making around 1 to three lakh transactions per day. So uh we had one big monolithic service that had all the modules built in. It was doing the complete payment processing and it was writing all the data to a single MySQL cluster and reporting was plain simple back then. So we just had to join couple of tables and then create a CSV or a Excel report. So if you look at the slide, so there is this sample settlement report that we generate. It has all the transactions that were done for a merchant and it includes the details about what was the payment method uh how much tax did we charge and uh whether that payment was settled or not. So these kind of details uh that we capture in a transactional report and u merchant use this reports for doing the reconciliation they also run some uh critical workflow on their side. So few example I can give you is we have AMC customers who use reports for stock allocation because they want a confirmation that the money is actually settled to the account and also we have uh quickcommerce customers who use reports for uh dispersing the payouts to their delivery partners. So the reports are actually part of the critical section for some of our merchants and as Razerp started gaining popularity uh Razerp started processing around 1 to 2 million transaction per day. So the scale increased and the obvious choice was to decompose the monolithic service into multiple microservices. So we created one service for each different payment method and different actions that come in the payment life cycle. So from the micros service design every service owns its own database. So it has it has an option that it can choose whether MySQL or Postgress and uh it has the complete autonomy over the database. So earlier if you look at the previous solution we had all the data into a single MySQL cluster. Now it is spread across multiple database clusters and multiple database types as well. So report generation was impossible with multiple clusters. So then we chose a popular architectural pattern that we created data pipelines. So this would look for the change events on the microser tables and then it would replicate the data into a central database. So here we chose tidb as our central database I think which sunny covered in the previous session. uh and we would put all the databases into the central database uh database tables and then reporting service would just do a couple of joins on the TIDB and generate a report. So this was also simple from the reporting point of view and then the scale further increased. Uh now Razer Pay was supporting uh transactions across different countries, different regions and then we started processing around 10 million plus transactions per day. Now the the problem was uh we were keeping all the data on TIDB and this turned out to be a very expensive solution. We were having a self-hosted TIDB and keeping like TBs of data on TIDB was turning out expensive for us. So that's when we wanted an alternative storage which is cheaper to maintain and uh which can support our future scale. So that's when we uh introduced uh data lake on S3 and we chose delta as the table format. Now the data pipelines would replicate the data at two places. One to your TIDB and another to data lake and it would keep the recent data on TIDB and the full data on the data lake. But this still created problems for reporting service because we were not able to generate the complete report from uh data lake primarily for two reasons. So one is the replication latency of 1 to two hours. So we were having a freshness gap of 2 hours on the data lake and the merchants who were generating the report they wanted a real-time data in the report and the next is the query performance. So if you have done any joins on data lake so they are super expensive and you need a very large cluster uh if if your data size is also huge. So one um problematic uh challenge for us us was consider an example you are joining payment and order details for a merchant and this order could have happened two years back and you are making recurring payments on that order. So now you don't know in which date or the month this order falls to. So you cannot apply filters on the order table. So you can just apply filters on the payments table and that means that you are doing a full table scan on the orders. So since the joints were resulting in full table scan on data lake so the queries were going for long hours and they were timing out and that's when we decided that we will serve the recent data which require real-time data from the TIDB and the historical reports would go to the data lake. So this would uh this we did by increasing the SLA for the historical reports but even then we were not able to keep up with the 1 hour SLA for report generation and we wanted a some some other solution and that's when we thought if joins are problematic on data lake why not just premputee the joints. So this is a typical architectural pattern used in industry where you can create a wide table by pre-joining the tables and when you are reading the data from the table you can just do filtering and no need to do any joints and we call this denormalized table as fats and this is how we generate the fats. Uh we have an airflow schedule that runs at off peak covers it triggers a spark batch job on EMR and this job reads all the tables from lake. So we have a configuration file on S3. So this fi configuration file contains which tables to read and which tables to join. So we have one configuration file for each unique report template and uh once it gets all the data from the lake tables uh the bad job would join them and create one large denormalized table and that that's what you see there which which has a large number of columns and it's a very wide table which has all the precomputed joints and we chose delta delta as the table format here. So this worked for some time when the data was less and then we started hitting the challenges. So we were eventually reached to a state where uh we had around 50 plus denormalized facts. uh and this represent different query pattern in the report queries and uh a single fact was joining around 5 to 50 tables and it had hundreds of columns and uh the size of those facts were going above TBS and then the problem that uh generating these facts on every day with such a long execution time was hurting us in terms of cost. So we chose to uh reschedule some of the fats on alternative days and because of that the data freshness increased to 48 hours. So there were three core challenges with the solution. So one is we were doing lot of full table scans to generate the facts and that was resulting in a longunning job which was which were running like for 15 hours and if I do a cumulative count it was taking 100 plus hours per day and uh since these are long longunning jobs so it is clear that you have to deal with spot loss because you won't be running such a long job on a on demand nodes which is very expensive so we went with spot nodes and we had to deal with spot losses so spark would usually recover from spot loss, but sometimes it would crash and it would have to uh restart from the scratch. And since the leg joints were expensive, we had to put every down table into the fact because doing the joins on the runtime for any table was expensive. So we moved everything to the fact and that's why we reached a join of 50 tables that is because to cover all different queries on reports. So we tried multiple short-term approaches and the first long-term attempt we did is creating a streaming solution. So we implemented a spark streaming job. So what it does is it will subscribe to the CDC topics of the tables it wants to join and once it gets all the events it will do a stream join. During the stream join since we have backdated references you might find all you might not find all the references in the streaming window. So that's when we use TIDB as the lookup store. We do the lookups on Tidb to complete the joints and then create one big denormalized uh data frame and then we upsert it on the target table. So this solution also worked for an year and then failed because uh primarily three reasons. One is the mutability of the data. So we had data highly mutable. A payment would go through a a lot many status changes and every uh couple of hour there would be some or the other attribute would change and we had to move that to the uh final fact because reports need accurate data. So we cannot compromise on the accuracy in the reports. So we were dealing with lot of uh events which is 10 million plus events in a 30-inut batch and uh we were doing lot of recomputation again and again and these events were these stream joints were stateful because we were doing lot of lookups and hence uh this created problems for us. The next is we were keeping all the data on TIDV because since we are doing the lookups uh we need references to be present in the TIDv so that we can complete the joints and that was also costing us a lot. And the last problem is the right amplification. So consider an example of a payment link. So using one payment link you can make uh thousands and even lakhs of payments. So now if you use your payment link as a secondary table in your fact and some attribute of that payment link changes now you have to go to uh thousands of payments in that fact and update that attribute. So that is like a right amplification where a one change in payment link is resulting in around lacks lacks of rights on the target team. So these were uh creating problems for us and we eventually discarded the solution. So then we uh went back to the scratchboard and uh brainstormed on how we can incrementally process uh changes on a denormalized table. So one insight was that so one we can create a one-time back fill of the full uh denormalized table and then as and when the change happens we batch the events and then propagate the changes to the target table. But this this sounds simple to understand but how to do that was a problematic. So there there were three questions we need to answer. So one is how do you get the change events uh which you can batch and process. So anyway we were getting the CDC events on Kafka and we wrote a data pipeline that would read this Kafka CDC changes and write to a table on lake partitioned on updated date. So if I have to get the change events on a particular day, I can go to that partition and get all the change events on that day. Now uh change events were sorted. The next part is how do you know which are all the related data? So a change event comes on payment. Now how do you know which all data you need so that you can update the fact. So that's when we created a dependency graph. This is just a inmemory data structure. This does not hold any data. This just tells us that uh in a fact configuration these are the tables I need to join and these are uh related in this way based on the join condition. And if you create a graph like this so I I I would know that in which order I need to look up so that I can complete my join. And the next part was uh this time we did not want to keep all the data on TDB. So we wanted an index on lake an index that will tell us uh uh the data about an ID faster than before and let's see how that secondary index works. So secondary index is a mini table. It is created uh as a subset of the original table. It has few columns. It contains your primary key and couple of join columns and then your partition value. So in in our case all our data was partitioned on date. So partition value was mostly date for us. And if you have a mapping like this where it says for this ID this is the date. So first you can reach the index and get the date and then you can actually go to the original source table for that partition and get the actual data. So if you look at the query on the left side uh right side the uh there is a nested query right it is picking all the created date from your index then you are passing that as a list of values in your main query. So that is how you do the lookup faster using the secondary index. So we can use the same secondary index to query on ID and query on the join column. So if you look at the second query here I'm looking up on payments where order ID equals to this. So this solved the lookups for us. Now the next part was how do we propagate the change events to the fat. So let's say uh you got all the change events on payment from the updated date partition. Now you have to complete this denormalized row. So basically you have to do the enrichment. In order to do the enrichment you have to get details of cards, orders, transfers and settlements. But there is a twist here that if you want to get the details about settlements first you need to look up for transfers and that is where this graph relationship helps us that it will tell us in which order we have to look up and how do we join those rows. So once we have this graph created from the fact configuration we traverse it and do a level order traversal uh and then we use the secondary index to do the lookups. So I get the card details, order details using lookups and once I have all the details required for enrichment I join it and create a big denormalized data frame which I can upsert on the target table. So now if you look at most of the databases like click house shift they give you an option of materialized view right so which you can create for premputing these joints but there is a problem there that it supports only changes on the primary table. If something changes on the primary table, it will propagate. But if something changes on the secondary table, it will not go in. So I will tell you why you need that. So consider a case of a payment that happened today. Now the amount is with razor pay nodal account and we have to settle that amount back to the merchant. So this can easily take a couple of days to weeks. So when settlement happens, we get a UTR number from the bank which we need to inform the merchant that so this is the UTR number where your payments were settled to your account. Now when the settlement happens you don't get a change event on payment. So payment has not changed only the settlement is the new event that you have got. Since we have to give the accurate data in the reports, we have to make sure that settlement UTR also reach uses your denormalized table and [snorts] uh graph actually helped us there because we uh from the graph we can know that in order to get the payment rows I need to do a backwards. So from the settlement I do the lookups on transfers and from transfers I do the lookup on payment and now I know which are all payments are related to my settlement. So once I know the payment rows I can go to my target fat and update all those payments. So this is how we solved the updates on secondary table. So now there there was one final piece of the puzzle that we did not want to keep dimension tables and high cardinality tables in the fact. So as I said before uh the right amplification problem with payment links. So we did not want to keep payment links as part of the fact because that would result in right amplification. So uh we wanted to do the joins at the runtime. So if you have to do a join of payment and a payment links factor, payments factor and payment links. So you can look at this query on the left side. So here you are doing the join and passing couple of filters. You're passing a merchant ID filter and a date filter. Right? Now Spark will pass this filter only on the primary table because you don't have any filters on the secondary table. So that that results in a full table scan on payment links and this is what we wanted to avoid during runtime joins and what we did is we did a bucketing on merchant ID so on both the sides and we added one more clause here saying that fact dot merchant ID equals to payment links do merchant ID now spark has this additional information that merchant ID is a common predicate between both and it can push that predicate to both the sources When we did this, it was able to push down the predicate on the payment links as well and it reduced the scan for us. And these are the numbers from that. So we had uh around 2TB of data in payment links without bucketing it was doing a scan of 2TB and when we added the bucketing it reduced to 180GB and within the bucket we also did a sorting on merchant ID. So when you do the sorting on merchant ID you will get a narrow uh metadata for a file. So you will have a narrow min and max so that you will be reading less number of files compared to earlier. So this reduced the scans and we chose iceberg for most of our new tables. Uh iceberg gave us the bucketing and sorting compaction option and also the major reason is the metadata pruning. The way it handles metadata pruning is far better than the earlier table formats that we used. And this is how the uh architecture evolved. We had a single data pipeline that was doing the denormalization and creating a fat that got changed to three different pipelines. One that generates your secondary index and another that generates your replication tables party bucketed on merchant ID and then you have a incremental incremental batch pipeline which will actually do your uh incremental processing of fat and these are the numbers that we got. We migrated five of the fats to the new strategy and we were able to cut down compute from 30 plus hours to less than 3 hours. This was straight 90% reduction and we got the same reduction on the cost as well and a large fat actually reduced from 8 hours to 1 hour. We were able to cut down on the tidb scan window and uh now we were able to support the full history on the fat. So once we do a back fill with full refresh and then we can incrementally update that fat. So you can maintain the full history in the denormalized and along the journey we learned lot of lessons and these are the few lessons I want to stress upon. So one is uh streaming stream processing did not work for us for doing the denormalization and if you have seen the approach that we took uh we were doing lot of uh lookups on tidb and our joints were mostly stateful joins so uh doing a one-time bad job per day was more cost efficient than running a stream job throughout the day and the next is we thought that lake would be mostly used for analytical purpose where we do some kind of aggregation but we never knew that there would be lookups coming into data lake and you need to optimize for that. So we created secondary index using that we were able to speed up the uh lookups on data lake. And the next is uh if you are modeling your fact based on the query patterns uh never create a big super fact which can serve all your queries. try to model the fact in a domain based modeling where you create fact for a particular domain and then uh you keep some of the tables which are less frequently query uh as a separate table. So you can do the joints at the runtime if you want to uh but do not create a very bulky fact with all the tables. And the last is so we were treating fact as one single white table and that's when we were doing the full refresh but if you actually look at how a fact is generated it is basically a group of entities right and these entities have a relationship. So if you know the relationship as in how those entities are related, you can recomputee only the affected data and not the complete data and that's how we did the incremental process. Yeah. So that's what I wanted to cover in this talk today and thank you so much for joining in. Yeah. Open to questions. First of all, thank you for the talk. It was quite interesting. Uh one question I had was like about how you kind of verify uh data quality uh especially when you're kind of going through these iterative uh fact recreation and all of that right so uh how do you ensure that uh you know the reporting service for example is looking at the accurate data against your OLTP and that sort of thing. See um for us moving from this full refresh to incremental right this was a new strategy that we introduced and we had to make sure that whatever the facts that we are generating it is accurate and it is matching with what the full refresh would do right so full refresh was the baseline for us and we were comparing it with the full refresh fact. So in the initial uh days what we did is we would generate a full refresh pack for a couple of high merch high high volume merchants and then we would compare that with the incremental fact and see if the data is matching and also we uh I think this is a very good question in terms of production readiness uh because this came up when we wanted to take this live and people asked this question that how do you ensure that different pipelines that you have they are producing the correct output. So we created DQ jobs for each of the pipeline. One for secondary index, one for your uh change events and another for your application tables. And for application tables, we use the actual source microser tables as a uh uh baseline. And then uh we would do a random sampling and not the complete data comparison because of the data size we have and that actually helped us to flag uh issues beforehand. Uh I think just looking at it from uh sorry just one more question. So uh if you were kind of like to take a fresh look at this right like uh how would you change it like in the sense that um like would you say that like uh splitting into microservices was fair at that time looking back now? And secondly uh like would you look at a different approach like you know maybe sharding uh and maybe horizontal partitioning to kind of uh alleviate some of these issues that you had. >> So uh bucketing is one of that approach where you did the sharding of the data right based on merchant ID. So most of the reports that we generate are for a particular merchant and if the if you know that all the related data for a merchant is sitting in one bucket you can do the join faster. So that that's we where we did this bucketing as a proxy for sharding and if you have to reook at the architecture. So one is we were using this tidb right I think now sunny was mentioning that they have now uh S3 backed uh storage right and keeping all the data on EBS was super expensive for us. I think with this option of S3 maybe we can explore u the joints on TIDB can be faster and we don't uh incur a lot of cost in storing that on EBS. Uh thanks Amed this is Vive here from Startry. So the question on um the SLA that you moved from uh multiple hours to 3 hours right is that accept acceptable SLA right now or are you still trying to improvise and reduce the time taking to generate a report? See for report uh the freshness gap is less than 5 minute the SLA is less than 5 minute and uh since facts cannot refresh that often right so we have 24 hours of freshness even for the incremental fact so this remaining gap that we have we serve it from the TIDB so TIDB uh the replication latency for TDB is within 5 minutes so uh whatever uh data gap that we have we read that from TIDB do a merge of uh incremental plus TDB to serve the reports so reports will never go stale But that is the data freshness part of it, right? But the report generation is still taking 3 hours today. >> No. Uh this is not the report generation. These are only the premputed tables. Report generation is still within 10 minutes. >> Got it. >> So once you have this denormalized fact, right? So you don't have to do the joins when you're generating a report. You are just doing a basic filtering saying that these are the dates and this is my merchant ID. You you're just doing basic filtering, no joints. So report generation would still be faster in few minutes. Got >> it. Thanks >> Amit. I have a question. Thank you for the presentation. Um wanted to know uh the uh reports which are generated for the users, right? Which are basically your partners, vendors etc. How much variance uh is there in the kind of reports that they uh they ask for and what impact does that have on your design? I think that was uh super critical for us because initially when we started we had maybe around 10 templates uh the settlements report transaction payments like that but as data size increased and we started shipping more and more features more products uh the number of templates increased to even around 60 70 these are standard templates I'm talking about which are available to all the merchants and then merchant can come to our uh account manager or the relationship manager and say that I want a certain data uh which is not available in your standard template Right? And he can create his own custom report template as well. And if you look at the overall number of templates, these are more than 3,000. Uh the custom templates are too many because merchants have a diverse set of requirements. And we tried to fit most of the reports as a based on a JSON based DSL where you define these are the tables you want to join and these are the filters. And running these kind of reports were easier because you have a DSL in front of it. But there are some customers the very enterprise customers like ATIL, IRCTC. So they have very custom requirements and for them we had to write a raw SQL uh plain SQL that actually does the join and these SQL are very uh expensive as in they have a lot of nesting and other things. Yeah. So we had a diverse set of templates to support and that actually created this much of mess because we had to uh have so many of premputed fact otherwise we would have gotten with one or two precomputed fact. >> Okay. Thank you. >> I had another question about you know analytical queries. So uh so so yeah so uh I had another question about analytical queries. So I'm assuming that you'll probably have like another team that is monitoring uh you know or maybe doing business intelligence on top of this and for that they'll need analytical kind of queries. So does this uh also support that workload or do you have another system for that? >> Uh so we uh use this as a common layer for both reporting and analytics. Uh but and also for analytics they don't want a most historical data as in a year back or two years back right. So for them we have used click house as one of the solution where we replicate some of the data to click house so that their dashboard and other things can run very fast. Uh because here we are doing still a lambda merge between IDB and fact. So for them it was not feasible because they have to show that in a dashboard and they want a real-time data and we use click house for most analytical queries. Ju just a quick question. You talked about this secondary index and you talked about the p the graph traversal for uh going up and down the graph, right? So question is how complex are these uh graphs that you have to deal with and uh which technique yields what kind of outcome? Would one of the two suffice for you or would did you have to do both or both were equally relevant for your solution? >> Yes. So yeah, so both are equally relevant. Uh so this graph is not actually a graph DB. It's just your dependency graph that you store it in your uh program that says this is how the entities are linked and if I have to recomputee the denormalized row, I can traverse that graph up and down and create that uh denormalized row. Right? And secondary index was important for us because uh we wanted to keep all our data on lake because lake provides that cheaper option that you have you can set it up on S3 and it is far cheaper than compared to TDB or other solutions. So if you have to do the lookups on lake uh the only option we needed was a kind of an index because lake does not have a native index capability. So we built the secondary index so that we can do the lookups faster and graph helped us to know which are all the related data we need to recomputee instead of full report. Yes. Correct. Correct.