分布式数据库-CrateDB架构分析与源码阅读之源码阅读

本章将以CrateDB 3.3.6的版本为基础对CrateDB的源码做介绍,首先会对CrateDB各个包的功能做个整体介绍,然后会以一条insert语句的执行流程为例,结合源码介绍介绍crate执行过程,最后会对关键模块各找一个典型类进行详细的源码分析。

由于CrateDB代码庞大,大概有几十万行,所以仅能通过对各个关键模块找典型类的方式介绍,也很推荐大家在阅读完源码介绍之后,详细的去看CrateDB的全部源码。通过阅读CrateDB源码,大家可以比较清晰地了解一个高效的分布式HTAP数据库的实现方式,分布式节点之间的协同方式,对于大家设计类似架构有很强的参考意义。对CrateDB基本架构和相关术语不熟悉的朋友可以参考我前面的几篇博客,也可以随时在评论区讨论。

CrateDB代码结构

CrateDB是一个开源项目,遵从apache 2.0协议,这是一个比较开放的协议,比较方便大家基于他的源码做二次开发。项目地址位于github上:https://github.com/crate/crate,项目主页位于:https://crate.io  打开CrateDB的源码,我们可以发现存在以下关键目录:

app :与elastic search的app源码目录类似,用于服务bootstrap相关,包含一些config解析和启动异常类;

azure-discovery :云服务自动发现功能相关;

benchmarks:CrateDB性能测试相关;

blackbox :CrateDB黑盒测试相关;

blob:CrateDB大对象存储支持相关;

common:CrateDB底层工具类,有一部分代码来自于elastic search;

dex:CrateDB中数据库相关的数据结构实现,包括Row和Iterator;

dns-discovery:dns自动发现

devs:用其他语言开发的工具

docs:文档

enterprise:CrateDB企业feature相关

es:elastic search,这里是抽取了elastic search的部分源码

http:http通信相关

idea :idea支持

sql-parser:sql层的语法解析器,主要引入了antlr组件

sql:CrateDB sql层实现

ssl :安全认证相关

udc:收集一部分统计信息

其实CrateDB抽取了很多elastic search和lucene的源码,而不是以引入jar包的方式进行调用,这就代表对于elastic search和luene的版本升级和新特性,CrateDB可能难以做到及时的吸取和跟踪。

CrateDB的启动入口位于app/src/main/java/io/crate/bootstrap/CrateDB,主要是用定义的CrateNode代替ES Node代替bootstrap。

依赖注入

CrateDB使用guice实现依赖注入来构造和管理对象,guice是google开发的一个依赖注入框架,在elastic search中得到了广泛使用,相对于spring的依赖注入会更加灵活一些。

CrateDB SQL执行流程

本节以一条insert语句在CrateDB的执行流程为例,介绍CrateDB各个模块的workflow。对于一个正常运行的CrateDB进程,通过JDBC协议接收到如下一条insert语句的时候:

insert into quotes values(1, "Don't be panic");

CrateDB内部会执行如下的处理流程:Netty接受请求-》handler接口建立任务进行异步处理-》SQL语句解析(analyze)-》生成logical plan(parse)-》生成optimized logical plan(optimizer)-》生成execution plan-》从RootTask开始执行Task-》基于es接口创建单个节点或者多个节点上的Shard请求-》异步写入,异步合并

下面结合源码详细介绍各个步骤:

1)netty接受客户端传来的jdbc请求:

CrateDB基于netty框架来处理TCP请求,sql包中的io.crate.netty.CrateChannelBootstrapFactory类定义了netty的启动参数:

public static ServerBootstrap newChannelBootstrap(String id, Settings settings) {
        EventLoopGroup boss = new NioEventLoopGroup(
            Netty4Transport.NETTY_BOSS_COUNT.get(settings), daemonThreadFactory(settings, id + "-netty-boss"));
        EventLoopGroup worker = new NioEventLoopGroup(
            Netty4Transport.WORKER_COUNT.get(settings), daemonThreadFactory(settings, id + "-netty-worker"));
        Boolean reuseAddress = TransportSettings.TCP_REUSE_ADDRESS.get(settings);
        return new ServerBootstrap()
            .channel(NioServerSocketChannel.class)
            .group(boss, worker)
            .option(ChannelOption.SO_REUSEADDR, reuseAddress)
            .childOption(ChannelOption.SO_REUSEADDR, reuseAddress)
            .childOption(ChannelOption.TCP_NODELAY, TransportSettings.TCP_NO_DELAY.get(settings))
            .childOption(ChannelOption.SO_KEEPALIVE, TransportSettings.TCP_KEEP_ALIVE.get(settings));
    }

CrateDB在netty中定义了消息处理的hangler类MessageHandler,这个类位于io.crate.protocols.postgres包中,对于传来的带sql语句的消息定义了处理逻辑,代码太多不全部贴上来了,channelRead0函数会调用dispatchState函数进行消息题解析,dispatchState类会根据传来的消息类型的不同调用不同函数来处理,比如对于连接断开消息,会调用handleClose函数,对于我们这个例子中的insert消息会调用handleExecute()函数,在hanleExecute函数中则会在sql session中注册一个异步执行任务,并定义执行完由这个session交给一个resultreceiver生成结果(session.execute(portalName, maxRows, resultReceiver))。

public void channelRead0(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception {
            final Channel channel = ctx.channel();

            try {
                dispatchState(buffer, channel);
            } catch (Throwable t) {
                ignoreTillSync = true;
                if (session != null) {
                    t = SQLExceptions.forWireTransmission(getAccessControl.apply(session.sessionContext()), t);
                }
                try {
                    if (session != null) {
                        AccessControl accessControl = getAccessControl.apply(session.sessionContext());
                        t = SQLExceptions.forWireTransmission(accessControl, t);
                    }
                    Messages.sendErrorResponse(channel, t);
                } catch (Throwable ti) {
                    LOGGER.error("Error trying to send error to client: {}", t, ti);
                }
            }
        }

2)SQLSession建立任务异步处理

在这里我们简单提一下这个SQLSession类,这个类从名字上就可以看出是一个处理SQL会话的一个类,主要是负责处理被netty反序列化的sql在CrateDB中的执行,这个类位于io.crate.action.sql包中(Session.java),其实可以当做sql层的入口类来看,写的很清晰友好。

对于一个传入的String类型的query,Session类首先会调用execute函数进行日志记录,以及为了减少带宽和flush次数,把解析出的请求存储到deferrdExecutionsByStmt中,达到一定的数目后按批量进行一次sync处理,sync函数会根据执行数目调用singleExec或者bulkExec(),这里以singleExec()为例讲述调用流程:

private CompletableFuture singleExec(Portal portal, ResultReceiver resultReceiver, int maxRows) {
        var activeConsumer = portal.activeConsumer();
        var analyzedStmt = portal.boundOrUnboundStatement();
        Plan plan;
        try {
            plan = planner.plan(analyzedStmt, plannerContext);
        } catch (Throwable t) {
            jobsLogs.logPreExecutionFailure(jobId, rawStatement, SQLExceptions.messageOf(t), sessionContext.user());
            throw t;
        }

        RowConsumerToResultReceiver consumer = new RowConsumerToResultReceiver(
            resultReceiver, maxRows, new JobsLogsUpdateListener(jobId, jobsLogs));
        portal.setActiveConsumer(consumer);
        plan.execute(executor, plannerContext, consumer, params, SubQueryResults.EMPTY);
        return resultReceiver.completionFuture();
    }

篇幅原因,这里删减了一部分代码使流程更清晰一些。先根据原始语句生成analyzedStmt,然后analyze之后的语句调用planner生成执行计划plan,然后调用plan.execute调用主体流程,上述历程执行完毕后调用resultReceiver.completionFuture();将执行结果传递给上层的future。这里的逻辑其实是非常清晰的,大家看代码的时候可以打断点单步调试到自己感兴趣的部分详细去看自己感兴趣的部分。

3)SQL语句解析——analyze

analyze是SQL语句传递到analyzer,生成一个AnalyzedStatement的过程,分为两步:

3.1语句解析 parse

CrateDB的语句解析其实是通过调用antlr这个工具包来实现的,这是一个很有名的开源项目,我后面也会专门写篇博客去介绍这个著名的sql解析工具包。印象中spark sql的解析器也使用的这个,但是这个解析器的规则并不在sql包中,而是在根目录下的sql-parser包中。CrateDB的一个叫SqlParser的类调用并解析SQL语句。

public class SqlParser{
    private Node invokeParser(){
        ParserRuleContext tree;

        return new AstBuilder().visit(tree);
    }
}

解析之后的结果是一个语法树,把根节点作为Node返回。

3.2语句分析 bound and analyze

最终会生成一个analyzedStatment,不同的语句会由不同的analyzer来生成,这个过程是由juice来处理的。对于insert语句是由一个InsertFromValuesAnalyzer来处理,主要做的工作包括绑定表的schema信息、字段校验、检查约束、解析出insert个字段的值,最终生成AnalyzedStatement。

这部分代码可以找InsertFromValuesAnalyzer类来看,这部分代码很简单清晰,这里不再赘述。

4)生成logical plan

执行palnner.plan之后生成的logical plan的过程,是由AnalyzedStatement调用planner.plan(),自顶向下递归地解析语法树,最终生成包含多个phase的NodeOperationTree,形如:

 *     Handler: n1
 *     Nodes involved: [n1, n2]
 *
 *          n1                        n2
 *     CollectPhase         CollectPhase
 *             \             /  (modulo based distribution based on the group by key)
 *              \___________/   (aggregation to "partial")
 *             /            \
 *            /              \
 *     MergePhase           MergePhase         // reducer
 *          |     _________/    (aggregation to "final")
 *          |    /
 *     MergePhase                              // localMerge
 *           (concat results; apply final limit)

5)生成execution plan

logical plan构建之后,会生成包含多个phase的NodeOperationTree,调用其根节点的build函数,递归调用Execution Plan,这个过程主要是由Execution phase划分成Task的过程。

一个ExecutionPhase主要由以下几个部分组成:5.1)NodeIds-在哪些节点上执行;5.2)Distribution:结果如何传递到DownStream;5.3)Projections-输入需要进行什么样的关系运算。

ExecutionPhase之间是需要不同节点之间传递数据的,所以判断是否需要把一个plan分割成多个execution phase的一个标准是,这个查询是否需要在多个节点上分布式执行。

NodeOperationTree的不同logical节点可能会在不同的node上执行,在对应的logical节点,会生成对应的语义的Projection,之后会构建对应的Projector。Task在执行期会通过Projector构建,构建的过程主要是通过phase构建对应Task,然后交给consumer执行,Task内部是通过构建嵌套的Iterator实现数据的pipeline获取。

比如insert语句会构造LegacyUpsertByIdTask执行,执行的过程中会取出Task中待插入的items,按照目标ShardID分组成多组ShardUpsertRequest:TransportAction.execute启动发送任务,把request通过transportService.sendRequest发送给各个es节点异步执行。

private CompletableFuture doExecute() {
        MetaData metaData = clusterService.state().getMetaData();
        List indicesToCreate = new ArrayList<>();
        for (LegacyUpsertById.Item item : items) {
            String index = item.index();
            if (isPartitioned && metaData.hasIndex(index) == false) {
                indicesToCreate.add(index);
            }
        }
        if (indicesToCreate.isEmpty() == false) {
            return createPendingIndices(indicesToCreate).thenCompose(resp -> createAndSendRequests());
        } else {
            return createAndSendRequests();
        }
    }

这段代码中items是在生成LegacyUpsertByIdTask的时候封装进去的,调用createAndSendRequests()的时候它会将这些items按照分配的ShardId分组:requestsByShard = groupRequests();然后对于每组requests生成一个lucene request调用elastic search API进行写入。

6)request发送之后的处理(ES写入流程)

es节点接受到requests之后会由TransportShardUpsertAction调用execute函数异步处理requests,对于主Shard的写入调用WritePrimaryResult,进一步调用IndexItem函数,向lucene发送请求,调用indexIntoLucene函数,写入lucene内存,在规则检查通过、写入成功buffer之后会持久化写入translog。如果存在副本,还会调用WriteReplicaResult,写入副本shards。

7)Lucene写入过程

        CrateDB在这部分的处理没有对lucene的代码进行修改,所以在lucene的写入层面与elastic search是完全一样的。lucene写入的数据首先都会先存放到内存的buffer中,之后每隔一段时间,elastic search会批量buffer中的数据sync到磁盘的segment中,此时segment处于打开状态,可以被查询检索到,这是数据写入的部分。

CrateDB中每个shard对应一个lucene index,而lucene index是由多个segment组成的,为了避免小文件过多,es会有单独的线程负责定时对这些segment做合并操作,为了避免对集群性能侵占过多,CrateDB会对合并过程进行限流,这部分的逻辑和限流设置位于根目录的es包下。

总结

如第一部分所说,CrateDB的代码还是具有一定的参考价值的,他的源码注释其实不算充分,但是各种UT覆盖的很全面,推荐可以对自己感兴趣的模块对照着UT去做一些调试,对了解它的运行机制很有帮助;另外可能一条sql的执行过程中可能会涉及到多次的RPC调用和线程池异步调用,可能造成调试上存在一些难度,其实这种情况对于分布式领域是很常见的,除了大胆猜测调用过程中涉及的类,多打断点之外,可以多借助火焰图工具对执行流程做个profiling,事半功倍。不过有一点需要注意的是,crateDB要求jdk版本在11以上,有些jdk8下很好用的profile工具在jdk11、13、14无法使用,切身尝试的经验是async_profiler是可以使用,如果一时找不到jdk11下可用的profiler工具的朋友可以尝试下。

 

大家如果想对CreateDB有一些深入了解的话,可以看一下我的CrateDB系列文章:

分布式数据库-CrateDB架构分析与源码阅读之总体概述与架构分析​​​​​ https://blog.csdn.net/u013970710/article/details/103219791

分布式数据库-CrateDB架构分析与源码阅读之常用命令 https://blog.csdn.net/u013970710/article/details/103219825

分布式数据库-CrateDB架构分析与源码阅读之搭建部署 https://blog.csdn.net/u013970710/article/details/103219820

分布式数据库-CrateDB架构分析与源码阅读之源码阅读 https://blog.csdn.net/u013970710/article/details/103219804

分布式数据库-CrateDB架构分析与源码阅读之最佳实践 https://blog.csdn.net/u013970710/article/details/103219797

分布式数据库-CrateDB架构分析与源码阅读之空间存储与空间计算 https://blog.csdn.net/u013970710/article/details/103226889

你可能感兴趣的:(#)