文章目录
- 存储结构
- 逻辑划分
- 列式存储
- 稀疏索引
- 存储源码实现
- 表引擎
- 数据流
- 存储HA
- 官网配置
- 高可用配置
存储结构
以下实例我们都以clickhouse最常用的*MergeTree
(合并树)子类引擎来做介绍。
逻辑划分
以分布式表为例,那么ck数据存放于该集群下多个shard分片中。如果shard不在一个节点上,也就是数据会分散到多台机下。每个分片中的数据会根据建表时指定的partition在进行划分,而单个partition中,如果数据容量超过一定阈值又会重新拆分。
# 表结构:
${ck_data}/metadata/path_to_table/*.sql
# 实际数据存放目录:
${ck_data}/data/path_to_table/${partition_*}/**
# 装卸数据目录
${ck_data}/data/path_to_table/detached
列式存储
clickhouse是真正的列式数据库管理系统,除了数据本身外基本不存在其他额外的数据。
下面我们看下clickhouse特有的计算优势。
- 多服务器分布式处理
在ClickHouse中,数据可以保存在不同的shard上,每一个shard都由一组用于容错的replica组成,查询可以多服务器并行地在所有shard上进行处理。 - 向量引擎
为了高效的使用CPU,计算时ck能按向量(列的一部分)进行处理,相对于实际的数据处理成本,向量化处理具有更低的转发成本。这样可以更加高效地使用CPU。
稀疏索引
Clickhouse 中最强大的表引擎当属 MergeTree (合并树)引擎及该系列(*MergeTree)中的其他引擎。MergeTree 引擎系列的基本理念如下。当你有巨量数据要插入到表中,你要高效地一批批写入数据片段,并希望这些数据片段在后台按照一定规则合并。相比在插入时不断修改(重写)数据进存储,这种策略会高效很多。主要优势:
- 存储的数据按主键排序。
这让你可以创建一个用于快速检索数据的小稀疏索引。 - 允许使用分区,如果指定了 分区键 的话。
在相同数据集和相同结果集的情况下 ClickHouse 中某些带分区的操作会比普通操作更快。查询中指定了分区键时 ClickHouse 会自动截取分区数据。这也有效增加了查询性能。 - 支持数据副本。
ReplicatedMergeTree 系列的表便是用于此。
以官网用例来看。我们以 (CounterID, Date) 以主键。排序好的索引的图示会是下面这样:
全部数据 : [-------------------------------------------------------------------------]
CounterID: [aaaaaaaaaaaaaaaaaabbbbcdeeeeeeeeeeeeefgggggggghhhhhhhhhiiiiiiiiikllllllll]
Date: [1111111222222233331233211111222222333211111112122222223111112223311122333]
标记: | | | | | | | | | | |
a,1 a,2 a,3 b,3 e,2 e,3 g,1 h,2 i,1 i,3 l,3
标记号: 0 1 2 3 4 5 6 7 8 9 10
如果指定查询如下:CounterID in ('a', 'h')
,服务器会读取标记号在 [0, 3) 和 [6, 8) 区间中的数据。CounterID IN ('a', 'h') AND Date = 3
,服务器会读取标记号在 [1, 3) 和 [7, 8) 区间中的数据。Date = 3
,服务器会读取标记号在 [1, 10] 区间中的数据。上面例子可以看出使用索引通常会比全表描述要高效。
稀疏索引会引起额外的数据读取。当读取主键单个区间范围的数据时,每个数据块中最多会多读 index_granularity * 2
行额外的数据。大部分情况下,当 index_granularity = 8192
时,ClickHouse的性能并不会降级。
稀疏索引让你能操作有巨量行的表。因为这些索引是常驻内存(RAM)的。ClickHouse 不要求主键惟一。所以,你可以插入多条具有相同主键的行。下面看在实际语法:
CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1],
name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2],
...
)
ENGINE MergeTree()
PARTITION BY toYYYYMM(EventDate)
ORDER BY (CounterID, EventDate, intHash32(UserID))
SETTINGS index_granularity=8192
- ENGINE - 引擎名和参数。
ENGINE = MergeTree(). MergeTree 引擎没有参数。 - PARTITION BY — 分区键 。
要按月分区,可以使用表达式 toYYYYMM(date_column) ,这里的 date_column 是一个 Date 类型的列。这里该分区名格式会是 “YYYYMM” 这样。 - ORDER BY — 表的排序键。
可以是一组列的元组或任意的表达式。 例如: ORDER BY (CounterID, EventDate) 。 - PRIMARY KEY - 主键,如果要设成 跟排序键不相同。
默认情况下主键跟排序键(由 ORDER BY 子句指定)相同。因此,大部分情况下不需要再专门指定一个 PRIMARY KEY 子句。
存储源码实现
存储部分相关大部分逻辑放在/src/Storages下。
表引擎
表的顶层抽象是IStorage,对此接口不同的实现成为不同的表引擎. 例如 StorageMergeTree, StorageMemory等,这些类的实例是表。
该接口中包含很多通用的方法,常用的增删改查read、write、alter、drop等
- read
表的readStreams方法能够返回多个IBlockInputStream 对象允许并行处理数据. 这些多个数据块输入流能够从一个表中并行读取数据. 然后你能够用不同的转换来封装这些数据流(例如表达式评估,数据过滤) 能够被单独计算。
virtual Pipes read(
const Names & /*column_names*/,
const SelectQueryInfo & /*query_info*/,
const Context & /*context*/,
QueryProcessingStage::Enum /*processed_stage*/,
size_t /*max_block_size*/,
unsigned /*num_streams*/)
{
throw Exception("Method read is not supported by storage " + getName(), ErrorCodes::NOT_IMPLEMENTED);
}
/** The same as read, but returns BlockInputStreams.
*/
BlockInputStreams readStreams(
const Names & /*column_names*/,
const SelectQueryInfo & /*query_info*/,
const Context & /*context*/,
QueryProcessingStage::Enum /*processed_stage*/,
size_t /*max_block_size*/,
unsigned /*num_streams*/);
- write
virtual BlockOutputStreamPtr write(
const ASTPtr & /*query*/,
const Context & /*context*/)
{
throw Exception("Method write is not supported by storage " + getName(), ErrorCodes::NOT_IMPLEMENTED);
}
数据流
- 数据块流
用于处理数据。我们使用数据块的数据流从某处读取数据,执行数据转换或者写入数据到某处。IBlockInputStream 有一个read方法获取下一个数据块。IBlockOutputStream 有一个write方法发送数据块到某处。
例如,当你从AggregatingBlockInputStream拉取数据时,它将从数据源上读取所有的数据,聚合它,然后为你返回一个汇总数据流。另一个示例:UnionBlockInputStream接收很多输入数据源和一些线程。它启动了多个线程,从多个数据源中并行读取数据。 - 数据块
是一个容器,代表了内存中一个表的子集。它也是三元组的集合:(IColumn,IDataType,columnname)
存储HA
高可用存储对应生产来说是必不可少的。这里看下ck的分布式存储,和hive的区别还是较大,首先是在查询上需要借助分布式表才能实现。值得注意的是,ck的分布式表并不直接存储数据,而是类似于视图的存在。读是自动并行的。读取时,远程服务器表的索引(如果有的话)会被使用。
官网配置
假设4个节点example01-01-1、example01-01-2、example01-02-1、example01-02-2。集群名称为logs。
<remote_servers>
<logs>
<shard>
<!-- Optional. Shard weight when writing data. Default: 1. -->
<weight>1</weight>
<!-- Optional. Whether to write data to just one of the replicas. Default: false (write data to all replicas). -->
<internal_replication>false</internal_replication>
<replica>
<host>example01-01-1</host>
<port>9000</port>
</replica>
<replica>
<host>example01-01-2</host>
<port>9000</port>
</replica>
</shard>
<shard>
<weight>2</weight>
<internal_replication>false</internal_replication>
<replica>
<host>example01-02-1</host>
<port>9000</port>
</replica>
<replica>
<host>example01-02-2</host>
<secure>1</secure>
<port>9440</port>
</replica>
</shard>
</logs>
</remote_servers>
我们在logs集群4个节点中都创建test1表,根据totalDate分区。
CREATE TABLE default.test1 on cluster logs
(`uid` Int32, `totalDate` String )
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/test1', '{replica}')
PARTITION BY totalDate ORDER BY totalDate SETTINGS index_granularity = 8192;
之后在集群中创建分布式表test1_all
-- 建分布式表指向test1
CREATE TABLE default.test1_all on cluster logs
as test1
ENGINE = Distributed(logs, default, test1, rand())
然后可以向分布式表写入些测试数据,之后到具体的节点查原表进行校验。因为系列六中有类似的实例就不在此赘述。
高可用配置
ck推荐采用复制表+内部同步实现。我们先看下上述配置中internal_replication
属性。当设置false时,插入到分布式表中的数据被插入到两个本地表中,因为不会检查副本的一致性,并且随着时间的推移,副本数据可能会有些不一样。
复制表 ,ck数据副本是提供的表级的,而非服务器级别的,所以,服务器里可以同时有复制表和非复制表。这又是ck和hive的一个很大不同。
下面我们来看下四种复制模式
- 非复制表,internal_replication=false
如果在插入期间没有问题,则两个本地表上的数据保持同步。我们称之为“穷人的复制”,因为复制在网络出现问题的情况下容易发生分歧,没有一个简单的方法来确定哪一个是正确的复制。 - 非复制表,internal_replication=true
数据只被插入到一个本地表中,但没有任何机制可以将它转移到另一个表中。因此,在不同主机上的本地表看到了不同的数据,查询分布式表时会出现非预期的数据。 显然,这是配置ClickHouse集群的一种不正确的方法。 - 复制表,internal_replication=true
插入到分布式表中的数据仅插入到其中一个本地表中,但通过复制机制传输到另一个主机上的表中。因此两个本地表上的数据保持同步。这是推荐的配置。 - 复制表,internal_replication=false
数据被插入到两个本地表中,但同时复制表的机制保证重复数据会被删除。数据会从插入的第一个节点复制到其它的节点。其它节点拿到数据后如果发现数据重复,数据会被丢弃。这种情况下,虽然复制保持同步,没有错误发生。但由于不断的重复复制流,会导致写入性能明显的下降。所以这种配置实际应该是避免的,应该使用配置3。