存储
ck使用稀疏索引,索引所占空间较小。一般每8192行一个索引。
这个单元的单位被称之为“Granule”,是数据按行划分时用到的逻辑概念。关于多少行是一个Granule这个问题,在老版本中这是用参数index_granularity设定的一个常量,也就是每隔确定行就是一个Granule。在当前版本中有另一个参数index_granularity_bytes会影响Granule的行数,它的意义是让每个Granule中所有列的sum size尽量不要超过设定值。老版本中的定长Granule设定主要的问题是MergeTree中的数据是按Granule粒度进行索引的,这种粗糙的索引粒度在分析超级大宽表的场景中,从存储读取的data size会膨胀得非常厉害,需要用户非常谨慎得设定参数。
索引文件是primary.idx,索引到数据的桥梁是col.mrk2文件,数据是col.bin文件。显然除了索引文件是每个分区一个,mrk2和bin都是每列一个文件。我们看一个例子:
date和city两列是主键,在primary.idx文件中,按date+city排序所有数据,每隔8192行,pick中这行的date+city值记录下来,这行的下标(图中的Mark M值)乘以8192就是行号。primary.idx文件非常小,可以常驻内存。date、city、action三列都各自有自己的mrk2和bin文件。
结合下面两图:
比如where条件中有id=12 AND Name=Herman,则通过primary.idx中二分查找确定下标就是1(Block集合,也就是mark number集合),在Id.mrk2(其它非索引列的col.mrk2也是同理)中通过下标知道两个offset,于是在Id.bin中就seek到6的位置,然后继续二分查找很快找到12(如果是非索引列,则顺序查找不超过一个Block的大小也能找到)。
如果SELECT中有非索引列,则在前述步骤确认行号集合后(多索引列条件需要对行号集合求交集),再拿着行号集合到非索引列的mrk2文件找两个offset,再到其bin文件中取对应行号的数据。
导入数据时每批可以生成多个parts,每个part最多1048576行。ClickHouse保证part级别原子生效(但不保证parts级别)。
计算
在executeQueryImpl函数中,返回的BlockIO对象来自于interpreter->execute()调用,interpreter有针对不同类型SQL语句的各种不同实现,比如一般query对应的InterpreterSelectQuery实现类。它的execute函数中,是如下代码:
BlockIO res;
QueryPlan query_plan;
buildQueryPlan(query_plan);
res.pipeline = QueryPipelineBuilder::getPipeline(std::move(*query_plan.buildQueryPipeline(
QueryPlanOptimizationSettings::fromContext(context), BuildQueryPipelineSettings::fromContext(context))));
return res;
通过调用buildQueryPipeline生成queryPipeline。然后QueryPipelineBuilder::getPipeline调用就让代码流程从Interpreter文件夹转移到了QueryPipeline文件夹。其函数内容为:
QueryPipeline res(std::move(builder.pipe));
res.setNumThreads(builder.getNumThreads());
res.setProcessListElement(builder.process_list_element);
return res;
基本执行流程为AST -> Step (Node) -> Pipeline (Processor) -> graph -> 调度线程。
AST的prefilter阶段,通过调用move_condition将部分列条件下推给存储,默认不超过10%。
Step阶段生成step数组,比如生成包含scan和filter两个成员的数组。
依次调用step数组的updatePipeline函数。
Processor(对应与IProcessor类)相当于Operator,有NeedData、PortFull等多种状态。一般线程池的个数等于CPU的个数。每个线程从Processor的池子中拿出一个执行,输入和输出均称为Trunk。Trunk包含多个column,每个column的内部实现是ColumnVector。
Processor代码注释中有其自身功能的详细解读。
数据exchange的接口是Port,具体实现有InputPort和OutputPort等类。
ExecutingGraph类基于Processor生成表达其上下游关系的图。
PullingPipelineExecutor为单线程,PullingAsyncPipelineExecutor为多线程,都负责Pulling executor for QueryPipeline。它们都包含了QueryPipeline类型的成员,并且是构造函数的唯一参数类型。
PullingAsyncPipelineExecutor::pull函数是驱动执行的环节。它调用了threadFunction,进而调用了data.executor->execute(num_threads),进而调用PipelineExecutor的executeImpl,进而调用线程个数次PipelineExecutor的executeSingleThread,进而调用PipelineExecutor的executeStepImpl。它来真正执行一个step。
Pulling(Async)PipelineExecutor在调用其pull函数时构造新的PipelineExecutor:
data->executor = std::make_shared<PipelineExecutor>(pipeline.processors, pipeline.process_list_element);
比如看一下MergeJoin::mergeInMemoryRightBlocks函数,其内容为:
auto pipeline = QueryPipelineBuilder::getPipeline(std::move(builder));
PullingPipelineExecutor executor(pipeline);
Block block;
while (executor.pull(block))
可以看到基于QueryPipeline构造了Pulling(Async)PipelineExecutor来调用其pull。
线程可以抢Processor来干活,直到消化完Processor所以不容易有长尾。
跨节点数据exchange的Processor称为OutputFormat,一定是单线程的。
据说ClickHouse的llvm的codegen默认不打开,较少使用。另外,开窗函数和不相关子查询可能在规划中或刚刚实现。
TODO
查询总入口、执行计划与优化器
MySQLHandler和其它接口的Handler中会调用executeQuery函数,该函数再调用静态函数executeQueryImpl函数对一个query进行parse、优化、执行,最终返回std::tuple<ASTPtr, BlockIO>。返回的BlockIO也就是一个stream,可以从中读取query返回的数据:
BlockIO executeQuery(
const String & query,
ContextMutablePtr context,
bool internal,
QueryProcessingStage::Enum stage)
{
ASTPtr ast;
BlockIO streams;
std::tie(ast, streams) = executeQueryImpl(query.data(), query.data() + query.size(), context, internal, stage, nullptr);
if (const auto * ast_query_with_output = dynamic_cast<const ASTQueryWithOutput *>(ast.get()))
{
String format_name = ast_query_with_output->format
? getIdentifierName(ast_query_with_output->format)
: context->getDefaultFormat();
if (format_name == "Null")
streams.null_format = true;
}
return streams;
}
executeQueryImpl中对query的执行流程参见前述计算部分。
TODO