Tối ưu 8 — Phân vùng, sharding & mở rộng quy mô

13 thg 7, 2026 3 lượt xem
#scaling
#postgresql
#sql
#sharding
#partitioning
#citus

Tối ưu 8 — Phân vùng, sharding & mở rộng quy mô

Suốt loạt bài này chúng ta đã làm mọi thứ để một PostgreSQL chạy nhanh hơn: đọc plan với EXPLAIN, thêm chỉ mục, viết lại truy vấn, giám sát, và khi một máy đọc không xuể thì nhân bản bằng Replication rồi tách đọc/ghi để đẩy truy vấn đọc sang replica.

Nhưng có hai bức tường mà tất cả những kỹ thuật trên đều đâm vào:

  1. Một bảng quá lớn. Khi bảng transactions có 2 tỷ dòng, index phình lên hàng trăm GB, mỗi lần VACUUM mất hàng giờ, việc xoá dữ liệu cũ (DELETE) khoá bảng và sinh ra bloat khổng lồ. Query vẫn đúng index nhưng bảng đơn giản là quá to để quản lý.
  2. Ghi (write) vẫn nghẽn ở một primary. Replica giúp scale đọc — nhưng mọi INSERT/UPDATE/DELETE vẫn phải đi qua một node primary duy nhất. Đến ngưỡng nào đó, một máy dù mạnh đến mấy cũng không nhận nổi lượng ghi.

Bài cuối của loạt sẽ nói về hai câu trả lời cho hai bức tường đó: partitioning (chia một bảng lớn thành nhiều mảnh trong cùng một instance) và sharding (chia dữ liệu ra nhiều instance). Đây là những kỹ thuật "hạng nặng" — dùng đúng lúc thì cứu cả hệ thống, dùng sớm thì tự chuốc lấy phức tạp.

Scale-up (dọc) vs Scale-out (ngang)

Có hai hướng mở rộng cơ bản, và hiểu ranh giới của chúng là nền tảng của cả bài:

  • Vertical scaling (scale-up) — tăng sức mạnh của một máy: thêm CPU, thêm RAM, đổi sang NVMe nhanh hơn. Ưu điểm cực lớn: không đổi kiến trúc, không đổi ứng dụng, PostgreSQL vẫn là một instance với đầy đủ giao dịch ACID, join, ràng buộc khoá ngoại. Đây luôn là bước đầu tiên nên làm.
  • Horizontal scaling (scale-out) — tăng số lượng máy và chia tải giữa chúng: nhiều replica để đọc, nhiều shard để ghi.

Giới hạn của scale-up là vật lý và kinh tế: giá tăng phi tuyến (một máy 128 core đắt hơn nhiều lần bốn máy 32 core), và luôn có một trần cứng — bạn không thể mua máy nhiều RAM vô hạn. Quan trọng hơn: scale-up không giải được bài toán ghi một cách bền vững, vì cuối cùng vẫn là một tiến trình PostgreSQL ghi vào một tập WAL.

Nhắc lại từ bài trước: replica scale đọc, không scale ghi. Mọi thay đổi vẫn chảy từ primary → replica qua WAL, nên throughput ghi tối đa của hệ thống = throughput ghi của một primary. Khi chính lượng ghi (chứ không phải đọc) là điểm nghẽn, replica bó tay — lúc đó mới cần partition hoặc shard.

Table partitioning: chia một bảng logic thành nhiều mảnh vật lý

Từ PostgreSQL 10, ta có declarative partitioning: khai báo một bảng "cha" (parent / partitioned table) không chứa dữ liệu, và nhiều bảng "con" (partition) mỗi cái giữ một dải dữ liệu. Với ứng dụng, nó vẫn là một bảng — bạn INSERT/SELECT vào bảng cha như bình thường; PostgreSQL tự định tuyến dòng vào đúng partition.

Có ba kiểu phân vùng:

  • RANGE — theo khoảng giá trị, phổ biến nhất. Điển hình là theo thời gian: mỗi tháng/quý một partition. Rất hợp với dữ liệu time-series như log, giao dịch, sự kiện.
  • LIST — theo danh sách giá trị rời rạc. Ví dụ phân vùng theo region IN ('north','central','south') hoặc theo country_code.
  • HASH — theo hash của khoá, chia dòng đều vào N partition. Dùng khi không có tiêu chí range/list tự nhiên nhưng vẫn muốn chia nhỏ bảng đồng đều (ví dụ phân vùng theo hash(customer_id) thành 8 phần).

Ví dụ khai báo một bảng transactions phân vùng theo tháng (RANGE trên created_at):

-- (minh hoạ, không chạy trong sandbox)
CREATE TABLE transactions (
    id          bigint       GENERATED ALWAYS AS IDENTITY,
    account_id  bigint       NOT NULL,
    amount      numeric(18,2) NOT NULL,
    created_at  timestamptz  NOT NULL,
    -- partition key PHẢI nằm trong PRIMARY KEY / UNIQUE
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- các partition con, mỗi tháng một bảng
CREATE TABLE transactions_2026_05
    PARTITION OF transactions
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');

CREATE TABLE transactions_2026_06
    PARTITION OF transactions
    FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

Một dòng INSERT ... VALUES (..., '2026-06-15') sẽ tự động rơi vào transactions_2026_06. Bạn không cần biết partition nào — nhưng planner thì có.

Partition pruning: lợi ích lớn nhất

Điều làm partitioning đáng giá không chỉ là "chia nhỏ cho gọn", mà là partition pruning: khi truy vấn có điều kiện lọc trên partition key, planner loại bỏ hẳn các partition không liên quan, không thèm động đến chúng.

-- (minh hoạ pruning — không đánh dấu chạy được, sandbox không có bảng partitioned)
EXPLAIN
SELECT sum(amount)
FROM transactions
WHERE created_at >= '2026-06-01' AND created_at < '2026-07-01';

Plan (rút gọn, minh hoạ) sẽ chỉ chạm đúng một partition:

Aggregate
  ->  Seq Scan on transactions_2026_06 transactions
        Filter: (created_at >= '2026-06-01' AND created_at < '2026-07-01')

Toàn bộ các partition tháng khác — có thể là 24, 60 partition — biến mất khỏi plan. Đây chính là điều một bảng đơn khối 2 tỷ dòng không bao giờ làm được: dù có index, nó vẫn phải mở B-tree khổng lồ; còn bảng phân vùng chỉ mở đúng mảnh nhỏ cần thiết.

Sơ đồ một bảng logic được pruning xuống đúng partition tháng:

Các lợi ích khác đi kèm:

  • Bảo trì rẻ. Muốn xoá dữ liệu quá 12 tháng? Thay vì DELETE FROM transactions WHERE created_at < ... (quét & khoá cả tỷ dòng, sinh bloat, phải VACUUM), bạn chỉ cần DROP TABLE transactions_2026_04 hoặc ALTER TABLE ... DETACH PARTITION — gần như tức thời, không sinh dead tuples.
  • VACUUM/ANALYZE nhỏ hơn. Autovacuum chạy trên từng partition độc lập; một partition tháng 30 GB dễ xử lý hơn nhiều một bảng 700 GB.
  • Index nhỏ hơn theo partition, cache hiệu quả hơn cho các partition "nóng" (tháng hiện tại).

Ba lưu ý bắt buộc nhớ

  1. Partition key nên xuất hiện trong điều kiện truy vấn. Nếu bạn phân vùng theo created_at nhưng phần lớn query lọc theo account_id mà không có created_at, pruning không xảy ra — planner phải quét mọi partition, còn tệ hơn bảng đơn. Chọn partition key theo cách bạn truy vấn.
  2. Unique/Primary key phải chứa partition key. PostgreSQL không thể đảm bảo tính duy nhất xuyên partition mà không có khoá cục bộ, nên PRIMARY KEYUNIQUE bắt buộc bao gồm cột phân vùng (như PRIMARY KEY (id, created_at) ở trên). Đây là ràng buộc thường gây bất ngờ khi chuyển từ bảng thường sang bảng phân vùng.
  3. Đừng chia quá nhiều mảnh. Hàng nghìn partition khiến planner tốn thời gian pruning và làm phình catalog. Vài chục đến vài trăm partition là hợp lý; tránh partition theo ngày cho dữ liệu giữ nhiều năm nếu không cần thiết.

Tự động hoá vòng đời partition với pg_partman

Tạo partition tháng mới và drop partition cũ bằng tay là công việc dễ quên và dễ sai. Extension pg_partman tự động hoá việc này: bạn khai báo chính sách (partition theo tháng, giữ lại 12 tháng), nó tự tạo partition tương lai trước hạn và tự detach/drop partition quá hạn theo lịch. Trong thực tế, partitioning nghiêm túc gần như luôn đi kèm pg_partman (hoặc một job cron tương đương).

Sharding: chia dữ liệu ra nhiều instance

Partitioning giải quyết "bảng quá lớn" nhưng vẫn trong một instance — nghĩa là vẫn một primary nhận toàn bộ ghi. Khi chính lượng ghi vượt sức một máy, ta cần sharding: chia dữ liệu thành nhiều tập, đặt trên nhiều node PostgreSQL độc lập, mỗi node nhận và ghi phần của mình. Đây là cách duy nhất để scale ghi thật sự, và cũng để vượt trần dung lượng của một máy.

Khoá quyết định mỗi dòng thuộc node nào gọi là shard key (distribution key). Ví dụ shard theo hash(customer_id): khách hàng A luôn nằm trên node 2, khách B trên node 5.

Có hai cách triển khai chính:

  • App-level sharding. Ứng dụng tự biết dòng nào đi node nào và định tuyến câu lệnh tương ứng. Toàn quyền kiểm soát, không phụ thuộc extension — nhưng bạn phải tự lo định tuyến, tự lo query gộp nhiều node, tự lo rebalancing khi thêm node. Rất nhiều logic hạ tầng đổ vào tầng ứng dụng.
  • Citus (extension cho PostgreSQL). Biến một cụm PostgreSQL thành CSDL phân tán: một node coordinator giữ metadata và nhận truy vấn, nhiều node worker giữ các shard thật. Bạn SELECT create_distributed_table('transactions', 'customer_id') để phân tán bảng theo distribution column; coordinator tự phân rã truy vấn xuống đúng worker và gộp kết quả. Với ứng dụng, cụm nhìn gần như một PostgreSQL bình thường.
-- (minh hoạ Citus, không chạy trong sandbox)
CREATE EXTENSION citus;
SELECT create_distributed_table('transactions', 'customer_id');
-- coordinator sẽ băm customer_id, trải shard đều trên các worker

Chọn shard key: quyết định sống còn

Shard key tốt phải đạt hai điều:

  • Phân bố đều. Nếu key lệch, dữ liệu dồn vào một node — gọi là hot shard — thì bạn vừa tốn công sharding vừa vẫn nghẽn ở một máy. Ví dụ shard theo country_code mà 80% khách ở một quốc gia là thất bại. Hash một khoá có độ đa dạng cao (như customer_id) thường cho phân bố đều.
  • Khớp với cách truy vấn (co-location). Các bảng liên quan nên dùng cùng shard key để những dòng cần join nằm cùng một node. Nếu ordersorder_items cùng shard theo customer_id, join giữa chúng chạy cục bộ trên từng worker, không cần chuyển dữ liệu qua mạng.

Cái giá phải trả

Sharding không miễn phí — nó đánh đổi khả năng ghi lấy độ phức tạp:

  • Join & giao dịch cross-shard. Query cần dữ liệu trải nhiều node (không cùng shard key) buộc phải gom dữ liệu qua mạng — chậm và tốn. Giao dịch ACID xuyên nhiều node cần two-phase commit, phức tạp và có chi phí.
  • Rebalancing. Thêm node mới nghĩa là di chuyển shard — một thao tác nặng, cần lập kế hoạch. Citus có công cụ rebalance nhưng vẫn là việc lớn.
  • Ràng buộc toàn cục yếu đi. Unique/foreign key xuyên shard khó đảm bảo; thường phải thiết kế để không cần đến chúng.

Partition vs Sharding: chọn cái nào?

Hai kỹ thuật dễ bị nhầm vì đều "chia nhỏ", nhưng khác nhau ở ranh giới máy:

PartitioningSharding
Phạm viMột instance PostgreSQLNhiều instance/node
Giải quyếtBảng quá lớn, bảo trì, pruningGhi vượt một máy, dung lượng vượt một máy
Scale ghi?Không (vẫn một primary)
Độ phức tạpThấp, nativeCao (định tuyến, cross-shard, rebalance)
Giao dịch/joinBình thường trong instanceCross-shard phức tạp

Quy tắc thực dụng: partition trước, shard sau. Rất nhiều hệ thống chỉ cần partitioning là đủ sống nhiều năm. Chỉ khi bản thân lượng ghi (không phải kích thước bảng, không phải lượng đọc) vượt sức một primary đã scale-up hết cỡ, mới nên tính sharding.

Kiến trúc tổng hợp

Trong thực tế các kỹ thuật này không loại trừ nhau mà chồng lớp. Một kiến trúc dữ liệu chịu tải lớn thường ghép cả loạt bài:

  • Primary + read replicasreplication để scale đọc, tách đọc/ghi để định tuyến truy vấn.
  • Partitioning trên các bảng khổng lồ (log, giao dịch) trong mỗi instance để quản lý và pruning.
  • Sharding/Citus khi lượng ghi vượt một node — và mỗi shard vẫn có thể tự partition và có replica riêng.

Nghĩa là bạn có thể có: một cụm Citus, mỗi worker là một PostgreSQL có replica riêng, và bên trong worker bảng lớn được partition theo tháng. Nhiều lớp, mỗi lớp giải một bài toán.

Khi độ phức tạp phân tán quá tải — cần ghi mạnh giao dịch phân tán mạnh đọc mạnh cùng lúc, với ràng buộc toàn cục thật sự — người ta cân nhắc các CSDL NewSQL / distributed SQL được thiết kế phân tán từ gốc như CockroachDB hay YugabyteDB. Chúng nói giao thức tương thích PostgreSQL nhưng phân phối dữ liệu và đồng thuận xuyên node ngay trong lõi — đổi lại có mô hình vận hành riêng. Đây là bước đi lớn, không phải lựa chọn mặc định.

Use case thực tế

Bảng transactions 2 tỷ dòng. Một ngân hàng lưu giao dịch nhiều năm; bảng phình tới 2 tỷ dòng, index vài trăm GB, và yêu cầu tuân thủ chỉ cần giữ 13 tháng gần nhất trong hệ thống nóng. Trước khi partition: mỗi query báo cáo tháng phải mở index khổng lồ; và mỗi cuối tháng job "dọn dữ liệu cũ" chạy DELETE hàng chục triệu dòng, khoá bảng và để lại bloat khiến autovacuum vật lộn hàng ngày.

Giải pháp: partition theo RANGE (created_at) mỗi tháng một partition, dùng pg_partman giữ 13 partition. Kết quả: (1) query báo cáo tháng chỉ chạm một partition nhờ pruning — nhanh hơn nhiều bậc; (2) "dọn dữ liệu cũ" thành một DETACH PARTITION + DROP gần như tức thời, không bloat, không khoá; (3) autovacuum chỉ làm việc trên từng partition. Đáng chú ý: đây thuần tuý là partitioning trong một instance — không thêm máy nào.

Khi ghi vượt một node. Một nền tảng SaaS đa khách (multi-tenant) tăng trưởng đến mức lượng INSERT sự kiện vượt khả năng của một primary đã scale-up tối đa. Đọc đã được các replica gánh, partition đã áp cho bảng sự kiện, nhưng chính throughput ghi mới là trần. Đây đúng là lúc sharding hợp lý: dùng Citus, shard theo tenant_id (khoá đa dạng, phân bố đều, và tự nhiên co-locate mọi bảng của cùng một tenant trên một worker). Ghi được trải trên nhiều worker, mỗi tenant nằm gọn một node nên phần lớn truy vấn của họ chạy cục bộ, tránh chi phí cross-shard.

Ghi nhớ

  • Scale-up (mạnh hơn một máy) luôn là bước đầu và đơn giản nhất; nó có trần vật lý/kinh tế và không giải được bài toán ghi bền vững. Scale-out (nhiều máy) mới vượt trần đó.
  • Replica scale đọc, không scale ghi — mọi ghi vẫn qua một primary. Muốn scale ghi phải shard.
  • Partitioning chia một bảng lớn thành nhiều mảnh trong một instance. Kiểu RANGE theo thời gian là phổ biến nhất. Lợi ích lớn nhất là partition pruning — planner bỏ qua partition không liên quan.
  • Partitioning còn giúp bảo trì rẻ (drop/detach partition thay vì DELETE lớn) và VACUUM/index nhỏ hơn. Nhớ: partition key nên nằm trong điều kiện query, và PK/UNIQUE phải chứa partition key. Dùng pg_partman để tự động vòng đời.
  • Sharding chia dữ liệu ra nhiều node theo shard key → scale được ghi và dung lượng. Làm ở tầng app hoặc bằng Citus (coordinator + workers, phân tán theo distribution column).
  • Shard key tốt phải phân bố đều (tránh hot shard) và co-locate các bảng cần join. Cái giá: join/giao dịch cross-shard phức tạp, rebalancing khi thêm node.
  • Partition (một instance) ≠ Sharding (nhiều instance). Partition trước, shard sau. Đừng sharding sớm — tối ưu query/index/partition, rồi scale-up, rồi replica; sharding là cửa cuối.
  • Kiến trúc lớn chồng lớp: primary + replica (đọc) + partition (bảng lớn) + sharding/Citus (ghi cực lớn). Khi cần phân tán toàn diện, cân nhắc NewSQL (CockroachDB, YugabyteDB) — một bước đi lớn, không phải mặc định.

Đến đây loạt Tối ưu & Quản trị CSDL khép lại. Con đường đi từ đọc một plan, qua chỉ mục, tới nhân bản và phân tán — và bài học xuyên suốt vẫn là: đo trước, tối ưu điều rẻ nhất trước, chỉ thêm phức tạp khi thật sự cần. Một máy PostgreSQL được điều chỉnh tốt đi xa hơn nhiều người tưởng.

Bài viết liên quan

Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.

13 thg 7, 2026 4

Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.

13 thg 7, 2026 4

Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.

13 thg 7, 2026 3

Truy vấn lồng, CTE (WITH), CTE đệ quy và hàm cửa sổ — vũ khí cho phân tích nâng cao.

13 thg 7, 2026 3