这是《ClickHouse 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 本章把常用查询能力串起来:投影、过滤、聚合、窗口、排序、去重、格式输出和查询生命周期。目标是写出可预测成本的查询,而不只是得到结果。
6.1 SELECT 执行结构
SELECT city_id, count() AS pv
FROM analytics.events_local
WHERE event_date = today()
GROUP BY city_id
HAVING pv > 100
ORDER BY pv DESC
LIMIT 20;
概念执行顺序:
FROM -> WHERE -> GROUP BY -> 聚合 -> HAVING -> SELECT -> ORDER BY -> LIMIT
ClickHouse 会做多种优化,但写 SQL 时仍应先通过过滤条件减少扫描量。
6.2 查看查询成本
SELECT
query_id,
event_time,
query_duration_ms,
read_rows,
read_bytes,
result_rows,
memory_usage,
exception
FROM system.query_log
WHERE type = 'QueryFinish'
ORDER BY event_time DESC
LIMIT 20;
关键指标:
| 指标 | 含义 |
|---|---|
| read_rows | 读取行数 |
| read_bytes | 读取字节数 |
| result_rows | 返回行数 |
| query_duration_ms | 总耗时 |
| memory_usage | 内存峰值 |
| ProfileEvents | 更细粒度事件 |
优化优先看扫描量和分区剪枝,而不是只看总耗时。
6.3 聚合
SELECT
event_type,
count() AS events,
countDistinct(user_id) AS uv,
round(sum(amount), 2) AS gmv,
round(avg(amount), 2) AS avg_amount
FROM analytics.events_local
WHERE event_date = today()
GROUP BY event_type;
常用组合:
| 表达式 | 说明 |
|---|---|
count() |
精确计数,通常比 count(*) 更符合习惯 |
sum(x) |
求和,注意空组结果 |
min(x) / max(x) |
极值 |
uniq(x) |
近似去重 |
uniqExact(x) |
精确去重 |
quantile(0.95)(x) |
分位数 |
topK(10)(x) |
高频值 |
6.4 条件与空值
SELECT
if(amount > 0, 'paid', 'free') AS event_scope,
multiIf(
amount >= 1000, 'high',
amount >= 100, 'middle',
'low'
) AS amount_level,
coalesce(city_name, 'unknown') AS city
FROM analytics.event_view;
逻辑条件:
SELECT count()
FROM analytics.events_local
WHERE event_date BETWEEN '2026-08-01' AND '2026-08-31'
AND (city_id = 1 OR city_id = 2)
AND event_type NOT IN ('spam');
6.5 子查询与 CTE
CTE:
WITH daily AS (
SELECT
event_date,
city_id,
count() AS pv
FROM analytics.events_local
WHERE event_date >= today() - 7
GROUP BY event_date, city_id
)
SELECT
city_id,
sum(pv) AS week_pv,
max(pv) AS max_day_pv
FROM daily
GROUP BY city_id
ORDER BY week_pv DESC;
CTE 提升可读性,但不等于自动缓存。复杂公共子查询是否能复用要结合执行计划确认。
6.6 窗口函数
SELECT
event_date,
city_id,
pv,
row_number() OVER (PARTITION BY event_date ORDER BY pv DESC) AS rn,
rank() OVER (PARTITION BY event_date ORDER BY pv DESC) AS rk,
sum(pv) OVER (PARTITION BY event_date) AS day_total
FROM (
SELECT event_date, city_id, count() AS pv
FROM analytics.events_local
WHERE event_date >= today() - 3
GROUP BY event_date, city_id
);
分组 TopN:
SELECT event_date, city_id, pv
FROM (
SELECT *,
row_number() OVER (PARTITION BY event_date ORDER BY pv DESC) AS rn
FROM (
SELECT event_date, city_id, count() AS pv
FROM analytics.events_local
GROUP BY event_date, city_id
)
)
WHERE rn <= 10;
窗口函数通常在聚合结果上使用。直接对超大数据集开窗口会造成大量排序和内存消耗。
6.7 数组与漏斗
展开数组:
SELECT
user_id,
arrayJoin(event_ids) AS event_id
FROM analytics.user_events;
漏斗:
SELECT
level,
count() AS users
FROM (
SELECT
user_id,
windowFunnel(86400)(
toUnixTimestamp(event_time),
event_type = 'view',
event_type = 'cart',
event_type = 'pay'
) AS level
FROM analytics.events_local
WHERE event_date = today()
GROUP BY user_id
)
GROUP BY level
ORDER BY level;
留存:
SELECT
sum(retention(event_type = 'install', event_date = today())) AS d1,
sum(retention(event_type = 'install', event_date = today() + 1)) AS d2
FROM analytics.events_local;
漏斗和留存函数的窗口定义、事件顺序和时间语义必须在业务侧先明确。
6.8 关联查询
内连接:
SELECT e.event_id, c.city_name
FROM analytics.events_local AS e
INNER JOIN analytics.city_dim AS c
ON e.city_id = c.city_id
WHERE e.event_date = today();
字典替代小维表:
SELECT
event_id,
dictGet('analytics.city_dict', 'city_name', toUInt64(city_id)) AS city_name
FROM analytics.events_local
WHERE event_date = today();
字典适合小而更新可控的维表,不适合频繁全量刷新的超大维度。
6.9 查询限制
用户级限制示例:
ALTER USER app_user
SETTINGS max_execution_time = 30, max_memory_usage = 2000000000;
语句级限制:
SELECT count()
FROM analytics.events_local
SETTINGS max_threads = 8, max_execution_time = 30;
常用保护:
| 参数 | 目的 |
|---|---|
| max_execution_time | 限制执行时间 |
| max_memory_usage | 限制单查询内存 |
| max_rows_to_read | 限制扫描行数 |
| max_bytes_to_read | 限制扫描字节 |
| readonly | 禁止写操作 |
| max_result_rows | 限制返回规模 |
6.10 结果格式
clickhouse-client --query "
SELECT number, number * 2
FROM system.numbers
LIMIT 3
FORMAT PrettyCompact
"
服务返回 JSON:
SELECT count()
FROM analytics.events_local
FORMAT JSONEachRow;
大结果集导出建议用 clickhouse-client 或对象存储,不要通过 HTTP 一次性拉取到应用内存。
6.11 查询故障排查
慢查询清单:
1. 是否有分区条件?
2. 条件是否作用在排序键前缀?
3. 是否扫描了大量不需要的列?
4. 是否高基数 group by 或精确去重?
5. 是否大表 JOIN?
6. 是否窗口函数作用在明细上?
7. 是否返回超大结果集?
8. 是否资源被其他查询占用?
查看运行中查询:
SELECT query_id, elapsed, read_rows, memory_usage, query
FROM system.processes
ORDER BY elapsed DESC;
取消查询:
KILL QUERY WHERE query_id = 'target-query-id';
本章小结
查询 ClickHouse 时,先让分区剪枝和排序键发挥作用,再选择合适聚合与去重函数。窗口函数、精确去重和大 JOIN 都是高成本能力,应配合过滤、预聚合、物化视图和资源限制使用。system.query_log 是性能治理的起点。
思考题
- 如何判断一个查询是否命中分区剪枝?
- uniq 和 uniqExact 如何选择?
- 窗口函数为什么常放在聚合之后?
- 字典 JOIN 适合什么维表?
- 你会给报表用户设置哪些查询限制?