简介
首先介绍下ClickHouse的特点以及适用场景,引用官方的介绍ClickHouse是一个用于联机分析(OLAP)的列式数据库管理系统(DBMS)。与mysql相比ClickHouse不支持事务,mysql是行式存储,ClickHouse是列式存储。
在传统的行式数据库系统中,数据按如下顺序存储:
学号 | 姓名 | 性别 | 班级 | 分数 |
---|---|---|---|---|
1 | 张三 | 男 | 1 | 100 |
2 | 李四 | 女 | 1 | 80 |
3 | 王五 | 女 | 2 | 75 |
N | ... | ... | ... | ... |
在列式数据库系统中,数据按如下的顺序存储:
学号 | 1 | 2 | 3 | N |
---|---|---|---|---|
姓名 | 张三 | 李四 | 王五 | ... |
性别 | 男 | 女 | 女 | ... |
班级 | 1 | 1 | 2 | ... |
分数 | 100 | 80 | 75 | ... |
可以看出行存储处于同一行中的数据总是被物理的存储在一起。列存储是来自不同列的值被单独存储,来自同一列的数据被存储在一起
行存储的写入是一次完成的,如果这种写入建立在操作系统的文件系统上,可以保证写入过程的成功或者失败,可以保证数据的完整性。列式存储需要把一行记录拆分成单列保存,写入次数明显比行存储多,这会增加写入出错的概率;数据读取时,行存储通常将一行数据完全读出,如果只需要其中几列数据的情况,就会存在冗余列,出于缩短处理时间的考量,消除冗余列的过程通常是在内存中进行的。列存储每次读取的数据是集合的一段或者全部,不存在冗余性问题。另外列式存储中的每一列数据类型是相同的,不存在二义性问题,这种情况使数据解析变得十分容易,其次对数据压缩更加友好,这一点对于数据量极大的业务场景可以有效降低存储成本。
综上所述,行式存储在数据写入和修改上具有很大优势,列式存储在数据读取和解析数据做数据分析上更具优势。因为使用列式存储,是ClickHouse适用于数据完整性要求不高的大数据处理领域的一个很重要的原因。
如何安装
这里介绍下使用docker安装单节点的方法,分布式的安装方法后面在介绍,首先拉取最新版本的docker镜像,然后启动clickhouse-server
docker run -d --ulimit nofile=262144:262144 -p 8123:8123 -p 9000:9000 -p 9009:9009 clickhouse/clickhouse-server:22.3.3.44
使用docker ps查看容器ID
执行docker exec -it 容器ID /bin/bash进入容器终端,然后通过容器内部自带的客户端和clickhouse-server进行交互
创建一张学生表做个测试,使用TinyLog引擎
create table student(id UInt8,name String,age UInt8) engine=TinyLog;
向表中插入数据
insert into student values(1,'张三',18),(2,'李四',19),(3,'王五',20);
表引擎
ClickHouse提供了很多种表引擎,一共分为四个系列,分别是Log系列、MergeTree系列、Integration系列、Special系列,各有各的用途,不同的存储引擎提供不同的存储机制、索引方式等功能,比如有Log系列用来做小表数据分析,MergeTree系列用来做大数据量分析,而Integration系列则多用于外表数据集成。Special系列中的表引擎Replicated、Distributed,可以根据场景和其他的表引擎组合使用。
MergeTree系列表引擎
Clickhouse 中最强大的表引擎当属 MergeTree
(合并树)引擎及该系列(*MergeTree
)中的其他引擎,这个也是官方主推的存储引擎,拥有最为强大的性能和最广泛的使用场合,有主键索引、数据分区、数据副本、数据采样、删除和修改等功能,支持几乎所有ClickHouse核心功能。MergeTree
系列表引擎包含:MergeTree、ReplacingMergeTree、SummingMergeTree(汇总求和功能)、AggregatingMergeTree(聚合功能)、CollapsingMergeTree(折叠删除功能)、VersionedCollapsingMergeTree(版本折叠功能)引擎,在这些的基础上还可以叠加Replicated和Distributed。其中MergeTree是家族系列最基础的表引擎,家族中其它引擎都是建立在它之上的,下面内容主要讲MergeTree
,以及叠加Replicated
和Distributed
的场景,其它的引擎只做一个简单介绍,具体内容参考官方文档
- ReplacingMergeTree
适用于在后台清除重复的数据以节省空间,但是它不保证没有重复的数据出现,按照ORDER BY
去重,在数据合并的时候, 从所有具有相同排序键(ORDER BY
)的行中选择一行留下
- SummingMergeTree
按照相同排序键去重,并把指定的字段做汇总
- AggregatingMergeTree
按照相同排序键去重,并把指定的字段按照自定义的规则做汇总
- CollapsingMergeTree
通过以增代删的思路,支持行级数据删除,分区合并时,同一数据分区内,sign标记为1和-1的一组数据会被抵消删除
- VersionedCollapsingMergeTree
在CollapsingMergeTree的基础上增加版本维度
- GraphiteMergeTree
用来对 Graphite数据进行瘦身及汇总
MergeTree
MergeTree系列的引擎被设计用于插入极大量的数据到一张表当中,数据可以以数据片段的形式写入磁盘,为了避免片段过多,ClickHouse会通过后台线程,定期合并这些数据片段,属于相同分区的数据片段会被合成一个新的片段,相比在插入时不断修改(重写)已存储的数据,这种策略会高效很多。这种数据片段合并的特点,也正是合并树名称的由来。分区是MergeTree引擎的核心特性,在大部分的业务场景中,常用时间字段作为分区字段,可以按照月、天、小时对数据进行分区,查询时数据时使用分区字段作为where条件,可以有效的过滤掉大量非结果集数据。
-
建表语句
CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1] [TTL expr1],
name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2] [TTL expr2],
...
INDEX index_name1 expr1 TYPE type1(...) GRANULARITY value1,
INDEX index_name2 expr2 TYPE type2(...) GRANULARITY value2
) ENGINE = MergeTree()
ORDER BY expr
[PARTITION BY expr]
[PRIMARY KEY expr]
[SAMPLE BY expr]
[TTL expr [DELETE|TO DISK 'xxx'|TO VOLUME 'xxx'], ...]
[SETTINGS name=value, ...]
关于以上建表语句的解释如下:
ENGINE:ENGINE = MergeTree(),MergeTree引擎没有参数。
ORDER BY:排序字段。比如ORDER BY (Col1, Col2),需要注意如果没有使用 PRIMARY KEY 显式的指定主键ORDER BY排序字段自动作为主键。如果不需要排序,则可以使用 ORDER BY tuple() 语法,这样的话,创建的表也就不包含主键。这种情况下,ClickHouse会按照插入的顺序存储数据。必选项
PRIMARY KEY:指定主键,如果排序字段与主键不一致,可以单独指定主键字段。否则默认主键是排序字段。大部分情况下不需要再专门指定一个 PRIMARY KEY子句。可选
注意在MergeTree中主键并不用于去重,而是会创建稀疏索引加快数据查询速度。SAMPLE BY:采样字段,如果指定了该字段,那么主键中也必须包含该字段。比如 SAMPLE BY intHash32(UserID) ORDER BY (CounterID, EventDate, intHash32(UserID))。可选
TTL:数据的存活时间。在MergeTree中,可以为某个列字段或整张表设置TTL。当时间到达时,如果是列字段级别的TTL,则会删除这一列的数据;如果是表级别的TTL,则会删除整张表的数据。可选。
SETTINGS:额外的参数配置。可选
-
示例
在名字为db_merge_tree的数据库中,创建student表
#创建数据库
create database db_merge_tree;
#切换数据库
use db_merge_tree;
create table db_merge_tree.student(
id UInt8,
name String,
age UInt8,
birthday Date
) engine = MergeTree()
order by id
primary key id
partition by toYYYYMM(birthday);
创建表时可以不指定数据库名字create table student(),默认在名字为default的数据库下
向表中插入一批数据
insert into student values(1,'张三',18,'2022-04-11'),
(2,'李四',19,'2022-01-08'),
(3,'王五',20,'2022-04-11'),
(1,'大聪明',20,'2022-05-12'),
(5,'小美',20,'2022-01-09');
查询表中的数据
select * from student;
从上图中可以看到,有两条ID值都为1的记录,印证了前面说的MergeTree中主键并不用于去重
目录解析
ClickHouse数据存放的目录规则是/var/lib/clickhouse/data/{库名}/{表名}
数据插入完成后,我们在容器内部,进入目录/var/lib/clickhouse/data/db_merge_tree/student,可以看到有几个以时间年月开头的目录,这个就是对应的分区目录,命令规则是分区最小的块编号最大块编号_经历了多少次合并
进入分区目录202201_2_2_0中,可以看到如下内容
上面这些就是保存数据的各个文件,以下是相关文件的介绍,有个了解就行了
- checksums.txt
目录中其它文件的大小以及大小的哈希值,用于快速校验文件的完整性和正确性 -
columns.txt
存储当前分区的所有列信息
- count.txt
记录当前数据分区目录下数据的总行数
[图片上传失败...(image-e63ef6-1651637128046)]
- data.bin
存储压缩后的数据,默认为LZ4压缩格式
- data.mrk3
列字段标记文件,使用二进制格式存储。标记文件中保存了data.bin文件中数据的偏移量信息
- default_compression_codec.txt
存储data.bin文件的数据压缩格式,如果是LZ4文件内容为CODEC(LZ4)
- partition.dat 与minmax_[列名].idx
指定了分区键时才会生成这两个文件,前者用于保存当前分区下分区表达式最终生成的值 ,后者保存原始数据的最大和最小值
- primary.idx
一级索引文件,使用二进制格式存储。用于存放稀疏索引,一张 MergeTree 表只能声明一次一级索引,即通过 ORDER BY 或者 PRIMARY KEY 指定字段。借助稀疏索引,在数据查询时可以有效减少数据扫描范围,加速查询速度
索引
MergeTree会根据创建表时的参数index_granularity为间隔(默认 8192 行),为数据表生成一级索引并保存至 primary.idx 文件内,索引数据按照主键进行排序,没有显式的指定主键时会以ORDER BY 对应的列作为主键。
由于ClickHouse设计之初就是为了存储海量的数据,考虑到降低索引存储的空间,采用了稀疏索引的技术,它的一个前提就是索引字段需要是排序的,数据结构类似于下面这样,每个索引点存储开始、结束位置以及数据节点的指针,查询时通过与start、end做对比进行二分查找,命中区间后再次进行查找
[
{
start: "a0000001",
end: "a0008192",
point: xxx,
},
.....
{
start: "a1000000",
end: "a1008192",
point: xxx,
},
....
{
start: "a2000000",
end: "a2008192",
point: xxx,
},
]
分布式
ClickHouse之所以能支持海量的数据储存,低延迟的查询,归功于它的分布式架构设计,学习这块之前首先要理解一下这几个概念。
- 副本
顾名思义同样的一份数据,在不同的节点上各存一份,这样做的目的是增加数据的冗余来防止数据的丢失,提高系统的可用性。单节点使用时副本的内容是一张表的全量数据,分布式的状态下副本的内容可以是一个分片下对应的数据 - 分片
一张表横水平切分为多份,每份中的数据不相同且存储在不同的节点上,这样查询数据时可以借助多台机器 - 分区
一张表根据PARTITION BY分出的多个目录
集群安装
使用docker-compose安装一个集群便于后续的学习,这个集群包含1个zookeeper容器(用于节点之间的同步以及元数据的存储)、6个ClickHouse容器,所需资源放在github上了点我进入
启动容器所需资源内容如下
docker-compose.yml配置了集群所需容器并组网,为了方便在宿主机器上查看ClickHouse节点的数据,把容器内部数据存储目录/var/lib/clickhouse挂载到宿主./volume/data/{容器名}
我们来看下其中一个ClickHouse节点的配置文件volume/config/node1/metrika.xml,内容如下
<yandex>
<remote_servers>
<mycluster>
<shard>
<internal_replication>true</internal_replication>
<replica>
<host>ch01</host>
<port>9000</port>
</replica>
<replica>
<host>ch02</host>
<port>9000</port>
</replica>
</shard>
<shard>
<internal_replication>true</internal_replication>
<replica>
<host>ch03</host>
<port>9000</port>
</replica>
<replica>
<host>ch04</host>
<port>9000</port>
</replica>
</shard>
<shard>
<internal_replication>true</internal_replication>
<replica>
<host>ch05</host>
<port>9000</port>
</replica>
<replica>
<host>ch06</host>
<port>9000</port>
</replica>
</shard>
</mycluster>
</remote_servers>
<zookeeper>
<node index="1">
<host>zk01</host>
<port>2181</port>
</node>
</zookeeper>
<macros>
<shard>01</shard>
<replica>ch01</replica>
</macros>
<networks>
<ip>::/0</ip>
</networks>
<ClickHouse_compression>
<case>
<min_part_size>10000000000</min_part_size>
<min_part_size_ratio>0.01</min_part_size_ratio>
<method>lz4</method>
</case>
</ClickHouse_compression>
</yandex>
在<remote_servers>节点下配置了一个名字为mycluster的集群,<shard>节点代表一个分片,<replica>节点代表副本,mycluster集群下面包含三个分片,每个分片有两个副本(在每个分片中至少配置一个副本
执行docker-compose up -d启动集群所需要的容器,看到如下的信息表示启动成功
ClickHouse目前只有 MergeTree 系列表引擎才支持副本 ,数据表如果想要有副本,创建表时需要在对应的表引擎前面加上Replicated前缀
副本示例
下面我们以ReplicatedMergeTree引擎为例,来看下ClickHouse中的数据副本。
分别进入node1、node2容器,通过clickhouse-client进入交互shell
docker exec -it $(docker ps | grep node1 | awk '{print $1}') /bin/bash
docker exec -it $(docker ps | grep node2 | awk '{print $1}') /bin/bash
创建student表
create table student(
id UInt8,
name String,
age UInt8,
birthday Date
) engine = ReplicatedMergeTree('/ClickHouse/tables/{shard}/student','{replica}')
order by id
primary key id
partition by toYYYYMM(birthday);
在node1节点上执行插入如下数据
insert into student values
(1,'张三',18,'2022-01-01'),
(2,'李四',19,'2022-01-02'),
(3,'王五',20,'2022-01-03');
在node1、node2上可以查询到相同的数据
分片示例
还是用上面的student表做演示,进入node3、node4、node5、node6的交互shell,创建student表
docker exec -it $(docker ps | grep node3 | awk '{print $1}') /bin/bash
docker exec -it $(docker ps | grep node5 | awk '{print $1}') /bin/bash
node3添加一条数据
insert into student values(1,'分片2',18,'2022-01-11');
接着在node5也添加一条数据
insert into student values(1,'分片3',18,'2022-01-22');
由于node1、node2容器属于分片1的副本,node3、node4容器属于分片2的副本,node5、node6容器属于分片1的副本,因此经过以上操作完成后6个容器的数据如下
-
node1、node2
-
node3、node4
-
node5、node6
可以看到三个分片拥有不同的数据,在单个节点上查询时只能查询到当前分片的数据,看到这里可能会想在实际的业务场景中如果想查询集群中student表中所有的数据时,难道需要在后台服务中从每个分片中取出数据在做组合?ClickHouse官方考虑到了这一点,另外提供了一个分布式引擎来解决这个问题,分布式引擎+MergeTree系列引擎组合是使用最广泛的场景
分布式引擎
分布式引擎本身只是一个逻辑层,用来屏蔽分片的复杂性,可以把分布式引擎看做成一个网关,执行写操作时把数据按照一定规则拆分后分流到不同分片上,执行读操作时并发的从各个分散的分片上取数据然后组合返回给客户端
创建数据表,可以采用以下语句:
CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1],
name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2],
...
) ENGINE = Distributed(cluster, database, table[, sharding_key[, policy_name]])
[SETTINGS name=value, ...]
分布式引擎参数
cluster
- 服务为配置中的集群名database
- 远程数据库名table
- 远程数据表名sharding_key
- (可选) 分片keypolicy_name
- (可选) 规则名,它会被用作存储临时文件以便异步发送数据
让我们来实际操作一把,以student表作为实际的数据表,创建一个名字为student_d的分布式表,在node1容器执行创建语句
CREATE TABLE student_d ON CLUSTER mycluster(
id UInt8,
name String,
age UInt8,
birthday Date
)
ENGINE = Distributed(mycluster, default, student2, id);
在node5上student2表中插入一条数据
insert into student2 values(0, '00', 0, '2022-01-01');
在任意节点对student_d表发起查询,可以看到这条数据
[图片上传失败...(image-31784c-1651639816331)]
我们再来尝试下对分布式表进行批量插入操作
在node1容器插入上面这1000条数据,然后再来观察下分片的数据分布情况. 点我获取sql
- node1(分片1)
- node3(分片2)
- node5(分片3)
JAVA连接ClickHouse
官方提供了JDBC驱动来连接ClickHouse,原生的 JDBC写起来比较繁琐,用common-dbutils来做交互
dependencies {
implementation 'ru.yandex.clickhouse:clickhouse-jdbc:0.2.6'
implementation 'commons-dbutils:commons-dbutils:1.7'
}
fun main() {
val ds = getDataSource()
insert(ds);
update(ds);
query(ds);
delete(ds);
}
fun getDataSource(): DataSource {
DbUtils.loadDriver("ru.yandex.clickhouse.ClickHouseDriver")
val props = ClickHouseProperties()
props.user = "root"
props.password = "root"
return BalancedClickhouseDataSource("jdbc:clickhouse://127.0.0.1:8123/default", props)
}
fun insert(ds: DataSource) {
val qr = QueryRunner(ds)
val df = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
val num = qr.update("insert into student(id, name, age, birthday) values(?, ?, ?, ?)", 255, "255", 255, df.format(Date()))
println("insert success: ${num == 1}")
}
fun update(ds: DataSource) {
val qr = QueryRunner(ds)
val num = qr.update("alter table student update name = ? where id = ?", "name255", 255)
println("update success: ${num >= 1}")
}
fun delete(ds: DataSource) {
val qr = QueryRunner(ds)
val df = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
qr.update("insert into student(id, name, age, birthday) values(?, ?, ?, ?)", 200, "200", 200, df.format(Date()))
val num = qr.update("alter table student delete where id = ?", 200)
println("delete success: ${num >= 1}")
}
fun query(ds: DataSource) {
val qr = QueryRunner(ds)
val version = qr.query("select version();", ScalarHandler<String>())
println("clickhouse version: $version")
val students = qr.query("select id, name, age, birthday from student;", BeanListHandler(Student::class.java))
println("students: $students")
val student = qr.query("select id, name, age, birthday from student where id = ?;", BeanHandler(Student::class.java), 1)
println("student: $student")
val count = qr.query("select count(id) from student where id = 100", ScalarHandler<BigInteger>()).toLong()
println(count)
}
data class Student(
var id: Long? = 0,
var name: String? = "",
var age: Int? = 0,
var birthday: Date? = null
)
参考资料
https://github.com/typ0520/clickhouse-learning
https://www.runoob.com/docker/docker-tutorial.html
https://www.runoob.com/docker/docker-compose.html
https://clickhouse.com/docs/zh/
https://github.com/ClickHouse/ClickHouse
https://hub.docker.com/r/clickhouse/clickhouse-server