Assume we have 1M monthly active users.
DAU = 1M / 30 = 33k users
Assume that each user shorten 8 links a day (5 links are unique), and for each links, there are 3 clicks
writes per day = 33k * 5 = 165k
Let's say long URL average length is 150 characters, so that's 150 bytes. For shortened URL, let's say there are 8 characters. Each write will take 158 bytes
Bytes per day = 165k * 158 = 26MB / day
storage for 5 years = 26MB * 365 * 5 = 47.45TB
reads per day = 165k * 3 = 495k
read per second = 495k / 24 / 60 / 60 = 5.7 read / s
To account for spike in traffic, we should support 50 read / s.
Shorten URL
Edit URL
Access URL
Delete URL
For delete, we have to ensure that the entry is not owned by other users. Otherwise, we should still keep it.
User
Subscription
UrlMap
UserUrl (composite keys for both IDs)
For analytics
ClickUrl
We want to retain data for only 30 days. Or up to 90 days for higher tier subscription users.
Given the relationships of users and URLs and clicks can be complex, we will use relational database (e.g. PostgreSQL) to support complex queries and joins.
We want to have API routers that route to either the read or write server depending on the request.
Before reaching the write or read server, we also want to have a load balancer, so that we can scale the system to more users (by scaling it horizontally).
We also have a write and read database, where periodically, write DB will update the read DB. Because of this, the data may not be consistent (users may not immediately be able to redirect). So on the webpage, we should warn users that it may take 5 seconds for URL shortening to take into effect.
User shorten a new URL
User goes to a shortened URL
Sharding
Rate limiting
Message queues
Replication
NoSQL vs SQL
Since we are using SQL, scaling horizontally is more challenging. We can shard the database, but we have to be careful not to shard in a way that slows down complex queries too much. As mentioned before, we chose PostgreSQL because the queries that we anticipated can be quite complex, and we want to optimise for that.
Microservice vs Monolithic architecture
I chose to separate the read and write servers because I prefer microservice instead of monolithic architecture. In this way, we can choose to scale up read or write servers independently.
Write and Read DB
We have both the write and read DB because we want to scale both the write and read DB independently as well. For example if there are many more read requests than write, then we just want to scale the read DB shard cluster.
If there is a failure when writing to the DB, we will return the response to the user, and display an error message.
Optionally, we can implement a queue to write to the logs so that we can monitor the failures. If we find that this is a bottleneck, we may want to implement a messaging queue so that the write to database can happen asynchronously. On the client-side, it will show that the server is processing the user's request, and will continue to check for updated results (we can use polling).
For the tech stack, we can use Kafka and RabbitMQ for the scenario. In order to prevent flooding, we can use the token bucket algorithm so that in cases where there are too many requests, we will throttle the request. If the request or write fails, we will requeue the message if it's a temporary failure, and retry for maximum of 2 times.
We can add a messaging queue for read and write servers, and also log every request so that we understand the pattern of the users. To prevent DB from overloading, we should also have a daemon service that deletes expired links.