ClickHouse 2 — Kiến trúc lưu trữ cột & chỉ mục
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ữ và 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.
| Codec | Bản chất | Khi nào dùng |
|---|---|---|
LZ4 | Nén chung, mặc định | Câ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. |
Delta | Lư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ỹ. |
DoubleDelta | Hiệ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. |
Gorilla | Nén số thực dấu phẩy động | Time-series số đo (giá, nhiệt độ) thay đổi chậm. |
T64 | Cắ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ộ:
- Truy vấn có điều kiện trên cột primary key, ví dụ
WHERE account_id = 42. - 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. - Với mỗi granule khớp, đọc mark file (
.mrk2) để lấy offset chính xác trong file.bincủa từng cột cần. - 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ịn | Mỗi dòng một entry | Mỗi granule (~8192 dòng) một entry |
| Kích thước index | Lớn (tỷ lệ số dòng) | Rất nhỏ, thường nằm gọn trong RAM |
| Tối ưu cho | Point lookup, truy vấn 1 vài dòng | Range scan, quét/tổng hợp khối lớn |
| Lưu trữ dữ liệu | Theo hàng, dữ liệu tách khỏi index | Theo cột, đã sort, dữ liệu chính là "index có thứ tự" |
| Ghi | Update tại chỗ, cân bằng cây | Ghi part mới bất biến + merge nền |
| Nhiều index thứ cấp | Bình thường, mỗi cột một B-tree | Hạ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 currency và balance, 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ý.
- 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.
- Đặ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;
Vì 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ánhSELECT *. - 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 = 8192dò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.
Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.
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.
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.