查询并发控制与资源隔离

系统讲解 ClickHouse 的工作负载调度与资源隔离:max_concurrent_queries 并发上限、Quotas 配额限流、Settings Profiles 差异化配置、查询优先级与 Workload 分类、内存/CPU/IO 隔离手段、多租户方案,以及防止单条大查询拖垮集群的落地实践与常见陷阱。

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 的负载物理隔开。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「数据库」更多文章

  1. 容量规划与成本优化
  2. 从 MySQL/PostgreSQL 迁移的 SQL 差异
  3. 轻量删除更新与变更语义