先说结论

ClickHouse 不是“不能并发”,而是不擅长让大量资源密集型 OLAP 查询同时无约束运行。它通过多线程并行加速单条查询,因此单查询性能很强,但高并行度会放大多查询之间的资源竞争。

并发能力必须结合查询重量描述。几百个只读少量数据的查询与几十个扫描数十亿行的聚合,压力完全不同。

OLTP 与 OLAP 的并发模型差异

MySQL 常见请求通过 B+Tree 定位几行,执行时间短、单请求资源较小,因此能够承载大量并发短查询。

ClickHouse 查询通常会:

  • 读取多个 Part 和大量压缩数据块。
  • 启动多线程读取与表达式计算。
  • 构建 GROUP BY 哈希表。
  • 执行排序、JOIN 或窗口函数。
  • 在分布式集群传输中间结果。

单个请求可能占用多个 CPU 核、数 GB 内存和大量磁盘带宽。当许多此类请求同时执行,瓶颈不是连接数量,而是物理资源。

原因一:单查询会使用多个线程

ClickHouse 通过 max_threads 等配置让一条查询并行读取与计算。假设一条查询使用 16 个线程,20 条并发重查询理论上就可能争用数百个执行线程。

线程数超过 CPU 有效并行能力后,会出现上下文切换、缓存失效和调度开销。降低单查询线程数可以提高可并发查询数量,但单条查询延迟可能上升,这是一种吞吐与延迟取舍。

原因二:聚合和 JOIN 占用大量内存

高基数 GROUP BY 要为大量 Key 保存聚合状态。哈希 JOIN 需要把一侧数据构建到内存。多条查询同时执行时,峰值内存会近似叠加。

达到内存阈值后可能触发外部排序、外部聚合或查询失败。外部化能避免 OOM,却会把压力转移到磁盘,导致整个集群延迟升高。

不能只把 max_memory_usage 调大,因为节点总内存是有限的。单查询允许 20GB、同时进入 20 条查询,就存在远超物理内存的风险。

原因三:磁盘带宽是共享资源

列式扫描即使压缩率高,仍可能读取数十 GB 数据。NVMe 顺序读很快,但多个查询、后台 Merge、Mutation 和 TTL 同时读写会竞争磁盘带宽与 IO 队列。

当磁盘饱和时,增加查询并发不会增加吞吐,只会让每条查询等待更久,最终形成超时和重试风暴。

原因四:后台 Merge 持续消耗资源

MergeTree 写入生成 Part,后台必须合并。Merge 会读取旧 Part、解压、归并、压缩并写出新 Part,消耗 CPU、磁盘和临时空间。

除了 Merge,还有 Mutation、TTL、复制获取和物化视图写入。前台查询和后台任务共享节点资源,高峰写入时查询能力会下降。

原因五:分布式查询会放大请求

一条 Distributed 查询可能向每个分片和副本发送子查询。入口只有 50 条并发,若集群有 20 个分片,底层可能产生大量并发子任务和网络连接。

协调节点还需要汇总、排序或合并各分片结果,容易成为 CPU、内存或网络热点。返回大结果集时,协调节点压力更明显。

原因六:复杂查询重量差异巨大

同一个查询模板使用不同时间范围、租户或过滤条件,读取量可能相差几个数量级。如果没有查询成本治理,一个用户查询全年数据就可能影响所有在线报表。

只限制连接数无法解决问题。需要限制读取行数、字节数、执行时间、内存和线程,并对不同用户实施不同资源策略。

原因七:查询启动存在固定成本

解析、计划、线程调度、打开 Part、读取索引和建立流水线都有固定成本。对于每秒数万次、每次只查一行的请求,这些成本相对数据处理本身过高。

此类请求更适合缓存、MySQL、Redis 或面向点查的存储。把 ClickHouse 暴露为无限制在线明细 API 通常不是好设计。

高并发下的典型表现

  • 查询 P99 急剧上升,但单独执行很快。
  • CPU 长时间接近满载,上下文切换增加。
  • 内存快速增长并频繁触发超限。
  • 磁盘利用率和 IO 等待升高。
  • Merge 队列、Part 数量和复制队列持续积压。
  • 查询超时后客户端自动重试,形成流量放大。

如何提高并发承载能力

1. 减少单查询读取量

优化排序键、强制时间范围、只读取必要列、预聚合热点指标。减少 read_rows/read_bytes 是最根本的办法。

2. 预计算与缓存

高频固定报表使用物化视图和聚合表;相同参数的结果使用缓存。缓存必须包含租户、权限和指标版本,防止越权或旧口径。

3. 设置并发队列

与其让所有查询同时进入并争抢资源,不如限制活跃查询数,剩余请求排队或快速失败。排队能提高总体吞吐稳定性,但要设置最大等待时间,避免无限堆积。

4. 分类资源隔离

在线查询、内部分析和离线任务使用不同用户、Profile、配额或独立集群。限制每类请求的:

  • max_threads
  • max_memory_usage
  • max_execution_time
  • 最大读取行数与字节数
  • 并发查询数量

离线大查询不应与面向用户的秒级报表争抢同一资源池。

5. 读副本与水平扩展

增加副本可以分散只读流量,增加分片可以分担数据和计算。但如果每条查询仍广播到所有分片,单纯增加分片也会增加协调和网络成本。

扩容前应确认瓶颈是 CPU、IO、内存、热点分片还是单协调节点。

6. 应用层合并请求

页面加载可能同时发起几十个相似指标查询。可以合并为一次查询、批量查询或后端统一聚合,减少重复扫描和连接开销。

7. 背压与禁止无界重试

当集群达到容量时,应返回明确的限流或超时结果。客户端采用指数退避和有限重试。立即重试会把短暂拥塞放大为雪崩。

一个容量估算思路

假设节点有 32 个 CPU 核,一条核心报表使用 8 个线程时耗时 2 秒。理论上同时运行 4 条可占满 CPU,但实际还要为后台 Merge、系统进程和查询波动留余量。

将每条查询线程数降到 4,可能允许更多请求并行,但单条耗时会增长。需要通过压测找到“吞吐最大且 P99 可接受”的点,而不是按 CPU 核数直接套公式。

压测应使用真实数据分布和查询混合,包括热点租户、长时间范围、写入与 Merge 同时发生的情况。

用一个容量例子串起来

我们曾遇到大促期间报表并发上升,ClickHouse CPU 95%,查询从 2 秒增加到 20 秒。query_log 显示多条查询重复扫描相同明细,每条使用 16 个线程。我们先限制分析用户并发并禁止自动重试止损;随后建立小时聚合表,将在线查询 max_threads 调整为 4,并把离线查询迁移到独立副本。最终读取字节下降约 90%,峰值并发下 P99 稳定在 3 秒左右。

这里的数字只是示例,放到实际项目里要换成真实指标和改动,不要凭空编数字。

常见问题

追问 1:降低 max_threads 为什么能提高并发?

它减少单查询占用的 CPU 线程,让更多查询同时获得执行机会;代价是单查询可能变慢,需要通过压测寻找整体吞吐与延迟平衡。

追问 2:增加副本一定能提高并发吗?

只读查询能被路由到不同副本时通常有帮助。但若查询仍广播到所有副本或瓶颈在协调节点、网络和共享存储,收益有限。

追问 3:ClickHouse 能支持多少并发?

没有固定数字。取决于查询读取量、聚合基数、线程数、硬件、后台写入和延迟目标。应通过真实查询混合压测给出安全并发。

追问 4:为什么并发越高总吞吐反而下降?

资源饱和后新增查询只增加上下文切换、缓存失效、IO 排队和内存压力,每条查询都变慢,超时重试还会进一步放大流量。

追问 5:如何区分查询问题还是 Merge 问题?

对照 query_log、系统资源、Part 数量和 Merge 队列时间线。若无查询时 IO 仍高且 Merge 积压,重点检查写入批次和 Part;若特定查询进入后 read_bytes 与内存突增,则优化查询和模型。

追问 6:为什么不用 ClickHouse 直接做高并发明细接口?

单行点查无法充分利用列式扫描和向量化优势,却承担查询启动与资源调度成本。高并发点查通常由 MySQL、Redis 或专门 KV 服务承接更合适。