更多技术交流、求职机会,欢迎关注字节跳动数据平台微信公众号,回复【1】进入官方交流群

背景

火山引擎增长分析DataFinder基于ClickHouse来进行行为日志的分析,ClickHouse的主要版本是基于社区版改进开发的字节内部版本。主要的表结构:

事件表:存储用户行为数据,以用户ID分shard存储。

--列出了主要的字段信息
CREATE TABLE tob_apps_all
(
`tea_app_id` UInt32, --应用ID
`device_id` String DEFAULT '', --设备ID
`time` UInt64,--事件日志接受时间
`event` String,--事件名称
`user_unique_id` String,--用户ID
`event_date` Date,--事件日志日期,由time转换而来
`hash_uid` UInt64 --用户ID hash过后的id,用来join降低内存消耗
)│

用户表:存储用户的属性数据,以用户ID分shard存储。

--列出了主要的字段信息
CREATE TABLE users_unique_all
(
`tea_app_id` UInt32, --应用ID
`user_unique_id` String DEFAULT '', -- 用户ID
`device_id` String DEFAULT '', -- 用户最近的设备ID
`hash_uid` UInt64,--用户ID hash过后的id,用来join降低内存消耗
`update_time` UInt64,--最近一次更新时间
`last_active_date` Date --用户最后活跃日期
)

设备表:存储设备相关的数据,以设备ID分shard存储。

--列出了主要的字段信息
CREATE TABLE devices_all
(
`tea_app_id` UInt32, --应用ID
`device_id` String DEFAULT '', --设备ID
`update_time` UInt64, --最近一次更新时间
`last_active_date` Date --用户最后活跃日期
)

业务对象表:存储业务对象相关的数据,每个shard存储全量的数据

--列出了主要的字段信息
CREATE TABLE rangers.items_all
(
`tea_app_id` UInt32,
`hash_item_id` Int64,
`item_name` String, --业务对象名称。比如商品
`item_id` String, --业务对象ID。比如商品id 1000001
`last_active_date` Date
)

业务挑战


随着接入应用以及应用的DAU日益增加,ClickHouse表的事件量增长迅速;并且基于行为数据需要分析的业务指标越来越复杂,需要JOIN的表增多;我们遇到有一些涉及到JOIN的复杂SQL执行效率低,内存和CPU资源占用高,导致分析接口响应时延和错误率增加。

关于Clickhouse的JOIN

在介绍优化之前,先介绍一下基本的ClickHouse JOIN的类型和实现方式

1. 分布式JOIN

SELECT
et.os_name,
ut.device_id AS user_device_id
FROM tob_apps_all AS et
ANY LEFT JOIN
(
SELECT
device_id,
hash_uid
FROM users_unique_all
WHERE (tea_app_id = 268411) AND (last_active_date >= '2022-08-06')
) AS ut ON et.hash_uid = ut.hash_uid
WHERE (tea_app_id = 268411)
AND (event = 'app_launch')
AND (event_date = '2022-08-06')
 

基本执行过程:

  1. 一个Clickhouse节点作为Coordinator节点,给每个节点分发子查询,子查询sql(tob_apps_all替换成本地表,users_unique_all保持不变依然是分布式表)
  2. 每个节点执行Coordinator分发的sql时,发现users_unique_all是分布式表,就会去所有节点上去查询以下SQL(一共有N*N。N为shard数量)

    • SELECT device_id, hash_uid FROM users_unique WHERE (tea_app_id = 268411) AND (last_active_date >= '2022-08-06')
  3. 每个节点从其他N-1个节点拉取2中子查询的全部数据,全量存储(内存or文件),进行本地JOIN
  4. Coordinator节点从每个节点拉取3中的结果集,然后做处理返回给client
    存在的问题:
  5. 子查询数量放大
  6. 每个节点都全量存储全量的数据

2. 分布式Global JOIN

SELECT
et.os_name,
ut.device_id AS user_device_id
FROM tob_apps_all AS et
GLOBAL ANY LEFT JOIN
(
SELECT
device_id,
hash_uid
FROM users_unique_all
WHERE (tea_app_id = 268411) AND (last_active_date >= '2022-08-06')
) AS ut ON et.hash_uid = ut.hash_uid
WHERE (tea_app_id = 268411)
AND (event = 'app_launch')
AND (event_date = '2022-08-06')

基本执行过程:

  1. 一个Clickhouse节点作为Coordinator节点,分发查询。在每个节点上执行sql(tob_apps_all替换成本地表,右表子查询替换成别名ut)
  2. Coordinator节点去其他节点拉取users_unique_all的全部数据,然后分发到全部节点(作为1中别名表ut的数据)
  3. 每个节点都会存储全量的2中分发的数据(内存or文件),进行本地local join
  4. Coordinator节点从每个节点拉取3中的结果集,然后做处理返回给client
    存在的问题:
  5. 每个节点都全量存储数据
  6. 如果右表较大,分发的数据较大,会占用网络带宽资源

3. 本地JOIN

SQL里面只有本地表的JOIN,只会在当前节点执行

SELECT et.os_name,ut.device_id AS user_device_id
FROM tob_apps et any LEFT JOIN
(SELECT device_id,
hash_uid
FROM rangers.users_unique
WHERE tea_app_id = 268411
AND last_active_date>='2022-08-06') ut
ON et.hash_uid=ut.hash_uid
WHERE tea_app_id = 268411
AND event='app_launch'
AND event_date='2022-08-06'

3.1 Hash join

  • 右表全部数据加载到内存,再在内存构建hash table。key为joinkey
  • 从左表分批读取数据,从右表hash table匹配数据
  • 优点是:速度快 缺点是:右表数据量大的情况下占用内存
    3.2 Merge join
  • 对右表排序,内部 block 切分,超出内存部分 flush 到磁盘上,内存大小通过参数设定
  • 左表基于 block 排序,按照每个 block 依次与右表 merge
  • 优点是:能有效控制内存 缺点是:大数据情况下速度会慢
    优先使用hash join当内存达到一定阈值后再使用merge join,优先满足性能要求

解决方案

1. 避免JOIN

1.1 数据预生成

数据预生成(由Spark/Flink或者Clickhouse物化视图产出数据),形成大宽表,基于单表的查询是ClickHouse最为擅长的场景
我们有个指标,实现的SQL比较复杂(如下),每次实时查询很耗时,我们单独建了一个表table,由Spark每日构建出这个指标,查询时直接基于table查询

SELECT event_date,count(distinct uc1) AS uv,sum(value) AS sum_value, ......
FROM
(SELECT event_date,hash_uid AS uc1,sum(et.float_params{'amount'}) AS value, count(1) AS cnt, value*cnt AS multiple
FROM tob_apps_all et GLOBAL ANY LEFT JOIN
(SELECT hash_uid AS join_key,int_profiles{'$ab_time_34'}*1000 AS first_time
FROM users_unique_all
WHERE app_id = 10000000 AND last_active_date >= '2022-07-19' AND first_time is NOT null) upt
ON et.hash_uid=upt.join_key
WHERE (查询条件)
GROUP BY uc1,event_date)
GROUP BY event_date;

数据量2300W,查询时间由7秒->0.008秒。当然这种方式,需要维护额外的数据构建任务。总的思路就是不要让ClickHouse实时去JOIN

1.2 使用IN代替JOIN

JOIN需要基于内存构建hash table且需要存储右表全部的数据,然后再去匹配左表的数据。而IN查询会对右表的全部数据构建hash set,但是不需要匹配左表的数据,且不需要回写数据到block
比如

SELECT event_date, count()
FROM tob_apps_all et global any INNER JOIN
(SELECT hash_uid AS join_key
FROM users_unique_all
WHERE app_id = 10000000
AND last_active_date >= '2022-01-01') upt
ON et.hash_uid = upt.join_key
WHERE app_id = 10000000
AND event_date >= '2022-01-01'
AND event_date <= '2022-08-02'
GROUP BY event_date

可以改成如下形式:

SELECT event_date,
count()
FROM tob_apps_all
WHERE app_id = 10000000
AND event_date >= '2022-01-01'
AND event_date <= '2022-08-02'
AND hash_uid global IN
(SELECT hash_uid
FROM users_unique_all
WHERE (tea_app_id = 10000000)
AND (last_active_date >= '2022-01-01') )
GROUP BY event_date

如果需要从右表提取出属性到外层进行计算,则不能使用IN来代替JOIN相同的条件下,上面的测试SQL,由JOIN时的16秒优化到了IN查询时的11秒

2. 更快的JOIN

2.1 优先本地JOIN

2.1.1 数据预先相同规则分区
也就是Colocate JOIN。优先将需要关联的表按照相同的规则进行分布,查询时就不需要分布式的JOIN

SELECT
et.os_name,
ut.device_id AS user_device_id
FROM tob_apps_all AS et
ANY LEFT JOIN
(
SELECT
device_id,
hash_uid
FROM users_unique_all
WHERE (tea_app_id = 268411) AND (last_active_date >= '2022-08-06')
) AS ut ON et.hash_uid = ut.hash_uid
WHERE (tea_app_id = 268411)
AND (event = 'app_launch')
AND (event_date = '2022-08-06')
settings distributed_perfect_shard=1

比如事件表tob_apps_all和用户表users_unique_all都是按照用户ID来分shard存储的,相同的用户的两个表的数据都在同一个shard上,因此这两个表的JOIN就不需要分布式JOIN了
distributed_perfect_shard这个settings key是字节内部ClickHouse支持的,设置过这个参数,指定执行计划时就不会再执行分布式JOIN了
基本执行过程:

  1. 一个ClickHouse节点作为Coordinator节点,分发查询。在每个节点上执行sql(tob_apps_all、users_unique_all替换成本地表)
  2. 每个节点都执行1中分发的本地表join的SQL(这一步不再分发右表全量的数据)
  3. 数据再回传到coordinator节点,然后返回给client

2.1.2 数据冗余存储
如果一个表的数据量比较小,可以不分shard存储,每个shard都存储全量的数据,例如我们的业务对象表。查询时,不需要分布式JOIN,直接在本地进行JOIN即可

SELECT count()
FROM tob_apps_all AS et
ANY LEFT JOIN
(
SELECT item_id
FROM items_all
WHERE (tea_app_id = 268411)
) AS it ON et.item_id = it.item_id
WHERE (tea_app_id = 268411)
AND (event = 'app_launch')
AND (event_date = '2022-08-06')
settings distributed_perfect_shard=1

例如这个SQL,items_all表每个shard都存储同样的数据,这样也可以避免分布式JOIN带来的查询放大和全表数据分发问题

2.2 更少的数据

不论是分布式JOIN还是本地JOIN,都需要尽量让少的数据参与JOIN,既能提升查询速度也能减少资源消耗

2.2.1 SQL下推

ClickHouse对SQL的下推做的不太好,有些复杂的SQL下推会失效。因此,我们手动对SQL做了下推,目前正在测试基于查询优化器来帮助实现下推优化,以便让SQL更加简洁
下推的SQL:

SELECT
et.os_name,
ut.device_id AS user_device_id
FROM tob_apps_all AS et
ANY LEFT JOIN
(
SELECT
device_id,
hash_uid
FROM users_unique_all
WHERE (tea_app_id = 268411)
AND (last_active_date >= '2022-08-06'
AND 用户属性条件1 OR 用户属性条件2)
) AS ut ON et.hash_uid = ut.hash_uid
WHERE (tea_app_id = 268411)
AND (event = 'app_launch')
AND (event_date = '2022-08-06')
settings distributed_perfect_shard=1

对应的不下推的SQL:

SELECT
et.os_name,
ut.device_id AS user_device_id
FROM tob_apps_all AS et
ANY LEFT JOIN
(
SELECT
device_id,
hash_uid
FROM rangers.users_unique_all
WHERE (tea_app_id = 268411)
AND (last_active_date >= '2022-08-06')
) AS ut ON et.hash_uid = ut.hash_uid
WHERE (tea_app_id = 268411)
AND (event = 'app_launch')
AND (event_date = '2022-08-06')
AND (ut.用户属性条件1 OR ut.用户属性条件2)
settings distributed_perfect_shard=1

可以看到,不下推的SQL更加简洁,直接基于JOIN过后的宽表进行过滤。但是ClickHouse可能会将不满足条件的users_unique_all数据也进行JOIN我们使用中有一个复杂的case,用户表过滤条件不下推有1千万+,SQL执行了3000秒依然执行超时,而做了下推之后60秒内就执行成功了

2.3Clickhouse引擎层优化

一个SQL实际在Clickhouse如何执行,对SQL的执行时间和资源消耗至关重要。社区版的Clickhouse在执行模型和SQL优化器上还要改进的空间,尤其是复杂SQL以及多JOIN的场景下

执行模型优化社区版的Clickhouse

目前还是一个两阶段执行的执行模型。第一阶段,Coordinator在收到查询后,将请求发送给对应的Worker节点。第二阶段,Worker节点完成计算,Coordinator在收到各Worker节点的数据后进行汇聚和处理,并将处理后的结果返回。

有以下几个问题:

  1. 第二阶段的计算比较复杂时,Coordinator的节点计算压力大,容易成为瓶颈
  2. 不支持shuffle join,hash join时右表为大表时构建慢,容易OOM
  3. 对复杂查询的支持不友好

字节跳动ClickHouse团队为了解决上述问题,改进了执行模型,参考其他的分布式数据库引擎(例如Presto等),将一个复杂的Query按数据交换情况切分成多个 Stage,各Stage之间则通过Exchange完成数据交换。根据Stage依赖关系定义拓扑结构,产生DAG图,并根据DAG图调度Stage。例如两表Join,会先调度左右表读取Stage,之后再调度Join这个Stage,Join的Stage依赖于左右表的Stage。

举个例子

SELECT
et.os_name,
ut.device_id AS user_device_id,
dt.hash_did AS device_hashid
FROM tob_apps_all AS et
GLOBAL ANY LEFT JOIN
(
SELECT
device_id,
hash_uid
FROM users_unique_all
WHERE (tea_app_id = 268411) AND (last_active_date >= '2022-08-06')
) AS ut ON et.hash_uid = ut.hash_uid
GLOBAL ANY LEFT JOIN
(
SELECT
device_id,
hash_did
FROM devices_all
WHERE (tea_app_id = 268411) AND (last_active_date >= '2022-08-06')
) AS dt ON et.device_id = dt.device_id
WHERE (tea_app_id = 268411) AND (event = 'app_launch') AND (event_date = '2022-08-06')
LIMIT 10

Stage执行模型基本过程(可能的):

  • 读取tob_apps_all数据,按照join key(hash_uid)进行shuffle,数据分发到每个节点。这是一个Stage
  • 读取users_unique_all数据,按照join key(hash_uid)进行shuffle,数据分发到每个节点。这是一个Stage
  • 上述两个表的数据,在每个节点上的数据进行本地join,然后再按照join key(device_id)进行shuffle。这是一个Stage
  • 读取devices_all数据,按照join key(device_id)进行shuffle,这是一个Stage
  • 第3步、第4步的数据,相同join key(device_id)的数据都在同一个节点上,然后进行本地JOIN,这是一个Stage
  • 汇总数据,返回limit 10的数据。这是一个Stage
    统计效果如下:

    查询优化器
    有了上面的stage的执行模型,可以灵活调整SQL的执行顺序,字节跳动Clickhouse团队自研了查询优化器,根据优化规则(基于规则和代价预估)对SQL的执行计划进行转换,一个执行计划经过优化规则后会变成另外一个执行计划,能够准确的选择出一条效率最高的执行路径,然后构建Stage的DAG图,大幅度降低查询时间
    下图描述了整个查询的执行流程,从 SQL parse 到执行期间所有内容全部进行了重新实现(其中紫色模块),构建了一套完整的且规范的查询优化器。

    还是上面的三表JOIN的例子,可能的一个执行过程是:
  • 查询优化器发现users_unique_all表与tob_apps_all表的分shard规则一样(基于用户ID),所以就不会先对表按 join key 进行 shuffle,users_unique与tob_apps直接基于本地表JOIN,然后再按照join key(device_id)进行shuffle。这是一个Stage
  • 查询优化器根据规则或者代价预估决定设备表devices_all是需要broadcast join还是shuffle join 如果broadcast join:在一个节点查到全部的device数据,然后分发到其他节点。这是一个Stage 如果shuffle join:在每个节点对device数据按照join key(device_id)进行shuffle。这是一个Stage
  • 汇总数据,返回limit 10的数据。这是一个Stage

效果:可以看到,查询优化器能优化典型的复杂的SQL的执行效率,缩短执行时间

总结

ClickHouse最为擅长的领域是一个大宽表来进行查询,多表JOIN时Clickhouse性能表现不佳。作为业内领先的用户分析与运营平台,火山引擎增长分析DataFinder基于海量数据做到了复杂指标能够秒级查询。本文介绍了我们是如何优化Clickhouse JOIN查询的。
主要有以下几个方面:

  1. 减少参与JOIN的表以及数据量
  2. 优先使用本地JOIN,避免分布式JOIN带来的性能损耗
  3. 优化本地JOIN,优先使用内存进行JOIN
  4. 优化分布式JOIN的执行逻辑,依托于字节跳动对ClickHouse的深度定制化

立即跳转火山引擎DataFinder官网了解详情

火山引擎在行为分析场景下的ClickHouse JOIN优化的更多相关文章

  1. MySQL在大数据、高并发场景下的SQL语句优化和"最佳实践"

    本文主要针对中小型应用或网站,重点探讨日常程序开发中SQL语句的优化问题,所谓“大数据”.“高并发”仅针对中小型应用而言,专业的数据库运维大神请无视.以下实践为个人在实际开发工作中,针对相对“大数据” ...

  2. 高性能场景下,HashMap的优化使用建议

    1. HashMap 在JDK 7 与 JDK8 下的差别 顺便理一下HashMap.get(Object key)的几个关键步骤,作为后面讨论的基础. 1.1 获取key的HashCode并二次加工 ...

  3. 一种特殊场景下的HASH JOIN的优化为NEST LOOP.

    应用场景: 有如下的SQL: select t.*, t1.ownerfrom t, t1where t.id=t1.id; 表t ,t1的数据量比较大,比如200W行.但是两张表能关联的行数却很少, ...

  4. 域控场景下windows安全日志的分析--审计认证行为和命令的历史记录

    https://www.cnblogs.com/KevinGeorge/p/8563458.html 一.域控windows安全日志基本操作 1.打开powershell或者cmd 1 #gpedit ...

  5. 硬核测试:Pulsar 与 Kafka 在金融场景下的性能分析

    背景 Apache Pulsar 是下一代分布式消息流平台,采用计算存储分层架构,具备多租户.高一致.高性能.百万 topic.数据平滑迁移等诸多优势.越来越多的企业正在使用 Pulsar 或者尝试将 ...

  6. 从 ClickHouse 到 ByteHouse:实时数据分析场景下的优化实践

    本文来自火山引擎公众号,原文发布于2021-09-06. 近日,字节跳动旗下的企业级技术服务平台火山引擎正式对外发布「ByteHouse」,作为 ClickHouse 企业版,解决开源技术上手难 &a ...

  7. 火山引擎MARS-APM Plus x 飞书 |降低线上OOM,提高App性能稳定性

    通过使用火山引擎MARS-APM Plus的memory graph功能,飞书研发团队有效分析定位问题线上case多达30例,线上OOM率降低到了0.8‰,降幅达到60%.大幅提升了用户体验,为飞书的 ...

  8. 火山引擎 DataLeap 的 Data Catalog 系统公有云实践

      Data Catalog 通过汇总技术和业务元数据,解决大数据生产者组织梳理数据.数据消费者找数和理解数的业务场景.本篇内容源自于火山引擎大数据研发治理套件 DataLeap 中的 Data Ca ...

  9. 还原火山引擎 A/B 测试产品——DataTester 私有化部署实践经验

      作为一款面向ToB市场的产品--火山引擎A/B测试(DataTester)为了满足客户对数据安全.合规问题等需求,探索私有化部署是产品无法绕开的一条路.   在面向ToB客户私有化的实际落地中,火 ...

  10. 火山引擎DataLeap数据调度实例的 DAG 优化方案

    更多技术交流.求职机会,欢迎关注字节跳动数据平台微信公众号,并进入官方交流群 实例 DAG 介绍 DataLeap 是火山引擎自研的一站式大数据中台解决方案,集数据集成.开发.运维.治理.资产管理能力 ...

随机推荐

  1. P8816 [CSP-J 2022] 上升点列

    Problem 考察算法:\(DP\). 题目简述 给你 \(n\) 个点,每个点有一个坐标 \((x_i,y_i)\),还可以添加 \(k\) 个点. 添加之后,求:最长的上升点列的长度. 上升点列 ...

  2. angular,vue,react三大框架选型

    三大框架,本质都是基于js的web应用(前端做的都是web应用包括移动)框架,他们都是帮助我们解决问题的工具,具体用哪个,要结合具体场景. 这三者中,Angular的适用领域相对窄一些,React可以 ...

  3. Web Woeker和Shared Worker的使用以及案例

    目录 1.前言 2.介绍 Web Worker 3.使用须知及兼容性 3.1.使用须知 3.2.兼容性 4.使用 Web Worker 4.1.创建 Web Worker 4.2.与主线程通信 4.3 ...

  4. NEFU OJ Problem1356 帽儿山奇怪的棋盘 题解

    帽儿山奇怪的棋盘 题目: Time Limit:1000ms | Memory Limit:65535K Description 军哥来到了帽儿山,发现有两位神人在顶上对弈.棋盘长成下图的模样: 每个 ...

  5. IT运营与DevOps:有何不同?

    IT 运营和 DevOps 满足许多现代企业密切相关的需求.然而,尽管这两种角色之间有许多相似之处,但也有重要的区别,将 IT 运营与 DevOps 混为一谈是错误的. 本文通过解释每种类型的角色是做 ...

  6. 数据库系列:RR和RC下,快照读的区别

    数据库系列:MySQL慢查询分析和性能优化 数据库系列:MySQL索引优化总结(综合版) 数据库系列:高并发下的数据字段变更 数据库系列:覆盖索引和规避回表 数据库系列:数据库高可用及无损扩容 数据库 ...

  7. 基于Python下MySQL数据库驱动

    由于MySQL服务器以独立的进程运行,并通过网络对外服务,所以,需要支持Python的MySQL驱动来连接到MySQL服务器. 1.mysql-connector-python ​ mysql-con ...

  8. C语言,可爱的小明特别喜欢爬楼梯,他有的时候一次爬一个台阶,有的时候一次爬两个台阶,有的时候一次爬三个台阶。如果这个楼梯有n个台阶,小明一共有多少种爬法呢?n值从键盘输入。

    /* 开发者:慢蜗牛 开发时间:2020.5.28 程序功能:小明爬楼梯 */ #include<stdio.h> int taijie(int n); long taijie(int n ...

  9. python列表之部分列表——切片

    目录 切片 全索引 半索引 负数索引 遍历切片 复制列表 切片 全索引 我们平常可能需要使用一个列表中的子列表,也就是部分元素,这个时候我们就需要用到切片这个功能啦 要创建切片,就如同range()函 ...

  10. SSPRQ码型设计

    serdes速率超过50G之后,在VSR光模块场景下SSPRQ使用较为广泛.SSPRQ用于PAM4光模块眼图压力,闭合率测试. 但是有很多serdes IP不支持此功能.出于测试需要需要新设计SSPR ...