要是想见,给我打个电话就行。
人和人谈不上义务性地见面,想见就见,想见才见。
—— 村上春树
ClickHouse是近年来备受关注的开源列式数据库,主要用于数据分析(OLAP)领域。目前国内社区火热,各个大厂纷纷跟进大规模使用:
今日头条 内部用ClickHouse来做用户行为分析,内部一共几千个ClickHouse节点,单集群最大1200节点,总数据量几十PB,日增原始数据300TB左右。
腾讯内部用ClickHouse做游戏数据分析,并且为之建立了一整套监控运维体系。
携程内部从18年7月份开始接入试用,目前80%的业务都跑在ClickHouse上。每天数据增量十多亿,近百万次查询请求。
快手内部也在使用ClickHouse,存储总量大约10PB, 每天新增200TB, 90%查询小于3S。
在国外,Yandex内部有数百节点用于做用户点击行为分析,CloudFlare、Spotify等头部公司也在使用。
在DB-engines排名上,如下图中红色曲线所示。ClickHouse开源时间虽短,但是增势迅猛。
图片
不同于事务处理(OLTP)的场景,比如电商场景中加购物车、下单、支付等需要在原地进行大量insert、update、delete操作,数据分析(OLAP)场景通常是将数据批量导入后,进行任意维度的灵活探索、BI工具洞察、报表制作等。数据一次性写入后,分析师需要尝试从各个角度对数据做挖掘、分析,直到发现其中的商业价值、业务变化趋势等信息。这是一个需要反复试错、不断调整、持续优化的过程,其中数据的读取次数远多于写入次数。这就要求底层数据库为这个特点做专门设计,而不是盲目采用传统数据库的技术架构。
在OLAP场景中,通常存在一张或是几张多列的大宽表,列数高达数百甚至数千列。对数据分析处理时,选择其中的少数几列作为维度列、其他少数几列作为指标列,然后对全表或某一个较大范围内的数据做聚合计算。这个过程会扫描大量的行数据,但是只用到了其中的少数列。而聚合计算的结果集相比于动辄数十亿的原始数据,也明显小得多。
OLTP类业务对于延时(Latency)要求更高,要避免让客户等待造成业务损失;而OLAP类业务,由于数据量非常大,通常更加关注写入吞吐(Throughput),要求海量数据能够尽快导入完成。一旦导入完成,历史数据往往作为存档,不会再做更新、删除操作。
OLAP类业务对于事务需求较少,通常是导入历史日志数据,或搭配一款事务型数据库并实时从事务型数据库中进行数据同步。多数OLAP系统都支持最终一致性。
分析场景下,随着业务变化要及时调整分析维度、挖掘方法,以尽快发现数据价值、更新业务指标。而数据仓库中通常存储着海量的历史数据,调整代价十分高昂。预先建模技术虽然可以在特定场景中加速计算,但是无法满足业务灵活多变的发展需求,维护成本过高。
ClickHouse从OLAP场景需求出发,定制开发了一套全新的高效列式存储引擎,并且实现了数据有序存储、主键索引、稀疏索引、数据Sharding、数据Partitioning、TTL、主备复制等丰富功能。以上功能共同为ClickHouse极速的分析性能奠定了基础。
与行存将每一行的数据连续存储不同,列存将每一列的数据连续存储。示例图如下:
相比于行式存储,列式存储在分析场景下有着许多优良的特性。
1)如前所述,分析场景中往往需要读大量行但是少数几个列。在行存模式下,数据按行连续存储,所有列的数据都存储在一个block中,不参与计算的列在IO时也要全部读出,读取操作被严重放大。而列存模式下,只需要读取参与计算的列即可,极大的减低了IO cost,加速了查询。
2)同一列中的数据属于同一类型,压缩效果显著。列存往往有着高达十倍甚至更高的压缩比,节省了大量的存储空间,降低了存储成本。
3)更高的压缩比意味着更小的data size,从磁盘中读取相应数据耗时更短。
4)自由的压缩算法选择。不同列的数据具有不同的数据类型,适用的压缩算法也就不尽相同。可以针对不同列类型,选择最合适的压缩算法。
5)高压缩比,意味着同等大小的内存能够存放更多数据,系统cache效果更好。
官方数据显示,通过使用列存,在某些分析场景下,能够获得100倍甚至更高的加速效应。
ClickHouse支持在建表时,指定将数据按照某些列进行sort by。
排序后,保证了相同sort key的数据在磁盘上连续存储,且有序摆放。在进行等值、范围查询时,where条件命中的数据都紧密存储在一个或若干个连续的Block中,而不是分散的存储在任意多个Block, 大幅减少需要IO的block数量。另外,连续IO也能够充分利用操作系统page cache的预取能力,减少page fault。
ClickHouse支持主键索引,它将每列数据按照index granularity(默认8192行)进行划分,每个index granularity的开头第一行被称为一个mark行。主键索引存储该mark行对应的primary key的值。
对于where条件中含有primary key的查询,通过对主键索引进行二分查找,能够直接定位到对应的index granularity,避免了全表扫描从而加速查询。
但是值得注意的是:ClickHouse的主键索引与MySQL等数据库不同,它并不用于去重,即便primary key相同的行,也可以同时存在于数据库中。要想实现去重效果,需要结合具体的表引擎ReplacingMergeTree、CollapsingMergeTree、VersionedCollapsingMergeTree实现,我们会在未来的文章系列中再进行详细解读。
ClickHouse支持对任意列创建任意数量的稀疏索引。其中被索引的value可以是任意的合法SQL Expression,并不仅仅局限于对column value本身进行索引。之所以叫稀疏索引,是因为它本质上是对一个完整index granularity(默认8192行)的统计信息,并不会具体记录每一行在文件中的位置。目前支持的稀疏索引类型包括:
minmax: 以index granularity为单位,存储指定表达式计算后的min、max值;在等值和范围查询中能够帮助快速跳过不满足要求的块,减少IO。
set(max_rows):以index granularity为单位,存储指定表达式的distinct value集合,用于快速判断等值查询是否命中该块,减少IO。
ngrambf_v1(n, size_of_bloom_filter_in_bytes, number_of_hash_functions, random_seed):将string进行ngram分词后,构建bloom filter,能够优化等值、like、in等查询条件。
tokenbf_v1(size_of_bloom_filter_in_bytes, number_of_hash_functions, random_seed):与ngrambf_v1类似,区别是不使用ngram进行分词,而是通过标点符号进行词语分割。
bloom_filter([false_positive]):对指定列构建bloom filter,用于加速等值、like、in等查询条件的执行。
ClickHouse支持单机模式,也支持分布式集群模式。在分布式模式下,ClickHouse会将数据分为多个分片,并且分布到不同节点上。不同的分片策略在应对不同的SQL Pattern时,各有优势。ClickHouse提供了丰富的sharding策略,让业务可以根据实际需求选用。
1) random随机分片:写入数据会被随机分发到分布式集群中的某个节点上。
2) constant固定分片:写入数据会被分发到固定一个节点上。
3)column value分片:按照某一列的值进行hash分片。
4)自定义表达式分片:指定任意合法表达式,根据表达式被计算后的值进行hash分片。
数据分片,让ClickHouse可以充分利用整个集群的大规模并行计算能力,快速返回查询结果。
更重要的是,多样化的分片功能,为业务优化打开了想象空间。比如在hash sharding的情况下,JOIN计算能够避免数据shuffle,直接在本地进行local join;支持自定义sharding,可以为不同业务和SQL Pattern定制最适合的分片策略;利用自定义sharding功能,通过设置合理的sharding expression可以解决分片间数据倾斜问题等。
另外,sharding机制使得ClickHouse可以横向线性拓展,构建大规模分布式集群,从而具备处理海量数据的能力。
ClickHouse支持PARTITION BY子句,在建表时可以指定按照任意合法表达式进行数据分区操作,比如通过toYYYYMM()将数据按月进行分区、toMonday()将数据按照周几进行分区、对Enum类型的列直接每种取值作为一个分区等。
数据Partition在ClickHouse中主要有两方面应用:
在partition key上进行分区裁剪,只查询必要的数据。灵活的partition expression设置,使得可以根据SQL Pattern进行分区设置,最大化的贴合业务特点。
对partition进行TTL管理,淘汰过期的分区数据。
在分析场景中,数据的价值随着时间流逝而不断降低,多数业务出于成本考虑只会保留最近几个月的数据,ClickHouse通过TTL提供了数据生命周期管理的能力。
ClickHouse支持几种不同粒度的TTL:
1) 列级别TTL:当一列中的部分数据过期后,会被替换成默认值;当全列数据都过期后,会删除该列。
2)行级别TTL:当某一行过期后,会直接删除该行。
3)分区级别TTL:当分区过期后,会直接删除该分区。
ClickHouse采用类LSM Tree的结构,数据写入后定期在后台Compaction。通过类LSM tree的结构,ClickHouse在数据导入时全部是顺序append写,写入后数据段不可更改,在后台compaction时也是多个段merge sort后顺序写回磁盘。顺序写的特性,充分利用了磁盘的吞吐能力,即便在HDD上也有着优异的写入性能。
官方公开benchmark测试显示能够达到50MB-200MB/s的写入吞吐能力,按照每行100Byte估算,大约相当于50W-200W条/s的写入速度。
在分析场景中,删除、更新操作并不是核心需求。ClickHouse没有直接支持delete、update操作,而是变相支持了mutation操作,语法为alter table delete where filter_expr, alter table update col=val where filter_expr。
目前主要限制为删除、更新操作为异步操作,需要后台compation之后才能生效。
ClickHouse通过主备复制提供了高可用能力,主备架构下支持无缝升级等运维操作。而且相比于其他系统它的实现有着自己的特色:
1)默认配置下,任何副本都处于active模式,可以对外提供查询服务;
2)可以任意配置副本个数,副本数量可以从0个到任意多个;
3)不同shard可以配置不提供副本个数,用于解决单个shard的查询热点问题;
ClickHouse在计算层做了非常细致的工作,竭尽所能榨干硬件能力,提升查询速度。它实现了单机多核并行、分布式计算、向量化执行与SIMD指令、代码生成等多种重要技术。
ClickHouse将数据划分为多个partition,每个partition再进一步划分为多个index granularity,然后通过多个CPU核心分别处理其中的一部分来实现并行数据处理。
在这种设计下,单条Query就能利用整机所有CPU。极致的并行处理能力,极大的降低了查询延时。
除了优秀的单机并行处理能力,ClickHouse还提供了可线性拓展的分布式计算能力。ClickHouse会自动将查询拆解为多个task下发到集群中,然后进行多机并行处理,最后把结果汇聚到一起。
在存在多副本的情况下,ClickHouse提供了多种query下发策略:
随机下发:在多个replica中随机选择一个;
最近hostname原则:选择与当前下发机器最相近的hostname节点,进行query下发。在特定的网络拓扑下,可以降低网络延时。而且能够确保query下发到固定的replica机器,充分利用系统cache。
in order:按照特定顺序逐个尝试下发,当前一个replica不可用时,顺延到下一个replica。
first or random:在In Order模式下,当第一个replica不可用时,所有workload都会积压到第二个Replica,导致负载不均衡。first or random解决了这个问题:当第一个replica不可用时,随机选择一个其他replica,从而保证其余replica间负载均衡。另外在跨region复制场景下,通过设置第一个replica为本region内的副本,可以显著降低网络延时。
ClickHouse不仅将数据按列存储,而且按列进行计算。传统OLTP数据库通常采用按行计算,原因是事务处理中以点查为主,SQL计算量小,实现这些技术的收益不够明显。但是在分析场景下,单个SQL所涉及计算量可能极大,将每行作为一个基本单元进行处理会带来严重的性能损耗:
1)对每一行数据都要调用相应的函数,函数调用开销占比高;
2)存储层按列存储数据,在内存中也按列组织,但是计算层按行处理,无法充分利用CPU cache的预读能力,造成CPU Cache miss严重;
3)按行处理,无法利用高效的SIMD指令;
ClickHouse实现了向量执行引擎(Vectorized execution engine),对内存中的列式数据,一个batch调用一次SIMD指令(而非每一行调用一次),不仅减少了函数调用次数、降低了cache miss,而且可以充分发挥SIMD指令的并行能力,大幅缩短了计算耗时。向量执行引擎,通常能够带来数倍的性能提升。
在经典的数据库实现中,通常对表达式计算采用火山模型,也即将查询转换成一个个operator,比如HashJoin、Scan、IndexScan、Aggregation等。为了连接不同算子,operator之间采用统一的接口,比如open/next/close。在每个算子内部都实现了父类的这些虚函数,在分析场景中单条SQL要处理数据通常高达数亿行,虚函数的调用开销不再可以忽略不计。另外,在每个算子内部都要考虑多种变量,比如列类型、列的size、列的个数等,存在着大量的if-else分支判断导致CPU分支预测失效。
ClickHouse实现了Expression级别的runtime codegen,动态地根据当前SQL直接生成代码,然后编译执行。如下图例子所示,对于Expression直接生成代码,不仅消除了大量的虚函数调用(即图中多个function pointer的调用),而且由于在运行时表达式的参数类型、个数等都是已知的,也消除了不必要的if-else分支判断。
近似计算以损失一定结果精度为代价,极大地提升查询性能。在海量数据处理中,近似计算价值更加明显。
ClickHouse实现了多种近似计算功能:
近似估算distinct values、中位数,分位数等多种聚合函数;
建表DDL支持SAMPLE BY子句,支持对于数据进行抽样处理;
复杂数据类型支持
ClickHouse还提供了array、json、tuple、set等复合数据类型,支持业务schema的灵活变更。
固定长度的整型,包括有符号整型或无符号整型。Clickhouse 对大小写敏感。
整型范围(-2^(n-1) ~ 2^(n-1) - 1):
Int8 [-128 : 127]
Int16 [-32768 : 32767]
Int32 [-2147483648 : 2147483647]
Int64 [-9223372036854775808 : 9223372036854775807]
无符号整型范围(0 ~ 2^n -1)
UInt8 [0 : 255]
UInt16 [0 : 65535]
UInt32 [0 : 4294967295]
UInt64 [0 : 18446744073709551615]
Float32 - float
Float64 - double
建议尽可能以整型形式存储数据。例如,将固定精度的数字转换为整数值,如果间用毫秒为单位表示,因为浮点型进行计算时可能引起四舍五入的误差。
与标准SQL 相比,ClickHouse 支持以下类别的浮点数:
Inf - 正无穷
没有单独的类型来存储布尔值。可以使用 UInt8 类型,取值限制为 0 或 1 。
1)String
字符串可以任意长度的。它可以包含任意的字节集,包含空字节。
2) FixedString(N)
固定长度N 的字符串,N 必须是严格的正自然数。当服务端读取长度小于N的字符串时候,通过在字符串末尾添加空字节来达到N 字节长度。当服务端读取长度大于 N 的字符串时候,将返回错误消息。
与String 相比,极少会使用FixedString,因为使用起来不是很方便。
ClickHouse 支持枚举类型,这是一种在定义常量时经常会使用的数据类型。ClickHouse 提供了Enum8 和Enum16 两种枚举类型,它们除了取值范围不同之外,别无二致。枚举固定使用(String:Int)Key/Value 键值对的形式定义数据,所以Enum8 和Enum16分别会对应(String:Int8)和(String:Int16)!
-- 枚举数据类型
CREATE TABLE test_enum(id Int8, color Enum8('red'=1, 'green'=2, 'blue'=3)) engine=Memory;
insert into test_enum values(1,'red'),(2,'red'),(3,'green');
-- 也可以使用这种方式进行插入数据:
insert into test_enum values(4,3);
insert into test_enum values(5,'pink'); -- 没有声明的值是不能插入
select id, toInt32(color) from test_enum;
– 节省存储空间,提升处理效率;底层存储Int类型,占用空间最小
包括 Enum8 和 Enum16 类型。Enum 保存 ‘string’ = integer 的对应关系。
Enum8 用 ‘String’=Int8 对描述。
Enum16 用 ‘String’=Int16 对描述。
Array(T):由T 类型元素组成的数组。
T 可以是任意类型,包含数组类型。但不推荐使用多维数组,ClickHouse 对多维数组的支持有限。例如,不能在MergeTree 表中存储多维数组。
可以使用 array 函数来创建数组:array(T)
也可以使用方括号:[]
– 数组数据类型
array(T) 数组是强数据类型
[e1,e2,d3…]
toTypeName() – 查看变量的数据类型
创建数组案例:
图片
是一个特殊的数据类型,集合
可以存储任意的数据类型,在定义的时候声明数据类型和数据元素个数
元组类型由1~n 个元素组成,每个元素之间允许设置不同的数据类型,且彼此之间不要求兼容。无组同样支持类型推断,
其推断依据仍然以最小存储代码为原则。与数组类似,元组也可以使用两种方式定义
常规方式tuple(T):元组中可以存储多种数据类型,但是要注意数据类型的顺序
Tuple(String, UInt8, Date)
select tuple(1, 'abc', 12.33) as x, toTypeName(x);
┌─x───────────────┬─toTypeName(tuple(1, \'abc\', 12.33))─┐
│ (1,'abc',12.33) │ Tuple(UInt8, String, Float64) │
└─────────────────┴──────────────────────────────────────┘
select (1, 2, 'hello') as x, toTypeName(x);
┌─x─────────────┬─toTypeName(tuple(1, 2, \'hello\'))─┐
│ (1,2,'hello') │ Tuple(UInt8, UInt8, String) │
└───────────────┴────────────────────────────────────┘
Nested 是一种嵌套表结构。一张数据表,可以定义任意多个嵌套类型字段,但每个字段的嵌套层级只支持一级;
即嵌套表内不能继续使用嵌套类型。对于简单场景的层级关系或关联关系,使用嵌套类型也是一种不错的选择;
create table test_nested (
id Int8,
name String,
hobby Nested(
hid Int8,
h1 String,
h2 String
)
)engine=Memory;
查看表结构:
┌─name──────┬─type──────────┬─default_type─┬─default_expression─┐
│ id │ Int8 │ │ │
│ name │ String │ │ │
│ hobby.hid │ Array(Int8) │ │ │
│ hobby.h1 │ Array(String) │ │ │
│ hobby.h2 │ Array(String) │ │ │
└───────────┴───────────────┴──────────────┴────────────────────┘
嵌套类型本质是一种多维数组的结构。嵌套表中每个字段都是一个数组,并且行与行之间数组的长度无须对齐。
需要注意的是,在同一行数据内每个数组字段的长度必须相等。
– 插入数据
insert into test_nested values (1, 'zss', [1,2,3],['吃','喝','睡'],['eat','drink','sleep']);
insert into test_nested values (2, 'lss', [1,2,3],['吃','喝','睡'],['eat','drink','sleep']);
– 查询数据
select * from test_nested;
┌─id─┬─name─┬─hobby.hid─┬─hobby.h1──────┬─hobby.h2────────────────┐
│ 1 │ zss │ [1,2,3] │ ['吃','喝','睡'] │ ['eat','drink','sleep'] │
└────┴──────┴───────────┴───────────────┴─────────────────────────┘
┌─id─┬─name─┬─hobby.hid─┬─hobby.h1──────┬─hobby.h2────────────────┐
│ 2 │ lss │ [1,2,3] │ ['吃','喝','睡'] │ ['eat','drink','sleep'] │
└────┴──────┴───────────┴───────────────┴─────────────────────────┘
– 复杂查询数据
select id, name, hobby.hid, hobby.h1, hobby.h1[1] from test_nested;
┌─id─┬─name─┬─hobby.hid─┬─hobby.h1──────┬─arrayElement(hobby.h1, 1)─┐
│ 1 │ zss │ [1,2,3] │ ['吃','喝','睡'] │ 吃 │
└────┴──────┴───────────┴───────────────┴───────────────────────────┘
┌─id─┬─name─┬─hobby.hid─┬─hobby.h1──────┬─arrayElement(hobby.h1, 1)─┐
│ 2 │ lss │ [1,2,3] │ ['吃','喝','睡'] │ 吃 │
└────┴──────┴───────────┴───────────────┴───────────────────────────┘
Domain pojo beans:https://clickhouse.tech/docs/zh/sql-reference/data-types/domains/ipv4/
域名类型分为IPv4和IPv6两类,本质上它们是对整型和字符串的进一步封装。IPv4类型是基于UInt32封装的
(1)出于便携性的考量,例如IPv4类型支持格式检查,格式错误的IP数据是无法被写入的,例如:INSERT INTO IPv4_test values(‘www.nauu.com’, ‘192.0.0.1’)
(2)出于性能的考量,同样以IPv4为例,IPv4使用UInt32存储,相比String 更加紧凑,占用的空间更小,查询性能更快。IPv6类型是基于FixedString(16) 封装的,它的使用方法与IPv4别无二致,在使用Domain类型的时候还有一点需要注意,虽然它从表象上看起来与String 一样,但Domain 类型并不是字符串,所以它不支持隐式的自动类型转换。如果需要返回IP的字符串形式,则需要显式调用IPv4NumToString 或 IPv6NumToString 函数进行转换。
数据库起到了命名空间的作用,可以有效规避命名冲突的问题,也为后续的数据隔离提供了支撑。任何一张数据表,都必须归属在某个数据库之下。
在CK中数据库也有自己的引擎,数据库目前支持的数据库引擎有5种:
:默认引擎,在绝大多数情况下我们都会使用默认引擎,使用时无须刻意声明。在此数据库下可以使用任意类型的表引擎
字典引擎,此类数据库会自动为所有数据字典创建它们的数据表
内存引擎,用于存放临时数据。此类数据库下的数据表只会停留在内存中,不会涉及任何磁盘操作,当服务重启后数据会被清除
日志引擎,此类数据库下只能使用Log系列的表引擎
MySQL引擎,此类数据库下会自动拉取远端MySQL中的数据,并为它们创建MySQL表引擎的数据表
MySQL数据同步;将MySQL数据全量或增量方式同步到clickhouse中,解决mysql服务并发访问压力过大的问题
ClickHouse 数据表的定义语法,是在标准SQL的基础上建立的,所以熟悉数据库的读者们在看到接下来的语法时,应该会感到熟悉。ClickHouse 目前提供了三种最基本的建表方法!但是注意的是在CK中建表一定要指定表的引擎,那么表的引擎的详细文档在后面。
create table [if not exists] [db_name.]table_name (
name1 [type][DEFAULT|MATERLIALIZED|ALIAS expr],
name2 [type][DEFAULT|MATERLIALIZED|ALIAS expr],
省略...
) ENGINE=engine
第一种建表方式:
create table test_db.test1(
id Int16 DEFAULT 0 comment '用户的标识',
name String comment '用户姓名',
age UInt8 comment '用户年龄'
) ENGINE=Log;
上述语句将会在default 默认的数据库下创建一张内存表。注意末尾的ENGINE 参数,它被用于指定数据表的引擎。表引擎决定了数据表的特性,也决定了数据将会被如何存储及加载。例如示例中使用的Memory表引擎,是ClickHouse最简单的表引擎,数据只会被保存在内存中,在服务重启时数据会丢失。
第二种方式建表:
这种方式其实就是复制已经存在的一张的表结构,可用于数据的备份,可用于多个数据库之间复制表结构
CREATE TABLE [IF NOT EXISTS] [db_name.]table_name AS [db_name2.]table_name2[ENGINE=engine];
示例:
create database newdb;
use newdb;
-- 将复制test_db 数据库中的test1 表的表结构
create table tb_test1 as test_db.test1;desc tb_test1;
第三种方式建表:
通过SELECT 查询的方式创建表,同时也会导入查询的结果数据
-- 语法
CREATE TABLE [IF NOT EXISTS] [db_name.]table_name ENGINE=engine AS SELECT ...
示例
create table tb_log engine=Memory as select * from test_db.tb_tinylog;
DROP TABLE [IF EXISTS] [db_name.]table_name;
ClickHouse 也有临时表的概念,创建临时表的方法是在普通表的基础之上添加TEMPORARY 关键字,相比普通表而言,临时表也如下两点特殊之处:
它的生命周期是会话绑定的,所以它只支持Memeory 表引擎,如果会话结束,数据表就会被销毁;
临时表不属于任何数据库所以在它的建表语句中,既没有数据库参数也没有表引擎参数
临时表的有优先级大于系统中的表,一般用于集群之间的数据传播的载体
临时表的创建语法如下:
CREATE TEMPORARY TABLE [IF NOT EXISTS] table_name (
name1 [type] [DEFAULT|MATERIALIZED|ALIAS expr],
name2 [type] [DEFAULT|MATERIALIZED|ALIAS expr],
...)
-- 直接创建临时表,临时表不属于任何一个数据库,也不会持久保存,不用指定引擎
create temporary table tb_tmp(id Int8, name String); -- 临时表不需要指定表的引擎
-- 创建临时表并加载数据
create temporary table log as select * from test_log;
-- 将临时表的数据导入到当前数据库表中,实现不同数据库的数据迁移
create table tb_log engine=Log as select * from log;
我们可以理解成临时表会将当前数据库中已经存在的同名表覆盖隐藏,当出现操作的时候,如果有临时表,那么会操作临时表!
临时表:
不属于任何数据库
会话断开以后表删除,不会持久化
如果本地表和临时表冲突,临时表优先
数据库之间的数据迁移
create table tb_name engine=Log as select * from db.tb;
create temporary table log as select * from test_db.test_log;
create table tb_log engine=Log as select * from log;
7.4.1 普通视图
ClickHouse拥有普通和物化两种视图,其中物化视图拥有独立的存储,而普通视图只是一层简单的查询代理
CREATE VIEW [IF NOT EXISTS] [db_name.]view_name AS SELECT …
普通视图不会存储任何数据,它只是一层单酏的SELECT 查询映射,起着简化查询、明晰语义的作用,对查询性能不会有任何增强。假设有一张普通视图 view_tb_v1,它是基于数据表 tb_v1创建的,那么下面的两格SELECT 查询是完全等价的。
SELECT * FROM tb_v1
-- tb_v1 的视图
SELECT * FROM view_tb_v1;
示例:
create table tb_teacher (
tid Int8,
name String,
age UInt8,
gender String,
sal Float64
)engine=Log;
insert into tb_teacher values
(1, 'wbb', 27, 'm', 20000),
(2, 'lbb', 25, 'f', 30000),
(3, 'mbb', 26, 'm', 40000),
(4, 'sbb', 37, 'f', 50000),
(5, 'lbb', 17, 'm', 60000);
create view teacher_view as select name, sal from tb_teacher;
-- 普通视图不存储数据,只是一个指向
select * from teacher_view;
7.4.2 物化视图
物化视图支持表引擎,相当于特殊的表,数据保存形式由它的表引擎决定,创建物化视图的完整语法如下所示:
create materialized view mv_log engine=Log populate as select * from log;
在磁盘上有表目录,目录中的文件和映射表的结构一样
-- 物化视图 特殊的表 结构 引擎 持久化
create materialized view m_teacher_view as engine=Log populate as select * from tb_teacher;
select * from m_teacher_view;
-- 向普通表tb_teacher插入数据
insert into tb_teacher values(6, 'xingge', 48, 'm', 1500);
-- 查询物化视图中的数据,同步数据
select * from m_teacher_view;
数据分区(partition)和数据分片(shard)是完全不同的两个概念。数据分区是针对本地数据而言的,是数据的一种纵向切分。而数据分片是数据的一种横向切分。数据分区对于一款OLAP数据库而言意义非凡:借助数据分共,在后续的查询过程中能够跳过不必要的数据目录,从而提升查询的性能。合理地利用分区特性,还可以变相实现数据的更新操作,因为数据分区支持删除、替换和重置操作。假设数据表按照月份分区,那么数据就可以按月份的粒度被替换更新。分区虽好,但不是所有的表引擎都可以使用这项特性,目前只有合并树(MergeTree)家族系列的表引擎才支持数据分区。接下来通过一个简单的例子演示分区表的使用方法。首先由PARTITION BY 指定分区键,并将其格式化为年月的形式:
create table tb_partition(
cid String,
ctime DateTime,
money Float64
)engine=MergeTree() -- MergeTree家族最简单的引擎
partition by toYYYYMM(ctime) -- 按照年月来分区
order by cid -- 指定排序字段; 当排序字段与主键字段是同一个字段时,就会将主键字段不指定
primary key cid;
-- 插入数据
insert into tb_partition values
(1, '2020-12-01 11:00:21', 100),
(2, '2020-12-02 11:12:21', 300),
(3, '2020-11-01 11:00:21', 200);
-- 查询tb_partition表的分区信息
select table, partition, engine, path from system .parts where table='tb_partition';
– 再次插入数据,进入到/var/lib/clickhouse/data/test_db/tb_partition/ 会发现11、12月份的数据都各有2个块
insert into tb_partition values
(4, '2021-05-01 11:00:21', 100),
(5, '2020-12-02 11:12:21', 300),
(6, '2020-11-01 11:00:21', 200);
-- 合并数据
optimize table tb_partition; -- 一次合并两个分区
– 进入到/var/lib/clickhouse/data/test_db/tb_partition/ 会发现11、12月份的数据都各有1个块
– 之前老的分区,默认8分钟会被删除
Kylin 也是大数据方面的,其实它是相当于把时间花在它的机器层面提前算好,但是如果我们的维度不固定,建 Cube 的时间会非常长。
MongoDB 要建一些索引,强依赖左侧原则,当走索引的时候性能确实很好,但我们的搜索条件不固定,无法每次都能靠上索引。
HBase 属于非结构化数据存储的数据库,在实时汇总计算方面也不合适。
clickhouse同样存在以下缺点
不支持事务,没有真正的 update/delete
不支持事务,没有真正的 update/delete,主要还是高并发的短板,所以我们应用都在一些能 Hold 住的场景下。如果对外放在公网,这个 QPS 就可能很难控制,这种场景用 ClickHouse 就要谨慎。
不支持高并发,可以根据实际情况修改 qps 相关配置文件
ClickHouse 吃 CPU,可能团队十个人通过执行同一个查询就可以把一台 CPU 40C 的物理机打爆,但是为什么我前面说我们有 几百亿的数据只需要十台物理机就可以扛得住呢?其实我们对 ClickHouse 做了很多保护。
底层数据大部分是离线的,一部分是实时的,离线数据我们现在大概有将近 3000 多个 job 每天都是把数据从 HIVE 拉到 ClickHouse 里面去;
实时数据主要是接外部数据,然后批量写到 ClickHouse 里面。数据智能平台 80%以上的数据都在 ClickHouse 上面。
应用端在查询的时候中间也有缓存的概念,其实所有的数据我都会通过缓存去过一遍,如果说所有的数据都从 ClickHouse 上拿的话,ClickHouse 的高并发是扛不住的。刚开始我用 ClickHouse 的时候,我们几台物理机每天早上 9 点钟的时候 CPU 会拉到很高,如果 CPU 打到百分之六七十的时候,响应时间就会变慢,这个时候会造成排队查询积压,然后就形成恶性循环,服务器可能就被打挂了,查询也进不来了。
因为我们有大量的单表数据量是超过好几亿的,所以每天的更新是尽量往增量的方式去更新,如果大家都全量更新,再好的服务器也会被我们拉挂,所以要尽量的根据增量方式来更新数据。下图是增量的数据同步流程:
清空 A_temp 表,将最近 3 个月的数据从 Hive 通过 ETL 导入到 A_temp 表;
将 A 表中 3 个月之前的数据 select into 到 A_temp 表;
将 A rename 成 A_temp_temp;
将 A_temp rename 成 A;
将 A_ temp_temp rename 成 A_tem。
比如说订单业绩数据。因为我们订单比较特殊,比如两个月前的订单状态可能还会发生变化,可能会提前好几个月预定。不像我们购物订单,一个月前的订单状态基本上已经固定下来了,快递、物流的订单两周前的状态基本上也不会发生变化,酒店、机票都会存在提前很长时间预定以及历史订单是否成交这种很久之前的订单状态还会发生变化,这种订单状态变化时间跨度会比较长,所以我们更新的历史数据也会比较多。
我们现在主要是增量更新过去三个月到未来,因为过去三个月的数据变化是基本上可以涵盖大部分,我们会把三个月的数据先导到一个 temp 表里面去,如图上也有一个轮询,一定要轮询检测到最近 3 个月的数据导入完成后,再把正式中三个月以前的数据导到这个 temp 表里面来。这个动作也不是一秒两秒就能执行完的,所以同样有个 job 会轮询,并且这个操作我们实践中也发现了一个隐患。
比如说我们的表里面数据已经有五个亿了,但是我们每天更新最近三个月的三五千万数据量,那另外几个亿的数据需要从正式表导到 temp 表的时候,CPU 和内存都会有波动,这是我一直在想办法解决的一个问题。现在我们的方式就是到第二步就把正式表往 temp 表里面导,后面就是和全量的流程是一样的走完,每天这样循环。
增量的整个流程我觉得挺复杂的,这种 case 从 ClickHouse 应用的角度暂时没有很好的解决方案,但建议是从业务场景的角度来切割,让不再发生变化的数据沉淀在固定的场景给用户查询,这是一个方案。
另外我们自己也尝试过用视图的方式,但视图有个问题是会让查询性能慢 2s 左右,这个是我不能接受的,所以我们现在正在用 REPLACE PARTITION 的方式,但这个涉及到文件操作,执行时间虽然是毫秒,我们还在谨慎的灰度中。
上图是一个主动缓存架构图。首先大部分数据从 HIVE 往 ClickHouse 里面导,所有数据都是两份或者三份。
现在没有用 ClickHouse 自带的分布式。因为 2019 年年初采用分布式遇到过一个问题,就是查 A 节点的时候,要从 B 节点和 C 节点拿数据过来时,这个中间分布式内部会存在一个数据交互,如果 QPS 太高压力足够大的时候一个节点挂掉,可能整个节点都会受到影响。当然,如果你的节点足够多是不用 care 这个问题的。如果分布式只是三到四个节点我们觉得意义并不大,所以我当时就临时把分布式给去掉了。
还有一点考量是分布式需要借助 Zookeeper,ClickHouse 大部分我们都是自运维的,如果我们要保证 ClickHouse 高可用首先要保证 Zookeeper 高可用。
采用这个架构就强依赖 Zookeeper 了,这个时候我们团队运维任务也会相应的增加很多。瓶颈会出现在 Zookeeper,因为每天大量的数据更新会导致 Zookeeper 产生大量的日志,所以我现在完全靠物理机。同一块数据保存在两台或者三台机器上面,然后我们会有一个 job 检测同一个表的数据在几台服务器上是否运行完成,完成后我们的 job 会轮询检查这两台机器上面的数据是不是一致,通过这种方式起到数据监控的作用。如果用分布式集群并且要检测数据的准确性,用同样的方式就需要更多的硬件成本,所以以我们的量级采用单机多份存储。
回到缓存上面来,比如针对 A 表建立缓存,我们有个 job 配置某几个 ETL 流程,然后一直在轮询今天是否运行完成,如果完成了我们会针对 A 表会有一个缓存的标志,比如设置一个时间戳,然后外面在应用的时候会获取一个缓存标志的时间戳,构建成为一个新的缓存 key,所以其实我们的缓存大家看上图就知道了。
先拿到缓存的标志中的值,构建成为一个查询缓存的 key,然后查缓存里面有没有数据,如果有则直接从缓存返回数据,如果没有才到 ClickHouse 里面拿数据同时写入缓存,下一次同样的请求就会走缓存了。
如果数据产品足够好的情况下,用户默认条件就可以看到他需要的大部分数据,那用户也不会再切查询条件了。根据这个用户习惯,我们会模拟过去 5 天都有访问过某些热点数据的用户,然后根据默认场景为这些用户提前创建一些缓存,这就是我们通常说的主动缓存。
我们主动模拟用户创建缓存后,用户在 9 点钟上班的时候查询的数据大部分就走缓存了,这样既可以提升整体平台的响应时间也可以降低 ClickHouse 的高并发。当然我们不可能 100%拦住用户所有的查询,但根据我们的埋点来看,我们现在整个平台每天有 60 多万的查询量,一半以上都是走缓存了,被动缓存其实与主动缓存类似,主动缓存是我们监控 etl 流程完成后靠 job 来模拟用户创建缓存,被动缓存就完全靠用户。
下图是平台的一个查询性能,完全下线原来的 SQL/ES 服务器强依赖 ClickHouse,所以后面查询响应就非常平稳了,99%控制在 3 秒内。
现在主要考核指标 :
PC 版在 3 秒,目前为止 99.6%以上的查询都是 3 秒内查询响应时间。这个整个查询次数,每天大概 60 多万,1 秒内的响应时间也有 98%以上;
app 考核是 2s,这两点在我们接入 ClickHouse 效果是很明显,但也有一些比较复杂的查询需要 case by case 优化并且大数据的查询性能也需要长期跟进优化。
这个指标也是我们在大数据技术上的一个沉淀结果,因为对于用户来说他并不知道查询的数据背后有多少亿数据量,多少表在支撑他的一个 query,所以在业务合理的情况下我们不能说因为大数据数据量很大所以我们慢。
1、数据导入之前要评估好分区字段
ClickHouse 因为是根据分区文件存储的,如果说你的分区字段真实数据粒度很细,数据导入的时候就会把你的物理机打爆。其实数据量可能没有多少,但是因为你用的字段不合理,会产生大量的碎片文件,磁盘空间就会打到底。
2、数据导入提前根据分区做好排序,避免同时写入过多分区导致 clickhouse 内部来不及 Merge
数据导入之前我们做好排序,这样可以降低数据导入后 ClickHouse 后台异步 Merge 的时候涉及到的分区数,肯定是涉及到的分区数越少服务器压力也会越小。
3、左右表 join 的时候要注意数据量的变化
再就是左右表 join 的问题,ClickHouse 它必须要大表在左边,小表在右边。但是我们可能某些业务场景跑着跑着数据量会返过来了,这个时候我们需要有监控能及时发现并修改这个 join 关系。
4、根据数据量以及应用场景评估是否采用分布式
如果你的应用场景向上汇总后数据量已经超过了单物理机的存储或者 CPU/内存瓶颈而不得不采用分布式 ClickHouse 也有很完善的 MPP 架构,但同时你也要维护好你的主 keyboard。
5、监控好服务器的 CPU/内存波动
做好监控,前面说过 ClickHouse 的 CPU 拉到 60%的时候,基本上你的慢查询马上就出来了,所以我这边是有对 CPU 和内存的波动进行监控的,类似于 dump,这个我们抓下来以后就可以做分析。
6、数据存储磁盘尽量采用 SSD
尽量用 SSD,因为我之前也开始用过机械硬盘,机械硬盘有一个问题就是当你的服务器要运维以后需要重启,这个时候数据要加载,我们现在单机数据量存储有超过了 200 亿以上,这还是我几个月前统计的。这个数据量如果说用机械硬盘的话,重启一次可能要等上好几个小时服务器才可用,所以尽量用 SSD,重启速度会快很多。
当然重启也有一个问题就是说会导致你的数据合并出现错乱,这是一个坑。所以我每次维护机器的时候,同一个集群我不会同时维护几台机器,我只会一台一台维护,A 机器好了以后会跟它的备用机器对比数据,否则机器起来了,但是数据不一定是对的,并且可能是一大片数据都是不对的。
7、减少数据中文本信息的冗余存储
要减少一些中文信息的冗余存储,因为中文信息会导致整个服务器的 IO 很高,特别是导数据的时候。
8、特别适用于数据量大,查询频次可控的场景,如数据分析、埋点日志系统
从成本角度来说,就像以前我们有很多业务数据的修改日志,大家开发的时候可能都习惯性的存到 MySQL ,但是实际上我认为这种数据非常适合于落到 ClickHouse 里面,比落到 MySQL 里面成本会更低,查询速度会更快。
九、ClickHouse当前存在的问题和规划
现在我们遇到的一些问题是当你服务器上面的数据存储超过你的服务器内存时,会存在内存泄漏。但每天就掉一点点,比如说 128g 内存可能 2-3 个月时间可用内存只有 60%左右,但是这个还是在我用 2018 年的版本时候。我们现在也正在灰度升级到今年的 20.9 的版本,看似还是没有解决。
我们每天大量的数据更新后为了减少用户端使用的影响,我们都是通过 rename 的方式,但对于有些查询并发比较高的表 rename 的时候会存在死锁的情况,这个在 20.9 的版本中已修复。
建议性问题:
1、如何保证高优先级的表在服务器维护后第一时间投入生产应用的问题?
对于 ClickHouse 一个建议性的问题就是服务器重启以后,如果服务器上面的数据量过大,可能要很久的数据加载,重新整理文件后服务器才可用,所以这个我跟俄罗斯研发团队有过沟通,让表分级,高优先级的表先启动,可以早点让服务器起来后投入生产应用,后面的表可以通过 lazy 的方式加载。
新功能的实践:
1、20.9 的新版支持订阅 MySQL 的 binlog 方式同步数据
新功能的时间包括现在有订阅 MySQL 的 binlog 方式同步数据方式,这个我们发现最近几个版本都有在修复一些 bug,所以暂时没有应用,但如果这个做好了是可以用于更多的场景,也可以更好的接入实时数据。
2、查看执行计划
以前的版本只能到服务器上看执行日志,但这个比较费劲,现在可以像 SQL 一样直接看执行计划了。
A1:对,ClickHouse 应用场景一定是在 QPS 控制得住的情况下,如果放在外网控制不住,那 ClickHouse 一台 40 核的 128G 的物理机,可以靠十个人手点把服务器点挂。
A2:是这样,比如数据仓库团队会负责每天把生产数据同步到仓库,后面有一个 DM 团队专门做数据整理,每个业务线的数据做一些逻辑,或做一些沉淀的主题表或者宽表,我们团队负责把我们用到的数据同步到我的 ClickHouse 里面来。因为我的应用不可能直接调 Hive,应用程序直接调 Hive 大家都知道很慢或者基于其他 spark,presto 从性能或者高可用上不能达到我的要求,但 ClickHouse 可以解决这个问题,所以我们用到的数据是 Hive 上有一份,ClickHouse 中也有一份。
A3:对,我们都是将 Hive 数据通过 ETL 同步到应用端来的。
A4:ClickHouse 比较适合于批量写,这个时间间隔可以设置的,之前准备尝试的,后面发现每个月的新版本都有在更新相关的 bug,所以暂停测试了,等稳定一点后我们准备测试;
A5:ClickHouse 我们用了两年多了,根据我们的经验发现,如果是这个机器上面的数据少,只有六七十 G 的时候,重启是没问题的。但是数据如果超过 100G,它的文件合并可能会有问题。当然我也没有具体去测试多大的数据文件是瓶颈,这个问题我反馈给了俄罗斯的研发团队,但现在应该还没有很好的解决方案。
A6:你是从 Oracle 直接同步到 ClickHouse 里面来是吧?
A7:这个问题我还真没有遇到过,如果丢数据我这里也会很容易发现,你的模式与我不同的是中间多了一个 CSV 中转,这个不建议,CSV 有各种分隔符分数据的方法,可能某些数据中包含一些特殊字符,你也不可能打开 CSV 一行一行检查,所以我建议你跳过 CSV 的中转,如果自己测试弄一些数据从 CSV 我觉得可以,投入生产不建议走 CSV 中转;因为我这里每天也有两千多用户都在用我这个平台,我的数据同时存在多份,如果丢数据我们监控是很容易发现的,但是从来没有遇到过丢数据这个问题。
A8:这个你要看一下日志,导数据的时候也会有日志,你要看 ClickHouse 内部的日志。在社区里面也没有看到过谁说导数据的时候会有丢数据的情况。
不建议你用 CSV,因为它会中转,你完全不知道中转的时候会做什么事情,导致文件中的数据行数可能变了。
A9:其实 ClickHouse 支持从 MySQL 同步过去,也支持走程序的方式批量写入或者 spark etl 写入,有很多种写入方式,但是我不建议中间是通过 CSV 中转这种方式去做。
Q10:MySQL 我们在尝试创建一个类似的 MySQL 引擎的 ClickHouse 表的时候,我就发现它的时间会很长,我也不太好评估这一个 SQL 下去会跑多久。
A10:一开始我们也不是通过 ETL 流程往 ClickHouse 上面导数据,也是先写到 MySQL 里面,通过程序的 job。因为你知道,它其实就相当于在 MySQL 上面把一个表导到另外一个表,只是加了一个服务器的 ip 上去,然后加上账号密码,其实很简单。但是我们以前都用这种方式,几千万不会有问题,只是说瓶颈就会压到你的 MySQL 服务器上面,几千万上亿的数据是否能从 MySQL 服务器上取出来,大量的数据读取会对 MySQL 服务器造成很大的压力,所以后来我们 ETL 工具支持后我全部切走断开了对 MySQL 的依赖,每个数据你要评估数据源是什么,可以有很多种写入 ClickHouse 的方式。