It needs to support 10 million unique urls per year. Assuming this application will run for 50 years, we need to support 500 million urls. With expiry times, we can mitigate the size of our database. The default expiry for a url, if it is not set in the request will be 5 years. As such, we should only need to support 50 million unique urls and can create a backend daily cleanup process to purge expired urls from our database. To give ourselves some leeway for inconsistent traffic patterns, let's assume our database will have 100 million active urls at any one point in time.
We will have two standalone microservices.
The first microservice will be for TinyUrl Operations and have four CRUD APIs. The create API will take in a longUrl and a userAlias in a POST request. The read API will be a GET request and return a tinyurl. The update API will take in a longUrl and a userAlias in a PUT request. The delete API will take in a longUrl and a userAlias in a DELETE request.
The second microservice will be for user management operations and have a create and read API. For simplicity, we will not support update and delete user operations.The create API will take in a userAlias and a hashed and salted password in a POST request. The read API will be a POST request and return a boolean true or false response and a JWT token with expiry to manage session state. The react frontend client will maintain the JWT token in local storage. The JWT token will be used to gate page accesses.
We will have one table to store the url mappings. The primary key will be an autoincrementing id. We will have another column that stores the tinyurl. A third column will store the userAlias for the owner.
We will have another table to store user information. The primary key will be the userAlias. We will have another column that stores the hashed and salted password.
We will have a client frontend website implemented in react. That will call our backend via our API gateway. We will implement standard network traffic controls on our API gateway like CORS headers. We will also put a WAF in front of the API gateway to prevent ddos attacks. There will be two standalone API microservices implemented as multiple autoscaling compute containeres behind load balancers to handle dynamic traffic patterns. Each microservice will then talk to the backend database. For simplicity, we will use a key value nosql store like dynamoDB. DynamoDB is a nonrelational, partitioned database that can use strong or sloppy quorum reads set at the client level for difference performance attributes. We will use sloppy quorum reads and strict quorum writes, and allow for dynamodb background processes like read repair to manage data quality. This will improve availability and latency. The problem with this approach is that we may have some stale reads when a user goes to another user's url, but this is a tolerable application risk.
Users will create user profiles with the user management microservice. The will start from the client frontend and either create a new user profile or login with an existing profile. This will send a request from the client, through the waf and api gateway, to the load balancer, to the api microservice, and finally to our database. The response will follow this same flow in the opposite direction. This is the same flow that CRUD operations will take for tinyurl management, except that these requests will be routed through the tinyurl api microservice.
The tinyurl microservice will generate new tinyurls from long urls. It will use the autoincrementing primary key and convert it from base 10 to a base 62 representation. Over 50 years, this means 500 million urls which means the longest will be 5 characters in length. We will use conditional writes with dynamodb to mitigate race conditions so that we do not write duplicates or overwrite other urls. This means strict quorum, consistent writes. But we can still support sloppy quorum reads. For updating or deleting URLs, we will implement conditional writes such that the userAlias passed in the request must be the same as that stored in our database if we allow a write to process. Otherwise, we will fail the request and return an access denied error. Since we are using our database id for generating unique urls, we can tolerate duplicate requests with separate ownership control. If two separate users request tinyurls for the same long url, we will generate two database entries that are owned by the respective creator user. This way, each user can manage expiry and deletion without impacting the other user.
The user management service will be a simple create and read service. It will also generate JWT session tokens for frontend page access controls. The data model is simple. If a user tries to choose a userAlias that is already taken, we can check this simply in our database and return a failure response.
The usage of dynamodb keeps things simple and cheap. This application presents a strong use case for NoSQL. And provides good tooling for managing race conditions and scaling with partitioning and consistent hashing. Using load balancers and autoscaling allows for easy traffic scaling and cost efficiency. Using an API gateway and web application firewall and mitigate a lot of network attacks.
There are other possible approaches to generating tinyurls, like using hash functions or UUIDs. However, using the database id easily removes the potential for collisions and makes the process quick which helps in achieving our SLAs.
By using the database id to generate unique urls, we can easily handle duplicate longurl requests. However, this is not the most efficient use of our database storage.
Try to discuss as many failure scenarios/bottlenecks as possible.
We could implement a memcache or redis to store high traffic tinyurls and serve them to clients faster.
We could implement duplicate management in a different way. However, this could lead to more complicated code around ownership control for editing and deleting tinyurls.