Let's say this key value store is used by a tinyUrl service.
QPS:
100 million DAU -> every day each person create one tinyUrl -> 100M / 86400 = write: 1,000 QPS
Read 10 tinyUrl -> 100M * 10 / 86400 = 10,000 QPS
Storage:
100M * 30 days * 12 months = 36 billion mappings
8 bytes for id + 100 bytes for longurl = 4TB per year
If a server node could provide 100GB memory cache, then we need 40 primary servers a year.
flowchart TB
subgraph server
direction TB
p1[P1] --> s1[S1.1]
p1[P1] --> s2[S1.2]
p2[P2] --> s3[S2.1]
p2[P2] --> s4[S2.2]
p3[P3] --> s5[S3.1]
p3[P3] --> s6[S3.2]
end
client <--> server
Client: the client is imported by the caller and provide get and set API to use. It's cluster-aware so when it's initialized it will be populated with the store cluster topology, which we will cover later.
Cluster: the cluster consists of primary nodes and secondary nodes to partition data.
flowchart LR
vn1((A-01)):::green --> vn2((B-01)):::red
vn2((B-01)):::red --> vn3((C-01)):::blue
vn3((C-01)) --> vn4((A-02)):::green
vn4((A-02)):::green --> vn5((B-02)):::red
vn5((B-02)):::red --> vn6((C-02)):::blue
vn6((C-02)) --> vn7((A-03)):::green
vn7((A-03)):::green --> vn8((B-03)):::red
vn8((B-03)):::red --> vn9((C-03)):::blue
vn9((C-03)) --> vn1((A-01)):::green
classDef red stroke:#f00
classDef green stroke:#0f0
classDef blue stroke:#00f
So we would like to distribute data into different nodes to improve performance and scalability. In a normal way, when to decide which node to save a key value pair, we calculate it by hash(key) % n, where n denotes the total number of nodes. This leads to a severe inflexibility in adding/deleting nodes from the whole cluster because the data migration will happen on the all data set by hash(key) % (n - 1) or hash(key) % (n + 1) causing a significant overhead.
We will introduce the consistent hashing to improve this.
Then we could come up with a metadata form which maps the server id to the ranges. For example:
{
NodeA: range 0 to 99, 300 to 399, 600 to 699, 900 to 999
Node B, range 100 to 199, 400 to 499, 700 to 799, 1000 to 1024
Node C, range 200 to 299, 500 to 599, 800 to 899
}
Pros:
To meet the requirement durability and avoid single point of failure, each primary node will have at least two secondary nodes to replicate data to.
Resilience: the primary node and 1st secondary node are within the same region but different availability zone, and the 2nd secondary node is within another region. This makes the system resilient to zone failure or region down.
So when a write request comes in, the primary node will write the data to its memory first, then it writes the data to its secondary nodes. After all of them succeed, primary node returns success to the client.
So there is some trade-off between availability and consistency here. User could configure their consistency level, whether they need strong consistency or eventual consistency.
Asynchronous replication has weak durability, a write is not guaranteed to be durable. But Async is widely used. We could use the semi-sync to make sure the nodes within the same zone are synchronously written.
Decision: we will use semi-synchronous solution. Each primary node will maintain a set of in-sync replicas (ISR) that are caught-up to itself. Only members of this set are eligible for election as leader.
For read request, all the nodes including primary and secondary will be involved to handle the requests.
Since we are gonna store the data in the memory, we will use LRU cache as the data structure to achieve faster data access. LRU comprises a hash table and linked list. When an item is accessed (read or written to), it becomes the most recently used item and is moved to the front of the cache. LRU caches are particularly useful in scenarios where there is a high likelihood of accessing the same data repeatedly over a short period.
Client API:
Flow:
Primary node is down:
Secondary node is down
Write to primary node failure:
Read failure:
Future Improvements