πŸ“š Study Notes / Home / HLD / Session 9
Session 09 Β· SQL vs NoSQL & Sharding

When to trust a relational database β€” and when to walk away

Up to now we've treated "the database" as one trusty box. In this session we open the box. We'll see what relational (SQL) databases give you for free, why those gifts quietly disappear the moment you split data across many machines, what NoSQL offers instead, and how to choose the single most important setting of a sharded system β€” the sharding key. As always, every topic starts with a tiny "explain like I'm 5" story, then we go deep with real schemas and queries.

⏱ 40 min readπŸ“– 6 topics

1 The strengths of SQL / relational databases


Explain like I'm 5

Imagine a very strict, very tidy librarian. Every book must go on the right shelf, in the right format, with a proper label β€” no exceptions. If you try to file a book that breaks the rules, she says "no" and hands it back. She also has a magic guarantee: if she's moving ten books at once and trips halfway, she puts all ten back exactly where they were, as if nothing happened β€” never five moved and five lost. A relational database is that librarian: obsessively organised, rule-enforcing, and all-or-nothing.

A relational database stores data in tables: rows (records) and columns (fields), like a spreadsheet. You talk to it using SQL (Structured Query Language). The big-name examples are PostgreSQL, MySQL, Oracle, and SQL Server. Their power comes from four pillars: ACID transactions, normalization, a fixed schema, and joins backed by indexes. Let's take each one.

SQL is a query language, not a database type

SQL is just a way of specifying what to fetch from the DB β€” it is not itself a kind of database. It happens to be the de-facto query language for relational databases, so when people say "SQL" they almost always mean "relational DB." You can even query relational DBs with other languages (for example GraphQL). Common relational implementations: MySQL, PostgreSQL, OracleDB, MSSQL, SQLite, IBM DB2, Amazon RDS.

All of these strengths apply at low scale

Low scale means the "queries/second + amount of data" is small enough to be handled by a single server. Keep this caveat in mind β€” in Topic 2 we'll see that when scale gets large, every one of these strengths turns into a weakness.

Pillar 1 β€” ACID transactions

A transaction is a group of operations that must succeed or fail as a single unit. ACID is the promise the database makes about them:

LetterMeansIn plain English
AtomicityAll-or-nothing.Either every step happens, or none does. No half-finished money transfer.
ConsistencyRules always hold.The data obeys every constraint before and after (no negative balances, no orphan rows).
IsolationNo interference.Two transactions running at once don't see each other's half-done work.
DurabilityIt sticks.Once committed, it survives a crash or power cut.
Atomicity β€” the classic money transfer

Imagine Khushboo is transferring $1 million to Vishudh. The transfer is three steps:

  1. Check that Khushboo has sufficient balance
  2. Deduct $1 million from Khushboo's account
  3. Add $1 million to Vishudh's account

It must never happen that the money leaves Khushboo but is not credited to Vishudh. Either the money is not deducted at all, or β€” if deducted β€” it must be successfully credited. The same idea in SQL: two updates must both happen or neither.

BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- debit Alice
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- credit Bob
COMMIT;

If the server crashes after the debit but before the credit, atomicity rolls the whole thing back β€” Alice's $100 reappears. No money vanishes. That guarantee is the superpower banks, shops, and ticketing systems are built on.

ACID Consistency β‰  CAP Consistency (don't mix them up!)

This trips everyone up. The "C" in ACID is a different thing from the "C" in CAP:

  • CAP consistency = no stale reads. When we have multiple copies of the data (replicas/cache) and one copy is out of sync, we should not read from the stale copy.
  • ACID consistency = DB constraints are enforced. The schema (columns & data-types), NOT NULL / UNIQUE / foreign-key constraints, custom constraints (e.g. "this column must be lowercase"), and triggers all hold. Every transaction must leave the database in a consistent state.
Isolation β€” what goes wrong without it

Isolation means simultaneous transactions don't mess with each other. (There are several isolation levels β€” worth reading up on as homework.) Suppose Khushboo starts with exactly $1 million, and two transfers run at once, each reading the balance before either writes:

Transaction 1: Khushboo -- $1M --> Vishudh   Transaction 2: Khushboo -- $1M --> Nandani
  balance = get_balance(Khushboo)             balance = get_balance(Khushboo)
  assert balance >= $1M                        assert balance >= $1M
  new = balance - $1M                          new = balance - $1M
  set_balance(Khushboo, new)                   set_balance(Khushboo, new)
  rb = get_balance(Vishudh)                    rb = get_balance(Nandani)
  set_balance(Vishudh, rb + $1M)               set_balance(Nandani, rb + $1M)

Now interleave them badly:

Khushboo started with $1M
  T1: steps 1..3
  T2: steps 1..3
  T1: step 4   --> Khushboo's new balance = 0
  T2: step 4   --> Khushboo's new balance = 0   (overwrites!)
  T1: steps 5..6
  T2: steps 5..6

Both receivers got $1M but Khushboo only ever had $1M β€” $1M was created from thin air. This is a lack of isolation, and it should NOT happen. Proper isolation prevents the two transactions from interleaving like this.

Durability β‰  protection against disk failure

Durability means any committed transaction is stored in non-volatile storage (HDD/SSD), not just volatile RAM, so it survives a crash. But note: durability does not protect you against a hard-disk failure β€” only replication does that.

Pillar 2 β€” Normalization

Normalization means storing each fact exactly once, in one place, and linking to it instead of copying it. You split data into focused tables and connect them with foreign keys (a column that points at another table's primary key).

Why bother?

If a customer's email lived in 10,000 order rows and they changed it, you'd have to update 10,000 places β€” and if you missed one, your data now disagrees with itself (an update anomaly). Store the email once in a customers table, and orders just reference the customer's id. One update, one source of truth.

Pillar 3 β€” Fixed schema

A schema is the blueprint: which tables exist, what columns they have, what type each column is, and what rules apply. In SQL the schema is fixed and enforced β€” the database rejects anything that doesn't fit (a letter in a number column, a missing required field). It's a strict contract, which makes the data predictable and safe to rely on.

Pillar 4 β€” Joins (backed by indexes)

Because data is split across normalized tables, you need a way to stitch it back together. A join combines rows from two tables wherever a column matches. To do this fast, the database uses an index β€” a sorted lookup structure (usually a B-tree) that finds matching rows in roughly O(log n) time instead of scanning every row.

Worked example β€” a tiny shop

Two normalized tables linked by a foreign key:

CREATE TABLE customers (
  id     SERIAL PRIMARY KEY,
  name   TEXT NOT NULL,
  email  TEXT UNIQUE NOT NULL
);

CREATE TABLE orders (
  id           SERIAL PRIMARY KEY,
  customer_id  INT REFERENCES customers(id),  -- foreign key
  total_cents  INT NOT NULL,
  created_at   TIMESTAMPTZ DEFAULT now()
);

-- index so the join below is fast
CREATE INDEX idx_orders_customer ON orders(customer_id);

Now ask "how much has each customer spent?" β€” the join recombines the split data:

SELECT c.name, SUM(o.total_cents) AS spent
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.name
ORDER BY spent DESC;

The email is stored once, the relationship is enforced, the join is index-fast, and if you wrapped order-writing in a transaction it would be ACID-safe. All four pillars in one screen.

More strengths β€” and one more thing about the schema

Beyond the four pillars, the fixed schema is well-defined (you know exactly which tables exist, which columns, and each column's data type β€” which lets the DB allocate a fixed amount of space per row on disk), static (it doesn't change row-to-row, and changing it is discouraged and hard), and enforced (a row that violates the schema is rejected β€” the write won't succeed).

Changing the schema is slow

You can add/remove columns from an existing table, but it requires a complete table re-write (a table migration): extremely slow, and it normally requires the service to be down. There are ways around the downtime (a rolling migration), but it's never free.

Relational DBs also have an enormous feature set: powerful querying (joins, filtering by column, aggregate calculations, grouping, nested queries, recursive queries via CTEs) plus extras like full-text search, geospatial queries, and JSON. They offer pretty much every feature any other database in the world provides β€” just at low scale. And they are extremely mature: relational database theory predates modern computer hardware, and they've been battle-tested across millions of companies and thousands of scenarios.

Keep rows small (< ~1 KB)

You should only use a relational DB if each individual item (one row) is "small" β€” under ~1 KB, or a few KB at most. If your rows are routinely > 10 KB, you probably shouldn't use a SQL DB. The DB won't complain if you try to store a 100 MB row, it's just a bad choice. This does NOT mean SQL can only handle 10 KB total β€” SQL can handle terabytes, as long as that data is spread across many rows and tables, and individual rows stay small.

Key takeaway

SQL databases trade some flexibility for very strong guarantees: correctness (ACID), no duplicated facts (normalization), enforced structure (schema), and the ability to ask rich, ad-hoc questions across related data (joins + indexes). For a single powerful machine, this is a fantastic deal.

Recap Relational databases store data in linked tables and give you four big wins: ACID (all-or-nothing, crash-safe correctness), normalization (each fact stored once), a fixed schema (enforced structure), and joins + indexes (fast, flexible queries across tables). Remember these four β€” the next topic is about losing them.

2 Why sharding breaks SQL's benefits


Explain like I'm 5

Our tidy librarian worked great while everything was in one building. But the library got so popular that one building can't hold all the books or all the visitors. So we open ten branches across the city and split the books between them. Now a problem appears: if a book in Branch A mentions a book in Branch G, checking both at once means phone calls between branches β€” slow and fragile. And the "all-or-nothing" promise is much harder when ten different buildings have to agree. Splitting up scaled the library, but it broke the easy guarantees.

Recall from earlier sessions that a single database server eventually hits a ceiling β€” too much data, too many requests. Sharding (also called horizontal partitioning) is the fix: split the rows of a table across multiple machines, called shards, so each shard holds a slice of the data and handles a slice of the load. The trouble is that the four SQL pillars from Topic 1 quietly assumed all the data lives in one place. Spread it out and they crack.

What breaks, and why

SQL pillarWhat sharding does to it
JoinsIf the two rows you want to join sit on different shards, the database can't just walk an index β€” it must fetch from multiple machines over the network and combine. This cross-shard join is slow and often disabled entirely.
ACID transactionsA transaction touching rows on several shards becomes a distributed transaction. To stay atomic, every shard must agree to commit together (a protocol called two-phase commit / 2PC) β€” slow, and it stalls if any shard is unreachable.
Uniqueness & constraintsA UNIQUE column or auto-increment id is trivial on one machine. Across shards, no single shard can see all the others, so guaranteeing global uniqueness needs extra coordination (or central id generators).
NormalizationSince joins are now expensive, you're pushed to denormalize β€” copy related data together so a query hits one shard. That reintroduces the duplicated-fact problems normalization was designed to prevent.
Worked example β€” the join that crosses shards

Say we shard orders by order_id and customers by customer_id. Now run our earlier query:

SELECT c.name, SUM(o.total_cents)
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.name;

A customer lives on, say, shard 3, but their orders are scattered across shards 1, 4, and 7 (because they were placed by order_id). To answer, the system must query every shard, ship partial results back, and merge them β€” a scatter-gather query. What was one index lookup is now a network-wide fan-out. Do this at scale and it crawls.

The deeper trade-off (CAP)

Once data spans many machines, the network will sometimes drop messages between them (a partition). The CAP theorem says that during a partition you must choose: keep every node perfectly Consistent (reject some requests) or stay Available (answer, but possibly with stale data). You can't have both at that moment. Single-machine SQL never had to make this choice; distributed systems must.

Why ACID needs a single server

SQL databases give ACID guarantees only within a single server, because everything ACID needs is easy there but hard once you spread out:

Inside a single serverAcross multiple servers
Locks are easy (OS & hardware provide locks/semaphores)Distributed locks are extremely hard & slow (PACELC) β€” need 2PC / Zookeeper
Shared memory (RAM, HDD) β€” no network overhead, no stale-read issues between threadsCan't share memory β€” you keep copies, which leads to consistency challenges
You know immediately whether a write succeeded or failedYou don't know if a write succeeded β€” you must pass acknowledgements
Writes mostly always succeedNetworks & servers regularly fail

Providing ACID guarantees across shards is possible β€” but very slow and very complex. In short: sharding nullifies ACID.

Strong schema breaks too: the Amazon product-listing problem

What if the data is inherently unstructured? Consider modeling product listings for Amazon: 10 million+ products across 10,000+ categories, where every category has a different attribute set:

  • T-shirts: Brand, Price, Color, Fabric, Neck-shape, Sleeve length
  • Laptops: Brand, Price, Screen Size, RAM, CPU, GPU, OS
  • Notebooks: Brand, Price, Page thickness, Number of Pages, Ruled

There are four ways to force this into SQL, and all of them hurt at scale:

Approach 1 β€” one giant table with all columns.

products: id, name, brand, price, Color, Fabric, Neck-shape, Sleeve length,
          Screen Size, RAM, CPU, GPU, OS, Page thickness, Number of Pages, Ruled, ...

10,000 categories Γ— ~10 unique cols each β‡’ ~100,000 columns. Every row only fills ~10 of them; the other 100,000 are NULL. And NULL is itself bad because it's ambiguous β€” it can mean missing data (the value exists, we just don't know it) or undefined (what's the page-thickness of a t-shirt?). Worse, SQL pre-allocates space for the entire row including the NULL columns, so you waste 99.99% of the disk space.

Approach 2 β€” multiple tables, one per product type.

products:   id, name, brand, price
tshirts:    product_id, Color, Fabric, Neck-shape, Sleeve length
laptops:    product_id, Screen Size, RAM, CPU, GPU, OS
notebooks:  product_id, Page thickness, Number of Pages, Ruled
... 10,000 such tables

Now a query like "find the top 10 most expensive products that are color red" must join across 10,000 tables. SQL can handle lots of rows and terabytes of data (few tables, lots of data in them) β€” but it cannot handle lots of "schema" (many tables).

Approach 3 β€” an attribute list (EAV).

products:            id, name, brand, price
product_attributes:  product_id, attribute_name, attribute_value
product_idattribute_nameattribute_value
1RAM16GB
1CPUi9 14400k
1Screen Size17"
1Fabriccotton
2Neck TypeRounded
2Sleeve Lengthfull
2Fabriccotton

Notice product 1 has both RAM and Fabric β€” schema enforcement is lost: a laptop can now have a fabric. You could enforce attributes in the application layer, but that breaks separation of concerns (validation now lives partly in the DB and partly in the app). Also, fetching a single entry is expensive β€” to show one laptop you must fetch all its attribute rows and join them into an array.

Approach 4 β€” JSON columns. Modern relational DBs (Postgres, MySQL) have first-class JSON support, so can't we just use that? Yes and no. It works, but it still isn't designed to handle scale.

The lesson

Because Amazon's product data is inherently semi-structured, SQL is a poor fit. At low scale (only 2–3 product categories) any of these approaches would've worked fine β€” the problem is scale plus schema variety, which is exactly what NoSQL was built for.

Key takeaway

Sharding is what lets you scale past one machine β€” but it is precisely the joins, transactions, and global constraints that get expensive or impossible across shards. You haven't done anything "wrong"; these guarantees simply cost coordination that a single box gave you for free. This pain is exactly what motivated a new family of databases: NoSQL.

Recap Sharding splits a table's rows across machines to scale. But cross-shard joins turn into slow scatter-gather, cross-shard transactions need expensive coordination (2PC), global uniqueness needs extra machinery, and you're pushed to denormalize. Add the CAP trade-off and you see why people reach for NoSQL β€” our next topic.

3 SQL vs NoSQL β€” and the four NoSQL families


Explain like I'm 5

SQL is the strict librarian who insists every book follows the rules. NoSQL is a big, relaxed warehouse with different kinds of storage: numbered lockers you grab by their tag, folders that can each hold whatever papers you like, giant pinboards organised by column, and a web of string connecting people who know each other. The warehouse doesn't fuss over rules β€” it's built to be huge and fast for one job at a time, and to spread across many buildings easily. You give up the strict tidiness; you gain enormous scale.

NoSQL is an umbrella term for databases that drop some relational rules to scale out across many machines more easily. They typically favour horizontal scaling (add cheap machines) over the SQL habit of vertical scaling (buy one bigger machine), and many offer eventual consistency β€” after a write, replicas converge to the same value "eventually" rather than instantly β€” in exchange for staying available. There are four main families.

NoSQL β‰  "No SQL"

NoSQL does not mean "don't use SQL." It stands for Not Only SQL β€” we keep using relational databases and augment them with additional non-relational ones. A bit of history: SQL theory predates modern computers, whereas NoSQL is recent β€” the first NoSQL DB was Google's BigTable, and NoSQL only got popular after ~2005 (most internet giants didn't even exist before then). Around 2009/2010 there was a conference on non-relational DBs; the promoters needed a Twitter hashtag to go viral, and picked #NoSQL β€” that's literally where the name comes from.

Don't jump to NoSQL

Your de-facto choice should always be a relational (SQL) database. Use NoSQL only if you can justify the need. Modern SQL DBs (Postgres, MySQL) can do absolutely everything NoSQL can β€” and more β€” just at low scale, and the bar for "low scale" rises every day. For up to a ~1 million userbase, a SQL DB will just work flawlessly. 99% of companies use SQL, and 99% do not primarily need NoSQL.

SQL vs NoSQL in one breath

SQLNoSQL
ACIDBASE
NormalizationDenormalized data & redundancy
Do everything, decently well (Jack of all trades)Do one thing, extremely well (Master of one)
1 serverMultiple servers (built-in sharding, auto-scaling, load balancing)

The four families at a glance

πŸ”‘
Key-Value
tag β†’ blob
Β·
πŸ“„
Document
JSON-like docs
Β·
🧱
Wide-Column
rows of sparse columns
Β·
πŸ•ΈοΈ
Graph
nodes + edges
  • Key-value β€” the simplest: store a value under a key and fetch it back by that key. Blazing fast, but you can only look things up by the key. Examples: Redis, DynamoDB, Riak. Great for caches, sessions, feature flags.
  • Document β€” store self-contained documents (usually JSON). Each document can have its own shape (a flexible schema), and you can query on fields inside it. Examples: MongoDB, Couchbase, Firestore. Great for catalogs, user profiles, content.
  • Wide-column β€” rows are identified by a key, but each row can hold a huge, sparse set of columns grouped into families; built to write and read enormous volumes across many nodes. Examples: Cassandra, HBase, Bigtable. Great for time-series, logs, write-heavy feeds.
  • Graph β€” data is nodes (things) connected by edges (relationships); you traverse the connections directly. Examples: Neo4j, Amazon Neptune. Great for social networks, fraud rings, recommendations β€” anything where the relationships are the point.
Worked example β€” the same customer, three ways

SQL needs a row that matches the table's columns exactly:

-- SQL row (fixed schema)
INSERT INTO customers (name, email) VALUES ('Mia', 'mia@x.com');

A document store keeps the whole customer as one flexible object β€” note the second customer has extra fields and no schema change was needed:

// Document store (e.g. MongoDB)
{ "name": "Mia", "email": "mia@x.com" }
{ "name": "Leo", "email": "leo@x.com",
  "address": { "city": "Pune" }, "tags": ["vip"] }

A key-value store just stashes the blob under a key for instant lookup by that key:

# Key-value store (e.g. Redis)
SET customer:42 '{"name":"Mia","email":"mia@x.com"}'

SQL vs NoSQL β€” the comparison

DimensionSQL (relational)NoSQL (typical)
Data modelTables of rows & columnsKey-value, document, wide-column, or graph
SchemaFixed & enforcedFlexible / schema-on-read
ScalingMostly vertical (bigger box)Mostly horizontal (more boxes)
ConsistencyStrong (ACID)Often eventual (tunable)
JoinsFirst-class, index-backedLimited or absent; you denormalize
Best forComplex, related data needing correctness (finance, orders)Massive scale, simple access patterns, flexible data
Query styleRich, ad-hoc (any join, any filter)Optimised for known access patterns

BASE β€” the NoSQL counterpart to ACID

Where SQL gives ACID, NoSQL gives BASE:

  • Basically Available β€” the system as a whole stays available, even if some services are unavailable to a small fraction of users for a short time. High availability for the whole system, not for every individual service/user.
  • Soft State β€” unlike ACID's all-or-nothing atomicity, transactions can sit in a partial state for some time (even weeks!) β€” but eventually the data becomes consistent and atomic. (We'll see this in the last HLD class: distributed transactions via the Saga pattern.)
  • Eventually Consistent β€” there may be stale reads, but eventually (if we wait long enough) every write is reflected across all replicas.

Horizontally scalable by design

SQL databases require manual sharding β€” they don't provide built-in sharding (most modern SQL DBs do have built-in replication, but not sharding). To shard a SQL DB you either use a 3rd-party extension, do it manually (write the LB code, manage server state), or use a managed cloud service like Amazon RDS. NoSQL DBs, by contrast, are automatically sharded: you get a bunch of servers, install the database, and it figures out load balancing, autoscaling, sharding, data distribution, replication, and fault tolerance itself.

Denormalization & replication, on purpose

SQL discourages denormalization & redundancy to remove anomalies. NoSQL embraces the opposite, because it realises that (1) the frontend is going to show denormalized data anyway, and (2) to prevent data loss you're going to keep multiple copies anyway. So NoSQL DBs encourage storing data in a denormalized, semi-structured (sometimes even schemaless) manner.

Weaknesses of NoSQL β€” the flip side of scale

SQL has tons of features: joins, powerful data structuring, enforceable constraints, triggers, ACID, powerful indexing & filtering, recursive queries, plus modern ones (full-text search, first-class JSON, spatial/nearest-neighbor indexing, vector/KNN, denormalized views). Its only con is that it's feasible only at low scale β€” it generalizes (jack of all trades, master of none). NoSQL can work at massive scale precisely because it drops most of those features: it's hard to do everything perfectly, but easy to do a few things flawlessly. NoSQL specializes β€” jack of one trade, and master of it.

Pick by access pattern, not by hype

The most important rule: SQL lets you store first and figure out queries later; NoSQL asks you to know your queries first and shape the data around them. So choose by how you'll read the data. Always fetching one item by a known id? Key-value. Storing varied, self-contained objects? Document. Writing torrents of time-stamped data? Wide-column. Chasing relationships hop-by-hop? Graph. Need correctness and flexible ad-hoc queries over related data? Stay with SQL.

It's not either/or

Real systems mix both β€” this is polyglot persistence. A shop might keep orders and payments in PostgreSQL (needs ACID), the product catalog in MongoDB (flexible), and sessions in Redis (fast key-value). Use the right tool per job.

Recap NoSQL drops some relational rules to scale horizontally and stay available, usually with eventual consistency. Its four families are key-value (tag→blob), document (flexible JSON), wide-column (sparse, write-heavy), and graph (nodes & edges). SQL wins on correctness and ad-hoc queries; NoSQL wins on scale and flexibility. Choose by your access pattern. We'll dig into how a NoSQL engine actually stores data (the LSM tree) in Session 10.

4 Choosing a sharding key


Explain like I'm 5

Imagine sorting a giant class of kids into ten lunch lines. If you sort by the first letter of their name, one line might be packed with all the "S" kids while another is empty β€” uneven and unfair. If instead you sort by the last digit of their student number, the lines come out roughly equal. The rule you pick for "which line do you join" is the sharding key. A good rule spreads everyone evenly and still lets you find a kid quickly; a bad rule jams everyone into one line.

When you shard (Topic 2), you must decide: for a given row, which shard does it live on? That decision is driven by the sharding key (also called the partition key) β€” a column whose value is run through a function to pick a shard, e.g. shard = hash(key) % number_of_shards. The sharding key decides how data gets distributed across DB servers, and also how queries get routed to find the data. This single choice quietly decides whether your system stays fast and balanced or falls over.

Primary key vs sharding key

These are two different things that coexist:

  • Primary key (PK) β€” uniquely identifies an item (a row). It's what you're talking about.
  • Sharding key (SK) β€” tells you how to distribute data: what data goes to what server. It's where to find the data.
Two quick PK questions

Must a PK be unique across tables? No. It's fine to have user_id = 1 and product_id = 1 β€” they're different entities in different tables.
Must a PK be unique across shards, for the same table? Yes! A PK must be unique for each row in a table whether or not the table is sharded. It would be wrong for Shard 1 to have user_id=1 name=Sai while Shard 2 has user_id=1 name=Abhishek.

Worked example β€” sharding three related tables by user_id

Three tables. For users, PK = id and SK = id. For both user_posts and user_friendship, PK = id but SK = user_id:

users(id, name, gender)               user_posts(id, user_id, title, content)
  A, Akshay,   Male                     1, A, Hi,       Hello World
  B, Balaji,   Male                     2, A, Bye,      Going to sleep
  C, Chandani, Female                   3, B, Wassup,   What's everyone doing
                                        4, C, Context?, Who are you guys?
user_friendship(id, user_id, friend_id, affinity)
  1, A, B, 100%   2, B, A, 100%   3, A, C, 50%   4, C, A, 50%

If we shard by user_id, every table must carry user_id, and a user's rows from all three tables land together:

Shard 1 (user A)                  Shard 2 (user B)            Shard 3 (user C)
  users: A, Akshay, Male            users: B, Balaji, Male      users: C, Chandani, Female
  posts: 1,A,Hi / 2,A,Bye           posts: 3,B,Wassup           posts: 4,C,Context?
  friends: 1,A,B / 3,A,C            friends: 2,B,A              friends: 4,C,A
Common sharding-key Q&A
  • Must the SK be unique across tables? No β€” the question is odd, since all tables are sharded the same way.
  • Must the SK be unique across shards? Yes β€” if it weren't, we couldn't route to different shards.
  • PK or SK β€” which do we use to read/write? Both! The SK tells you which server to go to (routing); the PK tells you which entry to touch inside that server. It's common (but not required) to use the same column for both β€” e.g. user_id as both PK and SK of users, while posts has PK post_id but SK user_id.
  • Can you omit the SK? Yes β€” the PK alone uniquely identifies a row. But without the SK, the query becomes a fan-out (you must hit all shards).
  • Can a DB have more than one SK? No. The SK can be composite (multiple columns), but it must be the same group of columns across the whole database. Need a different SK? Use a different database.
  • Must all tables have the SK column? Yes. For data that doesn't need splitting (or is needed everywhere), either replicate it across all shards (only safe for small, rarely-modified tables) or give it a separate database.
  • Can the SK be composite? Yes β€” e.g. (class-number, gender) in a non-co-ed school.
  • Can you change the SK later? Possible, but not recommended β€” it requires a complete re-shuffle of all data across servers (very expensive, likely needs downtime). Choose well up front.
  • What if a row's SK value is null? Bad idea, but the LB doesn't care β€” it treats "null" like any other value, so all SK = null rows end up in the same shard.
The "find your sister at school" analogy

You go to school to find your little sister. The primary key is her name & student id (which person). The sharding key is her class number (which room). You must know the PK to find her. If you also know the SK, you go straight to the right classroom (server). If you don't know the SK, you fan-out and search every classroom.

Now, what makes a sharding key good? Several goals guide it.

Mental model

Suppose you shard by column SK and consider two rows R1 and R2:

  • If R1.SK == R2.SK β†’ they land on the same shard, guaranteed.
  • If R1.SK != R2.SK β†’ they most likely land on different shards (assume so), though they can share a shard, because one shard holds many sharding-key values (consistent hashing).

Goal 1 β€” Equal data & load distribution

Data and traffic should spread roughly equally across shards β€” all key values should be equally likely. If some values are more likely than others, you get hot shards. Examples of skew:

  • Age: ages 0–5 and 50+ are less likely, 15–30 most likely, and almost nobody is 100+. Sharding by age gives poor distribution.
  • Gender: depending on the app, you get hot shards β€” e.g. most Scaler students are male (gender disparity in higher technical studies).
  • User id: some users post a lot, some post zero β€” but since each server houses 100,000s of users, the normal users and influencers average out, so distribution stays even. (Caveat: not always true β€” the celebrity problem, especially for notification systems.)
Does sharding by user_id mean one server per user?

No! 2 billion users does not mean 2 billion servers. A single server houses many users, because multiple key values hash to the same server. If Post_13 is by User_1 and Post_20 is by User_2, they most likely land on different servers β€” but each server still holds millions of users.

Goal 2 β€” High cardinality

Cardinality is the count of possible distinct values. It doesn't define how many servers you have, but it caps how many you can ever scale to:

  • Age {0..123} β†’ ~124 possibilities max. With 125 servers, one gets nothing β€” you're limited to ~124 shards.
  • Gender {Male, Female, LGBTQ+} β†’ 3 possibilities. With 4 servers, one is idle; max scaling is 3 shards, even with a billion users.
  • User_Id (64-bit) β†’ ~16 quintillion possibilities. No practical limit β€” 10 million servers? No problem. (We won't actually have quintillions of servers; one server houses millions of users.)

Goal 3 β€” Avoid hotspots & support your common queries (no fan-outs)

A hotspot is one shard getting far more traffic than the others, becoming a bottleneck while the rest sit idle. Relatedly, the SK should be part of every read/write request β€” otherwise you can't route, and you fan-out to every shard. The fastest query hits a single shard; the rule is that the most frequent queries should hit only 1 (at most 2) shards, and no frequent query should fan out. (Rare queries are okay to fan out β€” you can't optimize everything.)

Why "part of the request" matters β€” sharding by gender

Suppose we shard by gender and want Tanisq's profile page:

SELECT * FROM user_profiles WHERE user_id = 1234;

This fans out β€” the DB LB doesn't know Tanisq's gender, so it can't pick a shard. We could add the gender:

SELECT * FROM user_profiles
WHERE user_id = 1234 AND gender = 'MALE';

This won't fan out β€” but how would the app server even know Tanisq's gender while rendering his profile? It usually doesn't, which is exactly why a low-info key like gender is a poor SK.

Goal 4 β€” Immutable

The SK value should never change for a row. If it changes, you must re-shuffle that row to a different server. Example: age β€” what happens on the user's birthday? The age changes, so you'd have to move them to a different server. (Timestamps have the same problem, plus they create write hotspots β€” see below.)

Worked example β€” good key vs bad key

We're building a feed where the hot query is "get this user's recent posts."

Bad key β€” created_at (a timestamp):

-- every brand-new post has "now" as its key β†’
-- all writes hammer the single newest shard = HOTSPOT.
-- and "this user's posts" are scattered by time across ALL shards.
shard = hash(created_at) % N   -- βœ— skewed writes, scatter-gather reads

Good key β€” user_id:

-- millions of distinct users β†’ high cardinality β†’ even spread.
-- one user's posts all land on ONE shard β†’ single-shard read.
shard = hash(user_id) % N      -- βœ“ balanced writes, fast reads

The query "get user 42's recent posts" now routes to exactly one shard and uses its local index β€” fast and balanced. Same data, opposite outcome, decided entirely by the key.

Watch the cardinality trap

Sharding by something like country feels natural but has low cardinality and heavy skew β€” a "USA" or "India" shard can dwarf a "Luxembourg" shard. Low-cardinality keys can't spread data evenly no matter how clever the hash. Prefer high-cardinality keys.

Composite keys β€” getting both balance and locality

Sometimes one column isn't enough. A composite key combines columns, commonly a partition part (decides the shard) and a sort part (orders rows within that shard). This is the heart of wide-column stores like Cassandra and DynamoDB.

Example β€” composite key for a chat app
-- partition by chat_id (spreads chats across shards),
-- sort by created_at (messages within a chat stay ordered).
PARTITION KEY = chat_id
SORT KEY      = created_at

All of one chat's messages live together on one shard (single-shard, in-order reads), while different chats spread across shards (even load). You get balance and locality.

Resharding is painful β€” choose carefully now

If shard = hash(key) % N and you grow from N=4 to N=5, almost every key now hashes to a different shard, so nearly all data must physically move while the system is live β€” slow, risky, and disruptive. Two mitigations are standard: consistent hashing (only a small fraction of keys move when you add a node) and over-provisioning many small virtual shards up front. Either way: picking the right key early is far cheaper than changing it later.

Recap The sharding key (where to find data) and primary key (which row) coexist β€” the SK routes you to a server, the PK finds the row inside it. A good SK gives even data & load distribution (equally-likely values), high cardinality (so you can scale to many servers), is part of every request with no frequent fan-outs (single-shard common queries), and is immutable (never re-shuffle). Shard by what you filter on (e.g. user_id, not created_at or low-cardinality gender/age). Use composite keys for balance plus locality, and choose well from the start because resharding is expensive.

5 Choosing a sharding key β€” worked examples


Explain like I'm 5

Picking a sharding key is like deciding how to split toys into boxes so you can always find one fast. For your own toys, split by "whose toy is it" β€” all your toys in one box. But for a board game where everyone shares the pieces, split by "which game," not "which person," or you'd have to open everyone's box to find all the pieces. The right rule depends on what you'll look for most.

The same theory applied to four real systems. For each, list the most frequent operations first, then test candidate keys against the four goals.

Example 1 β€” Banking system

Users can have active bank accounts across cities. Most frequent operations:

  • Balance query (user_id, account_id)
  • Fetch transaction history (user_id, account_id, date_range)
  • Fetch a user's list of accounts (user_id)
  • Create transaction (sender_id, receiver_id, amount) β€” note sender_id and receiver_id are composite (user_id, account_id)
Candidate keyVerdict
location (city_id)Bad. A user can have accounts across cities, so their data splits across shards; "list of accounts" fan-outs; large cities are hot shards; not part of request; low cardinality.
branch_idSame issues as location, just more granular.
account_balanceBad. Changes constantly (move data on every deposit), not in the request, poor distribution.
timestampNEVER. Unequal load (recent = hot, future = empty), mutable (updated_at), not in request.
transaction_idBad. Generated in the response, not part of the request. Any system-generated id (ticket/booking/transaction) can't be the SK.
account_idFine for most queries, but breaks the dashboard: a user's different accounts could land on different servers β†’ fan-out for "list my accounts."
user_id βœ“Ideal. One user's data lives entirely on one server (still 1000s of users/server). Balance, history, and account-list all hit just 1 shard.
Transactions touch two shards (and that's okay)

A transaction is data about both sender and receiver, so it's stored in 2 shards (sender's + receiver's). Hitting 2 shards is not a fan-out and is acceptable β€” but it's still hard: it must be done atomically (2-Phase Commit), giving high latency and low availability.

Isn't user_id also system-generated?

Yes β€” but only once, at registration. On every subsequent request the client already has the user_id and sends it along (cookie / auth-token), so it is part of the request. A transaction_id, by contrast, only exists after the request.

Example 2 β€” IRCTC (Indian railway ticket booking)

Requirements: prevent double-booking of seats (at least for confirmed, non-RAC tickets), and handle peak Tatkal load (20Γ—+ the average). Core call:

book_ticket(user_id, train_id, date_of_journey, class,
            seat_preference, meal_preference, ...) => ticket: {id, details}
Candidate keyVerdict
ticket_idBad β€” generated in the response, not part of the request.
date of travelIt's a timestamp β†’ big no. Past dates become immutable; you can't book/modify a journey already completed.
user_idBad for de-duplication. Booked tickets are spread across users' shards, so to check whether a seat on Rajdhani 15-Feb is taken (Sanjana's, Rohit's, ...), you must hit every shard β†’ fan-out.
train_id βœ“Ideal. All tickets for one train live in one shard. Sanjana & Rohit (Rajdhani 15-Feb) are co-located; Vijay (Shatabdi) can't collide β€” different trains. Duplicate-check hits just 1 shard. IRCTC runs ~13k passenger trains β†’ decent cardinality.
But a user books across many trains β€” where's their data?

Use two databases. The user-dashboard service shards user data by user_id; the booking microservice shards ticket data by train_id. Each ticket is stored in 2 DBs β€” the tickets DB (by train_id) and the user's DB (by user_id).

"All journeys on a given day" would fan out β€” is that a problem?

It's a fan-out, but it's not a frequent query, so it's acceptable. If it were frequent, we wouldn't change the SK (train_id is ideal for de-dup) β€” we'd add another database sharded by date to serve it. (IRCTC actually does this!)

Example 3 β€” Facebook Messenger (1-1 chat)

Operations: sendMessage(sender_id, recipient_id, message) β†’ Ack/Failure and viewMessages(user_id, other_person_id) β†’ list of messages.

  • user_id βœ“ β€” all messages Sanjana sends and receives live in her shard. To view her conversation with Sachin, only her shard is hit. A "Sanjana β†’ Sachin" message is stored in both Sanjana's and Sachin's shards β€” that's 2 shards, not a fan-out.
  • message_id β€” created in the response (not in the request). Even if generated client-side, sharding by it scatters messages across all servers, so "all messages Sanjana sent Sachin" becomes a fan-out.

Example 4 β€” Slack (1-1 and group chat)

Groups can have up to 100,000 participants. Consider an "Announcements" group of 100,000 people where Sanjana sends "Hi".

Candidate keyVerdict
user_idTwo bad designs: (a) store "Hi" only in Sanjana's shard β†’ any reader must hit all 100,000 member shards (writes:1, reads:100,000); or (b) store "Hi" in all receivers' shards β†’ writes:100,000, reads:1. Both are terrible.
group_id βœ“All of a group's messages live in one shard. Sanjana's "Hi" to Announcements β†’ write 1 shard, read 1 shard.
So what's the final key for Slack (which has both 1-1 and groups)?

Two approaches: (1) two databases β€” group conversations sharded by group_id, 1-1 conversations sharded by user_id; or (2) treat every 1-1 chat as an "ad-hoc" group of 2 participants, so "Sanjana β†’ Hi β†’ Tarun" is just a message in conversation (sanjana_id, tarun_id) β€” then everything shards by conversation_id.

Recap Always start from the most frequent operations, then test keys against the goals. Reject system-generated ids (ticket/transaction/message id), timestamps, mutable values, and low-cardinality/skewed columns (location, gender, account_balance). Banking β†’ user_id; IRCTC de-dup β†’ train_id (with a separate user DB by user_id); Messenger β†’ user_id; Slack β†’ group_id (or conversation_id). When two access patterns conflict, use two databases sharded differently rather than compromising one key.

6 How storage works β€” sequential vs random access


Explain like I'm 5

Imagine a record player. Reading a whole song straight through is quick β€” the needle just rides along. But if you wanted to hear one second from this song, then one second from a different track, the needle would have to keep lifting and jumping around β€” slow! Reading data in order (sequential) is way faster than hopping all over (random), no matter what storage we use.

Storage comes in layers β€” Magnetic (HDD), Solid State (SSD), and RAM β€” each faster and pricier than the last (see the videos in References). The key idea spans all of them: sequential access is far faster than random access. Let's prove it with HDD math.

HDD math β€” spin time, seek time, bandwidth

A hard disk spinning at 7200 RPM:

7200 rpm = 7200/60 = 120 rotations/sec
time for 1 rotation = 1 / 120 s = ~8 ms
time to move the spindle (read head, "seek") = ~10 ms

Reading an entire track (100 MB of sequential data):

10 ms  to move the spindle to the correct track
 8 ms  to spin the disk once
----
bandwidth = 100 MB / 18 ms = 100 MB / 0.018 s = ~5.5 Gbps

Reading 4 KB of random data (why 4 KB? the disk is not byte-addressable β€” every read/write happens in 4 KB chunks). The data is on some other track:

10 ms  to move the spindle to the correct track
 8 ms  to spin the disk once
----
bandwidth = 4 KB / 18 ms = ~222 kbps
Sequential is up to 10,000Γ— faster than random

5.5 Gbps vs 222 kbps β€” that's the gap between sequential and random access on an HDD. And this is true irrespective of the storage technology: HDD, SSD, RAM, L3 cache β€” even your brain. This single fact drives huge amounts of database design (and is exactly why the LSM tree in Session 10 turns random writes into sequential ones).

Recap Disks read/write in 4 KB chunks, not bytes. On a 7200-RPM HDD, one rotation is ~8 ms and a seek is ~10 ms, so reading 100 MB sequentially hits ~5.5 Gbps while reading 4 KB at random is ~222 kbps. The takeaway: sequential access can be up to 10,000Γ— faster than random β€” across every storage tier.

β˜… Putting it all together


This session was really one continuous story about scale and trade-offs. Here it is in a paragraph:

The big idea

A relational (SQL) database gives you wonderful guarantees on one machine β€” ACID correctness, normalized single-source-of-truth data, an enforced schema, and fast joins over indexes. But to scale past one machine you must shard, and sharding is exactly what makes cross-shard joins, transactions, and global uniqueness expensive β€” so people reach for NoSQL, which trades strict rules for horizontal scale and (often) eventual consistency, in four flavours: key-value, document, wide-column, and graph. Whichever sharded system you build, its single most important knob is the sharding key: pick a high-cardinality key that spreads load evenly, avoids hotspots, and keeps your common queries on one shard β€” because changing it later (resharding) hurts.

Quick self-check

What do the four letters of ACID stand for, in one phrase each?

Atomicity (all-or-nothing), Consistency (rules always hold), Isolation (concurrent transactions don't interfere), Durability (committed data survives crashes).

Why does normalization save you from "update anomalies"?

Each fact (like a customer's email) is stored exactly once, so changing it is a single update. If it were copied into many rows, you'd risk missing some and leaving the data contradicting itself.

Name two SQL features that become hard or slow once you shard, and why.

Joins (rows to combine may sit on different machines β†’ slow scatter-gather) and transactions (atomicity across shards needs costly coordination like two-phase commit). Global uniqueness is another, since no single shard sees all the others.

You always fetch a single object by a known id and never run joins. Which NoSQL family fits?

A key-value store. It's optimised for exactly that: fetch a value by its key, extremely fast.

Why is a timestamp usually a terrible sharding key?

It's monotonic, so every new write has roughly the same "latest" value and lands on one shard β€” a write hotspot β€” while reads for a single entity get scattered across all shards by time.

What does a composite (partition + sort) key buy you?

Both balance and locality: the partition part spreads groups across shards evenly, while the sort part keeps a group's rows together and in order on one shard for fast single-shard reads.

Why is resharding with hash(key) % N so disruptive?

Changing N changes the result for almost every key, so nearly all data must move at once. Consistent hashing or many virtual shards reduce how much moves β€” which is why you choose the key carefully up front.

πŸ“š References & Further Reading


Class material

Papers, docs & deep dives