Design Deep Dive
Now that we have talked about all the parts of the email server, let’s go deeper into some key components and examine how to scale the system.
-
Metadata database
-
Search
-
Deliverability
-
Scalability
Metadata database
In this section, we discuss the characteristics of email metadata, choosing the right database, data model, and conversation threads (bonus point).
Characteristics of email metadata
-
Email headers are usually small and frequently accessed.
-
Email body sizes can range from small to big but are infrequently accessed. You normally only read an email once.
-
Most of the mail operations, such as fetching mails, marking an email as read, and searching are isolated to an individual user. In other words, mails owned by a user are only accessible by that user and all the mail operations are performed by the same user.
-
Data recency impacts data usage. Users usually only read the most recent emails. 82% of read queries are for data younger than 16 days 15.
-
Data has high-reliability requirements. Data loss is not acceptable.
Choosing the right database
At Gmail or Outlook scale, the database system is usually custom-made to reduce input/output operations per second (IOPS) 16, as this can easily become a major constraint in the system. Choosing the right database is not easy. It is helpful to consider all the options we have on the table before deciding the most suitable one.
-
Relational database. The main motivation behind this is to search through emails efficiently. We can build indexes for email header and body. With indexes, simple search queries are fast. However, relational databases are typically optimized for small chunks of data entries and are not ideal for large ones. A typical email is usually larger than a few KB and can easily be over 100KB when HTML is involved. You might argue that the BLOB data type is designed to support large data entries. However, search queries over unstructured BLOB data type are not efficient. So relational databases such as MySQL or PostgreSQL are not good fits.
-
Distributed object storage. Another potential solution is to store raw emails in cloud storage such as Amazon S3, which can be a good option for backup storage, but it’s hard to efficiently support features such as marking emails as read, searching emails based on keywords, threading emails, etc.
-
NoSQL databases. Google Bigtable is used by Gmail, so it’s definitely a viable solution. However, Bigtable is not open sourced and how email search is implemented remains a mystery. Cassandra might be a good option as well, but we haven’t seen any large email providers use it yet.
Based on the above analysis, very few existing solutions seem to fit our needs perfectly. Large email service providers tend to build their own highly customized databases. However, in an interview setting, we won’t have time to design a new distributed database, but it’s important to explain the following characteristics that the database should have.
-
A single column can be a single-digit of MB.
-
Strong data consistency.
-
Designed to reduce disk I/O.
-
It should be highly available and fault-tolerant.
-
It should be easy to create incremental backups.
Data model
One way to store the data is to use user_id as a partition key so data for one user is stored on a single shard. One limitation of this data model is that messages are not shared among multiple users. Since this is not a requirement for us in this interview, it’s not something we need to worry about.
Now let us define the tables. The primary key contains two components, the partition key, and the clustering key.
-
Partition key: responsible for distributing data across nodes. As a general rule, we want to spread the data evenly.
-
Clustering key: responsible for sorting data within a partition.
At a high level, an email service needs to support the following queries at the data layer:
-
The first query is to get all folders for a user.
-
The second query is to display all emails for a specific folder.
-
The third query is to create/delete/get a specific email.
-
The fourth query is to fetch all read or unread emails.
-
Bonus point: get conversation threads.
Let’s take a look at them one by one.
Query 1: get all folders for a user.
As shown in Table 1, user_id is the partition key, so folders owned by the same user are located in one partition.
Table 1 Folders by user
Query 2: display all emails for a specific folder.
When a user loads their inbox, emails are usually sorted by timestamp, showing the most recent at the top. In order to store all emails for the same folder in one partition, composite partition key <user_id, folder_id> is used. Another column to note is email_id. Its data type is TIMEUUID 17, and it is the clustering key used to sort emails in chronological order.
Table 2 Emails by folder
Query 3: create/delete/get an email
Due to space limitations, we only explain how to get detailed information about an email. The two tables in Table 3 are designed to support this query. The simple query looks like this:
SELECT * FROM emails_by_user WHERE email_id = 123;An email can have multiple attachments, and these can be retrieved by the combination of email_id and filename fields.
Table 3 Emails by user
Query 4: fetch all read or unread emails
If our domain model was for a relational database, the query to fetch all read emails would look like this:
SELECT * FROM emails_by_folder
WHERE user_id = <user_id> and folder_id = <folder_id> and is_read = true
ORDER BY email_id;The query to fetch all unread emails would look very similar. We just need to change ‘is_read = true’ to ‘is_read = false’ in the above query.
Our data model, however, is designed for NoSQL. A NoSQL database normally only supports queries on partition and cluster keys. Since is_read in the emails_by_folder table is neither of those, most NoSQL databases will reject this query.
One way to get around this limitation is to fetch the entire folder for a user and perform the filtering in the application. This could work for a small email service, but at our design scale this does not work well.
This problem is commonly solved with denormalization in NoSQL. To support the read/unread queries, we denormalize the emails_by_folder data into two tables as shown in Table 4.
-
read_emails: it stores all emails that are in read status.
-
unread_emails: it stores all emails that are in unread status.
To mark an UNREAD email as READ, the email is deleted from unread_emails and then inserted to read_emails.
To fetch all unread emails for a specific folder, we can run a query like this:
SELECT * FROM unread_emails
WHERE user_id = <user_id> and folder_id = <folder_id>
ORDER BY email_id;Table 4 Read and unread emails
Denormalization as shown above is a common practice. It makes the application code more complicated and harder to maintain, but it improves the read performance of these queries at scale.
Bonus point: conversation threads
Threads are a feature supported by many email clients. It groups email replies with their original message 8. This allows users to retrieve all emails associated with one conversation. Traditionally, a thread is implemented using algorithms such as JWZ algorithm 18. We will not go into detail about the algorithm, but just explain the core idea behind it. An email header generally contains the following three fields:
{
"headers" {
"Message-Id": "<7BA04B2A-430C-4D12-8B57-862103C34501@gmail.com>",
"In-Reply-To": "<CAEWTXuPfN=LzECjDJtgY9Vu03kgFvJnJUSHTt6TW@gmail.com>",
"References": ["<7BA04B2A-430C-4D12-8B57-862103C34501@gmail.com>"]
}
}| Message-Id | The value of a message ID. It is generated by a client while sending a message. |
|---|---|
| In-Reply-To | The parent Message-Id to which the message replies. |
| References | A list of message IDs related to a thread. |
Table 5 Email header
With these fields, an email client can reconstruct mail conversations from messages, if all messages in the reply chain are preloaded.
Consistency trade-off
Distributed databases that rely on replication for high availability must make a fundamental trade-off between consistency and availability. Correctness is very important for email systems, so by design we want to have a single primary for any given mailbox. In the event of a failover, the mailbox isn’t accessible by clients, so their sync/update operation is paused until failover ends. It trades availability in favor of consistency.
Email deliverability
It is easy to set up a mail server and start sending emails. The hard part is to get emails actually delivered to a user’s inbox. If an email ends up in the spam folder, it means there is a very high chance a recipient won’t read it. Email spam is a huge issue. According to research done by Statista 19, more than 50% of all emails sent are spam. If we set up a new mail server, most likely our emails will end up in the spam folder because a new email server has no reputation. There are a couple of factors to consider to improve email deliverability.
Dedicated IPs. It is recommended to have dedicated IP addresses for sending emails. Email providers are less likely to accept emails from new IP addresses that have no history.
Classify emails. Send different categories of emails from different IP addresses. For example, you may want to avoid sending marketing and important emails from the same servers because it might make ISPs mark all emails as promotional.
Email sender reputation. Warm up new email server IP addresses slowly to build a good reputation, so big providers such as Office365, Gmail, Yahoo Mail, etc. are less likely to put our emails in the spam folder. According to Amazon Simple Email Service 20, it takes about 2 to 6 weeks to warm up a new IP address.
Ban spammers quickly. Spammers should be banned quickly before they have a significant impact on the server’s reputation.
Feedback processing. It’s very important to set up feedback loops with ISPs so we can keep the complaint rate low and ban spam accounts quickly. If an email fails to deliver or a user complains, one of the following outcomes occurs:
-
Hard bounce. This means an email is rejected by an ISP because the recipient’s email address is invalid.
-
Soft bounce. A soft bounce indicates an email failed to deliver due to temporary conditions, such as ISPs being too busy.
-
Complaint. This means a recipient clicks the “report spam” button.
Figure 8 shows the process of collecting and processing bounces/complaints. We use separate queues for soft bounces, hard bounces, and complaints so they can be managed separately.
Email authentication. According to the 2018 data breach investigation report provided by Verizon, phishing and pretexting represent 93% of breaches 21. Some of the common techniques to combat phishing are: Sender Policy Framework (SPF) 22, DomainKeys Identified Mail (DKIM) 23, and Domain-based Message Authentication, Reporting and Conformance (DMARC) 24.
Figure 9 shows an example header of a Gmail message. As you can see, the sender @info6.citi.com is authenticated by SPF, DKIM, and DMARC.
You don’t need to remember all those terms. The important thing to keep in mind is that getting emails to work as intended is hard. It requires not only domain knowledge, but good relationships with ISPs.
Search
Basic mail search refers to searching for emails that contain any of the entered keywords in the subject or body. More advanced features include filtering by “From”, “Subject”, “Unread”, or other attributes. On one hand, whenever an email is sent, received, or deleted, we need to perform reindexing. On the other hand, a search query is only run when a user presses the “search” button. This means the search feature in email systems has a lot more writes than reads. By comparison with Google search, email search has quite different characteristics, as shown in Table 6.
| Scope | Sorting | Accuracy | |
|---|---|---|---|
| Google search | The whole internet | Sort by relevance | Indexing generally takes time, so some items may not show in the search result immediately. |
| Email search | User’s own email box | Sort by attributes such as time, has attachment, date within, is unread, etc. | Indexing should be near real-time, and the result has to be accurate. |
Table 6 Google search vs email search
To support search functionality, we compare two approaches: Elasticsearch and native search embedded in the datastore.
Option 1: Elasticsearch
The high-level design for email search using Elasticsearch is shown in Figure 10. Because queries are mostly performed on the user’s own email server, we can group underlying documents to the same node using user_id as the partition key.
When a user clicks the “search” button, the user waits until the search response is received. A search request is synchronous. When events such as “send email”, “receive email” or “delete email” are triggered, nothing related to search needs to be returned to the client. Reindexing is needed and it can be done with offline jobs. Kafka is used in the design to decouple services that trigger reindexing, from services that actually perform reindexing.
Elasticsearch is the most popular search-engine database as of June 2021 25 and it supports full-text search of emails very well. One challenge of adding Elasticsearch is to keep our primary email store in sync with it. One of the largest email providers in China, Tencent QQ Email, uses Elasticsearch 26.
Option 2: Custom search solution
Large-scale email providers usually develop their own custom search engines to meet their specific requirements. Designing an email search engine is a very complicated task and is out of the scope of this chapter. Here we only briefly touch on the disk I/O bottleneck, a primary challenge we will face for a custom search engine.
As shown in the back-of-the-envelope calculation, the size of the metadata and attachments added daily is at the petabyte (PB) level. Meanwhile, an email account can easily have over half a million emails. The main bottleneck of the index server is usually disk I/O.
Since the process of building the index is write-heavy, a good strategy might be to use Log-Structured Merge-Tree (LSM) 27 to structure the index data on disk (Figure 11). The write path is optimized by only performing sequential writes. LSM trees are the core data structure behind databases such as BigTable, Cassandra, and RocksDB. When a new email arrives, it is first added to level 0 in-memory cache, and when data size in memory reaches the predefined threshold, data is merged to the next level. Another reason to use LSM is to separate data that change frequently from those that don’t. For example, email data usually doesn't change, but folder information tends to change more often due to different filter rules. In this case, we can separate them into two different sections, so that if a request is related to a folder change, we change only the folder and leave the email data alone.
If you are interested in reading more about email search, it is highly recommended you take a look at how search works in Microsoft Exchange servers 28.
Each approach has pros and cons:
| Feature | Elasticsearch | Custom search engine |
|---|---|---|
| Scalability | Scalable to some extent. | Easier to scale as we can optimize the system for the email use case. |
| System complexity | Need to maintain two different systems: datastore and Elasticsearch. | One system. |
| Data consistency | Two copies of data. One in the metadata datastore, and the other in Elasticsearch. Data consistency is hard to maintain. | A single copy of data in the metadata datastore. |
| Data loss possible | No. Can rebuild the Elasticsearch index from the primary storage, in case of failure. | No. |
| Development effort | Easy to integrate. To support large scale email search, a dedicated Elasticsearch team might be needed. | Significant engineering effort is needed to develop a custom email search engine. |
Table 7 Elastic search vs custom search engine
A general rule of thumb is that for a smaller scale email system, Elasticsearch is a good option as it’s easy to integrate and doesn’t require significant engineering effort. For a larger scale, Elasticsearch might work, but we may need a dedicated team to develop and maintain the email search infrastructure. To support an email system at Gmail or Outlook scale, it might be a good idea to have a native search embedded in the database as opposed to the separate indexing approach.
Scalability and availability
Since data access patterns of individual users are independent of one another, we expect most components in the system are horizontally scalable.
For better availability, data is replicated across multiple data centers. Users communicate with a mail server that is physically closer to them in the network topology. During a network partition, users can access messages from other data centers (Figure 12).
Finished reading?
Mark it complete to track your progress.