Điều kiện tiên quyết: Đây là Phần 4 của Khóa Học System Design. Xem lại Phần 3: Chiến Lược Caching để nắm vững các chiến lược tầng cache trước khi đi sâu vào các giải pháp lưu trữ tầng cơ sở dữ liệu.
Answer-first: Đặc biệt với sự bùng nổ của các hệ thống AI-Native và kiến trúc phân tán quy mô lớn năm 2026, lượng dữ liệu ngày càng khổng lồ. Phân mảnh cơ sở dữ liệu (Database Sharding) là phương pháp chia dữ liệu theo chiều ngang (horizontally) ra nhiều phân vùng độc lập (shards) dựa trên một khóa phân vùng (shard key). Giải pháp này giúp giảm xung đột tài nguyên khi ghi dữ liệu (Write Contention) và hỗ trợ mở rộng dung lượng bộ lưu trữ theo tỷ lệ tuyến tính. Việc lựa chọn shard key không phù hợp có thể tạo ra các điểm nóng dữ liệu (Hot Spots), làm giảm hiệu năng hệ thống so với khi chưa phân mảnh.
So Sánh Vertical Scaling vs Horizontal Scaling — Khi Nào Cần Chuyển Đổi?
Answer-first: Mở rộng theo chiều dọc (Vertical Scaling - Scale-up) là việc nâng cấp cấu hình tài nguyên (CPU, RAM) trên một máy chủ đơn lẻ. Giải pháp này dễ triển khai nhưng nhanh chóng chạm trần giới hạn phần cứng và chi phí gia tăng phi tuyến tính. Mở rộng theo chiều ngang (Horizontal Scaling - Scale-out) là việc bổ sung nhiều máy chủ vào cụm xử lý. Phương án này không bị giới hạn bởi trần phần cứng và chi phí tăng trưởng tuyến tính, nhưng làm tăng độ phức tạp trong thiết kế kiến trúc và vận hành.
Các Giai Đoạn Mở Rộng Hệ Thống Database
| Giai Đoạn | Giải Pháp Kỹ Thuật | Dung Lượng Dữ Liệu | Mức Tải Ghi (Write QPS) | Độ Phức Tạp Vận Hành |
|---|---|---|---|---|
| 1 | Tối ưu 1 Database instance + Connection Pool | < 500 GB | < 5,000 QPS | Rất Thấp |
| 2 | Triển khai Read Replicas (Leader-Follower) | 500 GB–2 TB | < 20,000 QPS (Read-Heavy) | Thấp |
| 3 | Phân vùng bảng (Table Partitioning) | 1–10 TB | < 50,000 QPS | Trung Bình |
| 4 | Sharding cơ sở dữ liệu ở tầng Application | > 5 TB | > 50,000 QPS | Cao |
| 5 | Áp dụng NewSQL (TiDB, Google Spanner) | Không giới hạn | > 100,000 QPS | Trung Bình (Managed Service) |
[!IMPORTANT] Không thực hiện Sharding quá sớm (Don’t Shard Prematurely). Nhiều hệ thống lớn (như Shopee hay Netflix) đều bắt đầu với một cơ sở dữ liệu đơn lẻ trước khi chuyển đổi. Việc áp dụng Sharding quá sớm sẽ làm gia tăng độ phức tạp của các câu truy vấn liên phân vùng (Cross-Shard Queries) và giảm năng suất phát triển khi lưu lượng thực tế chưa đòi hỏi.
So Sánh B-Tree vs LSM-Tree — Phân Tích Động Cơ Lưu Trữ (Storage Engine Internals)
Answer-first: Động cơ B-Tree (như InnoDB trong MySQL hay PostgreSQL) được tối ưu hóa cho các hệ thống ưu tiên đọc (Read-Heavy Workloads), hỗ trợ tìm kiếm điểm (Point Lookups) và quét dải dữ liệu (Range Scans) với độ trễ thấp. Động cơ LSM-Tree (như RocksDB, Cassandra, TiKV) được thiết kế cho các hệ thống ghi dữ liệu cường độ cao (Write-Heavy Workloads) bằng cách lưu tạm dữ liệu trên RAM trước khi ghi nối tiếp (Sequentially) xuống đĩa cứng.
Động Cơ B-Tree (InnoDB, PostgreSQL Heap)
graph TD
Root["Node Gốc (Root Node) [50 | 100]"] --> L1["Node Trung Gian [10 | 30]"]
Root --> L2["Node Trung Gian [60 | 80]"]
Root --> L3["Node Trung Gian [110 | 150]"]
L1 --> Leaf1["Node Lá [1,5,8,10] →"]
L1 --> Leaf2["Node Lá [15,20,25,30] →"]
L2 --> Leaf3["Node Lá [55,60,65,70] →"]
style Root fill:#cce5ff,stroke:#004085
style Leaf1 fill:#d4edda,stroke:#28a745
style Leaf2 fill:#d4edda,stroke:#28a745
style Leaf3 fill:#d4edda,stroke:#28a745
- Tìm kiếm điểm (Point Lookup): $O(\log N)$ — duyệt từ node gốc tới node lá.
- Quét theo dải (Range Scan): Tối ưu nhờ các node lá được liên kết dạng danh sách liên kết đôi (Doubly-linked List).
- Hệ số phóng đại ghi (Write Amplification): Cao — các thao tác INSERT/UPDATE có thể kích hoạt phân tách trang (Page Splits) và sắp xếp lại cây.
- Hạn chế: Các thao tác ghi ngẫu nhiên (Random Writes) làm tăng áp lực ghi đĩa (IO Amplification) do phải cập nhật các trang không liên tục.
Động Cơ LSM-Tree (RocksDB, Cassandra, TiKV)
Các thao tác ghi được ghi nối tiếp vào MemTable (đã sắp xếp) trên RAM. Khi MemTable đạt giới hạn dung lượng, dữ liệu được đẩy xuống các file SSTables bất biến (Immutable) ở ổ đĩa. Tiến trình ngầm (Background Compaction) sẽ hợp nhất các SSTables:
graph LR
Write([Write Request]) --> MT["MemTable\n(Sắp xếp trên RAM)"]
MT -->|"Flush khi đầy"| L0["L0 SSTables\n(Dữ liệu có thể chồng lấp)"]
L0 -->|Compaction| L1["L1 SSTables\n(Đã sắp xếp & phân vùng)"]
L1 -->|Compaction| L2["L2 SSTables\n(Tăng trưởng dung lượng)"]
Read([Read Request]) --> MT
Read --> L0
Read --> L1
style MT fill:#fff3cd,stroke:#f0a500
- Thao tác ghi (Write): Ghi nối tiếp vào cuối file (Sequential Append) -> Đạt tốc độ ghi rất cao, giảm thiểu I/O ngẫu nhiên.
- Thao tác đọc (Read): Cần duyệt qua các tầng SSTables — được tối ưu bằng cấu trúc Bloom Filters.
- Chi phí Compaction: Tiêu tốn CPU và I/O ngầm trong quá trình sắp xếp và hợp nhất file.
Tiêu Chí Lựa Chọn Động Cơ Lưu Trữ
| Kịch Bản Sử Dụng | Động Cơ Phù Hợp | Lý Do Kỹ Thuật |
|---|---|---|
| Hệ thống giao dịch OLTP (Đơn hàng, Thanh toán) | B-Tree (PostgreSQL / MySQL InnoDB) | Tìm kiếm điểm với độ trễ thấp |
| Hệ thống ghi nhật ký / Time-series (Metrics, Logs) | LSM-Tree (Cassandra, ClickHouse) | Tối ưu hóa ghi nối tiếp tốc độ cao |
| Hệ thống kết hợp OLTP + OLAP | TiDB (TiKV = LSM-Tree + B-Tree Secondary Index) | Hỗ trợ mở rộng theo chiều ngang và giao dịch phân tán |
| Hệ thống Key-Value throughput cao | RocksDB / BadgerDB | Tối ưu hóa I/O cho thao tác ghi |
Tiêu Chí Lựa Chọn Shard Key Cho Cơ Sở Dữ Liệu
Answer-first: Một Shard Key hiệu quả bắt buộc phải thỏa mãn 3 tiêu chí: (1) Độ phân định cao (High Cardinality) — có số lượng giá trị duy nhất đủ lớn để phân bổ dữ liệu đều trên các shard; (2) Phân bổ tải ghi đồng đều — tránh tập trung dữ liệu mới vào một shard duy nhất (không dùng các chìa khóa tăng dần theo thời gian); (3) Định vị truy vấn cục bộ (Query Locality) — các câu truy vấn phổ biến chỉ cần quét trên một shard thay vì phải phân tán.
Ba Kỹ Thuật Phân Vùng Dữ Liệu (Partitioning Strategies)
1. Phân vùng theo khoảng (Range Partitioning) — Phù hợp với dữ liệu dạng chuỗi thời gian (Time-series)
-- Phân vùng bảng theo khoảng thời gian trong PostgreSQL
CREATE TABLE transaction_log (
id UUID NOT NULL,
user_id BIGINT NOT NULL,
amount NUMERIC(15, 2) NOT NULL,
status VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE transaction_log_2026_06 PARTITION OF transaction_log
FOR VALUES FROM ('2026-06-01 00:00:00+00') TO ('2026-07-01 00:00:00+00');
CREATE TABLE transaction_log_2026_07 PARTITION OF transaction_log
FOR VALUES FROM ('2026-07-01 00:00:00+00') TO ('2026-08-01 00:00:00+00');
-- Index cục bộ trên từng phân vùng
CREATE INDEX idx_txn_log_user_2026_06
ON transaction_log_2026_06 (user_id, created_at DESC);
Ưu điểm: Dễ dàng xóa dữ liệu cũ bằng lệnh DROP TABLE phân vùng mà không cần chạy VACUUM. Các truy vấn theo khoảng thời gian được tối ưu nhờ cơ chế Partition Pruning.
Nhược điểm: Dễ gặp sự cố điểm nóng dữ liệu (Write Hot Spot) khi toàn bộ dữ liệu mới ghi tập trung vào phân vùng thời gian gần nhất.
2. Phân vùng theo hàm băm (Hash Partitioning) — Phân bổ tải ghi đồng đều
CREATE TABLE user_events (
id BIGSERIAL,
user_id BIGINT NOT NULL,
event VARCHAR(100),
occurred_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY HASH (user_id);
CREATE TABLE user_events_0 PARTITION OF user_events
FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE user_events_1 PARTITION OF user_events
FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE user_events_2 PARTITION OF user_events
FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE user_events_3 PARTITION OF user_events
FOR VALUES WITH (MODULUS 4, REMAINDER 3);
3. Điều hướng Shard ở tầng ứng dụng (Application-Level Shard Router)
// Điều hướng kết nối database dựa trên User ID ở tầng ứng dụng
type ShardRouter struct{}
func (r *ShardRouter) GetShardDSN(userID int64) string {
switch {
case userID < 1_000_000:
return "postgres://shard-1:5432/users"
case userID < 2_000_000:
return "postgres://shard-2:5432/users"
default:
return "postgres://shard-3:5432/users"
}
}
Giao Thức Chốt Đơn Phân Tán Trong TiDB (Percolator 2PC Protocol)
Answer-first: TiDB áp dụng giao thức 2-Phase Commit (2PC) của Percolator trên nền tảng TiKV để thực thi các giao dịch phân tán hỗ trợ chuẩn ACID mà không cần thành phần điều phối tập trung ở tầng ứng dụng.
Pha 1 — Prewrite Phase:
1. Client chọn một Primary Key làm điểm đại diện và các Secondary Keys phụ thuộc.
2. Ghi bản ghi khóa (Primary Lock) lên TiKV kèm mốc thời gian start_ts từ dịch vụ PD.
3. Ghi khóa phụ trên các Secondary Keys kèm con trỏ tham chiếu tới Primary Key.
Pha 2 — Commit Phase:
Nếu tất cả các thao tác Prewrite thành công:
1. Ghi bản ghi Commit lên Primary Key kèm mốc thời gian commit_ts.
2. Giao dịch được xác nhận thành công (Committed) ngay khi Primary Key được commit.
3. Tiến trình ngầm sẽ bất đồng bộ dọn dẹp các khóa phụ trên Secondary Keys.
Nếu có bất kỳ thao tác Prewrite nào thất bại:
1. Tiến hành Rollback toàn bộ các khóa đã tạo trong giao dịch.
[!NOTE] Thời gian phản hồi commit của TiDB khoảng ~2–5ms (so với ~0.5ms của MySQL 1 node). Đây là sự đánh đổi về hiệu năng để đạt được khả năng mở rộng chiều ngang và tính tin cậy ACID trên cụm cơ sở dữ liệu phân tán.
Tối Ưu Bể Kết Nối (Connection Pool) Trong Go Bằng database/sql
Answer-first: Bể kết nối (connection pool) trong database/sql cần được cấu hình phù hợp với khả năng xử lý thực tế của cơ sở dữ liệu. Lỗi phổ biến thường gặp là bỏ trống tham số MaxOpenConns (mặc định không giới hạn), khiến ứng dụng mở quá nhiều kết nối song song và gây quá tải bộ nhớ trên máy chủ cơ sở dữ liệu.
So Sánh Mô Hình Kết Nối PostgreSQL vs MySQL
| Tiêu Chí So Sánh | PostgreSQL | MySQL |
|---|---|---|
| Mô hình kết nối | Tiến trình riêng cho từng kết nối (Process-per-connection) | Luồng riêng cho từng kết nối (Thread-per-connection) |
| Dung lượng RAM / kết nối | 5–10 MB (do chi phí bộ nhớ ảo và Shared Buffers) | Tối ưu hơn (~1–2 MB) |
| Số kết nối tối đa thực tế | Khoảng ~100–500 kết nối (nguy cơ cạn kiệt RAM) | Khả năng đáp ứng ~1000–5000 kết nối |
| Thư viện Proxy kết nối | Bắt buộc sử dụng PgBouncer | Tùy chọn sử dụng ProxySQL |
[!WARNING] Trên PostgreSQL, 500 kết nối song song có thể tiêu tốn khoảng 5 GB RAM chỉ cho chi phí quản lý kết nối. Trong môi trường production, hệ thống bắt buộc phải triển khai PgBouncer ở chế độ
transaction modephía trước PostgreSQL để gom (multiplex) hàng trăm kết nối từ ứng dụng thành số lượng nhỏ kết nối thực tế tới database.
Cấu Hình Bể Kết Nối Chuẩn Trong Go
package database
import (
"database/sql"
"fmt"
"log"
"time"
_ "github.com/lib/pq"
)
type DBConfig struct {
DSN string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
}
func InitDB(cfg DBConfig) (*sql.DB, error) {
db, err := sql.Open("postgres", cfg.DSN)
if err != nil {
return nil, fmt.Errorf("lỗi mở kết nối cơ sở dữ liệu: %w", err)
}
// Quy tắc 1: MaxOpenConns = min(DB max_connections * 0.8, (Số CPU Cores * 2) + Số đĩa cứng)
db.SetMaxOpenConns(cfg.MaxOpenConns)
// Quy tắc 2: Đặt MaxIdleConns = MaxOpenConns để tránh chi phí tạo/đóng kết nối liên tục
db.SetMaxIdleConns(cfg.MaxIdleConns)
// Quy tắc 3: ConnMaxLifetime phải nhỏ hơn timeout ngắt kết nối rảnh của DB và Firewall
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
// Quy tắc 4: ConnMaxIdleTime giúp giải phóng các kết nối rảnh trong khoảng thời gian lưu lượng thấp
db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("không thể kết nối ping tới cơ sở dữ liệu: %w", err)
}
log.Printf("DB pool initialized: maxOpen=%d maxIdle=%d lifetime=%v idleTime=%v",
cfg.MaxOpenConns, cfg.MaxIdleConns, cfg.ConnMaxLifetime, cfg.ConnMaxIdleTime)
return db, nil
}
// ProductionConfig trả về cấu hình đề xuất cho ứng dụng lưu lượng ~500 RPS
func ProductionConfig(dsn string) DBConfig {
return DBConfig{
DSN: dsn,
MaxOpenConns: 80,
MaxIdleConns: 80,
ConnMaxLifetime: 30 * time.Minute,
ConnMaxIdleTime: 15 * time.Minute,
}
}
[!TIP] Phát hiện rò rỉ kết nối (Detecting connection leaks): Khi chỉ số
db.Stats().WaitCounttăng cao vàInUse ≈ MaxOpenConns, hệ thống đang gặp sự cố rò rỉ kết nối. Nguyên nhân thường do các goroutines không thực hiệnrows.Close()hoặc thiếudefer tx.Rollback(). Đội ngũ phát triển cần đảm bảo luôn đặtdefer rows.Close()ngay sau khi gọidb.Query().
func GetOrders(db *sql.DB, userID int64) ([]Order, error) {
rows, err := db.Query("SELECT id, amount FROM orders WHERE user_id = $1", userID)
if err != nil {
return nil, err
}
defer rows.Close() // Giải phóng kết nối về pool sau khi xử lý xong
var orders []Order
for rows.Next() {
var o Order
if err := rows.Scan(&o.ID, &o.Amount); err != nil {
return nil, err
}
orders = append(orders, o)
}
// Kiểm tra lỗi trong quá trình duyệt qua các dòng dữ liệu
return orders, rows.Err()
}
Câu Hỏi Thường Gặp (FAQ)
Sự khác biệt cốt lõi giữa Vertical Scaling và Horizontal Scaling là gì?
Vertical Scaling (Scale-up): Nâng cấp tài nguyên CPU và RAM trên máy chủ hiện tại. Dễ triển khai và không yêu cầu thay đổi mã nguồn, nhưng bị giới hạn bởi trần thiết bị phần cứng và chi phí nâng cấp tăng mạnh.
Horizontal Scaling (Scale-out): Bổ sung thêm nhiều máy chủ vào hệ thống. Chi phí tăng trưởng tuyến tính và không bị giới hạn dung lượng, nhưng đòi hỏi phải phân rã dữ liệu (partitioning), điều phối giao dịch phân tán và tăng độ phức tạp trong vận hành.
Tiêu chí lựa chọn Shard Key tối ưu cho cơ sở dữ liệu?
Một Shard Key phù hợp cần đáp ứng 3 tiêu chuẩn: (1) Độ phân định cao (High cardinality) — phân bổ dữ liệu đều trên các shard; (2) Phân bổ tải ghi đồng đều — tránh tập trung toàn bộ dữ liệu mới vào một shard; (3) Định vị truy vấn cục bộ (Query locality) — đảm bảo các câu truy vấn phổ biến chỉ cần quét trên một shard. user_id thường là lựa chọn tối ưu trong các ứng dụng thương mại điện tử so với các trường timestamp.
Khi nào nên cân nhắc chuyển sang sử dụng NewSQL (TiDB)?
Nên sử dụng TiDB khi dung lượng dữ liệu vượt ngưỡng 1 TB, hệ thống đòi hỏi thực thi các câu truy vấn SQL phức tạp, yêu cầu khả năng mở rộng chiều ngang tự động mà không cần sharding thủ công, hoặc cần hỗ trợ giao dịch phân tán ACID. Không nên áp dụng TiDB khi ứng dụng yêu cầu độ trễ cực thấp (< 2ms) hoặc đội ngũ vận hành chưa có kinh nghiệm với hệ thống NewSQL.
🔗 Bay Sang Bài Tới: Phần 5: Kiến Trúc Hướng Sự Kiện & Kafka Trong Go (Event-Driven Architecture & Kafka in Go) — Mô hình Worker Pool Pattern, cơ chế Backpressure qua Buffered Channels và Exactly-Once Processing.