How Notion Scaled to 100 Million Users Without Their Database Exploding

Brief Summary

Notion faced a major performance crisis in 2021 due to their database reaching terabytes of data, causing slowdowns and threatening to lose customers. To address this, they implemented a multi-step sharding strategy, first splitting their database into 32 physical instances with 15 logical shards each. They used PgBouncer for connection pooling and implemented a data migration process to move existing data to the new shards. However, by late 2022, they encountered performance issues again, leading them to further shard their database into 96 instances with smaller logical shards. They used logical replication for data synchronization and implemented a dark read testing strategy to ensure data consistency before switching to the new sharded system. This resharding project significantly improved performance, increased capacity, and future-proofed Notion's infrastructure to handle continued user growth.

  • Notion's database reached terabytes of data, causing performance issues.
  • They implemented a multi-step sharding strategy to address the problem.
  • The resharding project significantly improved performance and increased capacity.

Database Crisis

Notion's database, built on PostgreSQL, experienced significant performance issues in 2021 due to its massive size. The database, which stores all data as "blocks" (including text, images, and pages), was reaching terabytes of data, even after compression. This led to slowdowns and increased latency for users, as the single, monolithic database struggled to handle the load. The VACUUM process, responsible for reclaiming storage space occupied by deleted data, was also stalling, further contributing to performance degradation. Additionally, the database was nearing a critical point where transaction IDs would wrap around, potentially rendering the database read-only and causing a major disruption for users.

Initial Sharding Strategy

To address the performance issues, Notion decided to implement horizontal scaling by sharding their database. They split the database into 32 physical instances, each containing 15 logical shards. The partition key for sharding was the workspace ID, as users typically access data within a single workspace. This ensured that related data remained within the same shard. The new sharded setup needed to handle existing data and scale to meet projected growth for at least two years. To achieve this, they chose instances with at least 60,000 IOPS (Input/Output Operations Per Second) and set limits of 500GB per table and 10TB per physical instance.

Data Migration and Routing

Notion explored several options for migrating existing data to the new sharded system. They considered writing directly to both old and new databases simultaneously, but this posed a risk of data inconsistency. They also considered using PostgreSQL's logical replication feature, but this was not feasible due to the lack of workspace IDs in the old database. Ultimately, they opted for an audit log approach, where they recorded changes to the old database and used a catch-up script to populate the new databases with the necessary data. This process took approximately three days, using a powerful m5.24xlarge instance with 96 CPUs. During the migration, they compared record versions to ensure that they were not overwriting more recent data. Once the migration was complete, they updated their connection pooling layer (PgBouncer) to route queries to the appropriate shards.

Resharding for Continued Growth

Despite the initial success of the sharding strategy, Notion encountered performance issues again in late 2022. Some shards were experiencing high CPU utilization, approaching full disk bandwidth utilization, and reaching connection limits in the PgBouncer setup. To address these issues, they decided to further shard their database, increasing the number of physical instances from 32 to 96. They provisioned smaller instances for the new shards, aiming to reduce CPU, IOPS, and costs. Each old instance, which contained 15 logical schemas, was replaced by three new instances, each containing only five logical schemas. These new instances were configured with smaller instance types due to their expected lower load.

Data Synchronization and Optimization

For data synchronization, Notion utilized PostgreSQL's logical replication feature to continuously apply changes to the new databases. They set up three publications on each existing database, each covering five logical schemas. On the new databases, subscriptions were created to consume these publications, effectively copying the relevant data. To optimize the synchronization process, they delayed index creation until after the data transfer, reducing the syncing time from three days to 12 hours.

PgBouncer Resharding and Dark Read Testing

During the transition, Notion had 100 PgBouncer instances, each managing 32 database entries. They planned to add 96 new entries to PgBouncer, temporarily redirecting them to the existing shards and gradually migrating data to the new shards. However, testing revealed a critical issue: since each old shard mapped to three new shards, they needed to either reduce the number of connections per PgBouncer instance or increase it by three times. Increasing connections would overload the system, while reducing connections would lead to query backlogs. The solution was to shard the PgBouncer cluster itself, creating four new clusters, each managing 24 databases. This allowed them to increase connections per PgBouncer instance per shard, limit total connections per Postgres instance, and isolate PgBouncer issues to a smaller portion of the fleet.

Before rolling out these changes to production, Notion implemented a "dark read" testing strategy. This involved fetching data from both the new and old databases when requests were made, comparing the results for consistency, and logging any discrepancies. To minimize impact on user experience, they limited the comparison to queries returning up to five rows and sampled a small portion of requests. A one-second delay was introduced before issuing the dark read query to allow time for replication catch-up. The testing showed nearly 100% identical data.

Failover and Success

The failover process to the new sharded system involved halting new connections while allowing ongoing queries to complete, ensuring new databases were fully caught up, updating PgBouncer to point to the new databases, and reversing replication direction to stream changes from the new database to the old database as a precaution. Once these steps were completed, traffic was resumed to the new shards. The resharding project was a significant success for Notion, resulting in increased capacity, improved performance (with CPU and IOPS utilization significantly reduced), and a future-proofed architecture to handle continued user growth and increased data demands.

Summarize any YouTube video in seconds

Get summaries like this one for any video, in your language, with the free Summ app.

Download on the App Store
Get it on Google Play

Transcript

Full transcript of “How Notion Scaled to 100 Million Users Without Their Database Exploding”. Click a timestamp to open the video at that moment. Need another video? Use the YouTube transcript generator.

0:00 In 2021 Notion's popularity skyrocketed, but its service became unbearably slow because their postgres was at terabytes of volume, even when compressed and was ready to explode. Faced with losing customers, they needed to solve this issue fast. In this video, let's learn how they managed to solve this crisis. To understand the root cause, we need to take a look at their unique data model. At its core, everything in Notion is a `block`. A block could be a piece of text, an image or even an entire page. Each block is stored as a row in Postgres, with its own unique ID. Blocks can be nested within other blocks to create complex tree-like structure. This structure allows for incredible versatility, but it also means that even a simple document can result in hundreds or thousands of database entries. Like this waifu document that err my friend has. Now, multiply that by millions of users using Notion.

0:46 While flexible, the sheer volume eventually caused users to notice increased latency when requesting page data. Notion was seeing delay because their single, monolithic database could no longer handle the load. Their Postgres VACUUM process began to stall consistently. VACUUM is a crucial database maintenance operation that reclaims storage occupied by dead tuples - data that's been updated or deleted but still occupies space. When VACUUM can't keep up, it leads to bloated tables and degraded performance. On top of that there was an alarming threat of transaction ID wraparound. In Postgres, each transaction is assigned a unique ID, these IDs are finite, and if they're exhausted, the database enters a read-only mode to prevent data loss; imagine a note app that cannot be edited or deleted. . For Notion, this would have been a disaster 6 7 Eventually their database became a ticking time bomb ready to explode at any moment. The first instinct might be to beef up their Postgres instance,

1:33 like me trying to get big at the gym. But there are physical limits to how much you can scale a single machine before costs increase exponentially. Moreover, query performance and maintenance processes often degrade well before reaching hardware limits. What did they try next, yup you guessed it horizontal scaling. Notion decided to shard all the tables linked to the block table via foreign keys. This included workspaces, discussion threads, and comments, keeping related data in the same shard. For the partition key, they chose the workspace id since users typically request data for a single workspace at a time. The new sharded setup needs to handle their existing data and scale to meet projected growth for at least two years. For that 1. the instance type needed at least 60K total IOPS. IOPS represents the number of read and write operations that can be performed per second. 2. To maintain RDS replication, they set limits of 500GB per table and 10TB per physical instance.

2:24 After careful consideration, Notion chose to create 32 physical database instances. For each physical machine, they chose to create 15 separate logical shards represented as postgres schema. Each shard would have its own tables like block, workspace and comments. For a total of 480 total shards across the 32 physical databases. The routing mechanism is determined at the application level. To determine where a data is, the application uses the workspace ID to determine which of the 32 physical databases the data resides on. Within that physical database, they use the workspace ID again to determine which of the 15 logical shards contains the data. They use PgBouncer has an intermediary between the application layer and the database. PgBouncer is a lightweight Postgres connection pooler. It reduces the overhead of establishing and tearing down connections by maintaining a pool of active connections,

3:12 allowing applications to reuse them. This is useful where frequent connections to the database are required, to minimize resource consumptions and improve performance. After setting the new databases, they needed to sync old data with the new data. There were a few options to consider: 1. write directly to old and new databases at the same time. However, if there was any issue with any write operation, this would lead to flaky data. 2. logical replication using postgres' build in publish, subscript model. This would have been perfect except that workspace id was not yet populated in the old database. This approach doesn't give the ability to add workspace id to the new as they're being moved. They could populate this column in the old data, but it would have increase the load on the old database even more. Ultimately, they decided to create an audit log when saving to old database and use a catch up script to populate the new databases with additional scheme changes.

4:02 Once new incoming data are now being populated to new databases, they began migrating existing data. This marathon took about three days, powered by a beastly m5.24xlarge instance with 96 CPUs. When migrating old data to new databases, they would compare the record versions to make sure that they're not overwriting more recent data. Finally, they executed a carefully orchestrated switch to the new sharded system, updating their connection pooling layer (PgBouncer) to route queries to the appropriate shards. Things went great for a while...until late 2022 when they started noticing some cracks with their trusty 32-shard set up was reaching its limits. Here's what they were seeing: 1. Some shards were going over 90% CPU utilization during peak traffic. 2. Many shards were approaching full utilization of their provision disk bandwidth, or IOPS. 3. They were also reaching connection

4:50 limits in their PgBouncer setup. To make matters worse, the new year is approaching and they historically see huge spikes in signups and usage around this that time. Gotta get started with those new year resolutions. To resolve this issue, they decided to shard their 32 databases to 96 databases to improve load distribution. Provisioning smaller instances for new shards in hope to reduce both the CPU, IOPS and costs. Recall that each old instance have 15 schemas. Now for each old instance, 3 new ones will take its place and each new instance will only contain 5 logical schemas. These new instances were configured with smaller instance type by design since they were expected to maintain smaller load. For data synchronization since there were no sql schema change, they used postgres logical replication to continuously apply new changes to the new databases. This process involved

5:39 setting up three postgres publications on each existing database, with each publication covering five logical schemas. On the new databases, subscriptions were created to consume one of the these three publications, effectively copying over the relevant set of data. One key optimization that they did was delaying the index creation to after all the data transfer. This approach reduced the syncing time from three days to just 12 hours. During the transition, they initially had 100 PgBouncer instances, each managing 32 entries. Each instance could open up to 6 connections per database, resulting in a maximum of 600 connections per database instance. Their migration plan involved adding 96 new entries to PgBouncer, temporarily redirecting them to the 32 existing shards, allowing them to gradually migrate data to the new shards.

6:25 As data was written to the old database, it would replicate to the appropriate new shards. However, testing uncovered a critical issue: since each old shard maps to 3 new shards they either needed to reduce the number of connections per PgBouncer instance or increase it by 3x. If they chose to increase the connections, each of the 100 PgBouncer instances could open up to 18 connections to each old shard. This would mean 1,800 connections per shard, which risked overloading the system. On the other hand, reducing connections per entry wasn't sufficient to handle the traffic, leading to query backlogs. The solution was to shard the PgBouncer cluster itself. They created four new clusters, each managing 24 databases. This allowed them to: 1. Increase connections per PgBouncer per shard to 8. 2. Limit total connections per Postgres instance to 200. 3. Isolate PgBouncer issues to 25% of the fleet.

7:14 This approach maintained appropriate connection numbers, prevented overload during migration, and enhanced system resilience. Before rolling this these changes to production, Notion implemented Dark read for testing - Notion added functionality to fetch data from both the new and old dbs when requests are being made - results are compared for consistency and any discrepancies are logged - to avoid impacting the user's experience, they limited the comparison to queries returning up to 5 rows and only sampled a small portion of the requests - A 1-second delay was introduced before issuing the dark read query to allow time for replication catch-up The testing show nearly 100% identical data. They proceeded with the failover process to the new sharded system involved: 1. Traffic pause: Halted new connections while allowing ongoing queries to complete. 2. Replication check: Ensured new databases were fully caught up. 3. Configuration update: - Revoked application access to old databases

7:59 - Updated PgBouncer to point to new databases - Reversed replication direction by streaming changes from new db to old db just in case. 4. Resume Traffic to new shards The resharding project was a significant success for Notion. Key outcomes: 1. increase capacity 2. improved performance: CPU and IOPS utilization decreased dramatically with new utilization hovering around 20% during peak traffic compared to previous 90% 3. future proofing: this new architecture positions Notion to handle continued user growth and increased data demands What do you think? Is Notion fast enough now? Hope you were able to learn something new today. And as always thank you so much for watching and see you in the next one!

Share

More video summaries