Mở rộng PostgreSQL ‘không giới hạn’ với Citus: Từ 1 tỷ đến hàng chục tỷ bản ghi

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

Khi nào một server PostgreSQL là không đủ?

Mình từng trực tiếp xử lý một hệ thống tracking log quảng cáo với tốc độ tăng trưởng chóng mặt. Mỗi ngày, database phải nạp thêm khoảng 20-30 triệu bản ghi mới. Ban đầu, team chọn cách nâng cấp phần cứng (Vertical Scaling): nâng RAM lên 128GB và dùng ổ SSD NVMe xịn nhất lúc bấy giờ. Tuy nhiên, khi bảng events chạm mốc 2 tỷ dòng, mọi thứ bắt đầu tồi tệ. Các câu lệnh COUNT đơn giản hay JOIN báo cáo mất đến vài phút để hoàn thành, khiến CPU luôn trong tình trạng quá tải 100%.

Thực tế cho thấy, nâng cấp phần cứng luôn có giới hạn và chi phí sẽ tăng vọt theo cấp số nhân. Phương án khả thi nhất lúc này là Horizontal Sharding (mở rộng theo chiều ngang). Thay vì dồn vào một server khổng lồ, mình chia dữ liệu ra 5, 10 hoặc 20 server nhỏ hơn. Citus chính là trợ thủ đắc lực nhất giúp biến PostgreSQL thành một database phân tán mà không bắt bạn phải đập đi xây lại toàn bộ logic ứng dụng.

Citus là gì mà lại được tin dùng đến vậy?

Citus không phải một bản fork (nhánh riêng) của PostgreSQL. Nó thực chất là một extension. Điểm ăn tiền ở đây là bạn vẫn giữ nguyên được hệ sinh thái Postgres: từ JSONB, Full-text search cho đến các extension khác như PostGIS hay TimescaleDB.

Mô hình của Citus hoạt động dựa trên hai thành phần chính:

  • Coordinator Node: Đóng vai trò “đầu não”. Nó lưu metadata, nhận query từ ứng dụng, phân tích và điều phối lệnh xuống các Worker.
  • Worker Nodes: Những “công nhân” thực thụ. Đây là nơi lưu trữ các mảnh dữ liệu (shards) và thực hiện tính toán song song.

Khi bạn gửi một câu truy vấn, Coordinator sẽ xẻ nhỏ nó ra và đẩy xuống các Worker chạy cùng lúc. Kết quả sau đó được tổng hợp lại và trả về cho bạn. Trải nghiệm sử dụng vẫn giống như một server duy nhất, nhưng sức mạnh thực tế lại là tổng hợp của cả một cụm cluster.

Dựng cụm Citus Cluster nhanh với Docker

Để bắt tay vào vọc vạch ngay, bạn có thể dựng một cụm Citus gồm 1 Coordinator và 2 Workers qua Docker Compose. Đây là cách nhanh nhất để kiểm thử trước khi nghĩ đến chuyện triển khai lên môi trường Production.

# File docker-compose.yml mẫu
version: '3'
services:
  db_master:
    image: citusdata/citus:12.1
    ports: ["5432:5432"]
    environment: &id001
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: secret

  worker_1:
    image: citusdata/citus:12.1
    environment: *id001
    depends_on: [db_master]

  worker_2:
    image: citusdata/citus:12.1
    environment: *id001
    depends_on: [db_master]

Sau khi khởi chạy bằng lệnh docker-compose up -d, hãy truy cập vào node Coordinator để đăng ký các Worker vào hệ thống:

-- Kết nối vào Coordinator và thêm node
SELECT citus_add_node('worker_1', 5432);
SELECT citus_add_node('worker_2', 5432);

-- Kiểm tra trạng thái các node
SELECT * FROM citus_get_active_worker_nodes();

Chiến thuật Sharding: Chọn đúng khóa để bứt tốc

Quyết định quan trọng nhất khi dùng Citus là chọn Distribution Column (Shard Key). Nếu chọn sai, dữ liệu sẽ bị lệch (data skew), khiến một server thì quá tải trong khi các server khác lại ngồi chơi.

Với bảng user_activities, mình thường chọn user_id làm shard key. Lý do là phần lớn các câu truy vấn thực tế đều lọc theo người dùng cụ thể. Khi đó, toàn bộ dữ liệu của một user sẽ nằm gọn trên cùng một worker, giúp các phép JOIN hay Aggregation diễn ra cực nhanh mà không cần giao tiếp mạng giữa các node.

-- Tạo bảng trên Coordinator
CREATE TABLE user_activities (
    id bigserial,
    user_id int NOT NULL,
    action text,
    created_at timestamp DEFAULT now(),
    PRIMARY KEY (user_id, id) -- Bắt buộc shard key phải nằm trong Primary Key
);

-- Kích hoạt phân tán bảng
SELECT create_distributed_table('user_activities', 'user_id');

Hệ thống sẽ tự động chia bảng thành 32 shards (mặc định) và rải đều chúng lên các Worker sẵn có.

Mẹo nhỏ: Tận dụng Reference Tables

Trong database luôn có những bảng danh mục ít thay đổi như categories hoặc countries. Nếu bạn cũng sharding chúng, việc JOIN dữ liệu giữa các Worker sẽ cực kỳ tốn kém tài nguyên mạng.

Giải pháp tối ưu là dùng Reference Table. Citus sẽ copy toàn bộ bảng này sang TẤT CẢ các worker để phục vụ việc JOIN tại chỗ.

CREATE TABLE categories (id int PRIMARY KEY, name text);

-- Nhân bản bảng này sang mọi node trong cluster
SELECT create_reference_table('categories');

Giám sát và gỡ lỗi (Monitoring)

Để biết dữ liệu thực sự đang nằm ở đâu, bạn có thể soi vào metadata của Citus bằng câu lệnh sau:

SELECT shardid, shardsize, nodename 
FROM citus_shards 
WHERE table_name::text = 'user_activities';

Một tính năng mình rất thích là khả năng EXPLAIN ANALYZE. Khi chạy lệnh này trên Coordinator, bạn sẽ thấy chi tiết từng task được gửi xuống Worker nào và mất bao nhiêu miligiây để thực thi.

EXPLAIN (VERBOSE ON) 
SELECT count(*) FROM user_activities WHERE user_id = 1001;

Nếu kết quả hiển thị Custom Scan (Citus Adaptive) và chỉ nhắm vào một node duy nhất, chúc mừng bạn, bạn đã chọn Shard Key rất chuẩn!

Ba bài học “xương máu” khi vận hành thực tế

Sau một thời gian dài “chinh chiến” với Citus trong production, mình rút ra 3 lưu ý quan trọng:

  1. Ràng buộc duy nhất (Unique Constraints): Citus chỉ đảm bảo tính duy nhất nếu shard key là một phần của constraint đó. Bạn không thể tạo Unique Index trên email nếu đang shard theo user_id.
  2. Giao dịch phân tán (Distributed Transactions): Dù Citus hỗ trợ tốt, nhưng các transaction kéo dài đụng đến nhiều shard sẽ rất dễ gây deadlock. Hãy cố gắng thiết kế query gói gọn trong một shard key duy nhất.
  3. Đồng nhất phần cứng: Đừng bao giờ mix các server mạnh yếu khác nhau. Cluster sẽ bị kéo lùi tốc độ bởi node chậm nhất (Straggler effect).

Chuyển đổi sang kiến trúc phân tán không khó về kỹ thuật, cái khó là thay đổi tư duy thiết kế schema. Nếu làm chủ được Shard Key, việc quản lý hàng chục TB dữ liệu trên Postgres sẽ trở nên nhẹ nhàng hơn bao giờ hết.

Anh em có đang đau đầu vì Postgres chạy chậm không? Để lại comment bên dưới, mình cùng tìm giải pháp nhé!

Share: