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 đủ:
- Cách bạn nói chuyện với database từ code (ORM, query builder, raw SQL) và những cái bẫy đi kèm với abstraction — đặc biệt là N+1 query problem.
- Cách bạn tiến hóa schema một cách an toàn theo thời gian (migration, zero-downtime).
- Cách bạn kết nối hiệu quả (connection pooling).
- Cách bạn scale reads (replication, read replica) và scale writes (sharding/partitioning), cùng mức consistency bạn phải đánh đổi.
- Cách bạn giảm tải cho database (caching, materialized view, CQRS) và suy nghĩ về multi-region cùng các tradeoff của CAP.
- Cách bạn tìm ra phần chậm (
EXPLAIN, query profiling, indexing).
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
- 01Code ứng dụng
- 02ORM / Query builder
- sinh ra SQL03Driver + Connection pool
- tái sử dụng kết nối TCP04Databaseparser → planner → executor → storage engine
- 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ơn | Nhiề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án | Headroom gần như vô hạn, cô lập lỗi |
| Nhược | Trần cứng, đắt ở mức cao, single point of failure, downtime khi resize | Phức tạp, các bài toán distributed systems (consistency, rebalancing) |
| Dùng khi | Bạn chưa chạm trần — làm cái này trước | Vertical đã 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ủ đề:
- Reads dễ scale: copy dữ liệu ra (replication) rồi trải các query lên các bản copy. Các read không xung đột với nhau.
- Writes khó scale: mọi bản copy đều phải đồng thuận, nên cuối cùng bạn buộc phải chia nhỏ dữ liệu (partitioning/sharding) để các máy khác nhau sở hữu các row khác nhau. Write xung đột với nhau và cần coordination.
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
- Ít boilerplate; CRUD chỉ trong một dòng.
- Type safety và autocomplete (đặc biệt Prisma, Drizzle, sqlc — vốn sinh type từ schema của bạn).
- Portability giữa các database engine (về lý thuyết).
- Chống SQL injection nhờ parameterize mặc định.
- Tích hợp sẵn migration, relation và validation.
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:
- Nó giấu đi việc thực sự có bao nhiêu query chạy (xem N+1 bên dưới).
- Các query báo cáo phức tạp, window function, CTE và
LATERALjoin thì vụng về hoặc bất khả thi để diễn đạt. - SQL được sinh ra có thể chưa tối ưu, và bạn không thấy được trừ khi bật query logging.
- “Database portability” phần lớn là huyền thoại một khi bạn dùng bất cứ thứ gì đặc thù của engine.
Khi nào nên chuyển xuống raw SQL
- Query phân tích/báo cáo, aggregation, window function, recursive CTE.
- Thao tác hàng loạt (
INSERT ... SELECT, batched upsert) — nơi mà gọi ORM theo từng row sẽ là thảm họa. - Bất kỳ query nào bạn đã profile output của ORM và thấy nó sai.
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
- Bật SQL query logging ở môi trường dev; một trang phát ra hàng trăm câu
SELECT ... WHERE id = ?gần như giống hệt nhau là dấu hiệu đặc trưng. - Công cụ APM/tracing (Datadog, New Relic, OpenTelemetry) đánh dấu các query lặp lại trong mỗi request.
- Đặc thù theo ORM:
django-debug-toolbar,SQLAlchemyecho, Prisma query event, gem Bullet (Rails).
Cách sửa
- 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.
- Prisma:
- Một câu JOIN — khi bạn cần parent+child trong một lần.
- 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.
- Forward (up) áp dụng thay đổi; backward (down) hoàn tác nó. Không phải migration nào cũng đảo ngược sạch sẽ được (drop một column là mất data) — hãy quyết định đường down một cách có chủ đích.
- Tooling: Prisma Migrate, TypeORM/Knex migration, Alembic (SQLAlchemy), Django migration, golang-migrate / goose, Flyway, Liquibase, Rails migration.
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:
- Đừ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 đó.
- Tránh các lock chặn traffic. Một câu
ADD COLUMNvới default không phải hằng số, hoặcCREATE INDEXkhông cóCONCURRENTLY, có thể lock cả table. DùngCREATE INDEX CONCURRENTLY, thêm column dạng nullable, và backfill theo batch thay vì mộtUPDATEkhổng lồ.
| Thao tác | An toàn? | Làm cách này |
|---|---|---|
| Thêm column nullable | An toàn | — |
Thêm column NOT NULL có default | Rủi ro trên engine cũ (rewrite table) | Thêm nullable → backfill → thêm constraint |
| Drop column | Phá code cũ vẫn còn đọc nó | Expand/contract; drop cuối cùng |
| Rename column | Phá cả hai chiều | Thêm mới → dual-write → backfill → drop cũ |
CREATE INDEX | Lock write | CREATE 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.
- requestsrequestsrequests
- pool: 20 kết nối warmmượn / trả
- database
- Pool nằm trong app (HikariCP,
pgxpool,QueuePoolcủa SQLAlchemy, pool tích hợp sẵn của Prisma) hoặc như một proxy bên ngoài (PgBouncer, RDS Proxy, ProxySQL). - Sizing: to hơn không phải là tốt hơn. Tổng số kết nối trên tất cả các instance app phải nằm dưới giới hạn của DB. Một công thức khởi điểm phổ biến cho DB bị giới hạn bởi CPU:
connections ≈ (số_core * 2) + số_spindle_hiệu_dụng. Hãy đo, đừng đoán. - Serverless/nhiều instance làm chuyện này trầm trọng: 500 instance Lambda × một pool nhỏ mỗi cái là vượt xa giới hạn. Đặt PgBouncer (chế độ transaction pooling) phía trước, hoặc dùng một data proxy.
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)
- Scale read theo chiều ngang; thêm replica là thêm read capacity.
- Cung cấp failover: nếu leader chết, promote một follower lên.
- Cho phép đẩy các read phân tích nặng sang một replica riêng để chúng không làm hại write ở production.
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ị cũ. “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:
- Read-your-writes: định tuyến read của một user về leader trong một khoảng thời gian ngắn sau khi họ write (hoặc pin session của họ).
- Synchronous / semi-sync replication cho dữ liệu quan trọng: leader chờ ít nhất một replica ack (an toàn hơn, chậm hơn).
- Giám sát lag (
pg_stat_replication,Seconds_Behind_Master) và định tuyến tránh khỏi những replica bị tụt quá xa.
| Chế độ replication | Durability/consistency | Write latency | Dùng cho |
|---|---|---|---|
| Asynchronous | Có thể mất write gần đây khi failover; replica lag | Thấp nhất | Mặc định, scale read |
| Semi-synchronous | Ít nhất một replica đã có nó | Cao hơn | Cân bằng |
| Synchronous | Không mất data khi lỗi một node | Cao nhất | Write 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 | Ưu | Nhược |
|---|---|---|---|
| Range | Các dải key → shard (ví dụ A–M, N–Z; hoặc theo ngày) | Range scan hiệu quả; đơn giản | Hotspot — key tuần tự (timestamp, ID auto-increment) dồn hết vào một shard |
| Hash | hash(key) % N → shard | Phân bố đều, không hotspot | Range query trúng mọi shard; resharding khi N đổi thì đau đớn |
| Directory / lookup | Một bảng tra cứu ánh xạ key → shard | Linh hoạt, rebalance được bằng cách sửa map | Directory 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
- Hotspot: một shard key tệ (hoặc một user “người nổi tiếng”, một tenant khổng lồ) đẩy tải không cân xứng vào một shard. Hãy chọn key có cardinality cao, phân bố đều.
- Query & join xuyên shard trở thành scatter-gather ở tầng ứng dụng;
JOINxuyên shard coi như biến mất. - Transaction xuyên shard mất ACID của một node; bạn cần saga hoặc two-phase commit (cả hai đều đau đớn).
- Resharding (đổi N) là một trong những thao tác khó nhất trong toàn bộ backend engineering — bạn phải ánh xạ lại và di chuyển vật lý dữ liệu trong khi vẫn phục vụ traffic. Hãy thiết kế key sao cho hiếm khi phải làm việc này.
- ID toàn cục duy nhất: auto-increment không hoạt động xuyên shard. Dùng UUID, ULID, hoặc một scheme như Snowflake ID.
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.
- Caching — đặt một store nhanh (Redis/Memcached) hoặc cache in-process phía trước các read tốn kém. Đây là nước đi scaling có đòn bẩy cao nhất cho các hệ read-heavy và có một note riêng: xem Caching về cache-aside, write-through, TTL và invalidation.
- Materialized view — kết quả của một query được lưu lại thành một table và refresh định kỳ (
REFRESH MATERIALIZED VIEW, lý tưởng làCONCURRENTLY). Tuyệt vời cho các aggregation/báo cáo tốn kém mà chấp nhận được độ trễ nhẹ (staleness). Khác với view thường, nó được precompute; khác với cache, DB tự quản lý nó. - CQRS (Command Query Responsibility Segregation, nhìn lướt qua) — tách write model khỏi read model. Write đi vào một store được normalize; một projection denormalized, tối ưu cho read (thường là một table hoặc datastore khác) phục vụ query. Nó loại bỏ tranh chấp read/write và cho phép mỗi bên scale độc lập, đổi lại là eventual consistency và nhiều thành phần hơn. Chỉ dùng đến nó trong các domain phức tạp, scale lớn — không phải mặc định.
Replication pattern & multi-region; tradeoff CAP
Vượt ra ngoài leader-follower một region:
- Multi-leader (multi-master): nhiều node chấp nhận write và replicate cho nhau. Cho phép write latency thấp ở nhiều region, nhưng phát sinh write conflict cần giải quyết (last-write-wins, CRDT, merge ở ứng dụng).
- Leaderless (kiểu Dynamo: Cassandra, DynamoDB, Riak): bất kỳ node nào cũng chấp nhận read/write; consistency được tinh chỉnh bằng quorum —
W + R > Ncho read “gần như” strong. Availability cao, tunable.
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
- Bật slow query log (
log_min_duration_statementtrong Postgres; slow query log trong MySQL). pg_stat_statements(Postgres) tổng hợp những query tốn thời gian nhất trên toàn bộ workload — bắt đầu từ đây.- Đo thời gian DB theo từng endpoint bằng APM.
Đọ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:
Seq Scan(full table scan) trên một table lớn nơi bạn filter/join → thường là thiếu index.- Số row ước lượng vs thực tế lệch nhau kinh khủng → statistics cũ (
ANALYZE) hoặc plan tệ. - Nested loop trên số row khổng lồ, hoặc sort/hash tràn ra disk.
Chiến lược index (tóm tắt)
- Index các cột dùng trong
WHERE,JOIN, vàORDER BY. - Composite index tuân theo quy tắc leftmost-prefix: một index trên
(user_id, status)phục vụWHERE user_id = ?vàWHERE user_id = ? AND status = ?, nhưng không phục vụWHERE status = ?một mình. Sắp thứ tự cột theo selectivity/mức độ dùng. - Covering index: bao gồm mọi cột một query cần để nó không bao giờ phải chạm vào table (Postgres
INCLUDE, index-only scan). - Index tăng tốc read nhưng làm chậm write và tốn storage — mỗi
INSERT/UPDATEphải bảo trì mọi index. Đừng over-index. - Dùng partial index cho các query lệch (
WHERE status = 'pending') và nhớ đến cardinality: index một boolean cardinality thấp hiếm khi có ích.
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ật | Vấn đề nó giải quyết | Tradeoff / cái giá |
|---|---|---|
| Thêm index | Read chậm / full table scan | Write chậm hơn, tốn storage |
| Sửa N+1 (eager load) | Quá nhiều round trip mỗi request | Query đơn hơi lớn hơn; phải nhớ mà load |
| Connection pool / PgBouncer | Cạn kết nối, overhead handshake | Config/vận hành; lưu ý về chế độ pooling (prepared statement) |
| Vertical scaling | Không đủ CPU/RAM/IO trên một máy | Trần, chi phí, downtime khi resize, vẫn là SPOF |
| Read replica | Read throughput quá cao cho leader | Replication 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 view | Aggregation tốn kém lặp lại | Cũ cho đến khi refresh; chi phí refresh |
| CQRS | Tranh chấp read/write trong domain phức tạp | Eventual consistency, phức tạp hơn nhiều |
| Partitioning (một node) | Table khổng lồ, prune dễ theo key/ngày | Chỉ ích khi query filter theo partition key |
| Sharding | Khối lượng write / dataset vượt một node | Mất join/txn xuyên shard, nỗi đau resharding, hotspot |
| Multi-region replication | Latency toàn cầu, failover theo region | CAP: consistency vs availability; giải quyết conflict |
Nguyên tắc vận hành
- Đo trước đã. Bật query logging và
pg_stat_statementstrước khi tối ưu. Đa số “chúng ta cần shard” hóa ra là “chúng ta cần một index”. - Scale theo thứ tự rẻ-đến-đắt: index & sửa N+1 → connection pool → vertical → read replica + cache → partition → shard. Đừng nhảy cóc.
- Thiết kế mọi migration cho zero downtime (expand/contract), kể cả những cái nhỏ — giả định rolling deploy và các phiên bản code lẫn lộn.
- Đừng tin ORM một cách mù quáng. Log SQL được sinh ra ở dev; giữ một lối thoát sang raw SQL cho những query quan trọng.
- Chọn shard/partition key để phân bố đều và theo query pattern; một key tệ rất đắt để thay đổi. Tránh dùng key tuần tự làm shard key duy nhất.
- Giả định replication lag luôn tồn tại. Định tuyến read-your-writes về leader; giám sát lag và né các replica cũ.
- Giới hạn và giám sát số kết nối trên tất cả các instance app so với giới hạn của DB — đây là một nguyên nhân hàng đầu gây sự cố production.
- Backfill theo batch, đừng bao giờ chạy một
UPDATE/DELETEkhổng lồ khóa cả table hoặc làm phình WAL. - Giữ write path gọn nhẹ. Đẩy các read tốn kém sang replica, cache và materialized view để leader làm đúng việc mà chỉ nó làm được: chấp nhận write một cách an toàn.
Để 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
- Designing Data-Intensive Applications — Martin Kleppmann (O’Reilly) — cuốn sách kinh điển về replication, partitioning và consistency.
- PostgreSQL Documentation — High Availability, Load Balancing, and Replication
- PostgreSQL Documentation — Using EXPLAIN
- Use The Index, Luke! — SQL indexing và performance
- PgBouncer — Connection pooler nhẹ cho PostgreSQL
- Prisma — Relation queries & tránh N+1
- Vitess — Tài liệu Sharding
- Martin Fowler — CQRS
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:
- How you talk to the database from code (ORMs, query builders, raw SQL) and the traps that come with abstraction — especially the N+1 query problem.
- How you evolve the schema safely over time (migrations, zero-downtime).
- How you connect efficiently (connection pooling).
- How you scale reads (replication, read replicas) and scale writes (sharding/partitioning), and the consistency you trade away to do so.
- How you offload the database (caching, materialized views, CQRS) and reason about multi-region and CAP tradeoffs.
- How you find the slow parts (
EXPLAIN, query profiling, indexing).
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
- 01Application code
- 02ORM / Query builder
- generates SQL03Driver + Connection pool
- reuses TCP connections04Databaseparser → planner → executor → storage engine
- 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) | |
|---|---|---|
| What | Bigger machine: more CPU, RAM, faster/NVMe disk | More machines: replicas, shards |
| Pros | Simple, no app changes, keeps one consistent copy | Near-unlimited headroom, fault isolation |
| Cons | Hard ceiling, expensive at the top, single point of failure, downtime to resize | Complex, distributed-systems problems (consistency, rebalancing) |
| Use when | You haven’t hit the ceiling yet — do this first | Vertical 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:
- Reads are easy to scale: copy the data (replication) and spread queries across copies. Reads don’t conflict with each other.
- Writes are hard to scale: every copy must agree, so you eventually have to split the data (partitioning/sharding) so different machines own different rows. Writes conflict and require coordination.
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
- Less boilerplate; CRUD in a line.
- Type safety and autocomplete (especially Prisma, Drizzle, sqlc — which generate types from your schema).
- Portability across database engines (in theory).
- Guards against SQL injection by parameterizing by default.
- Integrated migrations, relations, and validation.
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:
- It hides how many queries actually run (see N+1 below).
- Complex reporting, window functions, CTEs, and
LATERALjoins are awkward or impossible to express. - The generated SQL may be suboptimal, and you can’t see it unless you turn on query logging.
- “Database portability” is mostly a myth once you use anything engine-specific.
When to drop to raw SQL
- Analytical/reporting queries, aggregations, window functions, recursive CTEs.
- Bulk operations (
INSERT ... SELECT, batched upserts) where per-row ORM calls would be catastrophic. - Any query where you’ve profiled the ORM output and it’s wrong.
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
- Turn on SQL query logging in dev; a page issuing hundreds of near-identical
SELECT ... WHERE id = ?is the signature. - APM/tracing tools (Datadog, New Relic, OpenTelemetry) flag repeated queries per request.
- ORM-specific:
django-debug-toolbar,SQLAlchemyecho, Prisma query events, Bullet gem (Rails).
How to fix it
- 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.
- Prisma:
- A single JOIN — when you need parent+child in one shot.
- 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.
- Forward (up) applies the change; backward (down) reverts it. Not every migration is cleanly reversible (dropping a column loses data) — decide the down path deliberately.
- Tooling: Prisma Migrate, TypeORM/Knex migrations, Alembic (SQLAlchemy), Django migrations, golang-migrate / goose, Flyway, Liquibase, Rails migrations.
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:
- 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.
- Avoid locks that block traffic. A plain
ADD COLUMNwith a non-constant default, orCREATE INDEXwithoutCONCURRENTLY, can lock the table. UseCREATE INDEX CONCURRENTLY, add columns nullable, and backfill in batches instead of one giantUPDATE.
| Operation | Safe? | Do instead |
|---|---|---|
| Add nullable column | Safe | — |
Add NOT NULL column with default | Risky on old engines (table rewrite) | Add nullable → backfill → add constraint |
| Drop column | Breaks old code still reading it | Expand/contract; drop last |
| Rename column | Breaks both directions | Add new → dual-write → backfill → drop old |
CREATE INDEX | Locks writes | CREATE 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.
- requestsrequestsrequests
- pool: 20 warm connectionsborrow / return
- database
- Pool lives in the app (HikariCP,
pgxpool, SQLAlchemy’sQueuePool, Prisma’s built-in pool) or as an external proxy (PgBouncer, RDS Proxy, ProxySQL). - Sizing: bigger is not better. Total connections across all app instances must stay under the DB’s limit. A common starting formula for a CPU-bound DB:
connections ≈ (core_count * 2) + effective_spindle_count. Measure, don’t guess. - Serverless/many-instances make this acute: 500 Lambda instances × a small pool each blows past the limit. Put PgBouncer (transaction pooling mode) in front, or use a data proxy.
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)
- Scales reads horizontally; add replicas to add read capacity.
- Provides failover: if the leader dies, promote a follower.
- Enables offloading heavy analytical reads to a dedicated replica so they don’t hurt production writes.
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:
- Read-your-writes: route a user’s reads to the leader for a short window after they write (or pin their session).
- Synchronous / semi-sync replication for critical data: leader waits for at least one replica to ack (safer, slower).
- Monitor lag (
pg_stat_replication,Seconds_Behind_Master) and route away from replicas that fall too far behind.
| Replication mode | Durability/consistency | Write latency | Use |
|---|---|---|---|
| Asynchronous | Can lose recent writes on failover; replicas lag | Lowest | Default, read scaling |
| Semi-synchronous | At least one replica has it | Higher | Balance |
| Synchronous | No data loss on single-node failure | Highest | Financial/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:
| Strategy | How it maps | Pros | Cons |
|---|---|---|---|
| Range | Key ranges → shards (e.g. A–M, N–Z; or by date) | Efficient range scans; simple | Hotspots — sequential keys (timestamps, auto-increment IDs) pile onto one shard |
| Hash | hash(key) % N → shard | Even distribution, no hotspots | Range queries hit every shard; resharding when N changes is painful |
| Directory / lookup | A lookup table maps key → shard | Flexible, can rebalance by editing the map | The 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
- Hotspots: a bad shard key (or one celebrity user, one huge tenant) sends disproportionate load to one shard. Choose a high-cardinality, evenly-distributed key.
- Cross-shard queries & joins become application-level scatter-gather;
JOINs across shards are effectively gone. - Cross-shard transactions lose single-node ACID; you need sagas or two-phase commit (both painful).
- Resharding (changing N) is one of the hardest operations in all of backend engineering — you must re-map and physically move data while serving traffic. Design the key so you rarely have to.
- Globally unique IDs: auto-increment doesn’t work across shards. Use UUIDs, ULIDs, or a scheme like Snowflake IDs.
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.
- Caching — put a fast store (Redis/Memcached) or in-process cache in front of expensive reads. This is the highest-leverage scaling move for read-heavy systems and has its own dedicated note: see Caching for cache-aside, write-through, TTLs, and invalidation.
- Materialized views — a query result stored as a table and refreshed periodically (
REFRESH MATERIALIZED VIEW, ideallyCONCURRENTLY). Great for expensive aggregations/reports that tolerate slight staleness. Unlike a plain view, it’s precomputed; unlike a cache, the DB manages it. - CQRS (Command Query Responsibility Segregation, at a glance) — separate the write model from the read model. Writes go to a normalized store; a denormalized, read-optimized projection (often another table or datastore) serves queries. It removes read/write contention and lets each side scale independently, at the cost of eventual consistency and more moving parts. Reach for it in complex, high-scale domains — not by default.
Replication patterns & multi-region; CAP tradeoffs
Beyond single-region leader-follower:
- Multi-leader (multi-master): multiple nodes accept writes and replicate to each other. Enables low-latency writes in multiple regions, but introduces write conflicts that need resolution (last-write-wins, CRDTs, application merge).
- Leaderless (Dynamo-style: Cassandra, DynamoDB, Riak): any node accepts reads/writes; consistency is tuned with quorums —
W + R > Ngives strong-ish reads. Highly available, tunable.
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
- Enable the slow query log (
log_min_duration_statementin Postgres; slow query log in MySQL). pg_stat_statements(Postgres) aggregates the most time-consuming queries across the whole workload — start here.- APM per-endpoint DB timing.
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:
Seq Scan(full table scan) on a large table where you filter/join → usually a missing index.- Estimated vs actual rows wildly off → stale statistics (
ANALYZE) or a bad plan. - Nested loops over huge row counts, or a sort/hash that spills to disk.
Indexing strategy (recap)
- Index columns used in
WHERE,JOIN, andORDER BY. - Composite indexes follow the leftmost-prefix rule: an index on
(user_id, status)servesWHERE user_id = ?andWHERE user_id = ? AND status = ?, but notWHERE status = ?alone. Order the columns by selectivity/usage. - Covering index: include all columns a query needs so it never touches the table (Postgres
INCLUDE, index-only scan). - Indexes speed reads but slow writes and cost storage — every
INSERT/UPDATEmaintains every index. Don’t over-index. - Use partial indexes for skewed queries (
WHERE status = 'pending') and remember the cardinality: indexing a low-cardinality boolean rarely helps.
See Relational databases for index internals (B-tree, hash, GIN/GiST) and normalization.
Best Practices
Decision table: technique → problem → tradeoff
| Technique | Problem it solves | Tradeoff / cost |
|---|---|---|
| Add index | Slow reads / full table scans | Slower writes, more storage |
| Fix N+1 (eager load) | Too many round trips per request | Slightly larger single query; must remember to load |
| Connection pool / PgBouncer | Connection exhaustion, handshake overhead | Config/ops; pooling mode caveats (prepared statements) |
| Vertical scaling | Not enough CPU/RAM/IO on one box | Ceiling, cost, resize downtime, still a SPOF |
| Read replicas | Read throughput too high for leader | Replication lag → stale reads (eventual consistency) |
| Caching (Redis) | Repeated expensive reads | Invalidation complexity, stale data risk |
| Materialized view | Expensive recurring aggregation | Staleness until refresh; refresh cost |
| CQRS | Read/write contention in complex domains | Eventual consistency, much more complexity |
| Partitioning (single node) | Huge table, easy pruning by key/date | Only helps if queries filter on the partition key |
| Sharding | Write volume / dataset exceeds one node | Cross-shard joins/txns lost, resharding pain, hotspots |
| Multi-region replication | Global latency, regional failover | CAP: consistency vs availability; conflict resolution |
Operational rules
- Measure first. Turn on query logging and
pg_stat_statementsbefore you optimize. Most “we need to shard” turns out to be “we need one index.” - Scale in the cheap-to-expensive order: index & fix N+1 → connection pool → vertical → read replicas + cache → partition → shard. Don’t skip ahead.
- Design every migration for zero downtime (expand/contract), even small ones — assume rolling deploys and mixed code versions.
- Never trust the ORM blindly. Log the generated SQL in dev; keep an escape hatch to raw SQL for the queries that matter.
- Pick shard/partition keys for even distribution and query patterns; a bad key is expensive to change. Avoid sequential keys as the sole shard key.
- Assume replication lag exists. Route read-your-writes to the leader; monitor lag and fail away from stale replicas.
- Cap and monitor connections across all app instances against the DB limit — this is a leading cause of production outages.
- Backfill in batches, never one giant
UPDATE/DELETEthat locks a table or bloats the WAL. - Keep the write path lean. Move expensive reads to replicas, caches, and materialized views so the leader does what only it can: accept writes safely.
For how this fits the bigger picture of resilient systems, see Scalability & Reliability.
References
- Designing Data-Intensive Applications — Martin Kleppmann (O’Reilly) — the definitive book on replication, partitioning, and consistency.
- PostgreSQL Documentation — High Availability, Load Balancing, and Replication
- PostgreSQL Documentation — Using EXPLAIN
- Use The Index, Luke! — SQL indexing and performance
- PgBouncer — Lightweight connection pooler for PostgreSQL
- Prisma — Relation queries & avoiding N+1
- Vitess — Sharding documentation
- Martin Fowler — CQRS