ClickHouse 2 — Kiến trúc lưu trữ cột & chỉ mục

13 thg 7, 2026 2 lượt xem
#sql
#storage
#compression
#clickhouse
#sparse-index

ClickHouse 2 — Kiến trúc lưu trữ cột & chỉ mục

Ở bài Tổng quan ta đã biết ClickHouse là một OLAP database lưu theo cột (columnar), tối ưu cho việc quét hàng tỷ dòng trong tích tắc. Nhưng câu hỏi cốt lõi vẫn còn treo: bằng cách nào một truy vấn SELECT count() ... WHERE date >= '2026-06-01' trên bảng 5 tỷ dòng lại trả về trong dưới một giây, trong khi cùng dữ liệu đó trên PostgreSQL sẽ ì ạch?

Câu trả lời không phải "phần cứng mạnh" mà nằm ở cách dữ liệu được xếp trên đĩa. Ba trụ cột: (1) lưu theo cột nên chỉ đọc đúng cột cần và nén cực sâu; (2) dữ liệu chia thành các part bất biến rồi merge nền; (3) một chỉ mục thưa (sparse primary index) cho phép bỏ qua phần lớn dữ liệu mà không cần index từng dòng. Hiểu ba thứ này là hiểu 80% ClickHouse. Bài viết dùng bảng ví dụ transactions (giao dịch ngân hàng) xuyên suốt.

Sandbox của series là PostgreSQL read-only, nên toàn bộ SQL ClickHouse bên dưới ghi (minh hoạ) — mang sang clickhouse-client để chạy thật.

Lưu theo cột: mỗi cột một file

Trong một RDBMS hàng (row-store) như PostgreSQL, mọi cột của một dòng nằm liền kề nhau trong một block 8KB trên đĩa. Đọc "toàn bộ giao dịch #12345" chỉ tốn một lần chạm đĩa — tuyệt cho OLTP. Nhưng SELECT sum(amount) FROM transactions buộc phải đọc cả dòng dù chỉ cần một cột amount, kéo theo hàng chục cột vô dụng.

ClickHouse làm ngược lại: mỗi cột được cất riêng thành file độc lập. Với engine MergeTree (mặc định, sẽ đi sâu ở bài sau), một part trên đĩa gồm nhiều file, trong đó mỗi cột có một cặp:

  • <column>.bin — dữ liệu cột đã nén.
  • <column>.mrk2 (mark file) — chỉ mục vị trí, ánh xạ từ số thứ tự granule sang offset trong file .bin.

Ví dụ liệt kê thư mục một part thật:

/var/lib/clickhouse/data/banking/transactions/202606_1_120_4/
├── amount.bin        amount.mrk2
├── account_id.bin    account_id.mrk2
├── kind.bin          kind.mrk2
├── created_at.bin    created_at.mrk2
├── primary.idx       ← sparse primary index (chung cho part)
├── columns.txt       count.txt   checksums.txt
└── ...

Hệ quả trực tiếp là column pruning: truy vấn chỉ mở đúng những file .bin của cột xuất hiện trong SELECT/WHERE/GROUP BY. Chọn 2 cột trong bảng 60 cột thì chỉ đọc 2 file, phần còn lại nằm yên. Đây là lý do phải tránh SELECT * trong ClickHouse — nó ép mở mọi file cột, phá vỡ ưu thế lớn nhất của kiến trúc.

-- (minh hoạ, cú pháp ClickHouse)
-- Chỉ đọc 2 file: created_at.bin + amount.bin
SELECT toStartOfMonth(created_at) AS m, sum(amount)
FROM transactions
WHERE created_at >= '2026-01-01'
GROUP BY m;

Nén: vì sao cột nén rất tốt

Lưu theo cột không chỉ giúp đọc ít cột hơn — nó còn khiến nén ăn cực sâu. Lý do nằm ở entropy: dữ liệu trong cùng một cột đồng nhất về kiểu và độ dư thừa (redundancy) rất cao. Cột currency chỉ vài giá trị ('VND', 'USD'); cột kind lặp đi lặp lại; cột created_at là các timestamp gần nhau, tăng dần. Khi các giá trị giống nhau nằm liền kề, thuật toán nén (LZ4, ZSTD) tìm được pattern lặp dài và ép rất nhỏ — điều gần như không thể khi các cột khác kiểu bị trộn lẫn trong một dòng của row-store.

Trong thực tế, tỷ lệ nén 5–15 lần là bình thường với dữ liệu giao dịch, có cột lặp cao đạt vài chục lần. Nén tốt lợi kép: giảm dung lượng lưu trữgiảm số byte phải đọc từ đĩa khi quét — mà I/O thường là nút cổ chai của truy vấn analytic.

Chọn codec nén

ClickHouse cho phép cấu hình nén ở hai tầng: nén chung (general-purpose) và codec chuyên biệt theo từng cột.

CodecBản chấtKhi nào dùng
LZ4Nén chung, mặc địnhCân bằng: giải nén cực nhanh, tỷ lệ nén khá. Chọn khi không chắc.
ZSTD(level)Nén chung, mạnh hơnỔ đĩa/mạng là nút cổ chai, chấp nhận CPU cao hơn. ZSTD(1)ZSTD(3) phổ biến.
DeltaLưu hiệu số giữa các giá trị liền kềCột số tăng/giảm đều: ID, số dư tích luỹ.
DoubleDeltaHiệu số của hiệu sốCột gần như tuyến tính: timestamp lấy mẫu đều, sequence tăng ổn định.
GorillaNén số thực dấu phẩy độngTime-series số đo (giá, nhiệt độ) thay đổi chậm.
T64Cắt bit theo khoảng giá trịCột số nguyên phạm vi hẹp.

Điểm quan trọng: codec chuyên biệt và nén chung ghép được với nhau — Delta/DoubleDelta biến đổi dữ liệu cho dễ nén, rồi LZ4/ZSTD nén lần cuối. Với cột timestamp gần như đều nhau, DoubleDelta biến chuỗi lớn thành phần lớn số 0, và ZSTD ép các số 0 đó gần như biến mất.

-- (minh hoạ, cú pháp ClickHouse)
CREATE TABLE transactions (
    id          UInt64  CODEC(Delta, ZSTD(1)),
    account_id  UInt64  CODEC(ZSTD(1)),
    amount      Decimal(18, 2),                    -- LZ4 mặc định
    kind        LowCardinality(String),            -- từ điển hoá + nén
    created_at  DateTime CODEC(DoubleDelta, ZSTD(1))
) ENGINE = MergeTree
ORDER BY (account_id, created_at);

Ngoài codec, kiểu LowCardinality(String) cho cột ít giá trị phân biệt (như kind, currency) tạo một từ điển nội bộ và lưu số nguyên nhỏ thay cho chuỗi — vừa nén tốt vừa tăng tốc GROUP BY.

Part, data part và merge nền

Đơn vị vật lý của MergeTree là data part (thường gọi tắt là part). Mỗi lần INSERT, ClickHouse không sửa dữ liệu cũ mà ghi ra một part mới, hoàn chỉnh, độc lập. Part là bất biến (immutable) — đã ghi rồi thì không bao giờ chỉnh sửa tại chỗ. Đây là khác biệt nền tảng so với việc UPDATE/INSERT từng dòng trong OLTP.

Tên part mã hoá thông tin: 202606_1_120_4 nghĩa là partition 202606 (tháng 6/2026), gộp từ block min 1 đến max 120, đã trải qua 4 mức merge.

Tính bất biến giải quyết một bài toán khó của hệ thống ghi lượng lớn: không cần khoá đọc/ghi phức tạp. Reader luôn nhìn thấy một tập part ổn định, writer chỉ việc thêm part mới. Nhưng nó đẻ ra vấn đề: ghi liên tục thì số part phình lên, mỗi truy vấn phải mở hàng nghìn part → chậm. Giải pháp là merge nền (background merge): ClickHouse tự động, bất đồng bộ, chọn các part nhỏ cùng partition và gộp thành part lớn hơn, sắp xếp lại theo ORDER BY, dọn dữ liệu trùng (với engine ReplacingMergeTree) hay cộng dồn (SummingMergeTree). Merge chạy ngầm, không chặn truy vấn.

Hệ quả thực chiến: hãy INSERT theo lô lớn (hàng chục nghìn dòng/lần) thay vì từng dòng. Insert nhỏ lẻ đẻ ra vô số part tí hon, ép engine merge liên tục, tốn CPU và I/O; nếu vượt ngưỡng parts_to_throw_insert (mặc định 300 part/partition) ClickHouse sẽ từ chối insert với lỗi "Too many parts". Đây là lỗi vận hành phổ biến nhất của người mới — sẽ bàn kỹ ở bài ingest.

Granule và sparse primary index

Đây là phần thông minh nhất và cũng khác biệt nhất so với database truyền thống.

Granule — đơn vị đọc

Trong mỗi part, các dòng (đã sắp theo ORDER BY) được chia thành các khối gọi là granule. Kích thước granule do tham số index_granularity quy định, mặc định 8192 dòng. Granule là đơn vị đọc tối thiểu: ClickHouse không đọc từng dòng riêng lẻ mà luôn đọc trọn (ít nhất) một granule.

Sparse index — chỉ mục thưa

Điểm mấu chốt: ClickHouse không đánh index cho từng dòng. Thay vào đó, file primary.idx chỉ lưu giá trị khoá của dòng đầu mỗi granule. Với index_granularity = 8192, bảng 5 tỷ dòng chỉ cần ~610.000 entry index — đủ nhỏ để giữ toàn bộ trong RAM. Đó là lý do gọi là sparse (thưa): mỗi entry index đại diện cho 8192 dòng, không phải 1 dòng.

Pruning: cách bỏ qua granule

Vì dữ liệu đã sắp xếp theo primary key (ORDER BY), sparse index cho phép ClickHouse suy ra khoảng granule cần đọc mà không quét toàn bộ:

  1. Truy vấn có điều kiện trên cột primary key, ví dụ WHERE account_id = 42.
  2. ClickHouse tìm nhị phân trên primary.idx (nằm trong RAM) để xác định các granule mà giá trị 42 có thể rơi vào — dựa trên khoá đầu mỗi granule.
  3. Với mỗi granule khớp, đọc mark file (.mrk2) để lấy offset chính xác trong file .bin của từng cột cần.
  4. Giải nén và đọc chỉ những granule đó, bỏ qua tất cả granule còn lại.

Ví dụ: bảng 5 tỷ dòng, ORDER BY (account_id, created_at). Truy vấn theo một account_id cụ thể có thể chỉ chạm vài granule = vài chục nghìn dòng thay vì 5 tỷ. ClickHouse đọc thẳng byte offset cần, giải nén, xử lý vector hoá — quét analytic vì thế cực nhanh: ít cột (column pruning) × ít granule (index pruning) × dữ liệu nén sẵn.

Lưu ý: sparse index chỉ thu hẹp phạm vi. Vì granule là khối 8192 dòng, kết quả luôn kèm dòng thừa trong granule biên; ClickHouse lọc lại phần thừa ở bước xử lý. Đây là đánh đổi có chủ đích: index siêu nhỏ, đọc theo khối lớn — hoàn toàn phù hợp analytic (quét nhiều), nhưng không hợp point lookup kiểu OLTP.

So sánh với B-tree của OLTP

Để thấy rõ triết lý, đặt cạnh index B-tree của PostgreSQL/Oracle:

Tiêu chíB-tree (OLTP)Sparse primary index (ClickHouse)
Độ mịnMỗi dòng một entryMỗi granule (~8192 dòng) một entry
Kích thước indexLớn (tỷ lệ số dòng)Rất nhỏ, thường nằm gọn trong RAM
Tối ưu choPoint lookup, truy vấn 1 vài dòngRange scan, quét/tổng hợp khối lớn
Lưu trữ dữ liệuTheo hàng, dữ liệu tách khỏi indexTheo cột, đã sort, dữ liệu chính là "index có thứ tự"
GhiUpdate tại chỗ, cân bằng câyGhi part mới bất biến + merge nền
Nhiều index thứ cấpBình thường, mỗi cột một B-treeHạn chế; chủ yếu 1 primary key sort + skip index phụ trợ

Nói ngắn gọn: B-tree tối ưu tìm kim trong đống rơm (lấy đúng 1 dòng nhanh), còn sparse index tối ưu quét cả đống rơm rồi loại nhanh phần không cần (đọc hàng triệu dòng liên quan). Chúng phục vụ hai bài toán ngược nhau. Đó là lý do ClickHouse quét tổng hợp thần tốc nhưng nếu bạn dùng nó như OLTP (tra cứu từng dòng theo khoá tuỳ ý, update lẻ) thì hiệu năng và trải nghiệm sẽ tệ — chọn PostgreSQL (kiến trúc) hay chỉ mục B-tree (tối ưu index) cho việc đó.

Để dễ hình dung khác biệt row vs column layout, câu SQL sau chạy được trên sandbox Postgres — quan sát rằng dù chỉ cần currencybalance, row-store vẫn phải đọc trọn từng dòng accounts:

-- ▶ Chạy được
SELECT currency, count(*), sum(balance)
FROM accounts
GROUP BY currency
ORDER BY sum(balance) DESC;

Trong ClickHouse với dữ liệu tương tự, truy vấn này chỉ mở 2 file cột (currency.bin, balance.bin) và quét theo granule — khác biệt về I/O là bản chất, không phải tinh chỉnh.

Use case thực tế

Bối cảnh. Team dữ liệu NCB nạp log giao dịch thẻ vào bảng card_events trên ClickHouse: ~40 triệu dòng/ngày, giữ 18 tháng ≈ 21 tỷ dòng. Schema ORDER BY (card_id, event_time), partition theo tháng, index_granularity = 8192.

Vấn đề ban đầu. Đội ingest ban đầu đẩy dữ liệu bằng nhiều luồng insert nhỏ (mỗi luồng vài trăm dòng, insert liên tục). Sau vài giờ, ClickHouse báo lỗi "Too many parts (312). Merges are processing significantly slower than inserts" và từ chối ghi. Nguyên nhân: mỗi insert nhỏ đẻ một part, merge nền không đuổi kịp.

Xử lý.

  1. Gom insert thành lô ~200.000 dòng / lần, tần suất ~1 lần/10 giây (dùng buffer ở tầng ingest, chi tiết ở bài ingest & schema). Số part tạo mới giảm ~1000 lần, lỗi "Too many parts" biến mất.
  2. Đặt codec: event_time DateTime CODEC(DoubleDelta, ZSTD(1)), card_id UInt64 CODEC(ZSTD(1)), mcc LowCardinality(String). Dung lượng trên đĩa giảm từ ~1,8 TB xuống ~190 GB (tỷ lệ nén ~9,5×) nhờ cột đồng nhất + codec chuyên biệt.

Kết quả truy vấn. Báo cáo cảnh báo gian lận cần "tổng chi tiêu 24h qua theo từng thẻ nghi ngờ" (danh sách vài nghìn card_id):

-- (minh hoạ, cú pháp ClickHouse)
SELECT card_id, count() AS n, sum(amount) AS total
FROM card_events
WHERE event_time >= now() - INTERVAL 24 HOUR
  AND card_id IN (/* danh sách thẻ nghi ngờ */)
GROUP BY card_id;

card_id là cột đầu của primary key, sparse index thu hẹp về đúng các granule chứa những thẻ đó; event_time cột thứ hai giúp bỏ tiếp granule ngoài 24h; chỉ 3 file cột được mở (card_id, event_time, amount). Truy vấn quét vài triệu dòng thay vì 21 tỷ, trả về trong ~0,3 giây. Cùng khối lượng đó trên hệ row-store không nén sẽ mất hàng phút.

Ghi nhớ

  • ClickHouse (MergeTree) lưu mỗi cột một cặp file: .bin (dữ liệu nén) + .mrk2 (mark, ánh xạ granule → offset). Truy vấn chỉ mở file của cột cần → column pruning; vì thế tránh SELECT *.
  • Nén ăn sâu vì dữ liệu trong một cột đồng nhất, dư thừa cao. Mặc định LZ4 (nhanh); ZSTD khi cần tỷ lệ nén cao hơn; codec chuyên biệt Delta / DoubleDelta / Gorilla / T64 ghép trước nén chung, đặc biệt hiệu quả cho ID và timestamp.
  • Dữ liệu chia thành data part bất biến (immutable); mỗi INSERT tạo part mới. Merge nền gộp part nhỏ thành part lớn, sort lại theo ORDER BY. Hãy INSERT theo lô lớn để tránh lỗi "Too many parts".
  • Trong part, dòng chia thành granule (~index_granularity = 8192 dòng) — đơn vị đọc tối thiểu.
  • Sparse primary index chỉ lưu khoá đầu mỗi granule (không phải từng dòng như B-tree) nên đủ nhỏ để nằm trong RAM. Vì dữ liệu đã sort theo primary key, ClickHouse tìm nhị phân trên index để bỏ qua granule không khớp (pruning), chỉ giải nén phần cần.
  • Quét analytic cực nhanh = column pruning × index pruning × dữ liệu nén sẵn. Đổi lại, ClickHouse không hợp point lookup / update lẻ kiểu OLTP — việc đó dành cho B-tree của PostgreSQL/Oracle.

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