Skip to content

Thiết Kế Cơ Sở Dữ Liệu Cho Hệ Thống Lớn: Chuẩn Hóa, Phân Vùng, Sharding và Read Replica

By Nhân Nguyễn on Apr 19, 2026

Thiết Kế Cơ Sở Dữ Liệu Cho Hệ Thống Lớn: Chuẩn Hóa, Phân Vùng, Sharding và Read Replica

Bạn là kỹ sư dữ liệu tại X Bank. Vào một buổi sáng thứ Hai đẹp trời, hệ thống giám sát bỗng đỏ rực. Bảng transactions – trái tim của toàn bộ nghiệp vụ ngân hàng – đã phình to từ 100 triệu dòng (năm 2024) lên 500 triệu dòng chỉ trong hai năm. Những câu truy vấn từng mất 100ms giờ đây kéo dài 30 giây. Khách hàng phàn nàn, ứng dụng timeout, đội ngũ vận hành toát mồ hôi.

Ba giải pháp được đưa ra trong cuộc họp khẩn:

  • Kỹ sư A: “Thêm RAM đi, cache sẽ giải quyết tất cả!”
  • Kỹ sư B: “Không, thêm index mới đúng. Full table scan là thảm họa.”
  • Kỹ sư C: “Phân vùng theo ngày. Chúng ta chỉ cần giữ dữ liệu 2 năm, truy vấn chủ yếu 3 tháng gần đây.”

Bạn – người chịu trách nhiệm kiến trúc – phải quyết định. Nhưng trước hết, hãy cùng nhìn lại schema hiện tại:

CREATE TABLE transactions (
    id BIGINT PRIMARY KEY,
    account_id VARCHAR(20) NOT NULL,
    customer_id BIGINT NOT NULL,
    amount DECIMAL(15,2) NOT NULL,
    status VARCHAR(20),
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL,
    reference_id VARCHAR(100) UNIQUE,
    memo TEXT,
    internal_notes TEXT,
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (account_id) REFERENCES accounts(account_id)
);

Và đây là ba mẫu truy vấn điển hình:

-- Truy vấn A (80% lưu lượng): Lấy giao dịch gần đây của một tài khoản
SELECT * FROM transactions
WHERE account_id = 'ACC001' AND created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)
LIMIT 20;

-- Truy vấn B (15%): Tổng số tiền giao dịch của khách hàng trong năm
SELECT customer_id, SUM(amount) FROM transactions
WHERE customer_id = 123 AND YEAR(created_at) = 2026
GROUP BY customer_id;

-- Truy vấn C (5%): Xuất báo cáo toàn bộ giao dịch năm gần nhất
SELECT * FROM transactions
WHERE created_at > DATE_SUB(NOW(), INTERVAL 365 DAY)
LIMIT 100000;

Bài toán này không hề xa lạ với bất kỳ ai từng làm việc với cơ sở dữ liệu tăng trưởng nhanh. Trong bài viết hôm nay, chúng ta sẽ cùng nhau mổ xẻ bốn vũ khí chiến lược: Chuẩn hóa (Normalization), Phân vùng (Partitioning), Sharding và Read Replica. Bạn sẽ hiểu rõ cơ chế bên trong, cách triển khai thực tế bằng Spring Boot và Liquibase, và quan trọng nhất – những cái bẫy chết người cần tránh.


1. Chuẩn Hóa (Normalization) – Khi Nào Tuân Thủ, Khi Nào Phá Vỡ?

1.1 Ba Dạng Chuẩn Căn Bản (1NF, 2NF, 3NF)

Chuẩn hóa là quá trình tổ chức dữ liệu để giảm dư thừa và tránh dị thường khi cập nhật. Hãy tưởng tượng bạn lưu tên khách hàng trong mỗi dòng giao dịch. Khi khách hàng đổi tên, bạn phải cập nhật hàng ngàn dòng – chỉ cần sót một dòng là dữ liệu không nhất quán.

Dạng chuẩn 1 (1NF): Mỗi ô chỉ chứa một giá trị nguyên tử.

  • Vi phạm: Cột account_ids lưu chuỗi “ACC001,ACC002,ACC003”.
  • Đúng: Tách thành các dòng riêng biệt, mỗi dòng một account_id.

Dạng chuẩn 2 (2NF): Mọi thuộc tính không khóa phải phụ thuộc toàn bộ khóa chính.

  • Vi phạm: Trong bảng transactions, customer_name chỉ phụ thuộc vào customer_id, không phụ thuộc vào id (khóa chính).
  • Đúng: Chuyển customer_name về bảng customers.

Dạng chuẩn 3 (3NF): Mọi thuộc tính không khóa chỉ phụ thuộc trực tiếp vào khóa chính, không phụ thuộc bắc cầu.

  • Vi phạm: branch_name phụ thuộc vào branch_id, mà branch_id lại là thuộc tính không khóa trong bảng transactions.
  • Đúng: Tách bảng branches riêng.

1.2 Nghệ Thuật Phá Chuẩn (Denormalization)

Tuân thủ 3NF giúp dữ liệu “sạch”, nhưng đôi khi lại giết chết hiệu năng. Hãy xem xét truy vấn cần JOIN 4 bảng để lấy tên chi nhánh cho báo cáo doanh thu. Với 500 triệu dòng, JOIN là thảm họa.

Khi nào nên phá chuẩn?

  • Cột được đọc rất thường xuyên nhưng cập nhật rất hiếm.
  • Bạn muốn tránh JOIN trên đường dẫn truy vấn nóng.

Ví dụ thực tế: Lưu customer_account_type (loại tài khoản: VIP, Thường) trực tiếp vào bảng transactions. Loại tài khoản hầu như không đổi, nhưng xuất hiện trong hầu hết báo cáo giao dịch. Phá chuẩn giúp truy vấn nhanh gấp 2-3 lần, đánh đổi bằng chi phí cập nhật hàng loạt khi có thay đổi hiếm hoi.

Trong ngân hàng: Luôn giữ customer_id trong bảng transactions (3NF) vì khách hàng có thể thay đổi thông tin cá nhân. Nhưng hoàn toàn có thể phá chuẩn một vài trường ít biến động để tăng tốc.


2. Phân Vùng (Partitioning) – Chia Để Trị Trong Lòng Một Database

Phân vùng là kỹ thuật chia một bảng lớn thành nhiều phần nhỏ hơn về mặt vật lý, nhưng ứng dụng vẫn nhìn thấy một bảng logic duy nhất.

2.1 Ba Loại Phân Vùng Chính

a) Phân vùng theo khoảng (Range Partitioning): Lý tưởng cho dữ liệu chuỗi thời gian.

CREATE TABLE transactions (...)
PARTITION BY RANGE (YEAR(created_at)) (
    PARTITION p2023 VALUES LESS THAN (2024),
    PARTITION p2024 VALUES LESS THAN (2025),
    PARTITION p2025 VALUES LESS THAN (2026),
    PARTITION p2026 VALUES LESS THAN (2027),
    PARTITION pmax VALUES LESS THAN MAXVALUE
);

b) Phân vùng theo danh sách (List Partitioning): Dùng cho các giá trị rời rạc như khu vực.

PARTITION BY LIST (region) (
    PARTITION p_north VALUES IN ('HANOI', 'HAIPHONG'),
    PARTITION p_south VALUES IN ('HOCHIMINH', 'CANTHO')
);

c) Phân vùng băm (Hash Partitioning): Phân phối đều dữ liệu dựa trên hàm băm.

PARTITION BY HASH (customer_id) PARTITIONS 16;

2.2 Sức Mạnh Của Partition Pruning

Khi bạn thêm điều kiện WHERE created_at >= '2026-01-01', trình lập kế hoạch truy vấn (query planner) sẽ bỏ qua hoàn toàn các phân vùng p2023, p2024, p2025. Thay vì quét 500 triệu dòng, nó chỉ quét phân vùng p2026 (khoảng 125 triệu dòng). Tốc độ cải thiện gấp 4 lần chỉ với thao tác định nghĩa lại bảng.

Lưu ý quan trọng: Phân vùng chỉ phát huy tác dụng nếu bạn đưa khóa phân vùng vào điều kiện WHERE. Truy vấn WHERE account_id = 'ACC001' (thiếu created_at) vẫn sẽ quét tất cả các phân vùng.

2.3 Triển Khai Thực Tế với Liquibase

Dưới đây là đoạn migration an toàn để chuyển đổi bảng transactions sang phân vùng theo năm, giảm thiểu downtime:

<changeSet id="12-001-partition-transactions" author="db-team">
    <!-- Tạo bảng mới có phân vùng -->
    <sql>
        CREATE TABLE transactions_new (
            ...,
            PRIMARY KEY (id, created_at) -- Khóa phân vùng phải nằm trong PK
        ) PARTITION BY RANGE (YEAR(created_at)) (...);
    </sql>
    
    <!-- Copy dữ liệu (có thể mất vài phút) -->
    <sql>INSERT INTO transactions_new SELECT * FROM transactions;</sql>
    
    <!-- Đổi tên bảng -->
    <sql>RENAME TABLE transactions TO transactions_old;</sql>
    <sql>RENAME TABLE transactions_new TO transactions;</sql>
</changeSet>

3. Sharding – Phân Mảnh Cơ Sở Dữ Liệu Ngang Hàng

Khi một máy chủ database không còn chịu nổi 500 triệu dòng, bạn cần sharding: chia dữ liệu ra nhiều database vật lý độc lập (shard). Mỗi shard chứa một tập con dữ liệu.

3.1 Chọn Shard Key – Quyết Định Sinh Tử

Mọi yêu cầu đều phải đi qua Shard Router để xác định shard chứa dữ liệu.

shard = hash(account_id) % 4;
  • Tốt: Chọn account_id vì 80% truy vấn lọc theo tài khoản. Truy vấn chỉ cần đánh vào một shard duy nhất.
  • Xấu: Chọn customer_id tuần tự (1, 2, 3,…). Dữ liệu mới dồn vào một shard, gây ra hot shard.
  • Rất xấu: Sharding nhưng vẫn cần truy vấn theo customer_id (15% lưu lượng). Khi đó bạn buộc phải fan-out: gửi truy vấn đến tất cả 4 shard cùng lúc.

3.2 Chi Phí Fan-Out

// Truy vấn theo customer_id khi shard key là account_id
List<Transaction> result = parallelQueryToAllShards(customerId);

Thời gian phản hồi = MAX(thời_gian_của_shard_chậm_nhất). Nếu một shard bị chậm do tải cao hoặc sự cố mạng, toàn bộ truy vấn sẽ timeout. Đây là đánh đổi bạn phải chấp nhận khi thiết kế sharding.

3.3 Mã Nguồn Shard Router Đơn Giản

@Component
public class ShardRouter {
    private static final int NUM_SHARDS = 4;

    public int getShardForAccount(String accountId) {
        return Math.abs(accountId.hashCode()) % NUM_SHARDS;
    }

    public List<Integer> getShardsForCustomer(Long customerId) {
        // Bắt buộc query tất cả shard vì không biết customer ở đâu
        return IntStream.range(0, NUM_SHARDS).boxed().toList();
    }
}

4. Read Replica – Mở Rộng Khả Năng Đọc

Read Replica là bản sao chỉ đọc của cơ sở dữ liệu chính (Primary). Ứng dụng ghi vào Primary, đọc từ Replica. Điều này giảm tải đáng kể cho Primary.

4.1 Cạm Bẫy Chết Người: Replication Lag

Primary ghi xong, Replica cần một khoảng thời gian (thường 50-200ms) để đồng bộ. Điều gì xảy ra nếu bạn:

  1. Ghi lệnh trừ tiền vào Primary.
  2. Ngay lập tức đọc số dư từ Replica (chưa kịp đồng bộ).

Kết quả: Bạn thấy số dư , tưởng rằng giao dịch chưa thành công, dẫn đến sai logic nghiệp vụ.

4.2 Giải Pháp Cho Read-Your-Writes Consistency

Cách 1 – Route thông minh với Spring AbstractRoutingDataSource:

@Transactional(readOnly = true)  // Tự động route đến Replica
public BigDecimal getBalance(String accountId) { ... }

@Transactional(readOnly = false) // Tự động route đến Primary
public void debit(String accountId, BigDecimal amount) { ... }

Đối với các thao tác cần đọc ngay sau khi ghi, hãy đặt readOnly = false để buộc truy vấn đi vào Primary.

Cách 2 – Read-through Cache (Redis): Ghi vào Primary, đồng thời cập nhật cache. Các lần đọc sau đó lấy từ cache thay vì Replica. Cách này triệt tiêu hoàn toàn độ trễ đồng bộ.


5. Định Lý CAP Trong Ngân Hàng – Vì Sao Phải Chọn CP?

Định lý CAP khẳng định một hệ thống phân tán chỉ có thể đảm bảo tối đa 2 trong 3 yếu tố:

  • Consistency (Nhất quán)
  • Availability (Sẵn sàng)
  • Partition Tolerance (Chịu lỗi phân mảnh mạng)

Ngân hàng luôn chọn CP.

Hãy tưởng tượng mạng giữa Primary và Replica bị đứt (Partition xảy ra). Lúc này:

  • Hệ thống AP (ưu tiên Sẵn sàng): Replica vẫn trả về số dư cũ. Khách hàng thấy 10 triệu, rút 8 triệu, nhưng thực tế tài khoản chỉ còn 5 triệu. Thảm họa tài chính.
  • Hệ thống CP (ưu tiên Nhất quán): Hệ thống từ chối phục vụ yêu cầu đọc từ Replica (hy sinh Availability) để đảm bảo không ai nhìn thấy dữ liệu sai.

Trong lĩnh vực ngân hàng, dữ liệu sai còn tệ hơn hệ thống chậm hoặc không truy cập được trong giây lát.


6. Những Anti-Pattern Kinh Điển Cần Tránh

6.1 Phân Vùng Mà Không Sửa Truy Vấn

Sau khi phân vùng theo created_at, bạn vẫn chạy SELECT * FROM transactions WHERE account_id = 'ACC001'. Kết quả: Query planner vẫn phải quét tất cả phân vùng, hiệu năng không cải thiện. Luôn thêm created_at vào WHERE.

6.2 Sharding Bằng ID Tuần Tự

customer_id % 4 khi ID tăng dần đều. Nhưng trên thực tế, dữ liệu mới có xu hướng dồn vào một shard (hot shard) do phân phối không đều trong ngắn hạn. Sử dụng hàm băm nội dung như hashCode().

6.3 Phá Chuẩn Mà Không Có Chiến Lược Đồng Bộ

Bạn lưu customer_name vào bảng transactions. Khi khách hàng đổi tên, bạn quên cập nhật các dòng cũ. Kết quả: Báo cáo hiển thị cùng một khách hàng với hai tên khác nhau. Giải pháp: Sử dụng event-driven (phát sự kiện CustomerNameChanged) để cập nhật bất đồng bộ.

6.4 Dùng 3NF Cho Bảng Báo Cáo

Báo cáo tổng hợp doanh thu theo chi nhánh yêu cầu JOIN 4 bảng trên 500 triệu dòng. Điều này giết chết hiệu năng. Giải pháp: Tạo Materialized View hoặc bảng báo cáo riêng đã được phá chuẩn sẵn.


7. Khung Giải Đáp Phỏng Vấn – Gây Ấn Tượng Trong 5 Phút

Khi được hỏi: “Thiết kế database cho nền tảng giao dịch 1 tỷ dòng, đảm bảo nhất quán tuyệt đối.”

Phút 0-1: Làm rõ yêu cầu

  • SLA: Đọc <200ms? Ghi <500ms?
  • Tỉ lệ Đọc/Ghi? QPS bao nhiêu?

Phút 1-2.5: Kiến trúc đa tầng

  1. Chuẩn hóa 3NF cho dữ liệu nghiệp vụ.
  2. Index tổng hợp (account_id, status, created_at DESC) cho 80% truy vấn nóng.
  3. Phân vùng theo thời gian để kích hoạt partition pruning.
  4. Read Replica cho các truy vấn ít nhạy cảm về độ trễ.
  5. Cache Redis cho số dư và thông tin ít thay đổi.

Phút 2.5-4: Quyết định Sharding

  • Shard key: account_id.
  • Số lượng: 4-8 shard ban đầu.
  • Xử lý fan-out: Chấp nhận tăng độ trễ cho truy vấn theo customer_id (15% lưu lượng).

Phút 4-5: Giảm thiểu rủi ro

  • Replication lag: Dùng Primary cho read-after-write.
  • Hot shard: Giám sát phân phối dữ liệu, tái cân bằng khi lệch >20%.

Kết Luận

Thiết kế cơ sở dữ liệu cho hệ thống quy mô lớn không phải là việc chọn một giải pháp “vi diệu” duy nhất, mà là sự kết hợp hài hòa nhiều kỹ thuật:

  • Normalization giữ cho dữ liệu nhất quán.
  • Partitioning tăng tốc truy vấn theo thời gian.
  • Sharding mở rộng khả năng ghi và dung lượng lưu trữ.
  • Read Replica mở rộng khả năng đọc.

Hy vọng qua bài viết này, bạn không chỉ hiểu rõ lý thuyết mà còn tự tin triển khai các chiến lược trên vào hệ thống thực tế. Hãy nhớ rằng, mỗi quyết định đều đi kèm đánh đổi. Nhiệm vụ của kiến trúc sư là chọn đánh đổi ít đau đớn nhất cho bài toán của mình.

Bạn đã sẵn sàng cho 1 tỷ dòng giao dịch chưa?


Checklist Thành Thạo Database Design

  • Phân biệt được 1NF, 2NF, 3NF và giải thích khi nào nên phá chuẩn.
  • Giải thích được cơ chế Partition Pruning bằng sơ đồ.
  • Phân biệt rõ Partitioning (cùng máy) và Sharding (nhiều máy).
  • Mô tả được bài toán Read-Your-Writes Consistency và 3 cách giải quyết.
  • Liệt kê được 3 anti-pattern nguy hiểm khi sharding.
  • Tự tin thiết kế database cho bảng 1 tỷ dòng với tập truy vấn cụ thể.

Hãy kết nối

Nếu bạn quan tâm tới việc hợp tác, có câu hỏi về bài viết, hay chỉ đơn giản muốn chuyện trò về backend — cứ ping mình nhé.