Nghẽn cổ chai trên một node: Khi mở rộng theo chiều dọc chạm trần
Năm ngoái, đội ngũ của chúng tôi đã chứng kiến instance PostgreSQL chính bị quá tải nghiêm trọng trước lưu lượng ghi liên tục. Bảng theo dõi sự kiện vượt mốc 820 triệu dòng, disk I/O duy trì quanh mức 95%, và áp lực bộ nhớ khiến tỷ lệ buffer cache hit giảm xuống dưới 80%.
Chúng tôi bắt đầu bằng phương án xử lý sự cố quen thuộc: nâng cấp instance AWS RDS từ db.r6g.4xlarge (16 vCPU, 128 GB RAM) lên db.r6g.16xlarge (64 vCPU, 512 GB RAM), đồng thời tinh chỉnh WAL buffers lên 64 MB. Tuy nhiên, việc mở rộng theo chiều dọc (vertical scaling) chỉ đơn thuần là trì hoãn vấn đề. Nâng cấp phần cứng nhanh chóng chạm ngưỡng hiệu suất biên giảm dần: bạn phải đối mặt với tình trạng bão hòa socket CPU vật lý, hóa đơn đám mây hàng tháng lên tới hàng nghìn USD, cùng các chu kỳ autovacuum giữ maintenance lock từ 6 đến 8 tiếng trên những bảng có dung lượng nhiều terabyte.
Tự xây dựng cơ chế sharding thủ công ở tầng ứng dụng thoạt nhìn rất hấp dẫn. Dẫu vậy, việc phân tán connection pool trên nhiều microservice và tự triển khai lại 2-phase commit trong mã nguồn ứng dụng sẽ tạo ra một cơn ác mộng về bảo trì. Chúng tôi cần một kiến trúc giữ trọn các ngữ nghĩa nguyên bản của PostgreSQL—như complex join, composite index, transaction và foreign key—đồng thời phân bổ đều các tác vụ ghi và truy vấn theo chiều ngang trên các phần cứng tiêu chuẩn.
Khái niệm cốt lõi: Cách Citus phân mảnh PostgreSQL một cách minh bạch
Citus là một tiện ích mở rộng mã nguồn mở biến PostgreSQL tiêu chuẩn thành một công cụ cơ sở dữ liệu phân tán. Thay vì bắt buộc bạn phải tự xây dựng logic định tuyến thủ công, Citus gom nhiều instance PostgreSQL độc lập thành một cụm được phối hợp nhịp nhàng.
Kiến trúc cụm: Coordinator Node và Worker Node
Một cụm Citus phân chia trách nhiệm qua hai vai trò:
- Coordinator Node: Đóng vai trò là gateway duy nhất tiếp nhận kết nối từ client. Node này lưu trữ metadata của cụm trong các system catalog, phân tích cú pháp truy vấn, xây dựng execution plan phân tán, ủy quyền các subquery cho các worker và tổng hợp kết quả trả về. Ứng dụng kết nối trực tiếp đến node này qua các driver PostgreSQL tiêu chuẩn (như
pgx,psycopg2hoặc JDBC) mà không cần bất kỳ tùy biến giao thức nào. - Worker Nodes: Chứa các shard vật lý và xử lý truy vấn song song. Mỗi worker chạy một engine PostgreSQL nguyên bản cùng với Citus, tận dụng các nhân CPU, RAM và ổ cứng NVMe riêng biệt để thực thi đồng thời các subquery.
Các cấu trúc bảng (Table Topology) trong Citus
Các bảng trong Citus được chia thành ba loại riêng biệt:
- Distributed Tables: Được chia thành các shard dựa trên một distribution column (shard key). Citus phân bổ các shard này trên các worker bằng cơ chế consistent hashing qua 32 hoặc 64 phân vùng logic.
- Reference Tables: Được nhân bản hoàn toàn trên tất cả các worker node. Loại bảng này cực kỳ lý tưởng cho các bảng tra cứu nhỏ (dưới 50.000 dòng—chẳng hạn như các gói thuê bao, danh sách quốc gia hoặc vai trò phân quyền) thường xuyên được join với các bảng sự kiện phân tán.
- Local Tables: Các bảng PostgreSQL thông thường chỉ nằm duy nhất trên coordinator node. Chúng phù hợp cho các admin schema, metadata hoặc bảng ghi nhật ký migration.
Thực hành: Xây dựng cụm Multi-Node và phân mảnh dữ liệu
Hãy cùng xây dựng một cụm Citus gồm 3 node hoạt động hoàn chỉnh trên Docker, phân vùng bảng luồng sự kiện và kiểm tra cách cơ chế định tuyến truy vấn vận hành.
Bước 1: Khởi chạy cụm bằng Docker Compose
Chúng ta sẽ khởi chạy một coordinator và hai worker node bằng image Citus Docker chính thức trên một bridge network chung.
# Tạo thư mục dự án và tệp cấu hình compose
mkdir citus-cluster && cd citus-cluster
cat << 'EOF' > docker-compose.yml
services:
coordinator:
image: citusdata/citus:12.1
container_name: citus_coordinator
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secretpassword
POSTGRES_DB: analytics
ports:
- "5432:5432"
worker1:
image: citusdata/citus:12.1
container_name: citus_worker_1
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secretpassword
POSTGRES_DB: analytics
worker2:
image: citusdata/citus:12.1
container_name: citus_worker_2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secretpassword
POSTGRES_DB: analytics
EOF
docker compose up -d
Bước 2: Đăng ký các Worker Node với Coordinator
Tiếp theo, truy cập vào container coordinator thông qua psql và liên kết hai instance worker:
docker exec -it citus_coordinator psql -U postgres -d analytics
-- Kiểm tra xem tiện ích mở rộng Citus đã được tải chưa
CREATE EXTENSION IF NOT EXISTS citus;
-- Đăng ký các worker node bằng tên DNS của container
SELECT citus_add_node('worker1', 5432);
SELECT citus_add_node('worker2', 5432);
-- Kiểm tra tình trạng node và trạng thái cụm
SELECT nodename, nodeport, isactive, nodecluster FROM pg_dist_node;
Bước 3: Định nghĩa Schema và phân phối bảng
Chọn cột phân phối (distribution column) là quyết định kiến trúc quan trọng nhất. Trong các hệ thống multi-tenant, việc chọn tenant_id hoặc company_id giúp 95% các truy vấn của tenant được xử lý cục bộ trên một shard duy nhất, tránh được các bước trung chuyển mạng (network hop) tốn kém giữa các node.
-- Tạo bảng tham chiếu cho các gói tài khoản
CREATE TABLE account_tiers (
tier_id INT PRIMARY KEY,
tier_name VARCHAR(50) NOT NULL,
rate_limit INT NOT NULL
);
-- Nhân bản bảng tham chiếu trên tất cả các worker node
SELECT create_reference_table('account_tiers');
-- Chèn dữ liệu các gói tài khoản
INSERT INTO account_tiers VALUES
(1, 'Free', 1000),
(2, 'Pro', 10000),
(3, 'Enterprise', 100000);
-- Tạo bảng sự kiện được phân vùng
CREATE TABLE tenant_events (
event_id BIGSERIAL,
tenant_id INT NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (tenant_id, event_id)
);
-- Phân mảnh tenant_events trên các worker bằng tenant_id làm khóa băm
SELECT create_distributed_table('tenant_events', 'tenant_id');
Hãy chú ý kỹ đến khóa chính (primary key) của bảng tenant_events: nó kết hợp cả tenant_id và event_id. Citus cố tình áp đặt quy tắc này. Vì mỗi worker chỉ kiểm tra tính hợp lệ của constraint cục bộ trong phạm vi shard được giao, khóa kết hợp (compound key) sẽ đảm bảo tính duy nhất mà không bắt buộc phải thực hiện kiểm tra đồng thuận giữa các node (cross-node consensus) trên mỗi thao tác ghi.
Bước 4: Nạp và tạo dữ liệu mẫu
Nếu đang chuyển đổi các tệp CSV thô thành payload JSON để kiểm thử, các tiện ích chạy trên trình duyệt gọn nhẹ như toolcraft.app/vi/tools/data/csv-to-json sẽ giúp giữ an toàn cho dữ liệu nhạy cảm khỏi các máy chủ bên thứ ba từ xa. Sau khi đã thiết lập schema mục tiêu, hãy chèn hàng loạt các bản ghi kiểm thử giả lập trực tiếp qua coordinator:
-- Tạo 500.000 bản ghi giả lập phân bổ trên 50 tenant
INSERT INTO tenant_events (tenant_id, event_type, payload, created_at)
SELECT
(g % 50) + 1 AS tenant_id,
CASE (g % 3)
WHEN 0 THEN 'page_view'
WHEN 1 THEN 'item_purchased'
ELSE 'session_start'
END AS event_type,
json_build_object('metric_val', (random() * 100)::int, 'source', 'web')::jsonb,
NOW() - (random() * interval '30 days')
FROM generate_series(1, 500000) AS g;
Bước 5: Kiểm tra vị trí đặt Shard và định tuyến
Hãy kiểm tra cách Citus phân bổ 500.000 dòng trên các bảng vật lý tại worker1 và worker2:
-- Kiểm tra các shard đã tạo và các node lưu trữ chúng
SELECT
logicalrelid::regclass AS table_name,
shardid,
nodename,
nodeport
FROM pg_dist_placement
JOIN pg_dist_node USING (groupid)
ORDER BY shardid
LIMIT 10;
Theo mặc định, Citus tạo ra 32 logical shard. Nó chia đều các shard này trên các worker sẵn có (16 shard trên mỗi worker trong cụm 2-node của chúng ta), giúp mỗi node sở hữu các phân vùng bảng và cấu trúc chỉ mục độc lập của riêng mình.
Bước 6: Thực thi và tối ưu hóa truy vấn phân tán
Bên dưới hệ thống, Citus lập kế hoạch truy vấn theo hai hướng: định tuyến đơn shard (single-shard routing) và thực thi song song dạng map-reduce phân tán.
-- Chế độ 1: Truy vấn điểm có đích ngắm (Định tuyến đơn shard)
EXPLAIN ANALYZE
SELECT count(*)
FROM tenant_events
WHERE tenant_id = 12;
Hãy tìm dòng Custom Scan (Citus Adaptive) trong kết quả truy vấn. Do tenant_id = 12 tương ứng với một hash bucket cụ thể, coordinator sẽ định tuyến truy vấn trực tiếp đến worker đang nắm giữ shard đó. Thời gian phản hồi thường dưới 3 ms vì hoàn toàn không phát sinh giao tiếp giữa các node.
-- Chế độ 2: Tổng hợp đa shard kết hợp với bảng tham chiếu
EXPLAIN ANALYZE
SELECT
t.tier_name,
e.event_type,
count(*)
FROM tenant_events e
JOIN account_tiers t ON ((e.tenant_id % 3) + 1) = t.tier_id
WHERE e.created_at >= NOW() - interval '7 days'
GROUP BY t.tier_name, e.event_type
ORDER BY count(*) DESC;
Tại đây, coordinator sẽ đồng loạt gửi các subquery song song tới tất cả worker cùng lúc. Vì một bản sao đầy đủ của account_tiers đã tồn tại trên mỗi node, từng worker sẽ thực hiện phép join cục bộ với các shard của chính nó trước khi gửi các kết quả đếm từng phần nhỏ gọn về coordinator để tổng hợp lần cuối.
Những lưu ý quan trọng khi vận hành Citus trong môi trường Production
Mở rộng PostgreSQL với Citus giúp bạn vượt qua rào cản phần cứng của một node đơn lẻ mà không phải từ bỏ các công cụ quen thuộc như psql, các công cụ migration tiêu chuẩn hay các kiểu index trong PostgreSQL. Hãy ghi nhớ ba nguyên tắc sau trước khi đưa hệ thống lên môi trường staging.
Thứ nhất, hãy chọn một distribution key dùng chung cho các phép join đòi hỏi hiệu năng cao nhất; việc đặt dữ liệu cùng vị trí (co-location) sẽ tránh được việc xáo trộn dữ liệu qua mạng (network shuffle) vô cùng tốn kém. Thứ hai, hãy tận dụng tối đa reference table cho các bảng chiều (dimension table) có số lượng giá trị phân biệt thấp (low cardinality). Cuối cùng, hãy đưa EXPLAIN ANALYZE thành một bước tiêu chuẩn trong quy trình code review để phát hiện kịp thời các subplan phân tán trước khi chúng tiếp nhận lưu lượng truy cập thực tế trên production.

