Triển khai MongoDB Sharded Cluster trên Docker: Shard Key, Config Server và Tối ưu hóa cho Quy mô Terabyte

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Vấn đề thực tế: Vượt qua nút thắt cổ chai của cơ sở dữ liệu nguyên khối

Mở rộng cơ sở dữ liệu theo chiều dọc (vertical scaling) hoạt động hiệu quả cho đến khi bạn chạm trần giới hạn phần cứng. Khi tập dữ liệu vượt mốc 3 đến 5 terabyte và lượng ghi liên tục làm bão hòa ổ cứng NVMe ở mức 40.000 IOPS, việc nâng cấp thêm RAM cho một node primary replica duy nhất không còn giải quyết được vấn đề. Bộ nhớ cạn kiệt rất nhanh. Working set của WiredTiger liên tục phải đẩy các trang dữ liệu (evict pages) xuống đĩa. Quá trình sao lưu logic hàng đêm kéo dài từ 45 phút lên đến 8 tiếng đồng hồ đầy mệt mỏi, làm sụt giảm trực tiếp thông lượng phục vụ người dùng.

Phân vùng theo chiều ngang—sharding—giải quyết nút thắt cổ chai này bằng cách chia nhỏ các collection dung lượng lớn trên các nhóm server riêng biệt. Dẫu vậy, sharding không phải là viên đạn bạc; nó đánh đổi những hạn chế về phần cứng để lấy sự phức tạp của hệ thống phân tán.

Nếu chọn sai shard key hoặc cấu hình cluster không chuẩn, các truy vấn scatter-gather không có index sẽ phá hủy hiệu năng nhanh hơn bất kỳ nút thắt cổ chai đĩa cứng nào trên một node đơn lẻ. Thử nghiệm các kiến trúc này trực tiếp trên AWS hay GCP sẽ làm tiêu tốn ngân sách rất nhanh. Việc khởi dựng một cluster đa thành phần cục bộ bằng Docker cung cấp cho bạn một sandbox hoàn toàn miễn phí, tốc độ cao để đánh giá hành vi định tuyến trước khi áp dụng cho lưu lượng production thực tế.

Khái niệm cốt lõi: Phân tích kiến trúc MongoDB Sharded

Mọi MongoDB sharded cluster đang hoạt động đều dựa trên ba thành phần cốt lõi phối hợp cùng nhau:

  • Config Server Replica Set (CSRS): Bộ não của cluster. Các config server lưu trữ metadata có thẩm quyền, bảng định tuyến của cluster và ranh giới các chunk của collection. Từ MongoDB 3.4 trở đi, config server bắt buộc phải chạy dưới dạng một replica set để đảm bảo tính toàn vẹn của các transaction phân tán khi thay đổi metadata.
  • Mongos Query Router: Các proxy nhẹ, không lưu trạng thái (stateless), nằm giữa ứng dụng và các data node. Driver của ứng dụng không bao giờ giao tiếp trực tiếp với từng shard riêng lẻ; chúng gửi truy vấn đến tiến trình mongos. Router này sẽ đọc metadata được cache từ config server và chuyển tiếp từng tác vụ đến đúng shard chứa các bản ghi đó.
  • Shard Replica Set: Những cỗ máy gánh tải thực sự, lưu trữ các tập hợp phân vùng dữ liệu, được gọi là chunk. Mỗi shard xử lý việc đọc và ghi độc quyền cho phạm vi dữ liệu được chỉ định của nó. Trong môi trường thực tế, mỗi shard là một replica set đa node hoàn chỉnh với các node primary và secondary chuyên dụng.

Quyết định mang tính sống còn: Lựa chọn Shard Key hiệu quả

Shard key không thể thay đổi một cách tùy tiện một khi dữ liệu đã phân tán qua các node. Một lựa chọn vội vàng sẽ tích tụ nợ kỹ thuật (technical debt), đòi hỏi quá trình tái cấu trúc offline rất tốn công sức. Bạn phải cân bằng ba yêu cầu cạnh tranh sau:

  • Độ biến thiên cao (High Cardinality): Hãy chọn các trường có hàng triệu giá trị phân biệt, chẳng hạn như account_uuid hoặc order_id nội bộ. Các trường có độ biến thiên thấp như status (ví dụ: active, pending, archived) sẽ tạo ra các “jumbo chunk” khổng lồ. Một khi một chunk chứa các giá trị key giống hệt nhau vượt quá giới hạn mặc định 64MB, MongoDB sẽ không thể chia tách nó, dẫn đến việc dữ liệu bị phân bổ lệch lạc trên các ổ đĩa.
  • Phân bổ ghi đồng đều (Tránh điểm nóng – Hotspots): Các giá trị tuần tự như số nguyên tự tăng hoặc timestamp của BSON ObjectId tiêu chuẩn sẽ điều hướng mọi thao tác chèn dữ liệu vào một chunk duy nhất đang hoạt động trên một shard. Điều này làm quá tải một node trong khi các node khác lại nhàn rỗi. Một hashed shard key như { "account_id": "hashed" } sử dụng hàm băm MD5 để phân tán đều các lượt ghi tuần tự trên tất cả các shard trong cluster của bạn.
  • Cô lập truy vấn (Query Isolation): Hashed key phân bổ việc ghi rất đều, nhưng lại làm giảm hiệu năng của các truy vấn đọc theo khoảng (range reads). Khi bạn truy vấn một trường đã băm với $gt hoặc $lt, mongos không thể xác định chính xác đích đến. Nó buộc phải thực hiện truy vấn “scatter-gather” tốn kém, gửi tín hiệu đến tất cả các shard cùng lúc. Đối với các hệ thống multi-tenant nơi 90% yêu cầu đều chứa mã định danh tổ chức, hãy sử dụng compound key như { "tenant_id": 1, "created_at": 1 }. Lựa chọn này đảm bảo định tuyến chính xác đến một shard duy nhất, đồng thời vẫn giữ cho các truy vấn theo khoảng thời gian diễn ra nhanh chóng trong phạm vi của từng tenant.

Thực hành: Xây dựng Cluster trên Docker

Mô hình triển khai này dựng lên một sandbox hoàn chỉnh: một Config Server replica set, hai Shard replica set riêng biệt, và một mongos query router được kết nối thông qua một mạng bridge Docker cô lập.

Bước 1: Khai báo hạ tầng Docker Compose

Lưu các định nghĩa service sau thành file docker-compose.yml:

version: '3.8'

networks:
  mongo-shard-net:
    driver: bridge

services:
  # Thành viên Config Server Replica Set
  configsvr01:
    image: mongo:7.0
    container_name: configsvr01
    command: mongod --configsvr --replSet configReplSet --port 27019 --bind_ip_all
    networks:
      - mongo-shard-net
    ports:
      - "27019:27019"

  # Thành viên Shard 01 Replica Set
  shard01-a:
    image: mongo:7.0
    container_name: shard01-a
    command: mongod --shardsvr --replSet shard01ReplSet --port 27018 --bind_ip_all
    networks:
      - mongo-shard-net
    ports:
      - "27018:27018"

  # Thành viên Shard 02 Replica Set
  shard02-a:
    image: mongo:7.0
    container_name: shard02-a
    command: mongod --shardsvr --replSet shard02ReplSet --port 27018 --bind_ip_all
    networks:
      - mongo-shard-net
    ports:
      - "27028:27018"

  # Mongos Query Router
  mongos:
    image: mongo:7.0
    container_name: mongos
    command: mongos --configdb configReplSet/configsvr01:27019 --bind_ip_all --port 27017
    depends_on:
      - configsvr01
      - shard01-a
      - shard02-a
    networks:
      - mongo-shard-net
    ports:
      - "27017:27017"

Khởi chạy tất cả các container ở chế độ chạy nền:

docker compose up -d

Bước 2: Khởi tạo Config Server và Shard Replica Set

Trước khi mongos có thể định tuyến lưu lượng, từng replica set bên dưới phải được khởi tạo. Chạy các lệnh này trực tiếp qua mongosh bên trong các container đang hoạt động:

# Khởi tạo Config Server
docker exec -it configsvr01 mongosh --port 27019 --eval '
  rs.initiate({
    _id: "configReplSet",
    configsvr: true,
    members: [{ _id: 0, host: "configsvr01:27019" }]
  })
'

# Khởi tạo Shard 01
docker exec -it shard01-a mongosh --port 27018 --eval '
  rs.initiate({
    _id: "shard01ReplSet",
    members: [{ _id: 0, host: "shard01-a:27018" }]
  })
'

# Khởi tạo Shard 02
docker exec -it shard02-a mongosh --port 27018 --eval '
  rs.initiate({
    _id: "shard02ReplSet",
    members: [{ _id: 0, host: "shard02-a:27018" }]
  })
'

Bước 3: Đăng ký các Shard với Query Router

Tiếp theo, gắn các shard đã khởi tạo vào cluster bằng cách trỏ mongos đến định danh replica set của chúng:

docker exec -it mongos mongosh --port 27017 --eval '
  sh.addShard("shard01ReplSet/shard01-a:27018");
  sh.addShard("shard02ReplSet/shard02-a:27018");
'

Kiểm tra cấu trúc liên kết của cluster và đảm bảo cả hai shard đều hiển thị trạng thái hoạt động bình thường:

docker exec -it mongos mongosh --port 27017 --eval 'sh.status()'

Bước 4: Bật Sharding và Nạp dữ liệu kiểm thử

Để phân vùng các bản ghi của collection, trước tiên cần bật sharding ở cấp cơ sở dữ liệu, tạo một index trên key đã chọn, rồi định nghĩa quy tắc phân vùng. Ở đây, chúng ta phân vùng một collection analytics bằng cách sử dụng hashed customerId:

docker exec -it mongos mongosh --port 27017 --eval '
  sh.enableSharding("analytics_db");
  use analytics_db;
  db.events.createIndex({ "customerId": "hashed" });
  sh.shardCollection("analytics_db.events", { "customerId": "hashed" });
'

Nếu bạn làm việc với dữ liệu CSV thô xuất ra từ các pipeline phân tích, hãy chuyển đổi chúng thành định dạng JSON phân tách bằng dòng mới (newline-delimited JSON) trước khi nạp vào. Một công cụ nhanh gọn là toolcraft.app/vi/tools/data/csv-to-json, chạy hoàn toàn trên trình duyệt mà không gửi dữ liệu nguồn ra khỏi máy của bạn. Khi file JSON lines đã sẵn sàng, hãy import dữ liệu thông qua mongos:

docker cp seed_events.json mongos:/tmp/seed_events.json
docker exec -it mongos mongoimport --db analytics_db --collection events --file /tmp/seed_events.json

Bước 5: Danh sách kiểm tra tối ưu hóa cho quy mô Terabyte

Khi các tập dữ liệu vượt qua 10 terabyte, các cấu hình mặc định bắt đầu bộc lộ sự quá tải. Hãy tinh chỉnh các khu vực trọng yếu sau để giữ cho hệ thống production vận hành ổn định:

  • Targeted Query so với Scatter-Gather: Luôn kiểm tra kế hoạch thực thi các truy vấn chậm bằng explain("executionStats"). Hãy tìm kiếm mục SINGLE_SHARD trong kết quả trả về. Nếu bạn thấy SHARD_MERGE trên các route xử lý hơn 500 yêu cầu mỗi giây, truy vấn của bạn đang quét qua tất cả các node một cách không cần thiết. Hãy gắn thêm shard key vào tiền tố truy vấn để cô lập chúng ngay lập tức.
  • Giới hạn Chunk Balancing vào khung giờ thấp điểm: Quá trình cân bằng chunk di chuyển các khối dữ liệu 64MB qua mạng, làm tăng đột biến CPU, I/O đĩa và độ trễ replication. Hãy giới hạn bộ cân bằng (balancer) vào các khung giờ bảo trì thấp điểm để nó không bao giờ cạnh tranh tài nguyên với lưu lượng ban ngày:
sh.setBalancerState(true);
db.getSiblingDB("config").settings.updateOne(
  { _id: "balancer" },
  { $set: { activeWindow: { start: "02:00", stop: "05:30" } } },
  { upsert: true }
);
  • Phân chia trước Chunk (Pre-split) cho các đợt di chuyển dữ liệu lớn: Việc nạp 500GB vào một collection đã sharded nhưng còn rỗng sẽ kích hoạt quá trình chia tách và tái cân bằng liên tục trong thời gian chạy. Thông lượng nạp dữ liệu của bạn sẽ tụt dốc khi các node tranh chấp khóa tài nguyên. Hãy tránh điều này bằng cách tạo thủ công các điểm chia tách và phân phối sẵn các chunk trên tất cả các shard trước khi truyền dữ liệu:
// Cấp phát trước phạm vi chunk cho hashed key trên không gian số nguyên 64-bit
for (let i = -8; i < 8; i += 2) {
  let splitPoint = NumberLong(i * 1152921504606846976n);
  sh.splitAt("analytics_db.events", { customerId: splitPoint });
}

Lời kết

Mở rộng quy mô theo chiều ngang mang lại dung lượng lưu trữ khổng lồ và thông lượng vượt trội, nhưng nó cũng kéo theo độ trễ mạng phát sinh và đòi hỏi kỷ luật vận hành khắt khe. Việc chạy môi trường Docker này giúp bạn quan sát trực quan từng mắt xích: trạng thái config server, định tuyến proxy và sự phân bổ chunk giữa các node.

Trước khi triển khai lên máy chủ vật lý (bare metal) hoặc các instance đám mây production, hãy đánh giá tỷ lệ đọc/ghi thực tế của bạn. Chạy explain() trên 10 truy vấn xuất hiện nhiều nhất, đảm bảo bộ cân bằng không chạy trong giờ cao điểm và chắc chắn rằng shard key vừa có thể cô lập thao tác đọc vừa phân bổ thao tác ghi một cách đồng đều.

Share: