1. 为什么需要工作负载调度
ClickHouse 的单机性能极强,但它的资源调度是「尽力而为」的:默认情况下,一条大查询可以吃掉几乎所有内存和 CPU 线程,把同时运行的其它查询饿死。当同一个集群同时服务实时看板(要求低延迟)、离线报表(可跑很久)、数据回填(大批量)时,如果没有隔离机制,就会出现「看板卡死、报表排队」的经典事故。
工作负载调度要解决三类问题:
| 问题 | 目标 | 主要手段 |
|---|---|---|
| 并发过载 | 限制同时在跑的查询数 | max_concurrent_queries |
| 资源滥用 | 限制单用户/单查询的资源消耗 | Quotas、Settings Profiles |
| 优先级倒置 | 让重要查询优先拿到资源 | 查询优先级、Workload 分类 |
ClickHouse 提供的是一套分层控制:服务端全局 → 用户/配额 → 查询级 settings。理解这三层的覆盖关系,才能设计出稳定的隔离策略。
2. 并发控制:max_concurrent_queries
2.1 服务端全局上限
<!-- /etc/clickhouse-server/config.d/concurrency.xml -->
<clickhouse>
<max_concurrent_queries>200</max_concurrent_queries>
<max_concurrent_queries_for_user>50</max_concurrent_queries_for_user>
<max_concurrent_queries_for_all_users>150</max_concurrent_queries_for_all_users>
</clickhouse>
max_concurrent_queries:整个实例同时执行的查询上限,超过则新查询排队等待;max_concurrent_queries_for_user:单个用户的并发上限;max_concurrent_queries_for_all_users:所有用户合计的上限,用于防止某个用户开满全局额度。
这三个值的关系是「先到先得 + 总量封顶」:单用户上限防止独占,全局上限保护实例。
2.2 观察并发水位
SELECT
count() AS running,
user,
countIf(query_kind = 'Select') AS selects
FROM system.processes
GROUP BY user
ORDER BY running DESC;
system.processes 只显示当前正在执行的查询。若 running 长期贴着 max_concurrent_queries,说明集群处于饱和状态,需要扩容或优化慢查询。等待中的查询不会出现在 system.processes 里,而是在客户端排队——这也是「查询超时」往往先于「CPU 打满」出现的原因。
2.3 排队与超时
当并发达上限,新查询进入队列,由 max_execution_time 与客户端的 receive_timeout 共同决定何时放弃。生产上应保证:
SET max_execution_time = 60; -- 单查询最长执行 60 秒
SET max_queue_size = 100; -- 队列长度上限
队列长度不设限会导致连接堆积,最终触发客户端超时雪崩。
2.4 并发与线程池的关系
并发上限控制的是「同时执行的查询数」,而每个查询内部还会按 max_threads 申请执行线程。二者相乘才是实际的线程占用:
线程峰值 ≈ 并发查询数 × max_threads
如果 max_concurrent_queries = 200 且 max_threads = 16,理论上需要 3200 个线程——远超单机核数,会导致剧烈的线程切换开销。因此并发上限与 max_threads 必须一起设:核数有限时,宁可提高并发数、降低单查询线程数,让调度器更细粒度地复用 CPU。
3. 资源配额:Quotas
3.1 创建配额
Quota(配额)按时间窗口限制用户的资源使用量:
CREATE QUOTA dashboard_quota
FOR INTERVAL 1 hour MAX queries = 1000, errors = 100, result_rows = 1000000000
TO dashboard_user;
CREATE QUOTA etl_quota
FOR INTERVAL 1 hour MAX queries = 10000, read_bytes = 100000000000
TO etl_user;
可限制的维度包括 queries、errors、result_rows、read_rows、read_bytes、execution_time。MAX 表示硬上限,超出即拒绝;KEYED BY 可以做按维度的细粒度配额。
3.2 按用户维度细分
CREATE QUOTA tenant_quota
FOR INTERVAL 1 day MAX queries = 100000, read_bytes = 500000000000
TO ALL EXCEPT admin
KEYED BY user_name;
KEYED BY user_name 让每个用户各自独立计算配额,适合多租户场景。查看当前用量:
SELECT
quota_name,
quota_key,
is_current,
queries,
max_queries,
read_bytes,
max_read_bytes
FROM system.quotas_usage
WHERE is_current = 1;
3.3 配额与并发的关系
配额管的是累计消耗(一小时能跑多少查询),并发管的是瞬时占用(同时能跑几个)。两者互补:配额防止某用户一天跑十万次查询把集群占满,并发上限防止某用户瞬间开一百个查询。
4. Settings Profiles 与用户隔离
4.1 差异化配置
不同角色需要不同的默认 settings。CREATE SETTINGS PROFILE 可以把一组参数打包:
CREATE SETTINGS PROFILE dashboard_profile
SETTINGS
max_threads = 8,
max_memory_usage = 4000000000,
max_execution_time = 30,
max_concurrent_queries_for_user = 20
TO dashboard_user;
CREATE SETTINGS PROFILE etl_profile
SETTINGS
max_threads = 32,
max_memory_usage = 200000000000,
max_execution_time = 3600,
max_concurrent_queries_for_user = 5
TO etl_user;
关键隔离点:
max_threads:限制单查询使用的 CPU 线程数,防止一条查询吃满所有核;max_memory_usage:单查询内存上限,超限报错或触发 spill;max_execution_time:硬超时,防止失控查询;max_concurrent_queries_for_user:用户级并发。
4.2 读写分离的 profile
-- 只读用户:禁止写入
CREATE SETTINGS PROFILE readonly_profile
SETTINGS readonly = 1
TO readonly_user;
-- 限制写入吞吐
CREATE SETTINGS PROFILE writer_profile
SETTINGS max_insert_threads = 4,
max_insert_block_size = 1048576
TO writer_user;
readonly = 1 从 settings 层阻断写入,比靠权限体系更彻底。用户、角色与权限体系的完整模型见 /clickhouse-access-security/,这里的 profile 是它的性能侧补充。
4.3 查看生效值
SELECT name, value, changed, profile
FROM system.settings
WHERE changed = 1;
排查「为什么这个用户跑得慢」,先看它的 settings profile 是否把 max_threads 或 max_memory_usage 设得过低。
5. 查询优先级与调度
5.1 查询级优先级
ClickHouse 支持为查询设置优先级(数值越小越优先):
SELECT ... SETTINGS priority = 1; -- 高优先级
在并发排队时,高优先级查询会先被调度执行。默认优先级为 0。生产上常见的做法是:给交互式看板设置 priority = 1,给 ETL 回填设置 priority = 10,这样即使在高峰期,看板也能插队执行。
5.2 Workload 分类
较新版本引入了 Workload 概念,把查询按业务类型分类并施加不同的资源约束:
CREATE WORKLOAD dashboard_workload SETTINGS max_concurrent_queries = 50;
CREATE WORKLOAD etl_workload SETTINGS max_concurrent_queries = 10;
-- 把用户/查询归入某个 workload
CREATE SETTINGS PROFILE ... SETTINGS workload = 'dashboard_workload' TO dashboard_user;
Workload 是比 Quota 更细的调度单元,可以理解为「带资源上限的执行队列」。启用前务必确认版本支持,否则语句会直接报错。
5.3 优先级与公平性
需要澄清:ClickHouse 不提供严格的 CPU 时间片公平调度。优先级影响的是排队顺序,一旦查询开始执行,它仍按 max_threads 抢 CPU。真正的隔离必须靠 settings profile 的资源上限,而不是优先级。优先级解决「谁先跑」,资源上限解决「跑起来能占多少」。
6. 资源隔离的三个维度
6.1 内存隔离
内存是最容易出事故的资源。三层防线:
-- 1) 单查询上限
SET max_memory_usage = 10000000000; -- 10 GB
-- 2) 单查询在集群层面的上限(分布式查询)
SET max_memory_usage_for_all_queries = 50000000000;
-- 3) 内存超限时的行为
SET max_bytes_before_external_group_by = 5000000000; -- 触发 spill
SET max_bytes_before_external_sort = 5000000000;
max_memory_usage 触发的是报错,而 max_bytes_before_external_* 触发的是落盘。前者粗暴但可控,后者平滑但有磁盘 IO 代价。二者的权衡见 /clickhouse-memory-management-spill/。
6.2 CPU 隔离
SET max_threads = 16; -- 单查询线程上限
SET max_threads_for_indexes = 4; -- 索引读取线程
max_threads 是 CPU 隔离的主开关。把大查询的 max_threads 调低,可以把 CPU 让给更多的小查询——这是一种「牺牲单查询延迟、换取整体吞吐」的权衡。反之,报表类查询可以给高 max_threads 但低优先级。
6.3 IO 隔离
ClickHouse 对磁盘 IO 的直接隔离手段有限,主要靠:
- 冷热分层:把大扫描查询导向对象存储,避免抢占热数据的 NVMe;
max_read_buffer_size/min_bytes_to_use_direct_io:控制读缓冲;- 后台合并限流:
background_pool_size控制合并线程,防止合并把 IO 吃满。
如果 IO 是瓶颈,最有效的往往是分实例部署——把 ETL 集群与看板集群物理分开,而不是在同一实例里做软隔离。
6.4 隔离效果验证
配置完隔离后必须验证,否则可能只是「以为隔离了」。验证方法是主动制造压力并观察:
-- 终端 A:制造高内存查询
SELECT count() FROM numbers(10000000000) SETTINGS max_memory_usage = 20000000000;
-- 终端 B:观察是否被限住、是否影响其它查询
SELECT
query_id, user, elapsed,
formatReadableSize(memory_usage) AS mem,
substring(query, 1, 60) AS q
FROM system.processes ORDER BY memory_usage DESC;
再对比不同 profile 用户跑同一查询的资源占用,确认 max_threads、max_memory_usage 确实生效。若发现某个用户能突破限制,检查是不是查询内显式 SET 覆盖了 profile。
7. 多租户隔离方案
一个可落地的多租户模板:
-- 每个租户一个用户 + profile + quota
CREATE USER tenant_a IDENTIFIED BY 'xxx'
SETTINGS PROFILE tenant_profile;
CREATE SETTINGS PROFILE tenant_profile
SETTINGS max_threads = 8, max_memory_usage = 5000000000,
max_execution_time = 60, readonly = 1
TO tenant_a;
CREATE QUOTA tenant_a_quota
FOR INTERVAL 1 day MAX queries = 100000, read_bytes = 100000000000
TO tenant_a;
-- 行级隔离:每个租户只能看自己的数据
CREATE ROW POLICY tenant_a_policy ON events
FOR SELECT USING tenant_id = 'a'
TO tenant_a;
CREATE ROW POLICY 是租户数据隔离的关键,它把租户过滤条件下推到查询计划,租户无法绕过。三层组合——settings profile 限资源、quota 限用量、row policy 限数据——构成完整的多租户边界。
7.1 多租户用量监控
租户上线后要持续监控各自的资源消耗,才能及时发现「大户」并调整配额:
SELECT
user,
count() AS queries,
formatReadableSize(sum(read_bytes)) AS read,
round(sum(query_duration_ms) / 1000) AS total_sec,
round(avg(query_duration_ms)) AS avg_ms
FROM system.query_log
WHERE type = 'QueryFinish'
AND event_time >= now() - INTERVAL 1 DAY
GROUP BY user
ORDER BY sum(read_bytes) DESC;
按 user 维度聚合后,可以算出每个租户的 IO、耗时占比,作为配额调整与计费的依据。若某租户长期贴着 quota 上限,说明要么配额太小,要么它的查询需要优化。
7.2 租户隔离的边界
需要清醒认识软隔离的极限:所有租户共享同一份 CPU、内存与磁盘 IO,隔离只能限制上限,不能保证下限。当一个租户的查询把 IO 打满时,其它租户的查询即使资源配额充足,也会因为排队而变慢。因此对 SLA 差异极大的业务(如付费客户的实时看板与免费用户的批量导出),最可靠的方案仍是物理分实例,把软隔离用在同类业务的内部细分上。
8. 常见陷阱
- 只设并发不设内存:并发上限挡不住单条查询吃光内存,必须同时设
max_memory_usage。 - Quota 窗口过小:一小时配额在业务高峰被瞬间打满,导致后续查询全部被拒,应根据峰值评估窗口。
- 依赖 priority 做隔离:优先级只影响排队,不影响执行时的资源占用。
- 忽略
max_concurrent_queries_for_all_users:只设单用户上限,多个用户仍可合计占满实例。 - settings profile 与查询内 SETTINGS 冲突:查询内显式
SET的值优先级更高,若 profile 想强制约束,需用CONST或只读约束,否则用户可自行覆盖。
小结
ClickHouse 的工作负载调度是一套分层体系:并发上限管「同时能跑几个」,Quota 管「一段时间能跑多少」,Settings Profile 管「每个查询能占多少资源」,优先级与 Workload 管「谁先跑」。真正稳定的隔离靠的是资源上限而非优先级——给每类业务配一个 profile(内存、线程、超时),配一个 quota(用量),必要时再加 row policy(数据)。当软隔离不够时,最彻底的方案仍然是分实例部署,把不同 SLA 的负载物理隔开。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。