← Backend← Backend
BackendBackend19 Th7, 2026Jul 19, 202623 phút đọc19 min read

Thiết kế & Mở rộng DatabaseDatabase Design & Scaling

Thuộc bộ kiến thức Backend Roadmap.

Tổng quan

Mọi backend không tầm thường rồi cũng sống chết theo database của nó. Phần code ứng dụng bạn viết thường là phần dễ thay đổi nhất; còn tầng dữ liệu mới là nơi tập trung tính đúng đắn, latency và chi phí — và cũng là nơi một quyết định sai lầm khó đảo ngược nhất, bởi bạn không thể chỉ đơn giản redeploy một schema như redeploy một binary. Dữ liệu có tính durable, được chia sẻ, và bị ràng buộc bởi vật lý (disk, network, tốc độ của việc coordination), nên nó chống lại phản xạ “cứ thêm server vào” vốn hiệu quả với tầng app stateless.

Đây là một note mang tính cross-cutting. Nó giả định bạn đã biết cơ bản về việc lưu dữ liệu trong relational database và đã biết NoSQL database như một lựa chọn thay thế. Note tập trung vào tầng nằm giữa code và data store, và vào chuyện gì xảy ra khi một database instance đơn lẻ không còn đủ:

Mạch xuyên suốt: scaling là một chuỗi tradeoff, không phải một loạt bản nâng cấp. Mỗi kỹ thuật đem lại cho bạn một thứ (throughput, latency, availability) và bắt bạn trả một thứ khác (consistency, độ phức tạp vận hành, chi phí). Biết được cái giá phải trả chính là kỹ năng.

Kiến thức nền tảng

Các tầng giữa code và disk

Từ code ứng dụng xuống đĩa
  1. 01Code ứng dụng
  2. 02ORM / Query builder
  3. sinh ra SQL
    03Driver + Connection pool
  4. tái sử dụng kết nối TCP
    04Databaseparser → planner → executor → storage engine
  5. 05Index, buffer pool, WAL, disk

Đa số vấn đề performance không phải là “database chậm”. Chúng thường là: quá nhiều round trip (N+1), không tái sử dụng kết nối (pool exhaustion), thiếu index (full table scan), hoặc một query mà planner không thể tối ưu vì cách ORM viết nó. Bạn debug từ trên xuống nhưng chỗ cần sửa thường nằm ở một trong các tầng này.

Vertical vs horizontal scaling

Vertical (scale up)Horizontal (scale out)
Là gìMáy to hơn: nhiều CPU, RAM, disk NVMe nhanh hơnNhiều máy hơn: replica, shard
ƯuĐơn giản, không cần sửa app, giữ một bản copy nhất quánHeadroom gần như vô hạn, cô lập lỗi
NhượcTrần cứng, đắt ở mức cao, single point of failure, downtime khi resizePhức tạp, các bài toán distributed systems (consistency, rebalancing)
Dùng khiBạn chưa chạm trần — làm cái này trướcVertical đã cạn hoặc cần fault tolerance / geo-distribution

Nguyên tắc: vắt kiệt vertical scaling và read replica trước khi shard. Một instance đơn lẻ hiện đại (hàng chục core, hàng trăm GB RAM, NVMe) xử lý được nhiều hơn nhiều so với đa số team tưởng. Sharding là điểm không thể quay đầu về mặt độ phức tạp.

Reads và writes scale theo cách khác nhau

Sự phân biệt này tổ chức toàn bộ chủ đề:

Nếu workload của bạn read-heavy (đa số web app), thì replication + caching đi được rất xa. Nếu nó write-heavy (ingest event, metrics, giao dịch khối lượng lớn), bạn sẽ đối mặt với sharding sớm hơn.

Khái niệm chính

ORM & query builder

Một ORM (Object-Relational Mapper) ánh xạ các row trong database thành object trong ngôn ngữ của bạn: User.find(1) thay vì SELECT * FROM users WHERE id = 1. Một query builder là tầng mỏng hơn — nó dựng SQL bằng code nhưng không giả vờ row là object (db.select().from(users).where(...)). Ví dụ: Prisma, TypeORM, Drizzle (JS/TS); SQLAlchemy, Django ORM (Python); GORM, sqlc, Ent (Go); Hibernate/JPA (Java); ActiveRecord (Ruby).

Lợi ích

Nhược điểm — leaky abstraction

Một ORM giấu SQL đi cho đến khi nó không giấu được nữa. Abstraction bị rò rỉ ngay khi performance trở nên quan trọng, vì ORM không thể biết ý định của bạn:

Khi nào nên chuyển xuống raw SQL

ORM tốt luôn cho bạn một lối thoát — hãy dùng nó có chủ đích, giữ nó parameterized, và đặt nó gần model mà nó thuộc về.

# SQLAlchemy: ORM cho 90% trường hợp...
user = session.query(User).filter_by(id=1).one()

# ...và raw SQL (parameterized) cho 10% mà ORM xử lý kém:
rows = session.execute(
    text("""
        SELECT date_trunc('day', created_at) AS day, count(*) AS signups
        FROM users
        WHERE created_at >= :since
        GROUP BY 1
        ORDER BY 1
    """),
    {"since": since},
).all()
// GORM: API cấp cao cho CRUD...
db.First(&user, 1)

// ...và Raw khi cần kiểm soát:
db.Raw(`
  SELECT u.id, u.name, count(o.id) AS orders
  FROM users u LEFT JOIN orders o ON o.user_id = u.id
  GROUP BY u.id, u.name
`).Scan(&results)

N+1 query problem

Đây là bug performance của ORM phổ biến nhất. Bạn fetch một danh sách N parent, rồi lặp và truy cập lazy một relation trên từng cái — kích hoạt 1 query cho danh sách cộng N query cho các con. 100 blog post kèm author của chúng trở thành 101 query.

// N+1 với một ORM (giả/kiểu Prisma)
const posts = await db.post.findMany();          // 1 query
for (const post of posts) {
  const author = await db.user.findUnique({      // N query!
    where: { id: post.authorId },
  });
}

Cách phát hiện

Cách sửa

  1. Eager loading — bảo ORM load relation ngay từ đầu. Nó thường phát ra một query cho parent và một query IN (...) cho toàn bộ con (tổng 2 query), hoặc một join.
    • Prisma: include. TypeORM: relations / leftJoinAndSelect. SQLAlchemy: selectinload/joinedload. Django: select_related (join, cho FK) / prefetch_related (2 query, cho M2M/reverse). GORM: Preload.
  2. Một câu JOIN — khi bạn cần parent+child trong một lần.
  3. DataLoader pattern — trong ngữ cảnh GraphQL/batched, một DataLoader gom tất cả lời gọi .load(id) trong một tick rồi phát ra một query gộp, khử trùng lặp key. Cách này giải quyết N+1 tại tầng resolver.
// Đã sửa: eager load — tổng cộng 1 (hoặc 2) query
const posts = await db.post.findMany({
  include: { author: true },
});
# SQLAlchemy: selectinload phát ra 1 query cho posts + 1 query IN cho authors
posts = (
    session.query(Post)
    .options(selectinload(Post.author))
    .all()
)

Nguyên tắc: bất cứ khi nào bạn lặp qua một collection và chạm vào một relation bên trong vòng lặp, bạn đang (hoặc sẽ) có N+1. Hãy load relation trước vòng lặp.

Migration: tiến hóa schema an toàn

Một migration là một thay đổi có version, có thứ tự đối với schema (và đôi khi cả data), được commit vào git và áp dụng nhất quán trên mọi environment. Migration là cách một schema tiến hóa mà không phải chơi trò may rủi với ALTER TABLE thủ công.

Ví dụ một migration (SQL, không phụ thuộc tool):

-- 20260719120000_add_orders_status.up.sql
ALTER TABLE orders
  ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending';

CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);

-- 20260719120000_add_orders_status.down.sql
DROP INDEX IF EXISTS idx_orders_status;
ALTER TABLE orders DROP COLUMN status;

Zero-downtime migration

Phần khó không phải là SQL — mà là code ứng dụng cũ và mới chạy đồng thời trong lúc rolling deploy, nên schema phải tương thích với cả hai. Hai nguyên tắc:

  1. Đừng bao giờ thực hiện một thay đổi phá vỡ (breaking) trong một bước. Dùng pattern expand/contract (parallel change):
    • Expand: thêm column/table mới, để nó nullable hoặc có default. Deploy code ghi vào cả cột cũ lẫn mới.
    • Migrate: backfill các row hiện có theo từng batch.
    • Contract: chuyển read sang cột mới, ngừng ghi cột cũ, rồi drop cột cũ ở một lần deploy sau đó.
  2. Tránh các lock chặn traffic. Một câu ADD COLUMN với default không phải hằng số, hoặc CREATE INDEX không có CONCURRENTLY, có thể lock cả table. Dùng CREATE INDEX CONCURRENTLY, thêm column dạng nullable, và backfill theo batch thay vì một UPDATE khổng lồ.
Thao tácAn toàn?Làm cách này
Thêm column nullableAn toàn
Thêm column NOT NULL có defaultRủi ro trên engine cũ (rewrite table)Thêm nullable → backfill → thêm constraint
Drop columnPhá code cũ vẫn còn đọc nóExpand/contract; drop cuối cùng
Rename columnPhá cả hai chiềuThêm mới → dual-write → backfill → drop cũ
CREATE INDEXLock writeCREATE INDEX CONCURRENTLY (Postgres)

Connection pooling

Mở một kết nối database rất tốn kém: TCP handshake, TLS, authentication, cấp phát process/thread ở phía backend. Làm điều đó cho mỗi request vừa lãng phí thời gian, vừa tệ hơn nữa là database giới hạn số kết nối đồng thời (Postgres mặc định ~100). Dưới tải cao, hàng nghìn request mỗi cái mở một kết nối sẽ làm cạn giới hạn và database sụp — thường là bức tường scaling đầu tiên mà team đâm phải.

Một connection pool giữ một tập kết nối mở có giới hạn và cho các request mượn, trả lại pool khi xong.

Connection pool
  1. requests
    requests
    requests
  2. pool: 20 kết nối warmmượn / trả
  3. database

Scale reads: replication & read replica

Replication copy dữ liệu từ một leader (primary) sang một hoặc nhiều follower (replica). Hình dạng chuẩn là leader-follower (còn gọi là primary-replica): mọi write đi vào leader; read có thể được trải trên các replica.

        writes
client ────────► Leader ──stream WAL/binlog──► Follower 1 (reads)
                   │                          └► Follower 2 (reads)
                   └── reads (strong)

Replication lag & hệ quả về consistency

Replication thường là asynchronous — leader không chờ follower xác nhận. Nên một replica có thể trễ vài mili giây đến vài giây. Điều này tạo ra bug kinh điển:

Một user cập nhật profile (write → leader), app ngay lập tức đọc lại (read → một replica đang lag), và thấy giá trị . “Tôi đã lưu rồi mà nó không lưu!”

Đây là eventual consistency trên read. Cách giảm thiểu:

Chế độ replicationDurability/consistencyWrite latencyDùng cho
AsynchronousCó thể mất write gần đây khi failover; replica lagThấp nhấtMặc định, scale read
Semi-synchronousÍt nhất một replica đã có nóCao hơnCân bằng
SynchronousKhông mất data khi lỗi một nodeCao nhấtWrite tài chính/quan trọng

Scale writes: sharding & partitioning

Khi một leader đơn lẻ không hấp thụ nổi khối lượng write (hoặc dataset không vừa trên một máy), bạn chia dữ liệu ra nhiều node để mỗi node sở hữu một tập con. Thuật ngữ: partitioning = chia một dataset thành các phần; sharding = partitioning trên các database server riêng biệt. (Partitioning cũng có thể nằm trong một server — ví dụ Postgres declarative partitioning theo ngày.)

Bạn chia theo một partition/shard key. Chiến lược quyết định một row nằm ở đâu:

Chiến lượcÁnh xạ ra saoƯuNhược
RangeCác dải key → shard (ví dụ A–M, N–Z; hoặc theo ngày)Range scan hiệu quả; đơn giảnHotspot — key tuần tự (timestamp, ID auto-increment) dồn hết vào một shard
Hashhash(key) % N → shardPhân bố đều, không hotspotRange query trúng mọi shard; resharding khi N đổi thì đau đớn
Directory / lookupMột bảng tra cứu ánh xạ key → shardLinh hoạt, rebalance được bằng cách sửa mapDirectory là bottleneck / SPOF; thêm một hop

Liên quan: consistent hashing (và virtual node) giảm lượng data phải di chuyển khi bạn thêm/bớt một shard — chỉ chuyển ~1/N số key thay vì gần như toàn bộ.

Nỗi đau của sharding

Vì tất cả những điều này: shard sau cùng. Ưu tiên vertical scaling → read replica → caching → partitioning-trong-một-node → và chỉ khi đó mới true sharding. Một số database (Vitess, Citus, CockroachDB, YugabyteDB, MongoDB, Cassandra, DynamoDB) lo sharding giúp bạn và đáng để cân nhắc trước khi tự tay làm.

Giảm tải database: caching, materialized view, CQRS

Query rẻ nhất là query bạn không bao giờ chạy.

Replication pattern & multi-region; tradeoff CAP

Vượt ra ngoài leader-follower một region:

Multi-region thêm vào tốc độ ánh sáng: round trip xuyên region tốn hàng chục đến hàng trăm mili giây. Synchronous replication xuyên lục địa làm write chậm; asynchronous làm failover mất mát. Bạn chọn leader đặt ở đâu (write latency vs. locality) và chấp nhận lag ở nơi khác.

Về căn bản đây chính là CAP theorem: trong một Partition mạng, bạn phải chọn Consistency (từ chối/treo request để giữ đúng đắn — CP) hoặc Availability (phục vụ dữ liệu có thể cũ — AP). Trong thực tế nó là một dải phổ được tinh chỉnh theo từng operation, và PACELC liên quan nhắc bạn rằng ngay cả khi không có partition, bạn vẫn đánh đổi Latency vs. Consistency. Các hệ NoSQL thường mặc định AP; RDBMS truyền thống mặc định CP. Điều này nối trực tiếp với các consistency model được nói đến trong NoSQL database.

Profiling query chậm, EXPLAIN & chiến lược index

Bạn không thể scale cái mình không đo được. Trước khi thêm replica hay shard, hãy làm cho mỗi query rẻ đi.

Tìm query chậm

Đọc plan với EXPLAIN

EXPLAIN ANALYZE chạy thật query và cho thấy plan thực tế, số row, và thời gian.

EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 42 AND status = 'pending';

Chú ý tìm:

Chiến lược index (tóm tắt)

Xem Relational database về internal của index (B-tree, hash, GIN/GiST) và normalization.

Best Practices

Bảng quyết định: kỹ thuật → vấn đề → tradeoff

Kỹ thuậtVấn đề nó giải quyếtTradeoff / cái giá
Thêm indexRead chậm / full table scanWrite chậm hơn, tốn storage
Sửa N+1 (eager load)Quá nhiều round trip mỗi requestQuery đơn hơi lớn hơn; phải nhớ mà load
Connection pool / PgBouncerCạn kết nối, overhead handshakeConfig/vận hành; lưu ý về chế độ pooling (prepared statement)
Vertical scalingKhông đủ CPU/RAM/IO trên một máyTrần, chi phí, downtime khi resize, vẫn là SPOF
Read replicaRead throughput quá cao cho leaderReplication lag → read cũ (eventual consistency)
Caching (Redis)Read tốn kém lặp lạiĐộ phức tạp invalidation, rủi ro data cũ
Materialized viewAggregation tốn kém lặp lạiCũ cho đến khi refresh; chi phí refresh
CQRSTranh chấp read/write trong domain phức tạpEventual consistency, phức tạp hơn nhiều
Partitioning (một node)Table khổng lồ, prune dễ theo key/ngàyChỉ ích khi query filter theo partition key
ShardingKhối lượng write / dataset vượt một nodeMất join/txn xuyên shard, nỗi đau resharding, hotspot
Multi-region replicationLatency toàn cầu, failover theo regionCAP: consistency vs availability; giải quyết conflict

Nguyên tắc vận hành

Để hiểu điều này khớp vào bức tranh lớn hơn về hệ thống resilient như thế nào, xem Scalability & Reliability.

Tài liệu tham khảo

Part of the Backend Roadmap knowledge base.

Overview

Every non-trivial backend eventually lives and dies by its database. The application code you write is usually the easy part to change; the data layer is where correctness, latency, and cost concentrate — and where a bad decision is most expensive to reverse, because you cannot simply redeploy a schema the way you redeploy a binary. Data is durable, shared, and constrained by physics (disks, networks, the speed of coordination), so it resists the “just add more servers” reflex that works for stateless app tiers.

This note is cross-cutting. It assumes you already know the basics of storing data in a relational database and are aware of NoSQL databases as an alternative. It focuses on the layer between your code and the data store, and on what happens when a single database instance is no longer enough:

The through-line: scaling is a series of tradeoffs, not upgrades. Each technique buys you something (throughput, latency, availability) and charges you something else (consistency, operational complexity, cost). Knowing the price is the skill.

Fundamentals

The layers between your code and the disk

From application code to disk
  1. 01Application code
  2. 02ORM / Query builder
  3. generates SQL
    03Driver + Connection pool
  4. reuses TCP connections
    04Databaseparser → planner → executor → storage engine
  5. 05Indexes, buffer pool, WAL, disk

Most performance problems are not “the database is slow.” They are: too many round trips (N+1), no reuse of connections (pool exhaustion), a missing index (full table scan), or a query the planner can’t optimize because of how the ORM wrote it. You debug top-down but the fix is usually in one of these layers.

Vertical vs horizontal scaling

Vertical (scale up)Horizontal (scale out)
WhatBigger machine: more CPU, RAM, faster/NVMe diskMore machines: replicas, shards
ProsSimple, no app changes, keeps one consistent copyNear-unlimited headroom, fault isolation
ConsHard ceiling, expensive at the top, single point of failure, downtime to resizeComplex, distributed-systems problems (consistency, rebalancing)
Use whenYou haven’t hit the ceiling yet — do this firstVertical is exhausted or you need fault tolerance / geo-distribution

Rule of thumb: exhaust vertical scaling and read replicas before you shard. Modern single instances (dozens of cores, hundreds of GB of RAM, NVMe) handle far more than most teams assume. Sharding is the point of no return in complexity.

Reads vs writes scale differently

This distinction organizes the whole topic:

If your workload is read-heavy (most web apps), replication + caching goes a very long way. If it’s write-heavy (event ingestion, metrics, high-volume transactions), you’ll confront sharding sooner.

Key Concepts

ORMs & query builders

An ORM (Object-Relational Mapper) maps database rows to objects in your language: User.find(1) instead of SELECT * FROM users WHERE id = 1. A query builder is a thinner layer — it builds SQL programmatically but doesn’t pretend rows are objects (db.select().from(users).where(...)). Examples: Prisma, TypeORM, Drizzle (JS/TS); SQLAlchemy, Django ORM (Python); GORM, sqlc, Ent (Go); Hibernate/JPA (Java); ActiveRecord (Ruby).

Benefits

Drawbacks — the leaky abstraction

An ORM hides SQL until it can’t. The abstraction leaks the moment performance matters, because the ORM can’t know your intent:

When to drop to raw SQL

Good ORMs give you an escape hatch — use it deliberately, keep it parameterized, and keep it near the model it belongs to.

# SQLAlchemy: ORM for the 90% case...
user = session.query(User).filter_by(id=1).one()

# ...and raw SQL (parameterized) for the 10% the ORM handles poorly:
rows = session.execute(
    text("""
        SELECT date_trunc('day', created_at) AS day, count(*) AS signups
        FROM users
        WHERE created_at >= :since
        GROUP BY 1
        ORDER BY 1
    """),
    {"since": since},
).all()
// GORM: high-level API for CRUD...
db.First(&user, 1)

// ...and Raw when you need control:
db.Raw(`
  SELECT u.id, u.name, count(o.id) AS orders
  FROM users u LEFT JOIN orders o ON o.user_id = u.id
  GROUP BY u.id, u.name
`).Scan(&results)

The N+1 query problem

The single most common ORM performance bug. You fetch a list of N parents, then loop and lazily access a relation on each — triggering 1 query for the list plus N queries for the children. 100 blog posts with their authors becomes 101 queries.

// N+1 with an ORM (pseudo/Prisma-like)
const posts = await db.post.findMany();          // 1 query
for (const post of posts) {
  const author = await db.user.findUnique({      // N queries!
    where: { id: post.authorId },
  });
}

How to detect it

How to fix it

  1. Eager loading — tell the ORM to load the relation up front. It typically issues one query for parents and one IN (...) query for all children (2 queries total), or a join.
    • Prisma: include. TypeORM: relations / leftJoinAndSelect. SQLAlchemy: selectinload/joinedload. Django: select_related (join, for FK) / prefetch_related (2 queries, for M2M/reverse). GORM: Preload.
  2. A single JOIN — when you need parent+child in one shot.
  3. DataLoader pattern — in GraphQL/batched contexts, a DataLoader collects all .load(id) calls in a tick and issues one batched query, deduping keys. This solves N+1 at the resolver layer.
// Fixed: eager load — 1 (or 2) queries total
const posts = await db.post.findMany({
  include: { author: true },
});
# SQLAlchemy: selectinload issues 1 query for posts + 1 IN-query for authors
posts = (
    session.query(Post)
    .options(selectinload(Post.author))
    .all()
)

Rule: any time you iterate a collection and touch a relation inside the loop, you have (or will have) an N+1. Load relations before the loop.

Migrations: evolving the schema safely

A migration is a versioned, ordered change to the schema (and sometimes data), checked into git and applied consistently across every environment. Migrations are how a schema evolves without manual ALTER TABLE roulette.

Example migration (SQL, tool-agnostic):

-- 20260719120000_add_orders_status.up.sql
ALTER TABLE orders
  ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending';

CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);

-- 20260719120000_add_orders_status.down.sql
DROP INDEX IF EXISTS idx_orders_status;
ALTER TABLE orders DROP COLUMN status;

Zero-downtime migrations

The hard part isn’t the SQL — it’s that old and new application code run simultaneously during a rolling deploy, so the schema must be compatible with both. Two rules:

  1. Never make a breaking change in one step. Use the expand/contract (parallel change) pattern:
    • Expand: add the new column/table, make it nullable or defaulted. Deploy code that writes to both old and new.
    • Migrate: backfill existing rows in batches.
    • Contract: switch reads to the new column, stop writing the old, then drop the old column in a later deploy.
  2. Avoid locks that block traffic. A plain ADD COLUMN with a non-constant default, or CREATE INDEX without CONCURRENTLY, can lock the table. Use CREATE INDEX CONCURRENTLY, add columns nullable, and backfill in batches instead of one giant UPDATE.
OperationSafe?Do instead
Add nullable columnSafe
Add NOT NULL column with defaultRisky on old engines (table rewrite)Add nullable → backfill → add constraint
Drop columnBreaks old code still reading itExpand/contract; drop last
Rename columnBreaks both directionsAdd new → dual-write → backfill → drop old
CREATE INDEXLocks writesCREATE INDEX CONCURRENTLY (Postgres)

Connection pooling

Opening a database connection is expensive: TCP handshake, TLS, authentication, backend process/thread allocation. Doing it per request wastes time and, worse, databases cap concurrent connections (Postgres defaults to ~100). Under load, thousands of requests each opening a connection will exhaust the limit and the database falls over — often the first scaling wall teams hit.

A connection pool keeps a bounded set of open connections and lends them to requests, returning them to the pool when done.

Connection pool
  1. requests
    requests
    requests
  2. pool: 20 warm connectionsborrow / return
  3. database

Scaling reads: replication & read replicas

Replication copies data from a leader (primary) to one or more followers (replicas). The standard shape is leader-follower (a.k.a. primary-replica): all writes go to the leader; reads can be spread across replicas.

        writes
client ────────► Leader ──WAL/binlog stream──► Follower 1 (reads)
                   │                          └► Follower 2 (reads)
                   └── reads (strong)

Replication lag & consistency implications

Replication is usually asynchronous — the leader doesn’t wait for followers to confirm. So a replica can be milliseconds to seconds behind. This creates the classic bug:

A user updates their profile (write → leader), the app immediately reads it back (read → lagging replica), and sees the old value. “I saved it but it didn’t save!”

This is eventual consistency on reads. Mitigations:

Replication modeDurability/consistencyWrite latencyUse
AsynchronousCan lose recent writes on failover; replicas lagLowestDefault, read scaling
Semi-synchronousAt least one replica has itHigherBalance
SynchronousNo data loss on single-node failureHighestFinancial/critical writes

Scaling writes: sharding & partitioning

When a single leader can’t absorb the write volume (or the dataset won’t fit on one machine), you split the data across multiple nodes so each owns a subset. Terminology: partitioning = splitting a dataset into parts; sharding = partitioning across separate database servers. (Partitioning can also be within one server — e.g. Postgres declarative partitioning by date.)

You split by a partition/shard key. The strategy determines where a row lives:

StrategyHow it mapsProsCons
RangeKey ranges → shards (e.g. A–M, N–Z; or by date)Efficient range scans; simpleHotspots — sequential keys (timestamps, auto-increment IDs) pile onto one shard
Hashhash(key) % N → shardEven distribution, no hotspotsRange queries hit every shard; resharding when N changes is painful
Directory / lookupA lookup table maps key → shardFlexible, can rebalance by editing the mapThe directory is a bottleneck / SPOF; extra hop

Related: consistent hashing (and virtual nodes) reduces how much data moves when you add/remove a shard — moving ~1/N of keys instead of nearly all.

The pain of sharding

Because of all this: shard last. Prefer vertical scaling → read replicas → caching → partitioning-within-one-node → and only then true sharding. Some databases (Vitess, Citus, CockroachDB, YugabyteDB, MongoDB, Cassandra, DynamoDB) handle sharding for you and are worth reaching for before hand-rolling it.

Offloading the database: caching, materialized views, CQRS

The cheapest query is the one you never run.

Replication patterns & multi-region; CAP tradeoffs

Beyond single-region leader-follower:

Multi-region adds the speed of light: cross-region round trips are tens to hundreds of milliseconds. Synchronous replication across continents makes writes slow; asynchronous makes failover lossy. You choose where the leader lives (write latency vs. locality) and accept lag elsewhere.

This is fundamentally the CAP theorem: during a network Partition you must choose Consistency (reject/stall requests to stay correct — CP) or Availability (serve possibly-stale data — AP). In practice it’s a spectrum tuned per operation, and the related PACELC reminds you that even without partitions you trade Latency vs. Consistency. NoSQL systems often default AP; traditional RDBMS default CP. This connects directly to consistency models covered in NoSQL databases.

Profiling slow queries, EXPLAIN & indexing

You can’t scale what you can’t measure. Before adding replicas or shards, make each query cheap.

Find slow queries

Read the plan with EXPLAIN

EXPLAIN ANALYZE runs the query and shows the actual plan, row counts, and timing.

EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 42 AND status = 'pending';

Look for:

Indexing strategy (recap)

See Relational databases for index internals (B-tree, hash, GIN/GiST) and normalization.

Best Practices

Decision table: technique → problem → tradeoff

TechniqueProblem it solvesTradeoff / cost
Add indexSlow reads / full table scansSlower writes, more storage
Fix N+1 (eager load)Too many round trips per requestSlightly larger single query; must remember to load
Connection pool / PgBouncerConnection exhaustion, handshake overheadConfig/ops; pooling mode caveats (prepared statements)
Vertical scalingNot enough CPU/RAM/IO on one boxCeiling, cost, resize downtime, still a SPOF
Read replicasRead throughput too high for leaderReplication lag → stale reads (eventual consistency)
Caching (Redis)Repeated expensive readsInvalidation complexity, stale data risk
Materialized viewExpensive recurring aggregationStaleness until refresh; refresh cost
CQRSRead/write contention in complex domainsEventual consistency, much more complexity
Partitioning (single node)Huge table, easy pruning by key/dateOnly helps if queries filter on the partition key
ShardingWrite volume / dataset exceeds one nodeCross-shard joins/txns lost, resharding pain, hotspots
Multi-region replicationGlobal latency, regional failoverCAP: consistency vs availability; conflict resolution

Operational rules

For how this fits the bigger picture of resilient systems, see Scalability & Reliability.

References