Từ column storage → MergeTree → analytics thực chiến
OLAP · Column-oriented analytics
Phần 1
Mục tiêu: Hiểu ClickHouse là gì và workload nó phục vụ
Column-oriented DBMS — hệ quản trị cơ sở dữ liệu hướng cột
Thuộc nhóm OLAP — Online Analytical Processing
Tối ưu scan, filter, aggregate trên hàng triệu → hàng tỷ dòng
ClickHouse phục vụ workload analytics — OLAP scan/aggregate trên dữ liệu lớn.
Mục tiêu: Nhận diện pain point khi dữ liệu phân tích tăng quy mô
100M requests/ngày → 3 tỷ bản ghi / 30 ngày
Request hôm nay? API chậm nhất? P95?
Lỗi 5xx? Traffic theo giờ?
GROUP BY / COUNT nặng dần
Quét hàng triệu dòng — OLTP không đủ nhanh
Điểm chính: ClickHouse cho phân tích trên dữ liệu rất lớn (hàng tỷ bản ghi).
Mục tiêu: Giới thiệu case study api_logs — xuyên suốt bài học
50M request/ngày · 20 services · Dashboard: traffic, P95/P99, error rate, top API/quốc gia
PostgreSQL xử lý OLTP — nhưng GROUP BY trên tỷ dòng log thì chậm.
Columnar → ít đọc · ORDER BY → skip · MergeTree + batch · MV → dashboard · Cluster khi scale
| Chương | Quay lại case study |
|---|---|
| Column storage | Vì sao api_logs hợp columnar? |
| ORDER BY | Thiết kế api_logs theo query dashboard |
| Aggregation | P95 latency, error rate |
| MV / pipeline | Raw → 1m/1h → Grafana |
| Distributed | 50M/ngày → 500M/ngày |
Một câu chuyện duy nhất: mỗi chương trả lời một mảnh — cuối bài ghép thành hệ thống monitor API hoàn chỉnh.
Mục tiêu: Phân biệt OLTP và OLAP — nền tảng để hiểu vị trí của ClickHouse
INSERT / UPDATE / DELETE
Transaction, consistency
Query nhỏ, đọc ít rows
Update thường xuyên
Ví dụ: PostgreSQL, MySQL
SELECT * FROM users WHERE id = 123;
Scan + Aggregate
Đọc nhiều rows, đọc ít columns
GROUP BY, COUNT, AVG, P95
Data thường append, ít update
Ví dụ: ClickHouse
SELECT country, count()
FROM events
GROUP BY country;
ClickHouse nằm ở phía OLAP — tối ưu scan, filter, aggregate hàng tỷ records.
Mục tiêu: Phá bỏ sai lầm phổ biến — ClickHouse không thay PostgreSQL
Sai lầm: "ClickHouse là PostgreSQL nhưng nhanh hơn." — Không phải.
| PostgreSQL OLTP | ClickHouse OLAP | |
|---|---|---|
| Vai trò | CRUD, transaction — dữ liệu nghiệp vụ | Scan, aggregate — dữ liệu phân tích |
| Bảng ví dụ | users · orders · payments |
api_logs · order_events · metrics |
Kiến trúc phổ biến
Kết luận: Không phải PostgreSQL hoặc ClickHouse — mà thường là cả hai.
Mục tiêu: 7 yếu tố tạo nên tốc độ ClickHouse
| # | Yếu tố | Vai trò |
|---|---|---|
| 1 | Lưu theo cột | Chỉ đọc cột cần — giảm I/O |
| 2 | Nén dữ liệu | Nén theo cột — ít dung lượng đĩa |
| 3 | Dữ liệu sắp xếp | Dữ liệu liên quan nằm gần nhau |
| 4 | Chỉ mục thưa | Bỏ qua khối không liên quan |
| 5 | Xử lý vector | Lô + SIMD |
| 6 | Song song | Nhiều lõi CPU |
| 7 | Tổng hợp trước | Truy vấn nhẹ hơn |
Công thức: Đọc ít × nén tốt × bỏ qua vùng thừa × vector + song song → phân tích cực nhanh
Mục tiêu: Cách ClickHouse lưu trữ khác database truyền thống
Mỗi dòng nằm liền kề trên đĩa:
| Dòng | Dữ liệu vật lý |
|---|---|
| 1 | 1, An, 25, VN, 1000 |
| 2 | 2, Bình, 30, VN, 1500 |
| 3 | 3, John, 27, US, 2500 |
Tốt khi đọc toàn bộ dòng
Mỗi cột được lưu riêng biệt:
| Cột | Dữ liệu vật lý |
|---|---|
country | VN, VN, US |
salary | 1000, 1500, 2500 |
age | 25, 30, 27 |
Tốt khi đọc ít cột trên nhiều dòng
Case study: SELECT api, quantile(0.95)(duration_ms) … chỉ đọc 2 cột trên tỷ dòng.
Mục tiêu: Hiểu tại sao lưu theo cột phù hợp cho phân tích
users — 100 cột, 1 tỷ dòng — truy vấn chỉ cần 2 cộtSELECT country, avg(salary) FROM users GROUP BY country;
id
name
age
country ✓
salary ✓
… 95 cột khác
Đọc tất cả 100 cột ≈ 100 GB
98% dữ liệu lãng phí
Chỉ country + salary ≈ 2 GB
50× tiết kiệm I/O và RAM
Điểm chính: Phân tích thường chỉ đọc vài cột — lưu theo cột tránh đọc dữ liệu thừa.
Mục tiêu: Hiểu vì sao lưu theo cột giúp ClickHouse nén dữ liệu cực tốt
| Khía cạnh | Lưu theo cột | Lưu theo dòng |
|---|---|---|
| Nguyên lý | Cùng kiểu dữ liệu → nằm cạnh nhau → nén tốt | Xen kẽ nhiều kiểu → khó nén |
| Ví dụ | VN,VN,VN,US,US,JP → 3×VN, 2×US, 1×JP |
1,An,25,VN,1000 → Int + String xen kẽ |
| Log / sự kiện | Dữ liệu lặp lại nhiều → lưu theo cột nén 3–10× (1 TB thô → 100–300 GB) | |
Điểm chính: Các giá trị cùng kiểu, thường lặp lại hoặc tương quan → codec/encoding (LZ4, ZSTD, dictionary…) khai thác redundancy tốt hơn.
Mục tiêu: Hiểu cách ClickHouse xử lý dữ liệu theo block để tận dụng tối đa phần cứng
Xử lý hàng nghìn giá trị cùng lúc theo vector/block — không phải từng dòng.
for row in rows:
sum += row.value
# 1 phép/dòng · cache miss
process_vector(column_chunk)
# 1 phép/khối · SIMD + cache hit
Column Data
Dữ liệu từng cột
Vector / Block
Nhiều giá trị/lần
CPU + SIMD
Xử lý song song
Result
Kết quả gộp
Điểm chính: Vectorized execution tận dụng CPU cache và SIMD — nhanh hơn nhiều lần so với row-by-row.
Mục tiêu: Hiểu cách ClickHouse xử lý truy vấn song song
Gộp kết quả từng phần
SELECT country, count()
FROM events
GROUP BY country;
Tận dụng 16 lõi CPU đồng thời
Dữ liệu chia thành khối riêng biệt
Kết quả từng phần được gộp cuối cùng
Phân tích rất dễ song song hóa — tận dụng 16 lõi CPU, chia khối và gộp kết quả.
ClickHouse Course — Phần 3
Mục tiêu: Hiểu kiến trúc tổng quan của ClickHouse server
Client Layer
clickhouse-client · HTTP API · JDBC · Python · Java
Query Processing
SQL Parser → Planner → Executor
Storage Engine
MergeTree · ReplacingMergeTree · Distributed
Disk / SSD / Object Storage
clickhouse-client — CLI gốc
HTTP API — REST interface
JDBC / Java — Ứng dụng JVM
Python — clickhouse-driver
Grafana — Dashboard theo thời gian thực
Metabase / Superset — BI tools
HTTP API là cách đơn giản nhất để bắt đầu — không cần cài driver.
Mục tiêu: Hiểu ENGINE và các engine chính trong ClickHouse
Engine nền tảng — ghi parts, merge nền.
Insert version mới; dedup khi merge — dùng FINAL/argMax() khi cần ngay.
Tự tổng hợp số khi merge.
Lưu aggregation state — dùng với MV.
Lớp query cluster — gửi task xuống shard, merge kết quả.
CREATE TABLE events (
timestamp DateTime,
user_id UInt64,
event_type String
)
ENGINE = MergeTree
ORDER BY timestamp;
ENGINEquyết định cách dữ liệu lưu, ghi, đọc và merge.
Mục tiêu: MergeTree — engine cốt lõi và nguyên lý hoạt động
Part dữ liệu: Mỗi lần ghi tạo một part riêng.
Gộp nền: Part nhỏ gom thành part lớn hơn.
Engine nền tảng: Hầu hết bảng triển khai dùng MergeTree.
Đây là lý do tên engine là "Merge" + "Tree".
flowchart TD
I1[INSERT] --> P1[Part 1]
I1 --> P2[Part 2]
I1 --> P3[Part 3]
P1 --> MG[Gộp nền]
P2 --> MG
P3 --> MG
MG --> PF[Part lớn hơn]
Kéo để pan · Cuộn để zoom
Điểm chính: MergeTree phân mảnh dữ liệu rồi gom nền — tối ưu ghi và đọc phân tích.
Mục tiêu: Hiểu tại sao part dữ liệu gần như bất biến và điều này ảnh hưởng thế nào đến đọc/ghi
Insert → Tạo part mới
Cập nhật → Tạo phiên bản dữ liệu mới (mutation)
Xóa → Mutation / lightweight delete
Gộp → Gom part nền → part mới
Đọc nhanh — không cần khóa
Ghi tuần tự — chỉ ghi thêm
Nén tốt — dữ liệu cùng kiểu liền kề
Đồng thời tốt — đọc và ghi không xung đột
Không lý tưởng cho cập nhật / xóa liên tục
Không phù hợp CRUD giao dịch
Điểm chính: Part dữ liệu sau khi tạo là bất biến — ClickHouse tối ưu cho đọc và ghi tuần tự, không phải sửa đổi thường xuyên.
Mục tiêu: Các cơ chế update/delete hiện đại — không phải “append-only tuyệt đối”
| Nhu cầu | Cơ chế | Ghi chú |
|---|---|---|
| Bulk update | ALTER TABLE … UPDATE (mutation) | Chạy nền, không realtime |
| Upsert / CDC | ReplacingMergeTree + version | Dedup khi merge; FINAL/argMax() khi cần ngay |
| Sửa lẻ thường xuyên | Lightweight update | Phù hợp sửa ít cột, ít dòng |
| Xóa theo điều kiện | Lightweight delete / mutation | Ưu tiên TTL / DROP PARTITION cho log |
Triết lý vẫn là append-first — nhưng ClickHouse 2026 có đủ công cụ khi business bắt buộc sửa/xóa. Log analytics (api_logs) vẫn nên append + TTL.
Mục tiêu: Hiểu vì sao batch insert quan trọng với kiến trúc MergeTree
Mỗi request → một INSERT riêng
100.000 requests → 100.000 data parts
Metadata overload, merge pressure cực cao
CPU & disk I/O lãng phí cho merge liên tục
Gom 100.000 records thành vài batch lớn
Tạo ít parts, mỗi part có nhiều dữ liệu
Metadata nhẹ, merge nhanh & ít tốn tài nguyên
Compression tốt hơn — dữ liệu đồng dạng liền kề
App
hiện tại
Buffer
Vector / Kafka / App batch
ClickHouse
few large inserts
Nguyên tắc: ClickHouse thích few large inserts — client batch, async_insert (26.3+), hoặc buffer layer (Vector/Kafka) khi cần durability/retry.
Mục tiêu: async_insert và khi nào vẫn cần buffer bên ngoài
async_insert (ClickHouse 26.3+)Từ 26.3 LTS, async_insert bật mặc định — ClickHouse gom small inserts phía server rồi flush thành batch lớn.
Vẫn khuyến nghị: ≥1.000 rows/insert; lý tưởng 10k–100k.
SET async_insert = 1;
SET wait_for_async_insert = 1;
INSERT INTO api_logs FORMAT JSONEachRow
'{...}';
Client batch — app gom 10k rows rồi INSERT
async_insert — client gửi lẻ, server buffer
Vector / Kafka — queue, retry, routing, transform
Không chỉ để batch — cần durable queue, backpressure, filter/transform, tách producer khỏi ClickHouse.
Case study: api_logs nhận log qua Vector batch hoặc async_insert từ agent nhẹ.
Điểm chính: Không còn đúng tuyệt đối “app không INSERT trực tiếp” — chọn client batch, async_insert, hoặc pipeline tùy độ phức tạp.
Mục tiêu: Hiểu ORDER BY không chỉ là sắp xếp kết quả mà là quyết định vật lý dữ liệu
ORDER BY trong ClickHouseORDER BY dùng khi query
Sắp xếp kết quả trả về
Không ảnh hưởng cách lưu trên disk
ORDER BY xác định khi tạo bảng
Sắp xếp vật lý dữ liệu trên disk
Quyết định hiệu năng rất lớn
CREATE TABLE api_logs (
timestamp DateTime,
api LowCardinality(String),
endpoint String,
status UInt16,
duration_ms UInt32
) ENGINE = MergeTree
ORDER BY (api, timestamp);
Case study: api_logs dùng ORDER BY (api, timestamp) — filter theo API trước, range thời gian sau.
Điểm chính: ORDER BY trong ClickHouse = thiết kế schema, không phải thao tác truy vấn. Chọn sai = mất hiệu năng.
Mục tiêu: ORDER BY sắp xếp vật lý dữ liệu trên đĩa
ORDER BY (service, timestamp)| service | timestamp | status |
|---|---|---|
auth | 10:00 | 200 |
auth | 10:01 | 200 |
payment | 10:00 | 200 |
payment | 10:01 | 500 |
user | 10:00 | 200 |
Gom theo service, rồi timestamp
Bỏ qua nhóm auth & user
SELECT *
FROM api_logs
WHERE service = 'payment'
AND timestamp >= now()
- INTERVAL 1 HOUR;
Ý chính: Lọc theo cột đầu ORDER BY → bỏ qua toàn bộ khối dữ liệu không liên quan
Mục tiêu: Phân biệt đúng nghĩa PRIMARY KEY trong OLTP và ClickHouse
| PostgreSQL | ClickHouse | |
|---|---|---|
| Ví dụ | PRIMARY KEY (id) |
PRIMARY KEY (service, timestamp) |
| Giá trị | Duy nhất — mỗi giá trị 1 lần | Cho phép trùng — bình thường |
| Vai trò | Ràng buộc — DB tự kiểm tra | Chỉ mục thưa — mỗi mark ~ hàng nghìn dòng |
| Mục đích | Tra cứu đúng 1 dòng | Bỏ qua khối dữ liệu không cần đọc |
| vs ORDER BY | PRIMARY KEY thường là prefix của ORDER BY — có thể khác khi cần sort rộng hơn index (granule ~8192 rows) |
|
Cách nghĩ: OLTP hỏi "tìm đúng 1 dòng" — ClickHouse hỏi "bỏ được bao nhiêu dữ liệu không cần đọc?"
Mục tiêu: Hiểu cách ClickHouse bỏ qua dữ liệu không cần thiết
flowchart LR
IDX[Primary Index] --> G1[Khối 1]
IDX --> G2[Khối 2]
IDX --> G3[Khối 3]
G1 --> M1[~8192 dòng]
G2 --> M2[~8192 dòng]
G3 --> M3[~8192 dòng]
Kéo để pan · Cuộn để zoom
PostgreSQL: Index B-tree → vị trí từng dòng
ClickHouse: Chỉ mục thưa → khối dữ liệu (~8192 dòng/mark)
Bỏ qua khối dữ liệu không liên quan — không đọc toàn bộ part
Chỉ mục cực nhỏ: vài MB cho tỷ dòng
WHERE timestamp >= '10:30' chỉ đọc các khối dữ liệu từ mark tương ứng trở đi.
Mục tiêu: Hiểu cách PARTITION BY chia dữ liệu và khi nào nên dùng
PARTITION BY — Chia vùng dữ liệuXóa partition thay vì scan từng row
Expire dữ liệu cũ theo partition
Filter thời gian → bỏ partition không liên quan
Sao chép / chuyển cả partition
CREATE TABLE api_logs (
timestamp DateTime64,
client_id String,
api String,
status_code UInt16
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (api, timestamp);
ALTER TABLE api_logs
DROP PARTITION '202601';
Lưu ý: PARTITION BY để quản lý data, không phải index chính — ORDER BY quyết định hiệu suất đọc.
Mục tiêu: Phân biệt rõ vai trò của PARTITION BY và ORDER BY
PARTITION BY vs ORDER BYHai khái niệm khác nhau — xem chi tiết PARTITION BY ở slide trước.
PARTITION BY | ORDER BY | |
|---|---|---|
| Vai trò | Chia vùng / lifecycle | Thứ tự vật lý trong part |
| Ảnh hưởng | TTL, DROP PARTITION, pruning theo key | Tốc độ query, skip index |
| Sai lầm | Quá nhiều partition nhỏ | Nhét cột cardinality cao đầu key |
api_logs — case studyENGINE = MergeTree
PARTITION BY toYYYYMM(timestamp) -- retention 30 ngày
ORDER BY (api, timestamp); -- dashboard filter API
Quy tắc: ORDER BY → query nhanh hay chậm. PARTITION BY → giữ hay xóa dữ liệu theo thời gian.
Mục tiêu: Hiểu tại sao chọn đúng kiểu dữ liệu giúp tối ưu dung lượng và hiệu năng
| Cột | Thường dùng |
|---|---|
status_code | Int64 |
duration_ms | Int64 |
country | String |
timestamp | String |
| Cột | Nên dùng | B |
|---|---|---|
status_code | UInt16 | 2 |
duration_ms | UInt32 | 4 |
country | LowCardinality | ≈1 |
timestamp | DateTime64 | 8 |
Điểm chính: Kiểu nhỏ → dung lượng nhỏ → bộ nhớ đệm hiệu quả. status_code: Int64 → UInt16 giảm từ 8 byte xuống 2 byte (4×); dùng Bool thay UInt8 cho true/false.
Mục tiêu: Sử dụng LowCardinality để tối ưu storage và query cho cột có ít giá trị khác nhau.
Cột có ít giá trị khác nhau
Mã hóa từ điển: lưu số thay vì chuỗi lặp lại
Ví dụ: VN→1, US→2, JP→3
Phù hợp: country, method, service, browser, status_code
CREATE TABLE api_logs (
country LowCardinality(String),
method LowCardinality(String),
service LowCardinality(String),
status_code UInt16,
timestamp DateTime
) ENGINE = MergeTree
ORDER BY (service, timestamp);
Lợi ích: Giảm dung lượng, tăng tốc nén và GROUP BY / lọc trên cột có ít giá trị khác nhau.
Mục tiêu: Hiểu đánh đổi khi dùng Nullable và JSON trong ClickHouse
Tiện khi dữ liệu có thể null
Thêm cột phụ (bitmap null) cho mỗi Nullable
Giảm hiệu quả nén, tốn I/O + CPU
Lưu ý: Ưu tiên giá trị mặc định (0, '') thay Nullable khi có thể.
Hữu ích cho dữ liệu bán cấu trúc
Phù hợp log, sự kiện, telemetry
Truy vấn được trường lồng nhau trực tiếp
Không phải lựa chọn mặc định cho mọi trường hợp
Nguyên tắc: Trường truy vấn thường xuyên → tách thành cột riêng rõ ràng.
Điểm chính: Nullable có chi phí hiệu năng; JSON tiện cho dữ liệu bán cấu trúc — cột riêng luôn nhanh hơn.
Phần 5
Mục tiêu: Giới thiệu dataset sẽ dùng xuyên suốt các bài tập
api_logs| Cột | Kiểu | Mô tả |
|---|---|---|
timestamp | DateTime | Thời điểm request |
client_id | String | Client gọi API |
api | String | Đường dẫn API |
method | String | GET / POST / … |
status_code | UInt16 | HTTP status |
duration_ms | UInt32 | Độ trễ (ms) |
country | String | Quốc gia |
100M+ bản ghi · 7 cột · Engine MergeTree
PARTITION BY toYYYYMM(timestamp)
ORDER BY (api, timestamp)
Dashboard filter timestamp + GROUP BY api/country — không filter client_id.
Điểm chính: Mô phỏng log API thực tế — mỗi request ghi 1 dòng, phù hợp cho các ví dụ truy vấn phía sau.
Mục tiêu: Truy vấn đếm và lọc theo thời gian
Cùng pattern count() — thu hẹp phạm vi đọc bằng WHERE
SELECT count()
FROM api_logs
WHERE timestamp >= today();
SELECT count()
FROM api_logs
WHERE timestamp >= '2024-01-01'
AND timestamp < '2024-02-01';
Điểm chính: ClickHouse dùng partition pruning + primary index để loại bỏ phần lớn dữ liệu trước khi scan — sau đó xử lý các granule còn lại theo vector và song song.
Mục tiêu: GROUP BY cơ bản để phân tích traffic
Cùng pattern — chỉ đổi dimension để nhóm
SELECT api, count() AS requests
FROM api_logs GROUP BY api
ORDER BY requests DESC;
SELECT country, count() AS requests
FROM api_logs GROUP BY country
ORDER BY requests DESC;
SELECT client_id, count() AS requests
FROM api_logs GROUP BY client_id
ORDER BY requests DESC;
GROUP BY + ORDER BY requests DESC để tìm top values.
Mục tiêu: Các hàm aggregation cơ bản trong ClickHouse
avg()
Trung bình
min()
Nhỏ nhất
max()
Lớn nhất
sum()
Tổng
count()
Đếm
SELECT api, avg(duration_ms) AS avg_latency
FROM api_logs
GROUP BY api;
SELECT api,
min(duration_ms) AS min_lat,
max(duration_ms) AS max_lat
FROM api_logs
GROUP BY api;
Mẹo: ClickHouse chỉ đọc cột cần thiết, xử lý song song trên nhiều lõi CPU.
Mục tiêu: Hiểu phân vị (P50, P95, P99) và cách dùng trong giám sát
Trung bình ≈ 303 ms với dữ liệu: 20, 21, 20, 22, 19, 20, 2000 ms — bị lệch bởi 1 giá trị ngoại lệ
SELECT
api,
quantile(0.50)(duration_ms) AS p50,
quantile(0.95)(duration_ms) AS p95,
quantile(0.99)(duration_ms) AS p99
FROM api_logs
GROUP BY api;
| Chỉ số | Ví dụ | Ý nghĩa |
|---|---|---|
| P50 | 120ms | Trung vị — độ trễ điển hình |
| P95 | 300ms | 95% request nhanh hơn giá trị này |
| P99 | 900ms | 1% request chậm nhất |
Dùng P95/P99 cho SLO/SLA thay vì trung bình
Case study: dashboard api_logs — P95 latency và error rate theo từng API.
Mục tiêu: Tổng hợp dữ liệu theo khoảng thời gian cho dashboard
SELECT
toStartOfMinute(timestamp) AS minute,
count() AS requests
FROM api_logs
GROUP BY minute
ORDER BY minute;
| Hàm nhóm thời gian | Dùng khi |
|---|---|
toStartOfMinute() | Dashboard theo thời gian thực |
toStartOfHour() | Giám sát theo giờ |
toStartOfDay() | Báo cáo ngày |
toStartOfWeek() / Month() | Xu hướng dài hạn |
toStartOfInterval(t, INTERVAL 5 MINUTE) | Khoảng thời gian tùy chỉnh |
Mẹo: Dùng toStartOf* để nhóm theo thời gian — kết quả đã được gom theo từng khoảng.
Mục tiêu: Tính toán có điều kiện — đếm, tổng hợp, trung bình theo bộ lọc
So với OLTP: Thay SUM(CASE WHEN …) bằng countIf() — ngắn gọn và nhanh hơn.
SELECT
count() AS total,
countIf(status_code >= 500) AS errors,
countIf(status_code = 404) AS not_found,
avgIf(duration_ms, status_code < 400) AS avg_latency
FROM api_logs;
Điểm chính: Tính tỷ lệ lỗi, độ trễ đã lọc… trong 1 truy vấn — thay vì chạy nhiều truy vấn riêng.
Mục tiêu: Hiểu cách đếm giá trị duy nhất và đánh đổi giữa tốc độ và độ chính xác
| Hàm | Đặc điểm | Khi nào dùng |
|---|---|---|
uniq() |
Ước lượng · nhanh · ít RAM | Dashboard, báo cáo hàng ngày |
uniqExact() |
Chính xác · tốn RAM/thời gian hơn | Tính phí, quyết toán chính xác |
SELECT uniq(user_id) FROM events;
Điểm chính: uniq() = approximate, memory-efficient. uniqExact() = exact khi cần — không gắn một tỷ lệ chính xác cố định.
Mục tiêu: Mô hình raw → aggregate và chọn bảng cho dashboard
Mô hình nhiều tầng
api_logs_raw
Dữ liệu gốc — đầy đủ chi tiết
api_metrics_1m
Tổng hợp theo phút
api_metrics_1h
Tổng hợp theo giờ
api_metrics_1d
Tổng hợp theo ngày
| Khoảng thời gian | Bảng truy vấn |
|---|---|
| 15 phút gần nhất | raw hoặc _1m |
| 7 ngày gần nhất | _1h |
| 3 năm gần nhất | _1d |
Khoảng càng rộng → tầng tổng hợp càng cao → quét ít dữ liệu hơn. Materialized View tự điền bảng tổng hợp khi ghi vào bảng thô.
Điểm chính: Bảng thô cho điều tra chi tiết; bảng tổng hợp giữ dashboard nhanh dù dữ liệu lớn.
Mục tiêu: Materialized View cho dashboard realtime
Chi phí tính toán: lúc truy vấn → lúc ghi
CREATE MATERIALIZED VIEW api_metrics_1m
ENGINE = SummingMergeTree() ORDER BY (api, minute)
AS SELECT toStartOfMinute(timestamp) AS minute, api,
count() AS requests, sum(duration_ms) AS total_duration
FROM api_logs GROUP BY minute, api;
Trigger khi INSERT · không tự backfill · ghi vào target table
Điều quan trọng: Bảng thô giữ chi tiết; bảng tổng hợp phục vụ dashboard — ngàn dòng thay vì tỷ dòng.
Case study: api_logs → api_metrics_1m → Grafana (raw giữ 30 ngày, MV giữ 1 năm).
Mục tiêu: Hiểu SummingMergeTree — engine tự động tổng hợp dữ liệu cộng dồn
SummingMergeTreeDữ liệu có thể cộng dồn (sum) theo key
Ví dụ: request count theo phút, total bytes, revenue
Rows cùng ORDER BY key → gộp nền thành 1 dòng tổng
Trước merge
3 dòng cùng key
Sau merge
1 dòng = tổng
CREATE TABLE api_metrics_1m
(
timestamp DateTime,
api LowCardinality(String),
req_count UInt64,
total_ms UInt64
)
ENGINE = SummingMergeTree((req_count, total_ms))
ORDER BY (api, timestamp);
Merge là bất đồng bộ (eventual): rows chưa chắc đã merge ngay — query thường vẫn dùng SUM() / GROUP BY khi cần kết quả đúng tức thời.
Điểm chính: SummingMergeTree ghi thô → merge nền tự tổng hợp → truy vấn đọc ít dữ liệu hơn.
Mục tiêu: Hiểu cách AggregatingMergeTree lưu và gộp trạng thái trung gian theo từng tầng
| Hàm lưu trạng thái | Hàm gộp | Mục đích |
|---|---|---|
avgState | avgMerge | Trung bình |
uniqState | uniqMerge | Đếm giá trị khác nhau |
quantileState | quantileMerge | Phân vị |
flowchart LR
Raw[Sự kiện] -->|avgState| Min[Phút]
Min -->|merge| Hour[Giờ]
Hour -->|merge| Day[Ngày]
Kéo để pan · Cuộn để zoom
Điểm chính: AggregatingMergeTree lưu trạng thái trung gian — luồng gốc → phút → giờ → ngày.
Mục tiêu: Hiểu kiến trúc luồng ghi log thực tế với ClickHouse
Tách app khỏi DB · lọc/thử lại · 10k log → 1 INSERT
Lưu & tổng hợp · MV realtime · dashboard & API
Điểm chính: Ghi có thể qua client batch, async_insert (server buffer, mặc định từ 26.3) hoặc Vector/Kafka khi cần queue, retry, routing.
flowchart LR
App[Application] -->|logs| Buffer[Vector]
Buffer -->|batch| CH[(ClickHouse)]
CH --> GRF[Grafana]
CH --> API[Analytics]
Kéo để pan · Cuộn để zoom
Mục tiêu: Hiểu kiến trúc dual-database phổ biến trong hệ thống analytics thực tế
flowchart LR
App[Application] --> PG[(PostgreSQL OLTP)]
App -->|logs| CH[(ClickHouse OLAP)]
PG -->|CDC/ETL| CH
CH --> Dash[Grafana / BI]
Kéo để pan · Cuộn để zoom
api_logsApp → PostgreSQL (users, billing)
Logs → ClickHouse (analytics)
CDC (tùy chọn) → enrich dimension
Grafana ← MV từ api_logs
Đã giới thiệu ở Phần 1 — đây là kiến trúc end-to-end của case study.
Điểm chính: OLTP và OLAP bổ sung nhau — không thay thế mù quáng.
ClickHouse Course
Mục tiêu: Thay đổi tư duy khi tối ưu query ClickHouse
Câu hỏi chính: "Truy vấn này khiến ClickHouse phải đọc bao nhiêu dòng, bao nhiêu dung lượng?"
"Có index không?"
Tìm đúng dòng nhanh
Tra cứu chính xác
"Bỏ qua được bao nhiêu dữ liệu?"
Đọc ít dung lượng nhất
Tổng hợp nhanh nhất
Mục tiêu: Đọc ít dữ liệu nhất → xử lý phần còn lại nhanh nhất
Ví dụ: 1 tỷ dòng → lọc + nhóm → 200 dòng kết quả
Mục tiêu: Hiểu cách chỉ mục bỏ qua dữ liệu giúp giảm lượng dữ liệu đọc
Minh họa: Bỏ qua block
Block A: 200, 201, 204 — Bỏ qua
Block B: 500, 502, 503 — Đọc
Block C: 404, 499 — Bỏ qua
Dùng sau khi đã thiết kế primary key và datatype tốt — đây là lớp bổ sung. Query WHERE status_code = 500 → chỉ đọc Block B.
ALTER TABLE api_logs
ADD INDEX idx_status
status_code
TYPE set(100)
GRANULARITY 4;
ALTER TABLE api_logs
ADD INDEX idx_client
client_id
TYPE bloom_filter(0.01)
GRANULARITY 4;
Điểm chính: Chỉ mục bỏ qua giúp bỏ qua khối không liên quan — giảm dung lượng đọc mà không cần thay đổi truy vấn.
Mục tiêu: Projections — secondary sort order trong cùng một bảng
1 bảng api_logs
├─ ORDER BY (api, timestamp) ← chính
├─ projection by timestamp
└─ projection by client_id
Optimizer tự chọn projection phù hợp query — không duplicate full row như bảng phụ.
Case study: dashboard filter timestamp nhưng có query theo client_id → projection thứ hai.
ALTER TABLE api_logs
ADD PROJECTION proj_by_time (
SELECT * ORDER BY timestamp, api
);
ALTER TABLE api_logs
MATERIALIZE PROJECTION proj_by_time;
Điểm chính: Từ 26.1+, projections hoạt động như secondary index — bổ sung khi một ORDER BY không phục vụ hết query patterns.
Mục tiêu: Hiểu cách PREWHERE tối ưu I/O khi query trên bảng rộng
PREWHERE — Đọc cột filter trướcĐọc tất cả cột → filter sau → I/O lãng phí
Đọc cột lọc trước → chỉ đọc cột kết quả trên dòng còn lại
SELECT api, duration_ms
FROM api_logs
PREWHERE status_code = 500
WHERE timestamp >= today();
| Bước | Thao tác |
|---|---|
| 1 | Đọc status_code, timestamp → lọc dòng |
| 2 | Đọc api, duration_ms trên dòng còn lại |
Điểm chính: PREWHERE giảm I/O đĩa — bộ tối ưu có thể tự thêm khi phù hợp.
Mục tiêu: Sử dụng EXPLAIN để kiểm tra kế hoạch truy vấn và hiệu quả bỏ qua dữ liệu
EXPLAIN — Xem kế hoạch truy vấnEXPLAIN indexes = 1
SELECT * FROM api_logs
WHERE service = 'payment' AND timestamp >= today();
-- Kết quả: 12 / 10.000 khối (0,12%) · 48K / 10M dòng
Thứ tự xử lý
Filter ở bước nào
Khối dữ liệu đọc / bỏ qua
Chỉ mục bỏ qua có hiệu lực?
Xử lý song song các part
Giới hạn CPU/RAM
Điểm chính: EXPLAIN indexes = 1 kiểm chứng schema bỏ qua dữ liệu tốt — đọc ít = truy vấn nhanh.
Mục tiêu: Sử dụng system.query_log để tối ưu query thực tế
system.query_logSELECT query, query_duration_ms,
read_rows, read_bytes
FROM system.query_log
WHERE type = 'QueryFinish'
ORDER BY query_duration_ms DESC
LIMIT 20;
| Cột | Ý nghĩa |
|---|---|
query_duration_ms | Thời gian chạy query |
read_rows | Số dòng đọc từ đĩa |
read_bytes | Dung lượng đọc |
memory_usage | RAM sử dụng |
Số dòng đọc quan trọng hơn thời gian chạy!
Truy vấn 0,2s đọc 800M dòng × 100 lần/phút = rất tốn tài nguyên.
Điểm chính: Dùng số liệu thật — số dòng đọc, dung lượng đọc, RAM — để tối ưu.
Mục tiêu: Sử dụng system.parts để giám sát storage và phát hiện vấn đề
system.partsPart dữ liệu đang hoạt động · quá nhiều → tăng kích thước mỗi lần ghi
Tổng dòng mỗi bảng · so sánh kỳ vọng
bytes_on_disk / compressed · theo dõi hiệu quả nén
SELECT
table,
count() AS parts,
sum(rows) AS rows,
formatReadableSize(sum(bytes_on_disk))
FROM system.parts
WHERE active
GROUP BY table;
Ghi chú: Quá nhiều part nhỏ = dấu hiệu cần tăng kích thước mỗi lần ghi hoặc dùng bộ đệm (Vector/Kafka)
Mục tiêu:
Đọc mọi cột, kể cả cột không dùng
Phá vỡ ưu thế của lưu theo cột
Luôn chỉ chọn cột cần thiết
Nhóm theo cột hàng triệu giá trị khác nhau
Kết quả trả về quá lớn, tốn RAM
Xem xét tổng hợp trước hoặc dùng ước lượng
ClickHouse vẫn phải quét và sắp xếp toàn bộ trước
Nếu khóa sắp xếp khớp, không cần quét toàn bộ
Kiểm tra EXPLAIN để xác nhận
Quét tỷ dòng bảng thô mỗi lần tải dashboard
Dùng bảng tổng hợp trước (MV)
Tổng hợp theo phút/giờ phù hợp khoảng truy vấn
Điểm chính: Luôn đặt câu hỏi: truy vấn đọc bao nhiêu dung lượng? Có thể tổng hợp trước không?
Sharding · Replication · Cluster
Mục tiêu: Biết khi nào cần chuyển từ một máy chủ sang cluster
| # | Chiến lược mở rộng | Ghi chú / Máy chủ mạnh |
|---|---|---|
| ① | Bắt đầu với 1 máy chủ mạnh | 32–64 lõi CPU · 128 GB RAM · NVMe |
| ② | Tối ưu schema | Kiểu dữ liệu, ORDER BY, LowCardinality |
| ③ | Tối ưu truy vấn | PREWHERE, chỉ mục bỏ qua, tổng hợp |
| ④ | Nâng cấp phần cứng | Thêm CPU, RAM, chuyển NVMe |
| ⑤ | Mở rộng cluster | Chỉ khi đã tối ưu schema & truy vấn |
Sai lầm: Nhầm "ClickHouse = phải cluster ngay" — hãy tối ưu trước khi thêm máy chủ!
Quy tắc: Nâng cấp phần cứng trước, mở rộng cluster sau — đọc ít dữ liệu nhất trước khi nghĩ đến thêm máy chủ.
Mục tiêu: Hiểu cách ClickHouse phân mảnh dữ liệu qua sharding
Mỗi shard = một phần dữ liệu trên node riêng
Hash function xác định shard chứa dữ liệu
Query song song trên shard → tăng throughput
Thêm shard = thêm dung lượng + CPU
Ví dụ: cityHash64(user_id) % 3 — mỗi shard ~1/3 dữ liệu.
flowchart LR
C[Client] --> R{Router}
R --> S1[Shard 1]
R --> S2[Shard 2]
R --> S3[Shard 3]
S1 --> M[Merge]
S2 --> M
S3 --> M
Kéo để pan · Cuộn để zoom
Mục tiêu: Hiểu replication trong ClickHouse — sao chép dữ liệu để đảm bảo availability.
Replication = sao chép dữ liệu cho cùng một shard
Mỗi replica chứa cùng một tập dữ liệu
Nếu replica A chết, replica B tiếp tục phục vụ
Replication mặc định async / eventually consistent — cần insert_quorum nếu muốn guarantee mạnh hơn
Shard 1
Replica A
data ✗
Lỗi!
Replica B
data ✓
Vẫn hoạt động
Replica B tiếp tục phục vụ khi A gặp sự cố
Điểm chính: Replication ≠ Sharding — Sharding chia dữ liệu để scale, replication sao chép để HA.
Mục tiêu: Phân biệt sharding (scale) và replication (HA)
Chia dữ liệu thành nhiều shard
Mỗi shard giữ một phần dữ liệu khác nhau
Tăng dung lượng & thông lượng
Sao chép cùng dữ liệu lên nhiều node
Mỗi replica = bản sao đầy đủ
Tăng khả năng sẵn sàng
3 shards × 2 replicas = 6 nodes
Shard 1
R1
R2
Shard 2
R1
R2
Shard 3
R1
R2
Sharding chia để mở rộng · Replication sao chép để HA · Triển khai thực tế dùng cả hai.
Mục tiêu: Hiểu cách Distributed Table quản lý query xuyên cluster
CREATE TABLE events
ENGINE = Distributed(
cluster_3shard,
default,
events_local,
cityHash64(user_id)
);
Distributed engine thường là lớp routing/proxy — dữ liệu thực nằm trên local tables của từng shard
Lưu ý: Distributed table không lưu dữ liệu — chỉ điều phối query tới bảng local trên shard.
flowchart TB
C[Client] --> D[events Distributed]
D --> S1[Shard 1 local]
D --> S2[Shard 2 local]
D --> S3[Shard 3 local]
S1 --> R[Merge results]
S2 --> R
S3 --> R
R --> C
Kéo để pan · Cuộn để zoom
Mục tiêu: Replicate dữ liệu trong cluster
Bọc MergeTree — insert 1 replica, sync sang replica khác.
Thay ZooKeeper — metadata replication + leader election.
flowchart TB
K[Keeper] -.-> R1[Replica 1]
K -.-> R2[Replica 2]
K -.-> R3[Replica 3]
R1 <-->|sync| R2
R2 <-->|sync| R3
Kéo để pan · Cuộn để zoom
ClickHouse Course
Mục tiêu: Phân biệt khi nào dùng ClickHouse vs PostgreSQL
Không lặp bảng so sánh đầy đủ — dùng decision guide theo workload.
| Tình huống | Chọn | Ví dụ case study |
|---|---|---|
| CRUD, transaction, FK | PostgreSQL | Users, API keys, billing |
| Aggregate tỷ dòng log | ClickHouse | api_logs P95, error rate |
| Full-text search phức tạp | Elasticsearch / OpenSearch | Log message search (Phần 9) |
| Report SQL trên warehouse | Snowflake / BigQuery | BI cross-team (Phần 9) |
Điểm chính: Production thường là polyglot persistence — PG + CH cho api_logs là pattern phổ biến nhất.
Mục tiêu: Chọn đúng công cụ cho log/search analytics
| Workload | Elasticsearch | ClickHouse |
|---|---|---|
| Search / relevance / fuzzy | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| Exact / token / phrase search | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ (FTS GA 2026) |
| Filter + aggregate khối lượng lớn | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Nén & chi phí lưu trữ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| SQL analytics | ⭐⭐ | ⭐⭐⭐⭐⭐ |
Chọn Elasticsearch khi:
Search product — relevance ranking, fuzzy, ecosystem search
Chọn ClickHouse khi:
Observability analytics — filter + aggregate (+ text search cơ bản)
Điểm chính: Log analytics = filter + aggregate → ClickHouse. Search-centric product → Elasticsearch/OpenSearch.
Mục tiêu: So sánh ClickHouse với Cloud Data Warehouse phổ biến
| Tiêu chí | ClickHouse (OSS / Cloud) | BigQuery / Snowflake / Redshift |
|---|---|---|
| Vận hành | Tự quản lý hạ tầng · linh hoạt tuning | Managed service · ít vận hành hơn |
| Độ trễ | Thường tốt cho real-time khi tune đúng | Phụ thuộc workload |
| Elasticity | Scale theo cluster thiết kế | Scale tài nguyên linh hoạt |
| Chi phí | Hạ tầng có thể dự đoán | Pay-per-use · theo dõi usage |
Điểm chính: So sánh apples-to-apples: ClickHouse Cloud vs managed DW — hoặc OSS self-hosted vs tự vận hành cluster analytics.
Mục tiêu: Mô tả bài toán thực tế — nền tảng monitor API multi-service
Bối cảnh: Nhiều service — mỗi request = 1 dòng log · 300 users · 20 services · 50M logs/ngày
Dashboard: lưu lượng, độ trễ P95/P99, tỷ lệ lỗi, top API/quốc gia/client · Sau 1 năm: ~18,25 tỷ log ≈ 5,5 TB dữ liệu thô
Mô hình: PostgreSQL (OLTP) → Vector/CDC → ClickHouse (OLAP) → Grafana
flowchart TB
BE[Backend 20 svc] --> V[Vector batch]
V --> CH[api_logs_raw]
CH --> MV[MV 1m / 1h / 1d]
MV --> GF[Grafana]
Kéo để pan · Cuộn để zoom
Mục tiêu: Schema đề xuất cho bảng api_logs trong case study
api_logs| Cột | Kiểu | Ghi chú |
|---|---|---|
timestamp | DateTime64 | Thời điểm request |
client_id | LowCardinality(String) | Client gọi API |
api | String | Đường dẫn API |
method | LowCardinality(String) | GET / POST / … |
status_code | UInt16 | HTTP status |
duration_ms | UInt32 | Độ trễ (ms) |
country | LowCardinality(String) | Quốc gia |
ENGINE MergeTree · PARTITION BY toYYYYMM(timestamp)
ORDER BY (api, timestamp) — khớp dashboard: filter timestamp, GROUP BY api/country
LowCardinality / UInt — giảm dung lượng cột lặp lại và số nguyên
Append-only — mỗi request 1 dòng.
Mục tiêu: Các truy vấn chính phục vụ dashboard api_logs
Request / phút — lưu lượng theo phút (24h)
SELECT toStartOfMinute(timestamp) AS t, count() AS requests
FROM api_logs
WHERE timestamp >= now() - INTERVAL 1 DAY
GROUP BY t ORDER BY t;
Tỷ lệ lỗi — top API theo HTTP 5xx
SELECT api, countIf(status_code >= 500) AS errors,
round(errors / count(), 4) AS error_rate
FROM api_logs WHERE timestamp >= today()
GROUP BY api ORDER BY error_rate DESC;
P95 / P99 — độ trễ phân vị theo API
SELECT api, quantile(0.95)(duration_ms) AS p95,
quantile(0.99)(duration_ms) AS p99
FROM api_logs WHERE timestamp >= today() GROUP BY api;
API chậm — top 10 avg latency
SELECT api, avg(duration_ms) AS avg_ms
FROM api_logs WHERE timestamp >= today()
GROUP BY api ORDER BY avg_ms DESC LIMIT 10;
Top quốc gia — top 10 theo request
SELECT country, count() AS cnt
FROM api_logs WHERE timestamp >= today()
GROUP BY country ORDER BY cnt DESC LIMIT 10;
PARTITION BY toYYYYMM(timestamp) + ORDER BY (api, timestamp) — khớp các query dashboard bên trên.
Mục tiêu: Thiết kế luồng tổng hợp trước cho dashboard nhanh
Bảng thô
Mục đích:
Điều tra chi tiết, debug, xem log
Khoảng truy vấn:
15 phút gần nhất → theo thời gian thực
Tổng hợp theo phút
Mục đích:
Dashboard theo thời gian thực, cảnh báo, giám sát SLO
Khoảng truy vấn:
1 ngày → 7 ngày gần nhất
Theo giờ / ngày
Mục đích:
Báo cáo dài hạn, phân tích xu hướng, báo cáo quản lý
Khoảng truy vấn:
3 tháng → 3 năm gần nhất
Dùng Materialized View tự động tổng hợp khi ghi dữ liệu
SummingMergeTree / AggregatingMergeTree
Mục tiêu: Chiến lược giữ dữ liệu theo tầng — cân bằng truy vấn và chi phí
7–30 ngày
Chi tiết từng request — debug
6–12 tháng
Dashboard realtime
1–3 năm
Report dài hạn, YoY
Nguyên tắc: Raw giữ ngắn, aggregate giữ dài. Dùng PARTITION BY toYYYYMM() để xóa cực nhanh.
ALTER TABLE api_logs
MODIFY TTL timestamp
+ INTERVAL 30 DAY;
ALTER TABLE api_logs
DROP PARTITION '202601';
Phần 11
Mục tiêu: Checklist khi thiết kế bảng ClickHouse
Append hay update?
Event log → append + TTL. CDC → ReplacingMergeTree.
Query filter & ORDER BY?
Filter chính → đầu ORDER BY. api_logs: (api, timestamp).
Retention & PARTITION BY?
Range thời gian → partition theo ngày/tháng; DROP/TTL partition.
Datatype & cardinality?
LowCardinality cho enum; DateTime64; tránh String cho số.
MV / projection / TTL?
MV cho dashboard; projection cho query pattern thứ hai; TTL giảm chi phí.
Nguyên tắc: Bắt đầu từ query → filter → ORDER BY / PARTITION BY / datatype. Áp dụng lại cho api_logs từ đầu bài.
Mục tiêu: Checklist khi query chậm hoặc cần tối ưu performance
Truy vấn đọc bao nhiêu dòng/dung lượng?
Xem read_rows, read_bytes trong system.query_log
Có đọc cột thừa không?
Chỉ SELECT cột cần thiết, tránh SELECT *
Loại bỏ phân vùng hoạt động chưa?
Filter theo partition key → partition pruning; sorting key giúp skip granule
Khóa sắp xếp khớp bộ lọc không?
Lọc trên cột trái của ORDER BY → bỏ qua khối dữ liệu hiệu quả
GROUP BY có quá nhiều giá trị khác nhau?
Quá nhiều nhóm → bảng kết quả lớn → dùng GROUP BY có chọn lọc
Có thể tổng hợp trước không?
Materialized View / bảng tổng hợp → đẩy tính toán sang lúc ghi dữ liệu
Nguyên tắc: Đọc ít dữ liệu nhất → xử lý nhanh nhất → tổng hợp trước nếu có thể.
Tổng kết: 7 điều quan trọng nhất cần ghi nhớ
1. ClickHouse là OLAP — không phải OLTP
2. Lưu theo cột là nền tảng của mọi tốc độ
3. MergeTree là engine quan trọng nhất
4. ORDER BY quyết định hiệu năng
5. Ghi theo lô tốt hơn ghi từng dòng
6. Materialized View giúp dashboard theo thời gian thực
7. Không dùng ClickHouse như OLTP database
Cách nghĩ PostgreSQL
"Làm sao tìm đúng dòng
nhanh nhất?"
Cách nghĩ ClickHouse
"Làm sao đọc ít dữ liệu nhất,
và xử lý nhanh nhất?"
Công thức
Lưu theo cột + Nén dữ liệu + Chỉ mục thưa + Xử lý theo vector + Song song + Tổng hợp trước
Phân tích cực nhanh