用PHP对接ClickHouse的场景里,最常见的需求之一就是把事件表、日志表里几十万甚至上百万行数据取出来,做导出、清洗或者同步到别的存储。拿到这类需求最容易想到的做法是直接写一条SELECT,把结果整个读进PHP数组,结果脚本要么撞上memory_limit,要么被max_execution_time掐断。其实ClickHouse本身的查询速度并不慢,真正出问题的往往是PHP这一端的内存占用方式和取数策略。这篇文章把大结果集取数拆成几个环节来讲清楚,并给出可以直接落地的分页代码。

一、先定位问题:大结果集到底卡在哪
第一层瓶颈在PHP的内存模型。PHP的数组是哈希表实现,查询结果转成关联数组后,除了字段本身的值,每个元素还要额外维护zval、Bucket这些内部结构。一行只有五六个字段的数据,落进PHP内存可能就要占几百字节到一两KB,如果一次取一百万行,内存占用轻松突破1GB,而常见的memory_limit配置只有128M或者256M,爆内存是必然结果。
第二层瓶颈在传输和反序列化。PHP操作ClickHouse大多走8123端口的HTTP接口,比如常用的smi2/phpClickHouse这个客户端库。ClickHouse会把整批结果序列化后一次性返回,响应体有多大,PHP端的字符串缓冲就有多大,客户端再把结果反序列化成数组时,内存里会同时存在原始响应和数组两份数据,峰值内存直接翻倍。
第三层是超时链路。如果脚本跑在FPM或者Nginx后面,max_execution_time、request_terminate_timeout、fastcgi_read_timeout任何一环都可能把长任务打断。所以正确思路是分页:每次只取一小批数据,处理完立刻释放,再取下一批,让单次请求的内存峰值和耗时都控制在安全范围内。下面先给出连接ClickHouse的基础代码,后面三种方案都基于这个连接展开:
// 先安装客户端库:composer require smi2/phpClickHouse
require 'vendor/autoload.php';
use ClickHouseDB\Client;
$client = new Client([
'host' => '192.168.0.10',
'port' => 8123,
'username' => 'default',
'password' => 'your_password',
'timeout' => 60,
]);
$client->database('analytics');
// 确认连通性,能正常返回版本号说明配置没问题
print_r($client->select('SELECT version()')->rows());
二、方案一:LIMIT加OFFSET的传统分页写法
先看最直观的写法。ClickHouse支持LIMIT offset, count这种语法,也支持LIMIT count OFFSET offset,含义和MySQL一致。配合PHP的循环,就能把一张表按固定批次读出来:
$pageSize = 5000; // 每页行数,根据单行宽度调整
$offset = 0;
while (true) {
$sql = sprintf(
'SELECT user_id, event_name, created_at
FROM analytics.events
ORDER BY created_at
LIMIT %d, %d',
$offset,
$pageSize
);
$rows = $client->select($sql)->rows();
if (empty($rows)) {
break; // 取不到数据,说明已经读完
}
foreach ($rows as $row) {
exportRow($row); // 逐行业务处理
}
$offset += $pageSize;
unset($rows); // 释放本批数据
}
这段代码能跑通,但只适合数据量不大、翻页不深的场景。关键问题在于ClickHouse处理OFFSET的方式:它并不是真的跳过前offset行,而是把前offset加count行都读出来,再丢掉前面的部分。也就是说翻到第1000页时,服务端实际读取的数据量是第一批的很多倍,每次请求都在重复劳动,越往后越慢。
另外这种写法在持续写入的表上还有一致性隐患:翻页过程中如果有新数据落进来,排序位置发生变动,就可能出现某几行被重复读取或者被跳过的情况。所以后台管理页面翻个十几页没问题,做全量导出和跑批任务时,不推荐这种方式。
三、方案二:基于排序键的游标分页(推荐做法)
更高效的做法是游标分页,也有人叫seek法或者keyset分页。核心思路是每一批取完后,记住最后一行的排序键值,下一批查询用WHERE条件直接从那个位置往后取,而不是靠OFFSET去数前面有多少行:
$pageSize = 5000;
$lastTime = null; // 上一批最后一行的主排序键
$lastId = 0; // 辅助排序键,避免同一时刻的记录被跳过
while (true) {
if ($lastTime === null) {
// 第一批:从最前面取一段
$sql = 'SELECT user_id, event_name, created_at, id
FROM analytics.events
ORDER BY created_at, id
LIMIT ' . $pageSize;
} else {
// 后续批次:从上一批结束的位置继续往后取
$sql = sprintf(
"SELECT user_id, event_name, created_at, id
FROM analytics.events
WHERE (created_at, id) > ('%s', %d)
ORDER BY created_at, id
LIMIT %d",
$lastTime,
$lastId,
$pageSize
);
}
$rows = $client->select($sql)->rows();
if (empty($rows)) {
break;
}
foreach ($rows as $row) {
exportRow($row);
}
// 记录本批最后一行的排序键,作为下一批的起点
$lastRow = end($rows);
$lastTime = $lastRow['created_at'];
$lastId = $lastRow['id'];
unset($rows, $lastRow);
}
这个写法快的原因在于,WHERE条件里的排序键如果正好是建表时ORDER BY里声明的列,ClickHouse的MergeTree引擎可以借助主键稀疏索引快速定位到目标数据块,把前面已经读过的行直接裁剪掉,每一批的耗时基本稳定,不会像OFFSET那样越翻越慢。即使排序键不在主键里,先过滤再取数也比读取一大批再丢掉划算得多。
有两个细节要注意。第一,排序键一定要能唯一确定一行,像created_at这种秒级时间戳很容易撞重复值,所以要再拼一个id做第二排序键,用(created_at, id)元组比较的方式写WHERE条件,ClickHouse支持元组的字典序比较,写起来比一长串OR条件清爽得多。第二,游标分页只能顺序往后取,不能跳页,这恰好符合导出和同步类任务的使用习惯。两种方案的差异可以对照下表:
| 对比项 | LIMIT OFFSET分页 | 游标分页 |
|---|---|---|
| 深页性能 | 越往后越慢,服务端要读取并丢弃offset行 | 每批耗时基本稳定 |
| 跳页能力 | 支持任意跳页 | 只能按顺序往后取 |
| 数据一致性 | 翻页期间有新数据写入时可能重复或漏行 | 配合唯一排序键基本不重不漏 |
| 适用场景 | 后台列表、浅分页展示 | 全量导出、数据同步、定时跑批 |
四、方案三:分批处理加内存释放的完整步骤
确定了分页策略,接下来要把整条链路串起来。下面是一份可以直接跑的完整脚本,从连接、循环取数、逐批写文件到内存释放都覆盖到了,建议用命令行方式执行,避开Web请求的各种超时限制:
// export.php 建议命令行执行:php export.php
require 'vendor/autoload.php';
use ClickHouseDB\Client;
$client = new Client([
'host' => '192.168.0.10',
'port' => 8123,
'username' => 'default',
'password' => 'your_password',
'timeout' => 120,
]);
$client->database('analytics');
set_time_limit(0);
$pageSize = 10000;
$lastTime = null;
$lastId = 0;
$batch = 0;
$fp = fopen('events_export.csv', 'w');
while (true) {
$sql = ($lastTime === null)
? 'SELECT user_id, event_name, created_at, id
FROM analytics.events
ORDER BY created_at, id
LIMIT ' . $pageSize
: sprintf(
"SELECT user_id, event_name, created_at, id
FROM analytics.events
WHERE (created_at, id) > ('%s', %d)
ORDER BY created_at, id
LIMIT %d",
$lastTime,
$lastId,
$pageSize
);
$rows = $client->select($sql)->rows();
if (empty($rows)) {
break;
}
foreach ($rows as $row) {
fputcsv($fp, [
$row['user_id'],
$row['event_name'],
$row['created_at'],
$row['id'],
]);
}
$lastRow = end($rows);
$lastTime = $lastRow['created_at'];
$lastId = $lastRow['id'];
$batch++;
// 释放本批数据,隔一段手动触发一次垃圾回收
unset($rows, $lastRow);
if ($batch % 50 === 0) {
gc_collect_cycles();
echo '已导出 ' . ($batch * $pageSize) . ' 行,当前内存 '
. round(memory_get_usage() / 1024 / 1024, 2) . " MB\n";
}
}
fclose($fp);
echo "导出完成,共 {$batch} 批\n";
这份脚本里有几个步骤值得展开说明。批次大小$pageSize建议从一万行起步测试,观察单批查询耗时和内存峰值再调整,批太小会放大请求次数和网络开销,批太大又回到内存问题。每批处理完后用unset清掉行数据,让引用计数归零后内存能被下一批复用;循环里每隔五十批手动调一次gc_collect_cycles(),对付存在循环引用的场景更保险。
如果导出目标不是CSV而是写回MySQL或者其他库,把fputcsv那一段换成批量INSERT即可,同样遵循一批一提交的节奏,避免单个事务过大。对于特别大的数据量,还可以在查询里加上FORMAT CSVEachRow之类的子句让ClickHouse直接输出轻量格式,或者干脆用clickhouse-client命令行工具配合管道导出文件,PHP只负责后续处理,这样连反序列化的开销都省掉了。
五、几个容易踩的坑
第一,ORDER BY的列尽量选表的排序键。游标分页的性能红利来自主键索引裁剪,如果拿一个不在ORDER BY声明里的列去排序,ClickHouse每次都要对全表做一次完整排序,分页的优势会被抵消掉大半。所以建表时就要把常用的取数顺序考虑进ORDER BY里,取数时尽量顺着这个顺序来。
第二,注意时间列的格式和时区。ClickHouse的DateTime默认按Unix时间戳存储,PHP端拿到的字符串格式取决于查询时的设置,拼进WHERE条件前最好统一成同一种格式,否则会出现条件永远匹配不上、每一批都取到空结果的诡异现象。调试时可以先单独执行一次查询,确认边界值能不能查到数据再放进循环。
第三,跑批任务尽量放在CLI下执行。命令行PHP没有FPM那一层超时限制,配合set_time_limit(0)可以放心跑长任务;如果必须通过Web触发,建议改成投递异步任务的方式,由后台进程执行取数逻辑,接口只负责返回任务状态,避免请求被网关掐断后任务做到一半。
总结一下,PHP连接ClickHouse取大结果集,本质上是把一次大查询拆成多次小查询:OFFSET分页适合浅翻页的后台展示,游标分页适合全量顺序读取,再配合分批处理和及时释放内存,百万级数据也能稳定取完。把这几步落地之后,内存不足和执行超时的问题基本就告别了。
PHPClickHouse分页查询修改时间:2026-09-29 14:33:35