Database NotesRohit’s interview study guide
Chapter 26

Choosing a Database

How to pick between MySQL, PostgreSQL, MongoDB, Redis and the rest — the real trade-offs, CAP and PACELC, consistency models and workload shapes, demonstrated with real replicas, partitions and measurements rather than slogans.

7 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#SQL vs NoSQL: the real trade-offs

Added

"SQL vs NoSQL" is a misleading label. MongoDB has a query language and joins ($lookup); MySQL stores JSON documents; PostgreSQL does both very well. The differences that actually decide a design are about the data model (normalised tables you join at read time, or aggregates stored together), where the schema is enforced (by the database on every write, or by your code), what transactions and consistency you get by default, and how the system scales out.

A mental model: a relational database is a filing cabinet with cross-references. Each fact lives in exactly one drawer — the customer's name in customers, the product's price in products — and any question can be answered by following the references. A document database is a folder per order with everything about that order inside: you grab one folder and have it all, but the customer's name is now written in every folder they own, and changing it means visiting all of them.

QuestionRelational (MySQL, PostgreSQL)Document (MongoDB)
Shape of the datarows in normalised tables, related by keysnested documents shaped like your objects
Schemaenforced on write (types, NOT NULL, ENUM, foreign keys, CHECK)flexible by default; optional $jsonSchema validation
Reading an aggregatejoin at read timeone document read, no join
Changing shared dataupdate one rowupdate every document that copied it
Ad-hoc questions you didn't plan forSQL handles almost anythingeasy if the data is embedded the right way, otherwise $lookup and pipelines
Transactionsmulti-row ACID is the default modelsingle-document atomic; multi-document transactions since 4.0
Scaling writesone primary; sharding is external or manual (Vitess, Citus)built-in sharding
MySQL: order 1, assembled from four tables
SELECT o.id, c.name AS customer, o.status, p.name AS product, oi.quantity, oi.unit_price
FROM orders o
JOIN customers c    ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p     ON p.id = oi.product_id
WHERE o.id = 1;
Result
+----+----------+---------+---------------+----------+------------+
| id | customer | status  | product       | quantity | unit_price |
+----+----------+---------+---------------+----------+------------+
|  1 | Rohit    | shipped | ThinkBook 14  |        1 |     899.00 |
|  1 | Rohit    | shipped | USB-C Charger |        2 |      29.00 |
+----+----------+---------+---------------+----------+------------+
MongoDB: the same order as one document
db.orders.findOne({ _id: 1 })
Result
{
  _id: 1,
  status: 'shipped',
  ordered_at: ISODate('2025-02-01T10:00:00.000Z'),
  customer: { id: 1, name: 'Rohit', city: 'Toronto' },
  items: [
    {
      product_id: 1,
      name: 'ThinkBook 14',
      category: 'Laptops',
      qty: 1,
      unit_price: 899
    },
    {
      product_id: 7,
      name: 'USB-C Charger',
      category: 'Accessories',
      qty: 2,
      unit_price: 29
    }
  ]
}
What’s happening
  1. Both databases hold the site's sample data in ch25: MySQL has the normalised tables, and the MongoDB orders collection was built from them by a small Node script, one document per order with its customer and items embedded.
  2. MySQL assembles order 1 at read time by joining orders → customers, orders → order_items → products. The result is flat: one row per item, with the order and customer columns repeated on each row. Your code (or an ORM) turns those rows back into one object.
  3. MongoDB stores the order already assembled. findOne returns the nested object your app wants, in one read, with no join. That's the document model's main win: data that's read together is stored together.
  4. The price of that win is in the embedded copies: customer.name, the product names and the category names now live inside every order. MySQL keeps each of those facts once. Both choices are reasonable — order lines are often meant to be a snapshot of what was sold at the time.
  5. Neither result is "faster" in any meaningful way at this size — "Joins aren't the slow part" below measures it. The real differences show up when the data changes and when new questions arrive.
AdvancedMyth: NoSQL scales and SQL does not
AdvancedJoins aren't the slow part

A common claim is that documents are faster because joins are expensive. Measured on the same data, 2,000 reads each, warm connections:

read-order.mjs
import mysql from 'mysql2/promise';
import { MongoClient } from 'mongodb';
const db = await mysql.createConnection({ host: '127.0.0.1', port: 3307, user: 'root', password: 'study', database: 'ch25' });
const mongo = await MongoClient.connect('mongodb://127.0.0.1:27018/?directConnection=true');
const orders = mongo.db('ch25').collection('orders');
const SQL = `SELECT o.id, c.name AS customer, o.status, p.name AS product, oi.quantity, oi.unit_price
  FROM orders o JOIN customers c ON c.id = o.customer_id JOIN order_items oi ON oi.order_id = o.id
  JOIN products p ON p.id = oi.product_id WHERE o.id = ?`;
const N = 2000;
await db.query(SQL, [1]); await orders.findOne({ _id: 1 });   // warm up
let t = performance.now();
for (let i = 0; i < N; i++) await db.query(SQL, [1 + (i % 8)]);
const my = (performance.now() - t) / N * 1000;
t = performance.now();
for (let i = 0; i < N; i++) await orders.findOne({ _id: 1 + (i % 8) });
const mg = (performance.now() - t) / N * 1000;
console.log(`MySQL 4-table join by order id: ${my.toFixed(0)} µs`);
console.log(`MongoDB findOne by _id:         ${mg.toFixed(0)} µs`);
await db.end(); await mongo.close();

// MySQL 4-table join by order id: 111 µs
// MongoDB findOne by _id:         140 µs
// (second run: 115 µs and 233 µs)
What’s happening
  1. Each loop reads all eight orders round-robin, one round trip per read, so both databases do comparable work: find one order and everything in it.
  2. The four-table join took about 111–115 µs; the single-document read 140–233 µs. Every join here walks a primary key or the order_items primary key (order_id, product_id), so each step is an index lookup on data already in memory.
  3. At this scale both are dominated by the round trip and driver overhead, not by joining. Joins on indexed keys are cheap; what's expensive is joining unindexed columns or joining millions of rows — and that's just as expensive with $lookup.
  4. Where embedding really wins is at large scale and under sharding: one document lives on one shard, while a join across shards means talking to several machines. That's a scaling argument, not a "joins are slow" argument.
AdvancedSchema on write vs schema on read

Insert an order with a typo in its status in each database:

SQL
INSERT INTO orders (customer_id, status, ordered_at) VALUES (1, 'shippd', NOW());
Result
ERROR 1265 (01000) at line 1: Data truncated for column 'status' at row 1
mongosh
db.orders.insertOne({ _id: 99, status: 'shippd', customer: { id: 1, name: 'Rohit' }, items: [] })
db.orders.countDocuments({ status: 'shipped' })
db.orders.deleteOne({ _id: 99 })
Result
{ acknowledged: true, insertedId: 99 }
4
{ acknowledged: true, deletedCount: 1 }
What’s happening
  1. orders.status is an ENUM('pending','paid','shipped','cancelled'), and MySQL 9.7's default strict SQL mode rejects anything else with ERROR 1265 … Data truncated for column 'status'. The bad data never gets in. (In non-strict mode — the default before MySQL 5.7 — it would have stored an empty string with only a warning.)
  2. MongoDB accepts the document: no collection-level rules exist unless you add them. The typo'd order is now stored, and a query for shipped orders counts 4 — it silently doesn't see order 99 at all.
  3. This is "schema on write" vs "schema on read": in a document store the schema still exists, but it lives in your application code and in every reader's assumptions. Mongoose schemas and MongoDB's $jsonSchema validators move it back into a checked place (see Schema validation).
  4. Flexibility is useful when documents genuinely vary (product attributes that differ per category, third-party payloads). For core business entities, most teams end up adding validation anyway.
AdvancedDuplication moves cost from reads to writes

Rename the "Accessories" category in both:

SQL
UPDATE categories SET name = 'Gadgets' WHERE id = 5;
SELECT ROW_COUNT();
Result
+-------------+
| ROW_COUNT() |
+-------------+
|           1 |
+-------------+
mongosh
db.orders.updateMany(
  { 'items.category': 'Accessories' },
  { $set: { 'items.$[i].category': 'Gadgets' } },
  { arrayFilters: [{ 'i.category': 'Accessories' }] }
)
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 5,
  modifiedCount: 5,
  upsertedCount: 0
}
What’s happening
  1. In MySQL the category name exists once, so the rename touches 1 row, and every query that joins to categories sees the new name immediately.
  2. In MongoDB the name was copied into every order item in that category, so the same rename has to find and modify 5 documents (and, with arrayFilters, every matching item inside each). With 50 million orders that's a background migration, not a one-row update.
  3. Both renames were reverted straight afterwards, so the sample data is unchanged.
  4. The design rule that falls out: embed data that's read together and rarely changes (or should be frozen, like the price and name on a past order), and reference data that changes often or is shared widely. That's the core of Embedding vs referencing.

#CAP and PACELC

Added

CAP is about what a replicated system can promise when the network between its copies breaks. During a partition, a node can either keep answering (Availability — every request to a working node gets a non-error answer) or refuse until it can coordinate (Consistency — every read sees the latest acknowledged write, or fails). You can't have both during the partition. It isn't "pick two of three": partitions happen whether you like it or not, so the real choice is C or A when P happens.

PACELC (Daniel Abadi, 2010) adds the part CAP leaves out: Else — when there's no partition — you're still trading Latency against Consistency, because waiting for replicas to confirm a write takes time. So a system is described by two choices: PA or PC during a partition, EL or EC the rest of the time.

The mental model: two branches of a shop share one stock ledger by phone. If the phone line is cut (a partition), each branch either stops selling until the line is back (consistency) or keeps selling and reconciles later, risking selling the last unit twice (availability). And even when the phone works, calling the other branch before every sale makes each sale slower (latency vs consistency).

System and settingDuring a partitionOtherwise
Redis, default async replicationPA: primary keeps writing, replicas serve staleEL: writes don't wait for replicas
Redis with min-replicas-to-write, replica-serve-stale-data no, WAITleans PC: refuses writes and readsEC-ish: each write waits for a replica (still not strongly consistent, per the WAIT docs)
MySQL with async replicas, reads from replicasPAEL
MySQL Group Replication / semi-sync, reads from primaryPC: minority side stopsEC: commit waits for acknowledgements
MongoDB with w: 'majority' and readConcern: 'majority'PC: the minority side has no primaryEC: writes wait for a majority
Cassandra / DynamoDB-style quorum storestunable per query (ONE → PA, QUORUM → PC)tunable (EL or EC)
Terminal
$ redis-cli SET ch25:stock:product:3 20
OK
$ redis-cli -h replica GET ch25:stock:product:3
"20"
# Cut the network between them (a partition):
$ docker network disconnect study-db-ch25_default study-db-ch25-replica-1
# (waited 15 s)
$ docker exec study-db-ch25-replica-1 redis-cli INFO replication | grep master_link_status
master_link_status:down
# Primary side, default settings: keeps accepting writes
$ redis-cli SET ch25:stock:product:3 19
OK
# Replica side, default settings: still answers, with the old value
$ docker exec study-db-ch25-replica-1 redis-cli GET ch25:stock:product:3
20
# Now prefer consistency, on both sides:
$ redis-cli CONFIG SET min-replicas-to-write 1 min-replicas-max-lag 10
OK
$ redis-cli SET ch25:stock:product:3 18
(error) NOREPLICAS Not enough good replicas to write.
$ docker exec study-db-ch25-replica-1 redis-cli CONFIG SET replica-serve-stale-data no
OK
$ docker exec study-db-ch25-replica-1 redis-cli GET ch25:stock:product:3
MASTERDOWN Link with MASTER is down and replica-serve-stale-data is set to 'no'.
# Heal the partition
$ docker network connect --alias replica study-db-ch25_default study-db-ch25-replica-1
# (waited 6 s)
$ docker exec study-db-ch25-replica-1 redis-cli GET ch25:stock:product:3
19
$ redis-cli SET ch25:stock:product:3 18
OK
What’s happening
  1. This runs on a Redis 8.10 primary and replica started just for this chapter (Docker Compose project study-db-ch25); redis-cli runs inside the primary's container. Stock for product 3 (Pixel 10) is 20 on both. docker network disconnect then cuts the replica off — a real network partition, not a simulation inside Redis.
  2. The replica only notices after its repl-timeout. With the default 60 s it still reported master_link_status:up 12 seconds into a first attempt; for this run it was lowered to 10 s beforehand, and after 15 s it says down.
  3. Default behaviour is AP. The primary accepts the sale (19) and the replica keeps answering reads with 20. Both sides are available; the two copies now disagree, and a client reading the replica could sell a unit that's gone.
  4. Choosing C. min-replicas-to-write 1 makes the primary refuse writes when no replica has acknowledged within 10 s: NOREPLICAS Not enough good replicas to write. And replica-serve-stale-data no makes the replica refuse reads: MASTERDOWN Link with MASTER is down…. Neither side returns a possibly wrong answer — and neither side is available.
  5. After the partition heals the replica resyncs and returns 19, and the primary accepts writes again because a replica is back. The partition forced a choice; the configuration decided which.
AdvancedPitfall: ACID C, CAP C and isolation
AdvancedLatency vs consistency without a partition

The "else": latency vs consistency without a partition

With the network healthy, the question becomes how long each write should wait. Redis's WAIT numreplicas timeout blocks until that many replicas have acknowledged the write:

repl-lag.mjs
import { createClient } from 'redis';
const primary = await createClient({ url: 'redis://127.0.0.1:6391' }).connect();
const replica = await createClient({ url: 'redis://127.0.0.1:6392' }).connect();
const KEY = 'ch25:profile:customer:1:city';

// Save the user's change on the primary; as soon as it says OK, read it back from the replica.
async function trial(label, { busy, wait, trials = 500 }) {
  let stale = 0, writeMs = 0;
  for (let i = 1; i <= trials; i++) {
    if (busy) for (let j = 0; j < 2000; j++) primary.incr('ch25:counter:page-views');  // other traffic, not awaited
    const t = performance.now();
    await primary.set(KEY, `Toronto-${i}`);
    if (wait) await primary.wait(1, 1000);          // block until 1 replica has the write (or 1 s passes)
    writeMs += performance.now() - t;
    if ((await replica.get(KEY)) !== `Toronto-${i}`) stale++;
  }
  console.log(`${label.padEnd(30)} stale ${String(stale).padStart(3)}/${trials}   write ${(writeMs / trials * 1000).toFixed(0).padStart(4)} µs`);
}

await trial('quiet primary, async', { busy: false, wait: false });
await trial('quiet primary, WAIT 1', { busy: false, wait: true });
await trial('busy primary, async', { busy: true, wait: false });
await trial('busy primary, WAIT 1', { busy: true, wait: true });
await primary.del(['ch25:counter:page-views', KEY]);
await primary.close(); await replica.close();

// quiet primary, async           stale   0/500   write  100 µs
// quiet primary, WAIT 1          stale   0/500   write  178 µs
// busy primary, async            stale   1/500   write 2523 µs
// busy primary, WAIT 1           stale   0/500   write 3179 µs
// (second run: 0, 0, 1, 0 stale; 100, 179, 2508, 3080 µs)
What’s happening
  1. Each trial saves a value on the primary and, the moment the primary says OK, reads it back from the replica — what happens when a user saves a form and the next page load is served by a replica.
  2. Quiet primary, async: 0 stale reads in 500. Primary and replica are containers on the same machine, so replication finishes in microseconds, before the read gets there.
  3. Busy primary, async: 2,000 other writes are in flight each time, and 1 read in 500 came back stale in both runs — the replica hadn't applied the write yet. Across availability zones or regions the window grows from microseconds to milliseconds or seconds, and the stale rate with it.
  4. WAIT 1 removed the stale reads (0/500) and cost latency: 100 → 178 µs on a quiet primary (+78%), 2.5 → 3.1 ms on a busy one. That's PACELC's EL-or-EC choice in one number: on a real network, every write would pay a replica round trip.
  5. The Redis docs are explicit that WAIT doesn't make Redis strongly consistent — a failover can still pick a replica that missed the write. It narrows the window; it doesn't give linearizability.

#Consistency models

Added

"Consistent" covers a whole spectrum of guarantees about what readers can see, from strongest to weakest:

ModelWhat a reader is guaranteedTypical source
Linearizable (strong)every read sees the latest completed write; the system behaves like one copya single primary; consensus systems (etcd, ZooKeeper, Spanner)
Sequentialeveryone sees all operations in the same order, though maybe behind—
Causalif A caused B (I read A, then wrote B), everyone sees A before BMongoDB causally consistent sessions
Read-your-writesyou always see your own writesrouting a user to the primary after they write
Monotonic readsyou never see time go backwardssticking a user to one replica
Eventualif writes stop, all copies converge — eventuallyasync replicas, DNS, CDN caches

The last three are session guarantees: they're about what one user experiences, and they're what users actually notice. Strong consistency across every client is expensive; a product mostly needs "I see what I just did" and "things don't un-happen when I refresh".

The mental model: linearizable is one live scoreboard in the stadium that everyone watches; eventual consistency is newspapers delivered to different houses at different times — everyone gets the same final score, but at 9 pm your neighbour may still think the game is tied. Read-your-writes is "at least my own letter to the editor shows up in my copy".

consistency.mjs
import { createClient } from 'redis';
import { execSync } from 'node:child_process';
const primary = await createClient({ url: 'redis://127.0.0.1:6391' }).connect();
// The partition also cuts the replica's published port, so the replica is read with redis-cli inside its container
const replica = { get: async (k) => execSync(`docker exec study-db-ch25-replica-1 redis-cli GET ${k}`).toString().trim() };
const KEY = 'ch25:profile:customer:2:city';
const nodes = { primary, replica };

await primary.set(KEY, 'Mumbai');
await primary.wait(1, 1000);                       // both copies say Mumbai
execSync('docker network disconnect study-db-ch25_default study-db-ch25-replica-1');   // replication stops

await primary.set(KEY, 'Toronto');                  // Maya moves; the write succeeds on the primary
console.log('write: city = Toronto (acknowledged by the primary)');

// Her next page loads go to whichever node the load balancer picks
let line = [];
for (const node of ['replica', 'primary', 'replica', 'primary']) line.push(`${node}: ${await nodes[node].get(KEY)}`);
console.log('round-robin reads →', line.join(' → '));

// Read-your-writes: after a write, route this user's reads to the primary for a while
const stickyUntil = Date.now() + 5000;
const pick = () => (Date.now() < stickyUntil ? 'primary' : 'replica');
line = [];
for (let i = 0; i < 4; i++) { const n = pick(); line.push(`${n}: ${await nodes[n].get(KEY)}`); }
console.log('sticky reads      →', line.join(' → '));

execSync('docker network connect --alias replica study-db-ch25_default study-db-ch25-replica-1');
await new Promise((r) => setTimeout(r, 6000));
console.log('after the partition heals, replica says', await replica.get(KEY));
await primary.del(KEY);
await primary.close();

// write: city = Toronto (acknowledged by the primary)
// round-robin reads → replica: Mumbai → primary: Toronto → replica: Mumbai → primary: Toronto
// sticky reads      → primary: Toronto → primary: Toronto → primary: Toronto → primary: Toronto
// after the partition heals, replica says Toronto
What’s happening
  1. Maya (customer 2 in the sample data, who lives in Mumbai) has her city on the primary and the replica; WAIT 1 confirms both have it. Then the replica is cut off with docker network disconnect, so replication stops — an exaggerated but real version of replica lag.
  2. She updates her city to Toronto. The primary acknowledges the write, so from her point of view it's saved.
  3. Her next four page loads are spread round-robin across the two nodes and show Mumbai → Toronto → Mumbai → Toronto. That breaks two guarantees at once: read-your-writes (the first read doesn't show her own change) and monotonic reads (her city flips back to Mumbai after she's already seen Toronto). This is eventual consistency as a user experiences it.
  4. The fix here is a session rule, not a stronger database: for a few seconds after a user writes, send their reads to the primary. All four reads say Toronto. Other users may still see Mumbai on the replica — that's acceptable; Maya seeing it isn't.
  5. When the network comes back the replica catches up and says Toronto: the copies converged, which is all "eventual" promises. Other ways to get the session guarantees: pin a user to one replica (monotonic reads), or remember the replication position of the user's last write and only read from replicas that have reached it.
AdvancedMongoDB's causally consistent sessions

MongoDB implements the "remember the position of my last write" approach for you. A causally consistent session tracks the cluster time of each operation and sends it with the next one, so a secondary waits until it has caught up:

mongosh
const session = db.getMongo().startSession({ causalConsistency: true });
const orders = session.getDatabase('ch25').orders;
orders.updateOne({ _id: 6 }, { $set: { status: 'paid' } }, { writeConcern: { w: 'majority' } });
session.getOperationTime();
orders.find({ _id: 6 }, { status: 1 }).readConcern('majority').readPref('secondaryPreferred').toArray();
session.endSession();
Result
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
Timestamp({ t: 1792166663, i: 1 })
[ { _id: 6, status: 'paid' } ]
What’s happening
  1. startSession({ causalConsistency: true }) creates a session whose operations are causally ordered. Every operation in it returns an operation time, here Timestamp({ t: 1792166663, i: 1 }) (seconds plus a counter).
  2. The write uses w: 'majority', so it's acknowledged only once a majority of members have it — it can't be rolled back by a failover.
  3. The read allows a secondary (secondaryPreferred) with readConcern('majority'). Because it's in the same session, the driver attaches afterClusterTime equal to the write's operation time; a secondary that's behind waits until it reaches that point before answering. So the read sees the order as paid: read-your-writes, even from a secondary.
  4. The sandbox runs a one-member replica set, so there was no lagging secondary to wait for — the mechanism ran, but the delay it prevents couldn't be shown here. The order's status was set back to pending afterwards. Replica sets in detail: Read concern and read preference.
Which guarantee broke?

A user changes their display name and the confirmation page shows the new name. They refresh, and the old name is back. A minute later it's new again. Which consistency guarantee was violated, and what's the cheapest fix?

Show answer

Monotonic reads (and, on the refresh, read-your-writes).

What’s happening
  1. The confirmation page was probably rendered from the primary, or from the write's own response, so the user saw the new name.
  2. The refresh went to a lagging replica that hadn't applied the update, so the user saw an older state than one they'd already seen — time went backwards. That's a monotonic-reads violation; since it's their own write, read-your-writes is broken too.
  3. A minute later the replica caught up — eventual consistency did its job, just not in a way the user can tolerate.
  4. Cheapest fixes: after a user writes, read their data from the primary for a short window (a cookie or session flag); or pin each user's session to one replica so reads never go backwards. Making the whole database strongly consistent would be far more expensive than either.

#OLTP vs OLAP

Added

Databases serve two very different kinds of work. OLTP (online transaction processing) is the application itself: place an order, update a stock count, read one customer's profile. Each query touches a handful of rows by key, there are thousands of them per second from many users, and latency matters. OLAP (online analytical processing) is the reporting: revenue per category per month, cohort retention, a dashboard over two years of sales. Each query reads millions of rows but only a few columns, there are few of them, and throughput matters more than milliseconds.

The mental model: OLTP is the cashier — fast, one customer at a time, mustn't make mistakes. OLAP is the accountant at the end of the quarter — goes through every receipt, needs only the date and amount columns, and would make the queue very long if they did it at the till. That's why the accountant usually works from a copy.

The storage engines are built for one or the other. Row stores like InnoDB keep each row's columns together, perfect for "fetch order 500,000 with everything in it". Column stores (ClickHouse, BigQuery, Snowflake, Redshift, DuckDB, MySQL HeatWave) keep each column together, compressed, and process it in vectorised batches — perfect for "sum one column over a billion rows". Data moves from the OLTP database to the warehouse with ETL/ELT jobs or change data capture.

OLTP and OLAP on the same 1,000,000-row table
-- OLTP: one customer's order, by primary key
SELECT id, product_id, quantity, unit_price, sold_at
FROM sales WHERE id = 500000;

-- OLAP: revenue per month across every sale
SELECT DATE_FORMAT(sold_at, '%Y-%m') AS month,
       SUM(quantity * unit_price) AS revenue
FROM sales GROUP BY month ORDER BY month LIMIT 3;
Result
+--------+------------+----------+------------+---------------------+
| id     | product_id | quantity | unit_price | sold_at             |
+--------+------------+----------+------------+---------------------+
| 500000 |          1 |        1 |     899.00 | 2025-11-12 00:00:00 |
+--------+------------+----------+------------+---------------------+
1 row in set (0.000 sec)

+---------+--------------+
| month   | revenue      |
+---------+--------------+
| 2025-01 | 139067311.00 |
| 2025-02 | 128369720.00 |
| 2025-03 | 145183340.00 |
+---------+--------------+
3 rows in set (0.242 sec)
What’s happening
  1. sales is a 1,000,000-row table generated in ch25 from the sample products (product, quantity, unit price and a date spread over 2025); it takes 42.6 MB in InnoDB. The timings come from mysql -vvv.
  2. The OLTP query finds one row through the clustered primary key: a few B+tree page reads, reported as 0.000 sec. Ten thousand of these a second is routine for one MySQL server.
  3. The OLAP query has to read every row to sum them by month: 0.242 sec of a server's time for one report. Run it from a dashboard every few seconds for fifty users and the OLTP work starts queueing behind it.
  4. The two queries want opposite things from the storage: the first wants all columns of one row, the second wants three columns of all rows. A row store is ideal for the first and wasteful for the second.
  5. That's the practical rule: keep heavy analytics off the primary — run them on a replica, a reporting database or a column-store warehouse fed by CDC — and keep the OLTP database lean.
AdvancedWhat EXPLAIN ANALYZE says about each
SQL
EXPLAIN ANALYZE SELECT id, product_id, quantity, unit_price, sold_at FROM sales WHERE id = 500000;
EXPLAIN ANALYZE SELECT DATE_FORMAT(sold_at, '%Y-%m') AS month, SUM(quantity * unit_price) AS revenue FROM sales GROUP BY month ORDER BY month;
Result
EXPLAIN: -> Rows fetched before execution  (cost=0..0 rows=1) (actual time=875e-6..875e-6 rows=1 loops=1)

EXPLAIN: -> Sort: `month`  (actual time=221..221 rows=12 loops=1)
    -> Table scan on <temporary>  (actual time=221..221 rows=12 loops=1)
        -> Aggregate using temporary table  (actual time=221..221 rows=12 loops=1)
            -> Table scan on sales  (cost=100545 rows=997875) (actual time=0.549..66.4 rows=1e+6 loops=1)
What’s happening
  1. The point lookup is Rows fetched before execution: the optimizer resolved a primary-key equality during planning, so execution had nothing left to do (875 nanoseconds).
  2. The report is a Table scan on sales that read rows=1e+6 — every row — in 66 ms, then Aggregate using temporary table grouped them into 12 months, finishing at 221 ms. Most of the time went into processing a million rows one at a time, not into reading them.
  3. Read bottom-up: each -> line feeds the one above it. actual time=a..b is milliseconds to the first and last row; rows is what that step produced.
  4. More on reading these plans: EXPLAIN ANALYZE.
AdvancedCovering index, then PostgreSQL in parallel

A covering index helps the read, not the work

A row store's closest trick to "read only the columns you need" is a covering index on just those columns:

SQL
CREATE INDEX ix_sales_report ON sales (sold_at, quantity, unit_price);
EXPLAIN ANALYZE SELECT DATE_FORMAT(sold_at, '%Y-%m') AS month, SUM(quantity * unit_price) AS revenue FROM sales GROUP BY month ORDER BY month;
SELECT index_name, ROUND(stat_value * @@innodb_page_size / 1024 / 1024, 1) AS size_mb
FROM mysql.innodb_index_stats WHERE database_name = 'ch25' AND table_name = 'sales' AND stat_name = 'size';
Result
Query OK, 0 rows affected (1.454 sec)

-> Sort: `month`  (actual time=239..239 rows=12 loops=1)
    -> Table scan on <temporary>  (actual time=239..239 rows=12 loops=1)
        -> Aggregate using temporary table  (actual time=239..239 rows=12 loops=1)
            -> Covering index scan on sales using ix_sales_report  (cost=101659 rows=997875) (actual time=0.294..70.2 rows=1e+6 loops=1)
1 row in set (0.241 sec)

+-----------------+---------+
| index_name      | size_mb |
+-----------------+---------+
| PRIMARY         |    42.6 |
| ix_sales_report |    26.6 |
+-----------------+---------+
What’s happening
  1. The index holds only the three columns the report needs (plus the primary key InnoDB appends), so it's 26.6 MB against the table's 42.6 MB. The plan switches to Covering index scan … using ix_sales_report.
  2. The query time didn't improve: 0.241 s, the same as before. The data was already in memory, so reading less of it saved nothing that mattered; the cost is in evaluating DATE_FORMAT and summing a million rows one row at a time (70 → 239 ms in the aggregate step).
  3. That's the real reason column stores win at analytics: not just reading fewer bytes, but compressing each column and processing it in vectorised batches across many cores. A row engine executing row by row can't catch up by indexing.
  4. The index was dropped again afterwards. A column store wasn't available in this sandbox (MySQL HeatWave is a cloud service; ClickHouse wasn't installed), so no column-store timing is claimed here.

The same table and query in PostgreSQL 18, which can split one query across worker processes:

SQL
CREATE TABLE ch25.sales AS
SELECT i AS id, (1 + (i * 7919) % 8)::int AS product_id, (1 + i % 5)::int AS quantity,
       (ARRAY[899,1299,799,1099,39,49,29,249])[1 + (i * 7919) % 8]::numeric(10,2) AS unit_price,
       DATE '2025-01-01' + (i % 365)::int AS sold_at
FROM generate_series(1::bigint, 1000000) AS i;
ANALYZE ch25.sales;
EXPLAIN (ANALYZE, COSTS OFF) SELECT to_char(sold_at, 'YYYY-MM') AS month, SUM(quantity * unit_price) AS revenue FROM ch25.sales GROUP BY month ORDER BY month;
Result
                                              QUERY PLAN
-------------------------------------------------------------------------------------------------------
 Finalize GroupAggregate (actual time=80.246..81.479 rows=12.00 loops=1)
   Group Key: (to_char((sold_at)::timestamp with time zone, 'YYYY-MM'::text))
   Buffers: shared hit=2320 read=5056
   ->  Gather Merge (actual time=80.242..81.470 rows=36.00 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         Buffers: shared hit=2320 read=5056
         ->  Sort (actual time=79.139..79.139 rows=12.00 loops=3)
               Sort Key: (to_char((sold_at)::timestamp with time zone, 'YYYY-MM'::text))
               Sort Method: quicksort  Memory: 26kB
               Buffers: shared hit=2320 read=5056
               Worker 0:  Sort Method: quicksort  Memory: 26kB
               Worker 1:  Sort Method: quicksort  Memory: 26kB
               ->  Partial HashAggregate (actual time=79.121..79.124 rows=12.00 loops=3)
                     Group Key: to_char((sold_at)::timestamp with time zone, 'YYYY-MM'::text)
                     Batches: 1  Memory Usage: 48kB
                     Buffers: shared hit=2304 read=5056
                     Worker 0:  Batches: 1  Memory Usage: 48kB
                     Worker 1:  Batches: 1  Memory Usage: 48kB
                     ->  Parallel Seq Scan on sales (actual time=0.144..47.778 rows=333333.33 loops=3)
                           Buffers: shared hit=2304 read=5056
 Planning:
   Buffers: shared hit=63 read=11
 Planning Time: 0.184 ms
 Execution Time: 81.510 ms
(25 rows)
What’s happening
  1. The PostgreSQL table uses the same formula as MySQL's, and the query returns the same monthly totals (January 139,067,311.00, February 128,369,720.00, March 145,183,340.00 — checked with a separate SELECT … LIMIT 3).
  2. Workers Launched: 2 plus the leader means three processes each scanned about a third of the table (rows=333333.33 loops=3) and built a partial aggregate; Gather Merge and Finalize GroupAggregate combined the three partial results.
  3. 81.5 ms, against MySQL's 240 ms for the same work. MySQL executes a query on one thread (InnoDB's parallel read threads apply only to a few operations such as COUNT(*) and CHECK TABLE), so it can't spread a report over cores this way.
  4. This is one of the practical reasons teams pick PostgreSQL for mixed workloads with heavier reporting — but parallel row scans are still a long way from a real column store for billions of rows. More in MySQL vs PostgreSQL.

#Other kinds of database

Added

Relational, document and key-value stores cover most applications, but several other families exist because some problems have a very particular shape. Knowing what each is for — and that the general-purpose databases now cover a lot of it — is usually what an interviewer is after.

The mental model is a workshop: a good general-purpose saw (a relational database) cuts almost anything, but for some jobs a specialised tool is faster and safer — as long as you're willing to maintain another tool.

FamilyBuilt forExamplesGeneral-purpose alternative
Key-valuelookups by key at huge scale, caching, sessionsRedis, Valkey, DynamoDB, Memcacheda primary-key table
Documentaggregates with varying shapeMongoDB, Couchbase, FirestoreJSON columns in MySQL/PostgreSQL
Wide-columnmassive write throughput, partition-key accessCassandra, ScyllaDB, HBase, Bigtablesharded relational tables
Graphmany-hop relationship queries (friends of friends, fraud rings)Neo4j, Amazon Neptunerecursive CTEs (for a few hops)
Searchfull-text relevance, faceting, fuzzy matchingElasticsearch, OpenSearch, Redis Query EngineMySQL FULLTEXT, PostgreSQL tsvector
Time-seriesappend-heavy metrics with time-window aggregation and retentionTimescaleDB, InfluxDB, Redis time seriespartitioned tables
Columnar / warehouseanalytics over billions of rowsClickHouse, BigQuery, Snowflake, Redshift, DuckDB—
Vectornearest-neighbour search over embeddingspgvector, Redis vector sets, Pinecone, Milvus—
Distributed SQL (NewSQL)SQL and ACID transactions across many nodes and regionsSpanner, CockroachDB, TiDB, YugabyteDBa sharded relational setup
Embeddeda database inside the app processSQLite, DuckDB, RocksDB—
A graph question in SQL: Isla's chain of managers
WITH RECURSIVE chain AS (
  SELECT id, name, manager_id, 0 AS depth
  FROM employees WHERE name = 'Isla'
  UNION ALL
  SELECT e.id, e.name, e.manager_id, chain.depth + 1
  FROM employees e JOIN chain ON e.id = chain.manager_id
)
SELECT depth, name FROM chain ORDER BY depth;
Result
+-------+------+
| depth | name |
+-------+------+
|     0 | Isla |
|     1 | Hiro |
|     2 | Asha |
+-------+------+
What’s happening
  1. The sample schema's employees table is a graph: each row points to its manager through manager_id. "Who is above Isla?" is a path query.
  2. The anchor member selects Isla (depth 0). The recursive member joins employees to the rows found so far on e.id = chain.manager_id, adding one level each round: Hiro (her manager, depth 1), then Asha (Hiro's manager, depth 2). Asha's manager_id is NULL, so the next round finds nothing and the recursion stops.
  3. Each level is an indexed primary-key lookup, so a few hops are cheap in any relational database. Recursive CTEs arrived in MySQL 8.0; MySQL 5.7 had no way to do this in one query.
  4. A graph database stores relationships as direct pointers between nodes, so "friends of friends of friends who bought X" — five or six hops over millions of edges, with filters at each step — stays fast where a relational engine would build huge intermediate join results. If your core queries look like that, a graph database earns its place; if it's an org chart, it doesn't.
AdvancedOne Redis 8 server, four kinds of database

One Redis 8 server, four "kinds" of database

Several of those families are now features of general-purpose servers. The Redis 8.10 in this sandbox has time series, full-text search and vector similarity built in:

redis-cli
# Time series: CPU readings, downsampled into 1-minute averages
> TS.CREATE ch25:ts:cpu:web-1 RETENTION 86400000 LABELS host web-1 metric cpu
OK
> TS.MADD ch25:ts:cpu:web-1 1760000000000 31 ch25:ts:cpu:web-1 1760000030000 45 ch25:ts:cpu:web-1 1760000060000 80 ch25:ts:cpu:web-1 1760000090000 70
1) (integer) 1760000000000
2) (integer) 1760000030000
3) (integer) 1760000060000
4) (integer) 1760000090000
> TS.RANGE ch25:ts:cpu:web-1 - + AGGREGATION avg 60000
1) 1) (integer) 1759999980000
   2) 38
2) 1) (integer) 1760000040000
   2) 75
# Full-text search over hashes
> HSET ch25:product:1 name "ThinkBook 14" category Laptops price 899
(integer) 3
> HSET ch25:product:2 name "MacBook Air" category Laptops price 1299
(integer) 3
> HSET ch25:product:8 name "Noise-cancelling Headphones" category Accessories price 249
(integer) 3
> FT.CREATE ch25:idx:products ON HASH PREFIX 1 ch25:product: SCHEMA name TEXT category TAG price NUMERIC SORTABLE
OK
> FT.SEARCH ch25:idx:products "@category:{Laptops} @price:[0 1000]" RETURN 2 name price
1) (integer) 1
2) "ch25:product:1"
3) 1) "name"
   2) "ThinkBook 14"
   3) "price"
   4) "899"
> FT.SEARCH ch25:idx:products "headphone*" RETURN 1 name
1) (integer) 1
2) "ch25:product:8"
3) 1) "name"
   2) "Noise-cancelling Headphones"
# Vector set: nearest neighbours by embedding (toy 3-dimensional vectors)
> VADD ch25:vec:products VALUES 3 0.9 0.1 0.0 "ThinkBook 14"
(integer) 1
> VADD ch25:vec:products VALUES 3 0.8 0.2 0.1 "MacBook Air"
(integer) 1
> VADD ch25:vec:products VALUES 3 0.1 0.9 0.2 "Noise-cancelling Headphones"
(integer) 1
> VADD ch25:vec:products VALUES 3 0.0 0.3 0.9 "Designing Data-Intensive Applications"
(integer) 1
> VSIM ch25:vec:products VALUES 3 0.85 0.15 0.05 WITHSCORES COUNT 2
1) "ThinkBook 14"
2) "0.9978907045442611"
3) "MacBook Air"
4) "0.9973737811669707"
What’s happening
  1. Time series: TS.CREATE makes a series with a one-day retention and labels for querying; TS.MADD adds four readings 30 s apart (timestamps in ms). TS.RANGE … AGGREGATION avg 60000 downsamples into one-minute buckets aligned to the epoch: the first bucket holds 31 and 45 (average 38), the second 80 and 70 (75).
  2. Search: FT.CREATE indexes every hash whose key starts with ch25:product:, with name as full text, category as an exact-match tag and price as a sortable number. Hashes written before or after are indexed automatically.
  3. The first query combines a tag filter and a numeric range — laptops under 1,000 — and finds only the ThinkBook. The second is a prefix search: headphone* matches "Headphones" by prefix within the indexed words, something a SQL LIKE '%headphone%' can only do with a full scan.
  4. Vector set (Redis 8.0+): each product gets an embedding — real ones have hundreds of dimensions from a model; these are 3-dimensional toys. VSIM returns the nearest neighbours of a query vector by cosine similarity: the two laptops, with similarity scores near 1. That's the core operation behind semantic search and RAG.
  5. The trade-off is the usual one: a multi-model server saves running four systems, while a dedicated system (Elasticsearch for search, a warehouse for analytics) goes deeper on its one job.

#Polyglot persistence

Added

Polyglot persistence means using several kinds of database in one system, each for the job it's best at: MySQL for orders and payments, Redis for sessions and caching, a search engine for the catalogue, a warehouse for analytics. Martin Fowler popularised the term around 2011, and most large systems end up here whether they planned to or not.

It's powerful and it has a cost. Every extra store is another thing to run, secure, back up, monitor and know how to debug at 3 am. And the moment the same fact lives in two places, you have to keep them in sync — which is where most of the bugs are.

The mental model is a company's records: there's one ledger (the source of truth) and several copies made from it for convenience — the noticeboard (cache), the card index (search), the annual report (warehouse). Copies can be thrown away and rebuilt from the ledger. Trouble starts when someone writes on a copy and the ledger, separately, and one of the two writes doesn't happen.

StoreHoldsSource of truth?
MySQLcustomers, orders, payments, stockyes
Redissessions, carts, rate limits, cached pagesno — rebuildable, or short-lived
Search engineproduct catalogue for search and facetsno — derived from MySQL
Warehousesales history for analyticsno — derived via CDC/ELT

The safe way to feed derived stores is to make the database write the only write, and derive everything else from it. The transactional outbox pattern does that: the business change and an event describing it are written in the same database transaction, and a separate relay publishes the event:

outbox.mjs
import mysql from 'mysql2/promise';
import { createClient } from 'redis';
const db = mysql.createPool({ host: '127.0.0.1', port: 3307, user: 'root', password: 'study', database: 'ch25' });
const redis = await createClient({ url: 'redis://127.0.0.1:6380' }).connect();
await redis.del('ch25:events:orders');

// 1. The business write and the event go into MySQL in ONE transaction
async function placeOrder(customerId, items) {
  const conn = await db.getConnection();
  try {
    await conn.beginTransaction();
    const [o] = await conn.query("INSERT INTO orders (customer_id, status, ordered_at) VALUES (?, 'paid', NOW())", [customerId]);
    for (const [productId, qty, price] of items)
      await conn.query('INSERT INTO order_items VALUES (?, ?, ?, ?)', [o.insertId, productId, qty, price]);
    await conn.query("INSERT INTO outbox (topic, payload) VALUES ('order.paid', ?)",
      [JSON.stringify({ orderId: o.insertId, customerId, items })]);
    await conn.commit();                                       // both rows, or neither
    return o.insertId;
  } catch (err) { await conn.rollback(); throw err; }
  finally { conn.release(); }
}

// 2. A relay copies unpublished outbox rows to a Redis stream, then marks them published
async function relay() {
  const [rows] = await db.query('SELECT id, topic, payload FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT 100');
  for (const r of rows) {
    await redis.xAdd('ch25:events:orders', '*', { outboxId: String(r.id), topic: r.topic, payload: JSON.stringify(r.payload) });
    await db.query('UPDATE outbox SET published_at = NOW(3) WHERE id = ?', [r.id]);
  }
  return rows.length;
}

const orderId = await placeOrder(6, [[3, 1, 799.00], [7, 1, 29.00]]);   // Zara's first order
console.log('order', orderId, 'committed; relay published', await relay(), 'event(s); second run published', await relay());
const [[ev]] = (await redis.xRange('ch25:events:orders', '-', '+')).map((e) => [e.message]);
console.log('stream entry:', ev);
const [[row]] = await db.query('SELECT id, topic, published_at IS NOT NULL AS published FROM outbox');
console.log('outbox row:', row);

// put the sample data back
await db.query('DELETE FROM order_items WHERE order_id = ?', [orderId]);
await db.query('DELETE FROM orders WHERE id = ?', [orderId]);
await db.query('DELETE FROM outbox');
await redis.del('ch25:events:orders');
await redis.close(); await db.end();

// order 9 committed; relay published 1 event(s); second run published 0
// stream entry: {
//   outboxId: '1',
//   topic: 'order.paid',
//   payload: '{"items":[[3,1,799],[7,1,29]],"orderId":9,"customerId":6}'
// }
// outbox row: { id: 1, topic: 'order.paid', published: 1 }
What’s happening
  1. outbox is a plain MySQL table (id, topic, a JSON payload, created_at, published_at) created in ch25 for this example. placeOrder inserts Zara's order (customer 6 in the sample data, who had none), its two items and the outbox row inside one transaction. Either all three commit or none do — there's no state where the order exists but its event was lost, or the event exists for an order that rolled back.
  2. The relay runs separately (a loop, a cron job, or a CDC tool like Debezium reading the binlog instead of polling). It reads unpublished rows, XADDs each to a Redis stream, then marks the row published. The second relay run finds nothing new and publishes 0.
  3. Downstream consumers — the search indexer, the cache invalidator, the email sender — read the stream with consumer groups, as in Pub/Sub and Streams. None of them ever writes to MySQL to "sync"; they only derive from it.
  4. If the relay crashes after XADD but before the UPDATE, the event is published again on restart: delivery is at-least-once, so consumers must be idempotent. outboxId is in the message for exactly that — a consumer can remember the last id it processed.
  5. Look at the payload: MySQL's JSON type stored the keys as items, orderId, customerId, not in the order they were written. MySQL normalises JSON objects and sorts their keys (here shortest first), and its docs warn that the order isn't guaranteed across releases — so never depend on key order in a JSON column.
AdvancedPitfall: dual writes to two stores
AdvancedAdvice: start with fewer stores

#A decision guide

Added

There's no formula, but the same handful of questions settle most choices, and asking them out loud is exactly what an interviewer wants to hear. Start from the data and the access patterns, then the consistency you need, then scale, and only then preferences and fashion. The mental model is a doctor's checklist: you don't prescribe before you've asked where it hurts.

QuestionIf the answer is…Lean towards
How related is the data?many entities referencing each other, new questions all the timerelational (MySQL, PostgreSQL)
self-contained aggregates read and written wholedocument (MongoDB)
How strict must correctness be?money, stock, bookings — multi-entity invariantsrelational with transactions, or distributed SQL across regions
a few seconds of staleness is finereplicas, caches, eventually consistent stores
What do reads look like?by key, extremely fast, small valueskey-value (Redis, DynamoDB) — often as a cache
full-text relevance and facetsa search engine, or built-in full-text for small needs
scans and aggregates over historya column store or warehouse, fed from OLTP
many-hop relationshipsa graph database
How big, how fast?fits on one large machine with room to growa single relational primary plus replicas (most products)
write volume or data size beyond one machinea natively sharded store, or Vitess/Citus/distributed SQL
What does the team know?deep MySQL experience, little elsethat's a real cost to weigh, not a tiebreaker to ignore
Managed or self-hosted?small teama managed service (RDS/Aurora, Atlas, ElastiCache/MemoryDB, Cloud SQL)

A practical default for a new web product, which holds up in most interviews: one relational database as the source of truth, Redis in front for caching, sessions and rate limits, and specialised stores added only when a measured need appears. Be ready to argue against it: a schema-light content platform might start on MongoDB; a global, always-on ledger might need Spanner or CockroachDB; an IoT ingest pipeline might want Cassandra or a time-series database from day one.

Pick a database: a ride-hailing app

A ride-hailing app needs: driver locations updated every 4 seconds and queried by "drivers within 2 km"; trips and payments that must never be lost or double-charged; and a weekly report of revenue per city. What would you use for each part?

Show answer

Three workloads, three answers — with one source of truth.

What’s happening
  1. Driver locations: very high write rate, only the latest position matters, losing one update is harmless, and the query is "near me". Redis geo commands fit (GEOADD to update, GEOSEARCH … BYRADIUS 2 km), as shown in Data structures. It's not the source of truth for anything.
  2. Trips and payments: correctness matters most — a trip, its fare and the payment must change together, and charges must be idempotent. A relational database with transactions and unique constraints (an idempotency key on payments) is the natural fit.
  3. Weekly revenue per city: an aggregate over all trips. Run it on a replica at first; move it to a warehouse fed by CDC when the trip table gets large, so it never competes with live trips.
  4. The interview point is the reasoning: separate the workloads by their access pattern and consistency needs, keep one source of truth, and treat the others as derived or ephemeral.
Pick a database: a product catalogue with very different products

An online shop sells laptops (CPU, RAM, screen size), books (author, ISBN, pages) and clothes (size, colour, material). Product pages are read 1,000 times more often than they're edited, and customers search and filter by those attributes. MySQL, MongoDB, or something else?

Show answer

Either can work; the attributes and the search are what decide it.

What’s happening
  1. The attributes vary by category, which is awkward as fixed relational columns. Options in MySQL: a JSON column for the attributes (with generated columns and indexes on the ones you filter by), or an entity-attribute-value table, which is flexible but painful to query.
  2. MongoDB fits the shape naturally: each product document has the attributes it needs, and indexes (including wildcard indexes) cover filtering. Reads of a whole product are one document.
  3. Faceted search with relevance ("laptops, 16 GB, under $1,000, sorted by best match") is really a search-engine job: a search index built from the catalogue, whether the source of truth is MySQL or MongoDB.
  4. A defensible answer: keep products and their attributes wherever the rest of the shop's data lives if it's relational (orders must reference products reliably), store variable attributes as JSON, cache product pages in Redis, and feed a search index for search. Choosing MongoDB for the catalogue is also defensible, if you accept two databases.
Spot the bad reason

Which of these is a weak reason for choosing MongoDB over PostgreSQL? (a) Documents vary a lot in shape. (b) "Joins are slow." (c) We need built-in sharding for a dataset that will outgrow one machine. (d) The team already runs MongoDB well.

Show answer

(b). Joins on indexed keys are cheap.

What’s happening
  1. (a) is a real reason: a flexible document model fits genuinely variable data better than a fixed schema.
  2. (b) is the weak one: in SQL vs NoSQL: the real trade-offs a four-table join by primary key took about 111 µs against 140 µs for one document read. Joins become expensive across shards or on unindexed columns — which is an argument about (c), not about joins in general.
  3. (c) is a real reason: MongoDB shards natively, while PostgreSQL needs Citus or application-level sharding.
  4. (d) is a real reason too: operational experience is a large part of running a database well.

SQL and MySQL written fresh; MongoDB built from Rohit’s “mongoDB interview” doc. Every query was run on MySQL 9.7, MongoDB 9.0, Redis 8.10 or PostgreSQL 18.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.