Đ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ạnGiải Pháp Kỹ ThuậtDung Lượng Dữ LiệuMức Tải Ghi (Write QPS)Độ Phức Tạp Vận Hành
1Tối ưu 1 Database instance + Connection Pool< 500 GB< 5,000 QPSRất Thấp
2Triển khai Read Replicas (Leader-Follower)500 GB–2 TB< 20,000 QPS (Read-Heavy)Thấp
3Phân vùng bảng (Table Partitioning)1–10 TB< 50,000 QPSTrung Bình
4Sharding cơ sở dữ liệu ở tầng Application> 5 TB> 50,000 QPSCao
5Áp dụng NewSQL (TiDB, Google Spanner)Không giới hạn> 100,000 QPSTrung 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ợpLý 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 + OLAPTiDB (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 caoRocksDB / BadgerDBTố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ánhPostgreSQLMySQL
Mô hình kết nốiTiế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ối5–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ốiBắt buộc sử dụng PgBouncerTù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 mode phí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().WaitCount tă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ện rows.Close() hoặc thiếu defer tx.Rollback(). Đội ngũ phát triển cần đảm bảo luôn đặt defer rows.Close() ngay sau khi gọi db.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.