一、入门
1、特点
1.1、列式存储
以下面的表为例:
Id | Name | Age |
---|---|---|
1 | 张三 | 18 |
2 | 李四 | 22 |
3 | 王五 | 34 |
1)采用行式存储时,数据在磁盘上的组织结构为:
1 | 张三 | 18 | 2 | 李四 | 22 | 3 | 王五 | 34 |
---|---|---|---|---|---|---|---|---|
好处是想查某个人所有的属性时,可以通过一次磁盘查找加顺序读取就可以。但是当想查所有人的年龄时,需要不停的查找,或者全表扫描才行,遍历的很多数据都是不需要的。
2)采用列式存储时,数据在磁盘上的组织结构为:
1 | 2 | 3 | 张三 | 李四 | 王五 | 18 | 22 | 34 |
---|---|---|---|---|---|---|---|---|
这时想查所有人的年龄只需把年龄那一列拿出来就可以了
3)列式储存的好处:
Ø 对于列的聚合,计数,求和等统计操作原因优于行式存储。 Ø 由于某一列的数据类型都是相同的,针对于数据存储更容易进行数据压缩,每一列选择更优的数据压缩算法,大大提高了数据的压缩比重。 Ø 由于数据压缩比更好,一方面节省了磁盘空间,另一方面对于cache也有了更大的发挥空间。
1.2、多样化引擎
ClickHouse和MySQL类似,把表级的存储引擎插件化,根据表的不同需求可以设定不同的存储引擎。目前包括合并树、日志、接口和其他四大类20多种引擎。
1.3、高吞吐写入能力
类LSM Tree的结构,数据写入后定期在后台Compaction,在后台compaction时也是多个段merge sort后顺序写回磁盘。 官方公开benchmark测试显示能够达到50MB-200MB/s的写入吞吐能力,按照每行100Byte估算,大约相当于50W-200W条/s的写入速度。
1.4、数据分区&线程并行
ClickHouse将数据划分为多个partition,每个partition再进一步划分为多个index granularity(索引粒度) 单条Query就能利用整机所有CPU。极致的并行处理能力,极大的降低了查询延时。 因为单条查询使用多cpu,对于高qps的查询业务,ClickHouse并不是强项。
1.5、劣势
1、不适合搞qps的查询场景 2、join效率比较低
二、数据类型
1、整型
固定长度的整型,包括有符号整型或无符号整型。 整型范围(-2n-1~2n-1-1): Int8 - [-128 : 127] Int16 - [-32768 : 32767] Int32 - [-2147483648 : 2147483647] Int64 - [-9223372036854775808 : 9223372036854775807] 无符号整型范围(0~2n-1): UInt8 - [0 : 255] UInt16 - [0 : 65535] UInt32 - [0 : 4294967295] UInt64 - [0 : 18446744073709551615] 使用场景:个数、数量、也可以存储型id。
2、浮点型
Float32 - float Float64 – double 建议尽可能以整数形式存储数据。例如,将固定精度的数字转换为整数值,如时间用毫秒为单位表示,因为浮点型进行计算时可能引起四舍五入的误差。 使用场景:一般数据值比较小,不涉及大量的统计计算,精度要求不高的时候。比如保存商品的重量。
3、Decimal 型
有符号的浮点数,可在加、减和乘法运算过程中保持精度。对于除法,最低有效数字会被丢弃(不舍入)。 有三种声明: Ø Decimal32(s),相当于Decimal(9-s,s),有效位数为1~9 Ø Decimal64(s),相当于Decimal(18-s,s),有效位数为1~18 Ø Decimal128(s),相当于Decimal(38-s,s),有效位数为1~38 s标识小数位 使用场景:一般金额字段、汇率、利率等字段为了保证小数点精度,都使用Decimal进行存储。
4、字符串
1)String
字符串可以任意长度的。它可以包含任意的字节集,包含空字节。
2)FixedString(N)
固定长度 N 的字符串,N 必须是严格的正自然数。当服务端读取长度小于 N 的字符串时候,通过在字符串末尾添加空字节来达到 N 字节长度。 当服务端读取长度大于 N 的字符串时候,将返回错误消息。 与String相比,极少会使用FixedString,因为使用起来不是很方便。
使用场景:名称、文字描述、字符型编码。固定长度的可以保存一些定长的内容,比如一些编码,性别等但是考虑到一定的变化风险,带来收益不够明显,所以定长字符串使用意义有限。
5、枚举类型
使用场景:对一些状态、类型的字段算是一种空间优化,也算是一种数据约束。但是实际使用中往往因为一些数据内容的变化增加一定的维护成本,甚至是数据丢失问题。所以谨慎使用。
6、时间类型
目前ClickHouse 有三种时间类型 Ø Date接受年-月-日的字符串比如 ‘2019-12-16’ Ø Datetime接受年-月-日 时:分:秒的字符串比如 ‘2019-12-16 20:50:10’ Ø Datetime64接受年-月-日 时:分:秒.亚秒的字符串比如‘2019-12-16 20:50:10.66’ 日期类型,用两个字节存储,表示从 1970-01-01 (无符号) 到当前的日期值。
7、数组
Array(T):由 T 类型元素组成的数组。 T 可以是任意类型,包含数组类型。 但不推荐使用多维数组,ClickHouse 对多维数组的支持有限。例如,不能在MergeTree表中存储多维数组。
三、表引擎
1、使用
表引擎是ClickHouse的一大特色。可以说, 表引擎决定了如何存储表的数据。包括: Ø 数据的存储方式和位置,写到哪里以及从哪里读取数据。 Ø 支持哪些查询以及如何支持。 Ø 并发数据访问。 Ø 索引的使用(如果存在)。 Ø 是否可以执行多线程请求。 Ø 数据复制参数。 表引擎的使用方式就是必须显式在创建表时定义该表使用的引擎,以及引擎使用的相关参数。 特别注意:引擎的名称大小写敏感
2、TinyLog
以列文件的形式保存在磁盘上,不支持索引,没有并发控制。一般保存少量数据的小表,生产环境上作用有限。可以用于平时练习测试用。
create table t_tinylog ( id String, name String) engine=TinyLog;
3、Memory
内存引擎,数据以未压缩的原始形式直接保存在内存当中,服务器重启数据就会消失。读写操作不会相互阻塞,不支持索引。简单查询下有非常非常高的性能表现(超过10G/s)。 一般用到它的地方不多,除了用来测试,就是在需要非常高的性能,同时数据量又不太大(上限大概 1 亿行)的场景。
4、MergeTree
ClickHouse中最强大的表引擎当属MergeTree(合并树)引擎及该系列(*MergeTree)中的其他引擎,支持索引和分区,地位可以相当于innodb之于Mysql。而且基于MergeTree,还衍生除了很多小弟,也是非常有特色的引擎。
a)DDL
create table t_order_mt(
id UInt32,
sku_id String,
total_amount Decimal(16,2),
create_time Datetime
) engine =MergeTree
partition by toYYYYMMDD(create_time)
primary key (id)
order by (id,sku_id);
b)插入数据
insert into t_order_mt values (101,’sku_001’,1000.00,’2020-06-01 12:00:00’) , (102,’sku_002’,2000.00,’2020-06-01 11:00:00’), (102,’sku_004’,2500.00,’2020-06-01 12:00:00’), (102,’sku_002’,2000.00,’2020-06-01 13:00:00’), (102,’sku_002’,12000.00,’2020-06-01 13:00:00’), (102,’sku_002’,600.00,’2020-06-02 12:00:00’);
c)参数
MergeTree其实还有很多参数(绝大多数用默认值即可),但是三个参数是更加重要的,也涉及了关于MergeTree的很多概念。
partition by 分区(可选)
目的是分区降低扫描的范围,优化查询速度,如果不选则为一个分区。 MergeTree =列文件+索引文件+表定义,使用分区这些都会保存到不同的分区目录中。
任何一个批次的数据写入都会产生一个临时分区,不会纳入任何一个已有的分区。写入后的某个时刻(大概10-15分钟后),ClickHouse会自动执行合并操作(等不及也可以手动通过optimize执行),把临时分区的数据,合并到已有分区中。
optimize table xxxx final;
primary key主键(可选)
ClickHouse中的主键,和其他数据库不太一样,它只提供了数据的一级索引,但是却不是唯一约束。这就意味着是可以存在相同primary key的数据的。 主键的设定主要依据是查询语句中的where 条件。 根据条件通过对主键进行某种形式的二分查找,能够定位到对应的index granularity,避免了全表扫描。 index granularity:直接翻译的话就是索引粒度,指在稀疏索引中两个相邻索引对应数据的间隔。ClickHouse中的MergeTree默认是8192。官方不建议修改这个值,除非该列存在大量重复值,比如在一个分区中几万行才有一个不同数据。
稀疏索引
order by(必选)
order by 设定了分区内的数据按照哪些字段顺序进行有序保存。 order by是MergeTree中唯一一个必填项,甚至比primary key 还重要,因为当用户不设置主键的情况,很多处理会依照order by的字段进行处理(比如后面会讲的去重和汇总)。 要求:主键必须是order by字段的前缀字段。 比如order by 字段是(id,sku_id) 那么主键必须是id 或者(id,sku_id)
d)二级索引
create table t_order_mt2(
id UInt32,
sku_id String,
total_amount Decimal(16,2),
create_time Datetime,
INDEX a total_amount TYPE minmax GRANULARITY 5
) engine =MergeTree
partition by toYYYYMMDD(create_time)
primary key (id)
order by (id, sku_id);
其中GRANULARITY N 是设定二级索引对于一级索引粒度的粒度。
e)数据TTL
TTL即Time To Live,MergeTree提供了可以管理数据表或者列的生命周期的功能。
列级别TTL
create table t_order_mt3(
id UInt32,
sku_id String,
total_amount Decimal(16,2) TTL create_time+interval 10 SECOND,
create_time Datetime
) engine =MergeTree
partition by toYYYYMMDD(create_time)
primary key (id)
order by (id, sku_id);
表级TTL
下面的这条语句是数据会在create_time 之后10秒丢失
alter table t_order_mt3 MODIFY TTL create_time + INTERVAL 10 SECOND;
涉及判断的字段必须是Date或者Datetime类型,推荐使用分区的日期字段。 能够使用的时间周期:
- SECOND
- MINUTE
- HOUR
- DAY
- WEEK
- MONTH
- QUARTER
- YEAR
5、ReplacingMergeTree
ReplacingMergeTree是MergeTree的一个变种,它存储特性完全继承MergeTree,只是多了一个去重的功能。尽管MergeTree可以设置主键,但是primary key其实没有唯一约束的功能。如果你想处理掉重复的数据,可以借助这个ReplacingMergeTree。
数据的去重只会在合并的过程中出现。合并会在未知的时间在后台进行,所以你无法预先作出计划。有一些数据可能仍未被处理。 如果表经过了分区,去重只会在分区内部进行去重,不能执行跨分区的去重。 所以ReplacingMergeTree能力有限, ReplacingMergeTree 适用于在后台清除重复的数据以节省空间,但是它不保证没有重复的数据出现。
create table t_order_rmt(
id UInt32,
sku_id String,
total_amount Decimal(16,2) ,
create_time Datetime
) engine =ReplacingMergeTree(create_time)
partition by toYYYYMMDD(create_time)
primary key (id)
order by (id, sku_id);
ReplacingMergeTree() 填入的参数为版本字段,重复数据保留版本字段值最大的。 如果不填版本字段,默认按照插入顺序保留最后一条。
通过测试得到结论 Ø 实际上是使用order by 字段作为唯一键 Ø 去重不能跨分区 Ø 只有同一批插入(新版本)或合并分区时才会进行去重 Ø 认定重复的数据保留,版本字段值最大的 如果版本字段相同则按插入顺序保留最后一笔
6、SummingMergeTree
对于不查询明细,只关心以维度进行汇总聚合结果的场景。如果只使用普通的MergeTree的话,无论是存储空间的开销,还是查询时临时聚合的开销都比较大。 ClickHouse 为了这种场景,提供了一种能够“预聚合”的引擎SummingMergeTree
通过结果可以得到以下结论 Ø 以SummingMergeTree()中指定的列作为汇总数据列 Ø 可以填写多列必须数字列,如果不填,以所有非维度列且为数字列的字段为汇总数据列 Ø 以order by 的列为准,作为维度列 Ø 其他的列按插入顺序保留第一行 Ø 不在一个分区的数据不会被聚合 Ø 只有在同一批次插入(新版本)或分片合并时才会进行聚合
开发建议:设计聚合表的话,唯一键值、流水号可以去掉,所有字段全部是维度、度量或者时间戳。
四、建表优化
1、数据类型
1.1、时间类型
建表时能用数值型或日期时间型表示的字段就不要用字符串,全String类型在以Hive为中心的数仓建设中常见,但ClickHouse环境不应受此影响。 虽然ClickHouse底层将DateTime存储为时间戳Long类型,但不建议存储Long类型,因为DateTime不需要经过函数转换处理,执行效率高、可读性好。
create table t_type2(
id UInt32,
sku_id String,
total_amount Decimal(16,2) ,
create_time Int32
) engine =ReplacingMergeTree(create_time)
partition by toYYYYMMDD(toDate(create_time)) –-需要转换一次,否则报错
primary key (id)
order by (id, sku_id);
1.2、空值存储类型
官方已经指出Nullable类型几乎总是会拖累性能,因为存储Nullable列时需要创建一个额外的文件来存储NULL的标记,并且Nullable列无法被索引。因此除非极特殊情况,应直接使用字段默认值表示空,或者自行指定一个在业务中无意义的值(例如用-1表示没有商品ID)。
1.3、分区和索引
分区粒度根据业务特点决定,不宜过粗或过细。一般选择按天分区,也可以指定为Tuple(),以单表一亿数据为例,分区大小控制在10-30个为最佳。 必须指定索引列,ClickHouse中的索引列即排序列,通过order by指定,一般在查询条件中经常被用来充当筛选条件的属性被纳入进来;可以是单一维度,也可以是组合维度的索引;通常需要满足高级列在前、查询频率大的在前原则;还有基数特别大的不适合做索引列,如用户表的userid字段;通常筛选后的数据满足在百万以内为最佳。
1.4、表参数
Index_granularity是用来控制索引粒度的,默认是8192,如非必须不建议调整。 如果表中不是必须保留全量历史数据,建议指定TTL(生存时间值),可以免去手动过期历史数据的麻烦,TTL 也可以通过alter table语句随时修改。
1.5、写入和删除优化
(1)尽量不要执行单条或小批量删除和插入操作,这样会产生小分区文件,给后台Merge任务带来巨大压力 (2)不要一次写入太多分区,或数据写入太快,数据写入太快会导致Merge速度跟不上而报错,一般建议每秒钟发起2-3次写入操作,每次操作写入2w~5w条数据(依服务器性能而定)
写入过快报错,报错信息:
1. Code: 252, e.displayText() = DB::Exception: Too many parts(304). Merges are processing significantly slower than inserts
2. Code: 241, e.displayText() = DB::Exception: Memory limit (for query) exceeded:would use 9.37 GiB (attempt to allocate chunk of 301989888 bytes), maximum: 9.31 GiB
“ Too many parts 处理 ” :使用WAL预写日志,提高写入性能。 in_memory_parts_enable_wal 默认为 true 在服务器内存充裕的情况下增加内存配额,一般通过max_memory_usage来实现 在服务器内存不充裕的情况下,建议将超出部分内容分配到系统硬盘上,但会降低执行速度,一般通过max_bytes_before_external_group_by、max_bytes_before_external_sort参数来实现。
五、BitMap
如果没有达到阈值,用普通的阈值处理。doris和clickhouse都有,而redis没有
1、优劣势
1.1、优势
a)存储成本
好处一: 如果有一个超大的无序且不重复的整数集合,用Bitmap的存储成本是非常低的。
一个字节可以存8个整数,每一个数的存储成本实质上是1Bit。也就是说Bitmap的存储成本是Array[Int32]的1/32,是Array[Int64]的1/64。
7 |
6 |
5 |
4 |
3 |
2 |
1 |
0 |
---|---|---|---|---|---|---|---|
0 |
0 |
1 |
0 |
0 |
1 |
1 |
0 |
b)天然去重
好处二: 因为每个值都只对应唯一的一个位置,不能存储两个值,所以Bitmap结构可以天然去重。
c)快速定位
好处三:非常方便快速的查询某个元素是否在集合中。
查询3是否在集和中,如果用集合,时间复杂度为O(N),BitMap只需判断下角标1/0。时间复杂度O(1)
7 |
6 |
5 |
4 |
→3 |
2 |
1 |
0 |
---|---|---|---|---|---|---|---|
0 |
0 |
1 |
0 |
0 |
1 |
1 |
0 |
d) 集合间计算
好处四:集合与集合之间的运行非常快。
计算机做与、或、非、异或 等等操作是非常快的。传统方式[1,2,5]与[2,3,7] 取交集就要两层循环遍历。而Bitmap只要把00100110和10001100进行与操作就行了。
e)优势场景
- 海量数据的压缩存储
- 去重存储
- 判断值存在于集合
- 集合之间的交并差
1.2、局限
- 只能存储正整数字而不是字符串
- 存储的值必须是无序不重复
- 不适合存储稀疏的集合,比如一个集合存了三个数[5,1230000,88880000] 这三个数,用Bitmap存储的话其实就不太划算。(但是clickhouse使用的RoaringBitmap,优化了这个稀疏问题。)
2、用户画像实践
2.1、场景
现有如下用户的标签宽表:如果想根据标签划分人群,比如:90后 + 偏好美食。
用户 |
性别 |
年龄 |
偏好 |
---|---|---|---|
1 |
男 |
90后 |
数码 |
2 |
男 |
70后 |
书籍 |
3 |
男 |
90后 |
美食 |
4 |
女 |
80后 |
书籍 |
5 |
女 |
90后 |
美食 |
2.2、传统方案
对列值进行遍历筛选,如果优化也就是列上建立索引,但是当这张表有1000个标签列时,如果要索引生效并不是每列有索引就行,此时宽表列太多,显然这在方式不可行。 select xxx from xxx where xxx=?;
2.3、Bitmap方案
如果能把数据调整成这样的结构,想进行条件组合,那就简单了.
比如: [美食] + [90后] = Bitmap[3,5] & Bitmap[1,3,5] = 3,5这个计算速度相比宽表条件筛选是非常非常快的。
年龄 |
Array |
Bitmap |
---|---|---|
90后 |
1,3,5 |
00101010 |
80后 |
4 |
00010000 |
70后 |
2 |
00000100 |
性别 |
Bitmap |
---|---|
男 |
1,2,3 |
女 |
4,5 |
偏好 |
Bitmap |
|
---|---|---|
数码 |
1 |
|
美食 |
3,5 |
00101000 |
书籍 |
2,4 |
2.4、demo
a)原表
create table test.user_tag_merge
(
uid UInt64,
gender String,
agegroup String,
favor String
)engine=MergeTree()
order by (uid);
b)模拟数据
insert into user_tag_merge values(1,'M','90后','sm');
insert into user_tag_merge values(2,'M','70后','sj');
insert into user_tag_merge values(3,'M','90后','ms');
insert into user_tag_merge values(4,'F','80后','sj');
insert into user_tag_merge values(5,'F','90后','ms');
c)Bitmap表
Bitmap表必须选择AggregatingMergeTree引擎。 对应的Bitmap字段,必须是AggregateFunction(groupBitmap,UInt64),groupBitmap标识数据的聚合方式,UInt64标识最大可存储的数字长度。 业务结构上,稍作了调整。把不同的标签放在了同一张表中,但是因为根据tag_code进行了分区,所以不同的标签实质上还是物理分开的。
create table test.user_tag_value_string
(
tag_code String,
tag_value String ,
us AggregateFunction(groupBitmap,UInt64)
)engine=AggregatingMergeTree()
partition by (tag_code)
order by (tag_value);
标签 |
标签值 |
Bitmap |
---|---|---|
年龄段 |
90后 |
1,3,5 |
年龄段 |
80后 |
4 |
年龄段 |
70后 |
2 |
性别 |
男 |
1,2,3 |
性别 |
女 |
4,5 |
偏好 |
数码 |
1 |
偏好 |
美食 |
3,5 |
偏好 |
书籍 |
2,4 |
d)Bitmap表转换
insert into user_tag_value_string
select
tag_code_value.1 as tag_code
,tag_code_value.2 as tag_value
,groupBitmapState( uid ) us
from (
SELECT
arrayJoin([('agegroup', agegroup), ('gender', gender), ('favor', favor)]) AS tag_code_value,
uid
FROM user_tag_merge
)tv
group by tag_code_value.1,tag_code_value.2
e)转化详细步骤
1 首先第一步要把已有的宽表数据保存进
select
agegroup
,gender
,favor
,uid
from user_tag_merge
2 每个值前面,补上字段名,用()组合成元组
select
('agegroup', agegroup )
,('gender',gender )
, ('favor',favor )
,uid
from user_tag_merge
3 每个列用[]拼接成数组
select [ ('agegroup', agegroup ) ,
('gender',gender ) ,
('favor',favor )] tag_code_value
,uid
from user_tag_merge
4 用arrayJoin炸开,类似于hive中的explode
SELECT
arrayJoin([('agegroup', agegroup), ('gender', gender), ('favor', favor)]) AS tag_code_value,
uid
FROM user_tag_merge
5 把元组中的字段名和字段值拆开,并用这两个作为维度聚合uid
SELECT
tag_code_value.1 AS tag_code,
tag_code_value.2 AS tag_value,
groupArray(uid) AS us
FROM
(
SELECT
arrayJoin([('agegroup', agegroup), ('gender', gender), ('favor', favor)]) AS tag_code_value,
uid
FROM user_tag_merge
) AS tv
GROUP BY
tag_code_value.1,
tag_code_value.2
6 这已经和我们要求的结果非常接近了。只不过关于用户id的聚合,groupArray实现的是拼接成用户id的数组,而我们想要的聚合是,聚合成一个Bitmap。
那只要改一下聚合函数就可以了。
把groupArray 替换成 groupBitmapState
SELECT
tag_code_value.1 AS tag_code,
tag_code_value.2 AS tag_value,
groupBitmapState (uid) AS us
FROM
(
SELECT
arrayJoin([('agegroup', agegroup), ('gender', gender), ('favor', favor)]) AS tag_code_value,
uid
FROM user_tag_merge
) AS tv
GROUP BY
tag_code_value.1,
tag_code_value.2
f)查询
条件组合查询
查询[90后]+[美食]的用户 这里首先用条件筛选出us, 每个代表一个Bitmap结构的uid集合,找到两个Bitmap后用bitmapAnd函数求交集。
select bitmapToArray(
bitmapAnd(
( select us from user_tag_value_string
where tag_value='ms' and tag_code='favor' )
,
( select us from user_tag_value_string
where tag_value='90后' and tag_code='agegroup' )
)
)as res
范围值查询
比如要取 [90后]或者[80后] + [美食] 或者 消费金额大于1000 + [女性] 因为查询时,有可能需要针对某一个标签,取多个值,甚至是一个区间范围,那就会涉及多个值的userId集合,因此需要在子查询内部用groupBitmapMergeState进行一次合并,其实就多个集合取并集。
select bitmapToArray(
bitmapAnd( ( select groupBitmapMergeState(us) us from user_tag_value_string
where tag_value='ms' and tag_code='favor' )
, ( select groupBitmapMergeState(us) from user_tag_value_string
where tag_value in ('90后','80后') and tag_code='agegroup' )
)
)as res
g)函数总结
函数 |
|
---|---|
arrayJoin |
宽表转Bitmap表需要行转列,要用arrayJoin把多列数组炸成行。 |
groupBitmapState |
把聚合列的数字值聚合成Bitmap的聚合函数 |
bitmapAnd |
求两个Bitmap值的交集 |
bitmapOr |
求两个Bitmap值的并集 |
bitmapXor |
求两个Bitmap值的差集(异或) |
bitmapToArray |
把Bitmap转换成数值数组 |
groupBitmapMergeState |
把一列中多个bitmap值进行并集聚合。 (连续值) |