Databases, Indexing & Transactions
SQL vs. NoSQL for real, why an index is a B-tree, and what ACID actually guarantees when things go wrong.
A database is not where you put data after the design is done. It is the system that decides which reads are cheap, which writes are safe, and which failure modes your product can tolerate. Pick the wrong one and every downstream service inherits the pain.
Senior design answers do not say “use SQL” or “use NoSQL” as a reflex. They name the access patterns, consistency needs, data shape, and operational constraints — then pick the store that makes those constraints boring.
The mental model
A datastore is two things at once:
- A query engine optimized for certain access patterns.
- A consistency contract for what reads and writes mean under concurrency and failure.
# The design question hiding behind every database choice:
store = choose_database(
reads="lookup order by id, list orders by user, search recent failures",
writes="hundreds per second, must not lose money",
consistency="read your own writes and no double charges",
scale="fits on one primary today, replicas soon",
)
If you cannot list the reads and writes, you cannot choose the database. Start there before naming technology.
Common datastore shapes
| Store | Best fit | Watch out for |
|---|---|---|
| Relational (Postgres, MySQL) | Structured data, joins, constraints, transactions | Harder horizontal write scaling; schema changes need care |
| Document (MongoDB, Dynamo-style JSON) | Aggregate-oriented objects read/written together | Cross-document transactions and ad hoc joins are weaker |
| Key-value (Redis, DynamoDB key-value) | Fast lookup by key, sessions, counters, feature flags | Querying by anything except the key requires another index/model |
| Wide-column (Cassandra, HBase) | Huge write volume, time-series, append-heavy workloads | You must design queries up front; weak joins and transactions |
| Search index (Elasticsearch, OpenSearch) | Text search, filtering, ranking, logs | Usually a derived index, not the source of truth |
| Object store (S3-like) | Large blobs, images, backups, model artifacts | High latency for small transactional updates |
Most real systems use more than one: a relational source of truth, a cache for hot reads, an object store for blobs, and a search index for discovery.
| Relational | Document | Key-value | Wide-column | |
|---|---|---|---|---|
| Query flexibility | Ad-hoc joins | Within a doc | By key only | Pre-modeled |
| Transactions | Strong ACID | Single-doc | Limited | Limited |
| Write scale-out | Harder | Good | Great | Great |
| Read by key | Fast | Fast | Fastest | Fast |
| Schema changes | Migrations | Flexible | Flexible | Design up front |
Why indexes make reads fast
A table scan is O(n): the database checks row after row until it finds matches. A B-tree index keeps keys sorted in a shallow tree of pages, so lookup becomes O(log n) page hops. Because each page holds many keys, the tree stays short even for millions or billions of rows.
root page
-> internal page for org_id=42
-> leaf page with sorted (status, updated_at, row_pointer)
That read speed is not free. Every index adds:
- Write cost — inserts, updates, and deletes must update the index too.
- Storage cost — indexes can be as large as the table for wide keys.
- Memory pressure — hot index pages compete with hot table pages in cache.
A good index matches a query shape. For compound indexes, order matters: an index on (org_id, status, updated_at) helps queries that filter by org_id, then maybe status, then sort or range-scan by updated_at.
ACID vs. BASE
ACID is the relational transaction promise:
- Atomicity — all writes in the transaction happen, or none do.
- Consistency — constraints are preserved.
- Isolation — concurrent transactions do not corrupt each other.
- Durability — committed data survives crashes.
BASE describes many distributed NoSQL systems: basically available, soft state, eventually consistent. You trade some immediate consistency for higher availability and easier horizontal scaling.
Neither is morally better. Money movement wants ACID. A like counter can be BASE. The mistake is applying the same consistency requirement to every feature.
Isolation levels in practice
Isolation is where many production bugs hide. You do not need every anomaly name, but you should know the practical ladder:
| Isolation level | What it prevents | What can still happen |
|---|---|---|
| Read committed | Dirty reads | Non-repeatable reads, lost updates unless guarded |
| Repeatable read / snapshot | Seeing different values inside one transaction | Write conflicts or phantoms depending on database |
| Serializable | Behaves like transactions ran one at a time | More aborts, lower concurrency, higher cost |
For weak isolation, protect critical writes with one of these patterns:
- Row locks:
SELECT ... FOR UPDATEbefore modifying shared state. - Optimistic concurrency: update only if
versionstill matches. - Idempotency keys: make retries safe for externally visible actions.
- Database constraints: let uniqueness and foreign keys enforce invariants.
Pattern recognition
Variations
Worked problems
These are design drills, not coding puzzles. Name the access pattern first; the technology choice should follow naturally.
Pick the datastore for a marketplace
Design the data layer for a marketplace. Users browse listings by text and filters, sellers update inventory, and checkout must never sell the same item twice. Which stores do you use for each part?
Approach. Split source-of-truth writes from derived read models. Checkout has strong invariants; browsing wants search and denormalized reads.
Show solution
A reasonable design:
| Requirement | Store | Why |
|---|---|---|
| Users, listings, inventory, orders | Relational DB | Constraints and transactions protect ownership and checkout |
| Listing photos | Object store | Large immutable blobs do not belong in database rows |
| Browse/search | Search index | Text matching, filters, ranking, and pagination |
| Hot listing details | Cache | Absorb repeated reads for popular items |
| Analytics events | Stream/log | Append-only, consumed by multiple downstream systems |
Checkout should reserve inventory in a transaction:
begin transaction
read listing row for update
if inventory > 0 and listing is active:
decrement inventory
create order
else:
reject purchase
commitThe search index is derived from the relational source of truth. It can be a few seconds stale; checkout cannot. That split lets browsing scale without weakening the invariant that one physical item sells once.
Design indexes for a task dashboard
A multi-tenant task app has a tasks table with org_id, assignee_id, status, priority, due_at, and updated_at. The main queries are: list open tasks for an org ordered by recent update; list a user’s open tasks ordered by due date; and fetch one task by id within an org. What indexes would you add?
Approach. Build compound indexes that match filters first and ordering/range columns last. Avoid one index per column; that rarely matches real queries.
Show solution
Use the primary key for direct lookup, ideally scoped by tenant:
primary key: (org_id, task_id)Then add indexes shaped like the two list queries:
| Query | Index | Reason |
|---|---|---|
| Org open tasks by recent update | (org_id, status, updated_at desc) |
Equality filters first, then sort order |
| Assignee open tasks by due date | (org_id, assignee_id, status, due_at asc) |
Tenant and assignee narrow the scan before due order |
| Unique external task id | (org_id, external_id) unique, if needed |
Enforces idempotent imports |
Do not add separate indexes on status, priority, and due_at just because they appear in queries. status = open may match a large fraction of the table, so the database still has to scan many rows. Compound indexes reduce the scanned range to the tenant and access pattern you actually serve.
Reason about a lost update
Two admins edit the same account quota at nearly the same time. Each reads the current quota of 100, adds 10, and saves 110. The intended result was 120. What went wrong, and how do you fix it?
Approach. This is a lost update: both writers read the same old value and one write overwrote the other. Fix it by making the read-modify-write atomic or by rejecting stale writes.
Show solution
The unsafe flow is:
Admin A reads quota = 100
Admin B reads quota = 100
Admin A writes quota = 110
Admin B writes quota = 110
Final value: 110, but two increments happenedThree common fixes:
| Fix | How it works | Trade-off |
|---|---|---|
| Atomic update | quota = quota + 10 happens inside the database |
Best for simple counters |
| Row lock | Lock the account row during read-modify-write | Correct but reduces concurrency |
| Optimistic version | Update only if version still equals what you read |
No long lock, but caller must retry |
Optimistic flow:
read quota=100, version=7
try update quota=110, version=8 where account_id=... and version=7
if 0 rows updated: someone else wrote first; reread and retrySerializable isolation can also make the database abort one transaction, but you still need retry logic. The product decision matters too: for quotas and money, reject/retry is correct; for a display name, last-writer-wins may be acceptable.