实时数据统计查询实践方案-clickhouse

1,122 阅读14分钟

背景

业务需求需要对每次浏览都进行记录.对店铺、商品等多维度访问情况(pv,uv)的记录统计,mysql已不足以支撑该类型数据存储,在线统计分析查询,经过调研分析后,采用了clickhouse进行数据统计的存储,查询。

clickhouse介绍

ClickHouse是俄罗斯的Yandex于2016年开源的一个用于联机分析(OLAP:Online Analytical Processing)的列式数据库管理系统(DBMS:Database Management System) , 主要用于在线分析处理查询,能够使用SQL查询实时生成分析数据报告。

ClickHouse是一个完全的列式数据库管理系统,支持线性扩展,简单方便,高可靠性,容错。它它的系统在生产环境中可以应用到比较大的规模,因为它的线性扩展能力和可靠性保障能够原生支持 shard + replication (分片+副本)这种解决方案

   

  Clickhouse全称是 Click Stream, Data WareHouse, 简称 ClickHouse

Yandex

       ClickHouse 背后的研发团队是来自俄罗斯的 Yandex 公司 这是家俄罗斯本土的互联网企业,于 2011 年在纳斯达克上市,它的核心产品是搜索引擎。 根据最新(2020年)的数据显示,Yandex 占据了本国 47 %以上的搜索市场,是现今世界上最大的俄语搜索引擎。 Google是它的直接竞争对手

OLTP、OLAP

OLTP on-line transaction processing(联机事务处理) 是传统的关系型数据库的主要应用,主要是基本的、日常的事务处理。强调数据库内存效率,强调内存各种指标的命令率,强调绑定变量,强调并发操作;

OLAP On-Line Analytical Processing (联机分析处理) 是数据仓库系统的主要应用,支持复杂的分析操作,侧重决策支持,并且提供直观易懂的查询结果。 

常用场景

  • 用户行为分析:在采集用户行为日志之后,进行 PV、UV、留存、转化漏斗等操作,例如头条、快手、喜马拉雅等。
  • 用户画像圈选:每个公司都拥有大量的用户和用户画像标签,如何快速从用户画像标签里圈选出某几类标签的人群,例如阿里、喜马拉雅等。
  • 机器日志监控 &查询:每台机器都产生大量日志,如何快速监控、查询机器日志,以确保整体服务没有问题。基本上所有的互联网公司都在这样使用 ClickHouse 的。

数据库表引擎

MergeTree

  • 适用于高负载,最强大的表引擎

  • 支持快速大量(百万行级)插入数据,应用规则在后台合并这些部分

  • 支持数据复制、分区、辅助数据跳过索引以及其他引擎不支持的其他功能

种类

-   MergeTree
-   ReplacingMergeTree
-   SummingMergeTree
-   AggregatingMergeTree
-   CollapsingMergeTree
-   VersionedCollapsingMergeTree
-   GraphiteMergeTree

Log

  • 轻量级引擎,功能最少。当您需要快速编写许多小表(最多约100万行)并在以后作为一个整体读取它们时,它们是最有效的。

  • 种类

    • TinyLog
    • StripeLog
    • Log
  • Integration Engines

与其他数据存储和处理系统通信的引擎

  • 种类

    • Kafka
    • MySQL
    • ODBC
    • JDBC
    • HDFS
    • S3

Special Engines(特殊引擎,不知道如何分类,ClickHouse特有的)

  • Distributed
  • MaterializedView
  • Dictionary
  • Merge
  • File
  • Null
  • Set
  • Join
  • URL
  • View
  • Memory
  • Buffer

    表引擎可以说是clickhouse的核心,每种引擎都有各自的实现及使用场景,MergeTree家族(合并树)又可以说是表引擎的核心,除了少数场景外,选型到的表引擎无一例外都是用的MergeTree,部分clickhouse核心功能也仅在使用MergeTree时才能实现。

MergeTree建表说明

CREATE TABLE shop_view_log_suppday
(
    `user_type` UInt8 DEFAULT 0 COMMENT '用户类型  0 游客 1类型A,2类型B',
    `supp_id` UInt32 DEFAULT 0 COMMENT '供应商id',
    `supp_name` String DEFAULT '' COMMENT '供应商名称',
     `pv` UInt64 DEFAULT 0 COMMENT 'pv',
     `uv` AggregateFunction(uniq, String)  COMMENT 'uv',
    `terminal_type` UInt32 DEFAULT 0 COMMENT '终端类型 1 网页端 2 手机端',
    `create_time` Date DEFAULT now() COMMENT '创建时间'
)
ENGINE = SummingMergeTree((pv,uv))
PARTITION BY toYYYYMM(create_time)
ORDER BY (supp_id,create_time,terminal_type,user_type,source)

PRIMARY KEY (可选)

默认情况下,主键与排序键(由ORDER BY子句指定)相同。因此,在大多数情况下,没有必要指定单独的PRIMARY KEY子句。

ORDER BY

列名或任意表达式的元组。示例:ORDER BY (CounterID, EventDate)。

如果子句未显式定义主键,则 ClickHouse 将排序键用作主键PRIMARY KEY。

PARTITION BY(Merge阶段实现,可选)

在大多数情况下,您不需要分区键,而在大多数其他情况下,您不需要比几个月更细的分区键。分区不会加速查询(与 ORDER BY 表达式相反)。你永远不应该使用太细化的分区。不要按客户端标识符或名称对数据进行分区(而是将客户端标识符或名称作为 ORDER BY 表达式中的第一列)。

对于按月分区,请使用toYYYYMM(date_column)表达式,其中date_column是日期类型为Date的列。此处的分区名称具有"YYYYMM"格式。

稀疏索引(使用主键为标记):

密集索引:文件中的每个搜索码值都对应一个索引值,就是叶子节点保存了整行,比如InnoDB

稀疏索引:文件只为索引码的某些值建立索引项,比如MyISAM,kafka

由于clickhouse为面对大数据量统计而生,所以在此使用的也为稀疏索引。默认索引区为8192(可调)

故可以理解,使用索引的前提,一定是数据存储有序的,所以在建表语句中,order by 为必选,primary key 为可选,当未选择primary key时,默认使用order by 键值作为主键。

数据的写入及整理

数据写入阶段

   每一次insert语句的写入,clickhouse都会在临时分区文件夹生成文件,存储写入的数据,等到后台进程到了执行Merge任务的时间,才会对该批数据进行数据排序等操作。数据写入不建议频繁或者少量写入。

原因:

    Merge阶段执行时间较长,花费资源多,如果频繁写入,未合并的临时文件过多,clickhouse又按照文件夹进行合并,同样时间的合并速度已经低于写入速度,长期下来分区功能近乎于没有,clickhouse考虑到此问题,对当前表存在的临时文件量(part)进行了上限限制报错,所以不支持少量及频繁写入。

CREATE TABLE partition_directory_merge
(
    partition_key Int32,
    orderBy_key String,
    data String
)
ENGINE = MergeTree()
PARTITION BY partition_key
ORDER BY orderBy_key

insert into partition_directory_merge values(1, '001', 'Value');

insert into partition_directory_merge values(1, '002', 'Value2');

insert into partition_directory_merge values(1, '003', 'Value3');

insert into partition_directory_merge values(2, '004', 'Value3');

insert into partition_directory_merge values(2, '005', 'Value3')
  ,(3, '006', 'Value3');




Merge阶段

  MergeTree起名由来便因其核心独特功能在Merge阶段进行实现。clickhouse程序启动后,会启动一个后台定时任务进行分区合并(一般为10-15分钟),排序,Merge过程可能涉及到大量数据处理,所以是一件十分消耗资源的事情。

执行强制合并:

数据的删除与更新

ALTER TABLE [db_name.]table_name DELETE WHERE filter _expr
ALTER TABLE [db_name.]table_name UPDATE columnl = exprl [, . .. ] WHERE filter _expr

 

  Clickhouse将数据的删除、更新定义为重的DDL语言。执行后会开启异步任务在后台执行。对涉及表、列创建新目录进行复制,过滤掉筛选条件,待复制完成后,则使用新目录供给查询,旧目录将会在下一次Merge阶段进行删除。

MergeTree家族

  在MergeTree的基础(稀疏索引,分区,合并)上,在合并阶段增加额外的处理流程,便衍生出了MergeTree家族

ReplacingMergeTree(去重树)

 在MergeTree的基础,增加合并阶段时,对同一分区内,相同排序键(order by字段),进行数据去重

create table tb_replace_tree4(
uid UInt8,
name String,
city String,
version UInt8
)engine=ReplacingMergeTree(version)
partition by city
order by uid;

insert into tb_replace_tree4 values
(1,'zs1','BJ',1),
(1,'zs2','BJ',2),
(1,'ls1','SH',2),
(1,'ls2','SH',1);

select * from tb_replace_tree4;   //都保留了版本大的数据
┌─uid─┬─name─┬─city─┬─version─┐
│   1 │ zs2  │ BJ   │       2 │
└─────┴──────┴──────┴─────────┘
┌─uid─┬─name─┬─city─┬─version─┐
│   1 │ ls1  │ SH   │       2 │
└─────┴──────┴──────┴─────────┘

insert into tb_replace_tree4 values(1,'zs3','BJ',3);
insert into tb_replace_tree4 values(1,'ls3','SH',1);

optimize table tb_replace_tree4 final;
select * from tb_replace_tree4; 
┌─uid─┬─name─┬─city─┬─version─┐
│   1 │ zs3  │ BJ   │       3 │
└─────┴──────┴──────┴─────────┘
┌─uid─┬─name─┬─city─┬─version─┐
│   1 │ ls1  │ SH   │       2 │
└─────┴──────┴──────┴─────────┘ 

SummingMergeTree(求和树)

   各种在线分析功能,往往针对一类源数据,去进行多种维度统计查询。如公司里,按供应商、单位、时间等。SummingMergeTree,AggregatingMereTree两种表引擎,在Merge阶段,进行了数据预聚合处理,SummingMergeTree对同一分区内,相同排序键的数字类型数据(order by字段),进行数据累加,最后将聚合结果进行持久保存,达到同一分区内存储数据为Sum的结果。能够减少聚合查询所需数据,存储的目的。

create table order_table5
(
    id           UInt32,
    item_id       String,
    total_amount Decimal(16, 2),
    create_time  Datetime
) engine = SummingMergeTree(total_amount)
partition by toYYYYMMDD(create_time) 
primary key (id)
order by (id,item_id );

如果设置primary key ,需要是排序键(order by)的前缀字段

insert into order_table5
values (101, 's_001', 1000.00, '2020-06-01 12:00:00'),
       (102, 's_002', 2000.00, '2020-06-01 11:00:00'),
       (102, 's_004', 2500.00, '2020-06-01 12:00:00'),
       (102, 's_002', 2000.00, '2020-06-01 13:00:00'),
       (102, 's_002', 12000.00, '2020-06-01 13:00:00'),
       (102, 's_002', 600.00, '2020-06-02 12:00:00');

可以看出,未到达合并阶段时,数据并不会被聚合处理。

强制执行合并

OPTIMIZE TABLE order_table5;

总结:

以 SummingMergeTree()中指定的列作为汇总数据列

◼ 可以填写多列必须数字列,如果不填,默认以所有非维度列且为数字列的字段为汇总数据列

◼ 以 order by 的列为准,作为维度列进行Sum操作

◼ 其他的列按插入顺序保留第一行

◼ 不在一个分区的数据不会被聚合

实际查询语句:

select id,sum(total_amount) total_amount from order_table5 
where id = 102

AggregatingMereTree(聚合树)

  与SummingMergeTree类似,都是在Merge阶段对数据进行聚合处理,差别是该引擎能支持更多类型的聚合,不止数据累加和。

CREATE TABLE emp_aggregatingmergeTree
(
    company_id  UInt16 COMMENT '公司id',
    company_name     String COMMENT '公司名称',
    dept_id     UInt16 COMMENT '部门id',
    depart     String COMMENT '部门',
    work_place String COMMENT '所在省份',
    salary     AggregateFunction(sum, Decimal32(2)) COMMENT '营业额',
    max_salary     AggregateFunction(max, Decimal32(2)) COMMENT '最大营业额'
) ENGINE = AggregatingMergeTree() 
  ORDER BY (company_id, dept_id)
  PRIMARY KEY company_id 
  PARTITION BY work_place;
INSERT INTO TABLE emp_aggregatingmergeTree 
SELECT 1,'测1','1','部门1','上海',sumState(toDecimal32(10000,2)
                                      ,maxtate(toDecimal32(10000,2));
INSERT INTO TABLE emp_aggregatingmergeTree 
SELECT 1,'测1','1','部门1','上海',sumState(toDecimal32(20000,2))
                                      ,maxtate(toDecimal32(20000,2));
INSERT INTO TABLE emp_aggregatingmergeTree 
SELECT 2,'测2','2','部门2','广州',sumState(toDecimal32(20000,2)),
                                      ,maxtate(toDecimal32(20000,2));

-- 查询数据
SELECT company_id,company_name,sumMerge(salary) maxMerge(max_salary) max
FROM emp_aggregatingmergeTree 
GROUP BY emp_id,name;


-- 结果输出
┌─emp_id─┬─name─┬─sumMerge(salary)─┐┬─max ────────────┐
│      1 │ 测130000.0020000.00  │
└────────┴──────┴──────────────────┘──────────────────┘
│      2 │ 测220000.0020000.00  │
└────────┴──────┴──────────────────┘──────────────────┘

AggregateFunction 

(聚合函数 特殊的数据类型)

聚合函数说明

聚合函数类型

示例:uniq函数(去重作用)

CREATE TABLE shop_view_log_supporgday
(
    .....
     `uv` AggregateFunction(uniq,String) COMMENT 'uv',
    ....
)

对数据源表(shop_view_log)的guid列进行去重得到uv

insert into shop_view_log_supporgday  
select uniqState(guid) uv from  shop_view_log
group by ....
select uniqMerge(uv) uv from shop_view_log_supporgday ......

  AggregateFunction(uniq,String) 即对传进来的数据执行uniq函数,结果以二进制形式进行保存。uniq计算数据哈希值进行去重保存为字符串。

  对AggregateFunction类型数据插入时,调用对应State方法获取函数中间状态进行保存,如uniqState,对uniq类型数据聚合查询时,调用对应Merge方法,此处为uniqMerge,对函数中间状态进行处理去重后计算总数,

VersionedCollapsingMergeTree  

(版本折叠树-逻辑更新、删除数据)

该引擎在合并阶段,对排序键(order by)相等的且sign字段为-1,1的两个数据为一组进行删除。

折叠名字来源:

标记,版本的设置:

CREATE TABLE test
(
    ...
    sign       Int8,
    version    Int8
) ENGINE = VersionedCollapsingMergeTree(sign, version) 
  order by ...

sign用于标记数据是否被删除,-1已删除,1未删除,硬性规定,否则不利于当前引擎的数据查询。

version用于显示当前版本,新数据版本号需要比之前的数据都大(删除除外),折叠版本树会在所有查询语句后增加order by version desc(如原语句有order by 则补在排序尾)字段的逻辑,使得查询到的数据必然是最新一条。

也可看出,数据的删除,版本字段依赖于类似binlog等数据提供新旧数据,版本。

建表语句:

CREATE TABLE emp_versioned
(
    emp_id     UInt16 COMMENT '员工id',
    name       String COMMENT '员工姓名',
    work_place String COMMENT '工作地点',
    age        UInt8 COMMENT '员工年龄',
    depart     String COMMENT '部门',
    sign       Int8,
    version    Int8
) ENGINE = VersionedCollapsingMergeTree(sign, version) 
  ORDER BY (emp_id, name) 
  PARTITION BY work_place;
  

-- 先插入需要被删除的数据,即sign=-1的数据
INSERT INTO emp_versioned VALUES (1,'tom','上海',25,'销售部',-1,1);
-- 再插入sign=1的数据
INSERT INTO emp_versioned VALUES (1,'tom','上海',25,'销售部',1,1);
-- 在插入一个新版本数据
INSERT INTO emp_versioned VALUES (1,'tom','上海',25,'销售部',1,2);


-- 先不执行合并,查看表数据
select * from emp_versioned;
┌─emp_id─┬─name─┬─work_place─┬─age─┬─depart─┬─sign─┬─version─┐
│      1 │ tom  │ 上海       │  25 │ 销售部 │    11 │
└────────┴──────┴────────────┴─────┴────────┴──────┴─────────┘
┌─emp_id─┬─name─┬─work_place─┬─age─┬─depart─┬─sign─┬─version─┐
│      1 │ tom  │ 上海       │  25 │ 销售部 │   -11 │
└────────┴──────┴────────────┴─────┴────────┴───── ┴─────────┘
┌─emp_id─┬─name─┬─work_place─┬─age─┬─depart─┬─sign─┬─version─┐
│      1 │ tom  │ 上海       │  26 │ 技术部 │    12 │
└────────┴──────┴────────────┴─────┴────────┴──────┴─────────┘

-- 获取正确查询结果
SELECT emp_id,name,sum(age * sign) FROM emp_versioned
GROUP BY emp_id,name 
HAVING sum(sign) > 0;

┌─emp_id─┬─name─┬─sum(multiply(age, sign))─┐
│      1 │ tom  │                    26    │
└────────┴──────┴──────────────────────────┘

-- 手动合并
optimize table emp_versioned;

-- 再次查询
select * from emp_versioned;
┌─emp_id─┬─name─┬─work_place─┬─age─┬─depart──┬─sign─┬─version─┐
│      1 │ tom  │ 上海       │  26 │ 技术部  │     12 │
└────────┴──────┴────────────┴─────┴──────── ┴───────┴─────────┘

    可见上面虽然在插入数据乱序的情况下,依然能够实现折叠的效果。之所以能够达到这种效果,是因为在定义version字段之后,VersionedCollapsingMergeTree会自动将version作为排序条件并增加到ORDER BY的末端,就上述的例子而言,最终的排序字段为ORDER BY emp_id,name,version desc。

物化视图:

   上述说明的两种聚合场景使用引擎SummingMergeTree,AggregatingMergeTree的确可以通过预聚合减轻统计查询压力,每张预聚合表都能统计一个至多个维度的数据,但如果统计维度变多之后,增加一张变要多人为写一次插入语句进行数据存储,使流程变得繁琐,且源数据表与聚合表可能存在数据统计后结果极大不一致的隐患。clickhouse所提供的物化视图便能解决该问题。

   物化视图能够在源表插入数据后,触发同步操作,将增量数据同步至物化视图对应表中进行固化存储。不过由于视图表未对历史数据进行存储,仅存储聚合后结果,故源表删除,更新数据操作并不支持同步至物化视图表中。




本次统计实践方案-聚合树+物化视图

需求数据源表:

image.png

1.创建聚合表,用于存储不同维度预聚合数据:

image.png

2.创建物化视图,指定统计后数据存储于预聚合表中,创建后当数据源表(shop_view_log)在之后的每次数据增量时,都会将新生成数据传给视图进行统计。

image.png

3.初始化视图实际表,将视图创建之前的数据进行统计存入视图实际表中

image.png

image.png

参考资料:

OLTP、OLAP简述

详解大厂实时数仓建设

阿里云clickhouse线上沙龙

<<clickhouse原理解析与应用实践>>等