简介:ClickHouse社区在21.8版本引入了ClickHouseKeeper。ClickHouseKeeper是一个完全兼容Zookeeper协议的分布式协调服务。本文分析的是开源版本ClickHousev21.8.10.19-lts的源码。作者简介:范震(小名陈凡),阿里云开源大数据-OLAP方向负责人。内容框架背景架构图核心流程图排序内部代码流排序Nuraft关键配置陷阱结论关于我们参考背景注:以下代码分析版本为开源版本ClickHousev21.8.10.19-lts。类图和时序图没有严格按照UML规范;为了表达方便,函数名和函数参数没有严格按照原代码。HouseKeeperVsZookeeperZookeeperjava开发存在JVM痛点,执行效率不如C++;Znode过多容易出现性能问题,FullGC较多。Zookeeper运维复杂,需要独立部署组件。之前有很多问题。HouseKeeper有多种部署形式,包括独立模式和集成模式。ZookeeperZXID溢出问题,HouseKeeper没有这个问题。HouseKeeper提升了读写性能,支持读写线性一致性。一致性程度见https://xzhu0027.gitbook.io/b...。HouseKeeper的代码与CK统一,自封闭可控。具有很强的未来可扩展性,MetaServer可以基于此进行设计和开发。主流的MetaServer基本都是Raft+rocksDB的组合,可以借助这个codebase进行开发。ZookeeperClientZookeeperClient完全不需要修改,HouseKeeper完全适配Zookeeper的协议。ZookeeperClient是CK自己开发的。CK没有使用libZookeeper(这是一个臭味代码库),而是自己从TCP层对其进行封装,遵循ZookeeperProtocol。架构图中有3种部署模式。推荐第一种独立模式。您可以选择小型SSD磁盘以最大限度地发挥Keeper的性能。核心流程图梳理了类图关系入口的主要功能。它主要做了两件事:初始化Poco::Net::TCPServer,定义处理请求的KeeperTCPHandler。实例化keeper_storage_dispatcher并调用KeeperStorageDispatcher->initialize()。该函数的主要作用如下:实例化类图中的几个Thread,以及相关的ThreadSafeQueue,保证不同线程之间的数据同步。实例化KeeperServer对象,它是最核心的数据结构,也是整个Raft最重要的部分。KeeperServer主要由state_machine、state_manager、raft_instance、log_store(间接)组成,它们分别继承了nuraft库中的父类。一般来说,所有基于raft的应用程序都应该实现这些类。调用KeeperServer::startup()主要是初始化state_machine和state_manager。启动时会调用state_machine->init(),state_manager->loadLogStore(...)分别加载snapshot和log。从最新的raft快照恢复到最新提交的latest_log_index,并形成一个内存数据结构(最关键的是Container数据结构,即KeeperStorage::SnapshotableHashTable),然后继续加载raft日志文件中的每条记录到logs(也就是数据结构std::unordered_map),这两个加粗的数据结构是整个HouseKeeper的核心,也是大内存占用者,后面会提到。KeeperTCPHandler的主循环是读取socket请求,将requestdispatcher->putRequest(req)传递给requests_queue,然后通过responses.tryPop(res)从中读取response,最后写socket返回response给客户。主要经过以下步骤:确认整个集群是否有leader,如果有则发送握手。注意:HouseKeeper使用了naraft的auto_forwarding选项,所以如果请求被非leader接受,它会承担代理的角色,将请求转发给leader,读写请求都会经过代理。获取请求的session_id。新连接获取session_id的过程就是server端keeper_dispatcher->internal_session_id_counter的自增过程。keeper_dispatcher->registerSession(session_id,response_callback),将对应的session_id绑定到回调函数上。将请求keeper_dispatcher->putRequest(req)传递给requests_queue。通过循环responses.tryPop(res)从中读取response,最后写入socket将response返回给客户端。处理请求的线程模型从TCPHandler线程开始,经过时序图中不同的线程调用,完成整个链路的请求处理。读请求直接由requests_thread调用state_machine->processReadRequest处理,在这个函数中,调用了storage->processRequest(...)接口。写入请求通过nuraft库raft_instance->append_entries(entries)的UserAPI写入日志。达成共识后,通过nuraft库内部线程调用commit接口,执行storage->processRequest(...)接口。Nuraft库正常的日志复制处理流程如下:Nuraft库维护了两个核心线程(或线程池),即:raft_server::append_entries_in_bg,leader角色负责检查log_store中是否有新条目并复制追随者。raft_server::commit_in_bg,所有角色(role、follower)检查自己的状态机sm_commit_index是否落后于leader的leader_commit_index,如果是则向状态机apply_entries。内部代码流程排序总体来说,nuraft实现了一个编程框架,需要实现类图中红色标记的几个类。LogStore和SnapshotLogStore负责持久化日志,继承自nuraft::log_store,这一系列接口中比较重要的有:写入:包括顺序写入KeeperLogStore::append(entry),覆盖(截断写入)KeeperLogStore::write_at(index,entry),批量写入KeeperLogStore::apply_pack(index,pack)等读取:last_entry(),entry_at(index)等合并后清理:KeeperLogStore::compact(last_log_index),主要是在快照。当调用KeeperStateMachine::create_snapshot(last_log_idx)时,在所有快照将数据序列化到磁盘后,将调用log_store_->compact(compact_upto),其中compact_upto=new_snp->get_last_log_idx()-params->reserved_log_items_。这是一个小坑。compact的compact_upto索引不是最新快照的索引。它需要部分保留。对应的配置是reserved_log_items。ChangeLog是LogStore的pimpl,提供了LogStore/nuraft::log_store的所有接口。ChangeLog主要由current_wirter(日志文件写入器)和logs(内存std::unordered_map数据结构)组成。每次插入日志时,都会将该日志序列化到文件缓冲区中,并插入到内存日志中。所以可以肯定的是,在做快照之前,日志占用的内存会不断增加。完成快照主机后,序列化磁盘中compact_upto的索引将从内存日志中删除。因此,我们需要权衡两个配置项snapshot_distance和reserved_log_items。目前这两个配置项的默认值为10w,容易占用大量内存。推荐值为:100005000KeeperSnapshotManager提供了一系列ser/deser接口:KeeperStorageSnapshot主要提供KeeperStorage和filebuffer之间的相互ser/deser操作。初始化时直接通过Snapshot文件进行deser操作,恢复到文件指示的索引对应的KeeperStorage数据结构(如snapshot_200000.bin,指示的索引为200000)。在KeeperStateMachine::create_snapshot时,根据提供的snapshot元数据(index、term等),执行ser操作将KeeperStorage数据结构序列化到磁盘。Nuraft库中提供的快照传输:当新加入的follower节点或者follower节点的日志远远落后(已经落后于最新的logcompactionupto_index),leader会主动发起InstallSnapshot流程,如下图:Nuraft库提供了几个接口。KeeperStateMachine的实现很简单:read_logical_snp_obj(...),leader直接发送内存中的最新快照latest_snapshot_buf。save_logical_snp_obj(...),follower接收并序列化磁盘,更新自己的latest_snapshot_buf。apply_snapshot(...),最新快照latest_snapshot_buf,生成最新版本存储。KeeperStorage类用于模拟与Zookeeper等效的功能。核心数据结构是Zookeeper的Znode存储:使用Container=SnapshotableHashTable,结合std::unordered_map和std::list实现一个无锁数据结构。key是Zookeeper路径,value是ZookeeperZnode(包括存储Znode的stat元数据),Node定义为:structNode{Stringdata;uint64_tacl_id=0;///0--默认没有ACLboolis_sequental=false;协调::Statstat{};int32_tseq_num=0;ChildrenSet孩子{};};SnapshotableHashTable结构中的map始终保存最新的数据结构以满足读取需求。list提供了两种数据结构来保证新插入的数据不会影响被快照的数据。实现非常简单。详见:https://github.com/ClickHouse...提供ephemerals、session_and_watchers、session_and_timeout、acl_map、watches等数据结构,实现很简单,就不一一介绍了.所有Requests都是从KeeperStorageRequest父类实现的,包括下图中的所有子类。每个Request实现一个纯虚函数来操作KeeperStorage内存数据结构。虚拟std::pair
