Doris-03-Doris的查询(Join查询、去重)

查询

查询设置

(1)增大内存

一个查询任务,在单个 BE 节点上默认使用不超过 2GB 内存,内存不够时, 查询可能会出现‘Memory limit exceeded’。

SHOW VARIABLES LIKE "%mem_limit%";

exec_mem_limit 的单位是 byte,可以通过 SET 命令改变 exec_mem_limit 的值。如改为 8GB。

SET exec_mem_limit = 8589934592;

上述设置仅仅在当前 session 有效, 如果想永久有效, 需要添加 global 参数。

SET GLOBAL exec_mem_limit = 8589934592;

(2)修改超时时间

doris 默认最长查询时间为 300s, 如果仍然未完成, 会被 cancel 掉,查看配置:

SHOW VARIABLES LIKE "%query_timeout%";

可以修改为 60s:

SET query_timeout = 60;

同样, 如果需要全局生效需要添加参数 global。

set global query_timeout = 60;

当前超时的检查间隔为 5 秒,所以小于 5 秒的超时不会太准确。

查询重试和高可用(ProxySQL)

当部署多个 FE 节点时,用户可以在多个 FE 之上部署负载均衡层来实现 Doris 的高可用。

  • 代码方式:自己在应用层代码进行重试和负载均衡。比如发现一个连接挂掉,就自动在其他连接上进行重试。应用层代码重试需要应用自己配置多个 doris 前端节点地址。

  • JDBC Connector:如果使用 mysql jdbc connector 来连接 Doris,可以使用 jdbc 的自动重试机制:

    jdbc:mysql://[host1][:port1],[host2][:port2][,[host3][:port3]]...
    [/[database]][?propertyName1=propertyValue1[&propertyName2=proper
    tyValue2]...]
    
  • ProxySQL 方式

    ProxySQL 是灵活强大的 MySQL 代理层, 是一个能实实在在用在生产环境的 MySQL 中间件,可以实现读写分离,支持 Query 路由功能,支持动态指定某个 SQL 进行 cache,支持动态加载配置、故障切换和一些 SQL 的过滤功能。

    Doris 的 FE 进程负责接收用户连接和查询请求,其本身是可以横向扩展且高可用的,但是需要用户在多个 FE 上架设一层 proxy,来实现自动的连接负载均衡。

(1)安装 ProxySQL (yum 方式)

配置 yum 源
# vim /etc/yum.repos.d/proxysql.repo
[proxysql_repo]
name= ProxySQL YUM repository
baseurl=http://repo.proxysql.com/ProxySQL/proxysql-
1.4.x/centos/\$releasever
gpgcheck=1
gpgkey=http://repo.proxysql.com/ProxySQL/repo_pub_key
执行安装
# yum clean all
# yum makecache
# yum -y install proxysql
查看版本 
# proxysql --version
设置开机自启动
# systemctl enable proxysql
# systemctl start proxysql 
# systemctl status proxysql
启动后会监听两个端口, 默认为 60326033。6032 端口是 ProxySQL 的管理端口,
6033 是 ProxySQL 对外提供服务的端口 (即连接到转发后端的真正数据库的转发端口)# netstat -tunlp

(2)ProxySQL 配置

ProxySQL 有配置文件 /etc/proxysql.cnf 和配置数据库文件/var/lib/proxysql/proxysql.db。这里需要特别注意:如果存在如果存在"proxysql.db"文件(在/var/lib/proxysql 目录下),则ProxySQL 服务只有在第一次启动时才会去读取 proxysql.cnf 文件并解析;后面启动会就不会读取 proxysql.cnf 文件了!如果想要让 proxysql.cnf 文件里的配置在重启 proxysql 服务后生效(即想要让 proxysql 重启时读取并解析 proxysql.cnf 配置文件),则需要先删除/var/lib/proxysql/proxysql.db 数据库文件,然后再重启 proxysql 服务。这样就相当于初始化启动 proxysql 服务了,会再次生产一个纯净的 proxysql.db 数据库文件(如果之前配置了proxysql 相关路由规则等,则就会被抹掉)

  • 查看及修改配置文件:主要是几个参数,在下面已经注释出来了,可以根据自己的需要进行修改:

    # vim /etc/proxysql.cnf
    datadir="/var/lib/proxysql" #数据目录
    admin_variables=
    {
          
          
     admin_credentials="admin:admin" #连接管理端的用户名与密码
     mysql_ifaces="0.0.0.0:6032" #管理端口,用来连接 proxysql 的管理数据库
    }
    mysql_variables=
    {
          
          
     threads=4 #指定转发端口开启的线程数量
     max_connections=2048
     default_query_delay=0
     default_query_timeout=36000000
     have_compress=true
     poll_timeout=2000
     interfaces="0.0.0.0:6033" #指定转发端口,用于连接后端 mysql 数据库的,相当于代理作用
     default_schema="information_schema"
     stacksize=1048576
     server_version="5.7.28" #指定后端 mysql 的版本
     connect_timeout_server=3000
     monitor_username="monitor"
     monitor_password="monitor"
     monitor_history=600000
     monitor_connect_interval=60000
     monitor_ping_interval=10000
     monitor_read_only_interval=1500
     monitor_read_only_timeout=500
     ping_interval_server_msec=120000
     ping_timeout_server=500
     commands_stats=true
     sessions_sort=true
     connect_retries_on_failure=10
    }
    mysql_servers =
    ()
    mysql_users:
    ()
    mysql_query_rules:
    ()
    scheduler=
    ()
    mysql_replication_hostgroups=
    ()
    
  • 连接 ProxySQL 管理端口测试:

    # mysql -h 127.0.0.1 -P 6032 -u admin -p
    查看 main 库(默认登陆后即在此库)的 global_variables 表信息
    show databases;
    use main;
    show tables;
    
  • ProxySQL 配置后端 Doris FE

    使用 insert 语句添加主机到 mysql_servers 表中,其中:hostgroup_id 为 10 表示写组,为 20 表示读组,我们这里不需要读写分离,无所谓随便设置哪一个都可以。

    mysql -u admin -p admin -P 6032 -h 127.0.0.1
    insert into mysql_servers(hostgroup_id,hostname,port) values(10,'192.168.8.101',9030); 
    insert into mysql_servers(hostgroup_id,hostname,port) values(10,'192.168.8.102',9030);
    insert into mysql_servers(hostgroup_id,hostname,port) values(10,'192.168.8.103',9030);
    
    # 如果在插入过程中,出现报错:
    #    ERROR 1045 (#2800): UNIQUE constraint failed: 
    mysql_servers.hostgroup_id, mysql_servers.hostname, 
    mysql_servers.port
    # 说明可能之前就已经定义了其他配置,可以清空这张表 或者 删除对应 host 的配置
    select * from mysql_servers;
    delete from mysql_servers;
    
    # 查看这 3 个节点是否插入成功,以及它们的状态。
    select * from mysql_servers\G;
    
    # 如上修改后,加载到 RUNTIME,并保存到 disk,下面两步非常重要,不然退出以后配置信息就没了,必须保存
    load mysql servers to runtime;
    save mysql servers to disk;
    
  • 监控 Doris FE 节点配置

    添 doris fe 节点之后,还需要监控这些后端节点。对于后端多个 FE 高可用负载均衡环境来说,这是必须的,因为 ProxySQL 需要通过每个节点的 read_only 值来自动调整它们是属于读组还是写组。

    首先在后端 master 主数据节点上创建一个用于监控的用户名。

    # 在 doris fe master 主数据库节点行执行:
    # mysql -h hadoop1 -P 9030 -u root -p 
    create user monitor@'192.168.8.%' identified by 'monitor';
    grant ADMIN_PRIV on *.* to monitor@'192.168.8.%';
    
    # 然后回到 mysql-proxy 代理层节点上配置监控
    # mysql -uadmin -padmin -P6032 -h127.0.0.1
    set mysql-monitor_username='monitor';
    set mysql-monitor_password='monitor';
    # 修改后,加载到 RUNTIME,并保存到 disk
    load mysql variables to runtime;
    save mysql variables to disk;
    # 验证监控结果:ProxySQL 监控模块的指标都保存在 monitor 库的 log 表中。
    # 以下是连接是否正常的监控(对 connect 指标的监控):
    # 注意:可能会有很多 connect_error,这是因为没有配置监控信息时的错误,配置后如果connect_error 的结果为 NULL 则表示正常。
    select * from mysql_server_connect_log;
    # 查看心跳信息的监控(对 ping 指标的监控)
    select * from mysql_server_ping_log;
    # 查看 read_only 日志此时也为空(正常来说,新环境配置时,这个只读日志是为空的)
    select * from mysql_server_read_only_log;
    load mysql servers to runtime;
    save mysql servers to disk;
    # 查看结果
    select hostgroup_id,hostname,port,status,weight from mysql_servers;
    
  • 配置 Doris 用户

    上面的所有配置都是关于后端 Doris FE 节点的,现在可以配置关于 SQL 语句的,包括:发送 SQL 语句的用户、SQL 语句的路由规则、SQL 查询的缓存、SQL 语句的重写等等。

    以下是 SQL 请求所使用的用户配置,例如 root 用户。这要求我们需要先在后端 Doris FE 节点添加好相关用户。这里以 root 和 doris 两个用户名为例。

    # 首先,在 Doris FE master 主数据库节点上执行:
    # mysql -h hadoop1 -P 9030 -u root -p
    # root 用户已经存在,直接创建 doris 用户:
    create user doris@'%' identified by 'doris';
    grant ADMIN_PRIV on *.* to doris@'%';
    # 回到 mysql-proxy 代理层节点,配置 mysql_users 表,将刚才的两个用户添加到该表中。
    insert into mysql_users(username,password,default_hostgroup) values('root','000000',10);
    insert into mysql_users(username,password,default_hostgroup) values('doris','doris',10);
    # 加载用户到运行环境中,并将用户信息保存到磁盘
    load mysql users to runtime;
    save mysql users to disk;
    
    select * from mysql_users\G
    # 只有 active=1 的用户才是有效的用户。确保 transaction_persistent 为 1:
    update mysql_users set transaction_persistent=1 where username='root';
    update mysql_users set transaction_persistent=1 where username='doris';
    load mysql users to runtime;
    save mysql users to disk;
    # 这里不需要读写分离,将这两个参数设为 true:
    UPDATE global_variables SET variable_value='true' WHERE variable_name='mysql-forward_autocommit';
    UPDATE global_variables SET variable_value='true' WHERE variable_name='mysql-autocommit_false_is_transaction';
    LOAD MYSQL VARIABLES TO RUNTIME;
    SAVE MYSQL VARIABLES TO DISK;
    

    这样就可以通过 sql 客户端,使用 doris 的用户名密码去连接了 ProxySQL 了 。

  • 通过 ProxySQL 连接 Doris 进行测试

    分别使用 root 用户和 doris 用户测试下它们是否能路由到默认的 hostgroup_id=10 (它是一个写组)读数据。下面是通过转发端口 6033 连接的,连接的是转发到后端真正的数据库。

    mysql -udoris -pdoris -P6033 -h hadoop1 -e "show databases;"
    

    到此就结束了,可以用 MySQL 客户端,JDBC 等任何连接 MySQL 的方式连接 ProxySQL 去操作 doris 了。

  • 验证:将 hadoop1 的 fe 停止,再执行

    mysql -udoris -pdoris -P6033 -h hadoop1 -e "show databases;"
    

    能够正常使用。

简单查询

  • 简单查询

    SELECT * FROM example_site_visit LIMIT 3;
    SELECT * FROM example_site_visit ORDER BY user_id;
    
  • Join

    SELECT SUM(example_site_visit.cost) FROM example_site_visit
    JOIN example_site_visit2
    WHERE example_site_visit.user_id = example_site_visit2.user_id;
    select 
    example_site_visit.user_id,
    sum(example_site_visit.cost)
    from example_site_visit join example_site_visit2
    where example_site_visit.user_id = example_site_visit2.user_id
    group by example_site_visit.user_id;
    
  • 子查询

    SELECT SUM(cost) FROM example_site_visit2 WHERE user_id IN (SELECT 
    user_id FROM example_site_visit WHERE user_id > 10003);
    

Join 查询

Broadcast Join

系统默认实现 Join 的方式,是将小表进行条件过滤后,将其广播到大表所在的各个节点上,形成一个内存 Hash 表,然后流式读出大表的数据进行 Hash Join

Doris 会自动尝试进行 Broadcast Join,如果预估小表过大则会自动切换至 Shuffle Join。注意,如果此时显式指定了 Broadcast Join 也会自动切换至 Shuffle Join。

(1)默认使用 Broadcast Join:

EXPLAIN SELECT SUM(example_site_visit.cost) 
FROM example_site_visit
JOIN example_site_visit2
WHERE example_site_visit.city = example_site_visit2.city;

(2)显式使用 Broadcast Join:

EXPLAIN SELECT SUM(example_site_visit.cost) 
FROM example_site_visit
JOIN [broadcast] example_site_visit2
WHERE example_site_visit.city = example_site_visit2.city;

Shuffle Join(Partitioned Join)

如果当小表过滤后的数据量无法放入内存的话,此时 Join 将无法完成,通常的报错应该是首先造成内存超限。可以显式指定 Shuffle Join,也被称作 Partitioned Join。即将小表和大表都按照 Join 的 key 进行 Hash,然后进行分布式的 Join。这个对内存的消耗就会分摊到集群的所有计算节点上

SELECT SUM(example_site_visit.cost) 
FROM example_site_visit
JOIN [shuffle] example_site_visit2
WHERE example_site_visit.city = example_site_visit2.city;

Colocation Join

Colocation Join 是在 Doris0.9 版本引入的功能,旨在为 Join 查询提供本性优化,来减少数据在节点上的传输耗时,加速查询。

(1)原理

Colocation Join 功能,是将一组拥有 CGS 的表组成一个 CG。保证这些表对应的数据分片会落在同一个 be 节点上,那么使得两表再进行 join 的时候,可以通过本地数据进行直接join,减少数据在节点之间的网络传输时间。

  • Colocation Group(CG):一个 CG 中会包含一张及以上的 Table。在同一个 Group 内的 Table 有着相同的 Colocation Group Schema,并且有着相同的数据分片分布。
  • Colocation Group Schema(CGS):用于描述一个 CG 中的 Table,和 Colocation 相关的通用 Schema 信息。包括分桶列类型,分桶数以及副本数等。

一个表的数据,最终会根据分桶列值 Hash、对桶数取模的后落在某一个分桶内。假设一个 Table 的分桶数为 8,则共有 [0, 1, 2, 3, 4, 5, 6, 7] 8 个分桶(Bucket),我们称这样一个序列为一个 BucketsSequence。每个 Bucket 内会有一个或多个数据分片(Tablet)。当表为单分区表时,一个 Bucket 内仅有一个 Tablet。如果是多分区表,则会有多个。

使用限制:

  • 建表时两张表的分桶列的类型和数量需要完全一致,并且桶数一致,才能保证多张表的数据分片能够一一对应的进行分布控制。
  • 同一个 CG 内所有表的所有分区(Partition)的副本数必须一致。如果不一致,可能出现某一个 Tablet 的某一个副本,在同一个 BE 上没有其他的表分片的副本对应。
  • 同一个 CG 内的表,分区的个数、范围以及分区列的类型不要求一致。

(2)使用

  • 建两张表,分桶列都为 int 类型,且桶的个数都是 8 个。副本数都为默认副本数。

    CREATE TABLE `tbl1` (
        `k1` date NOT NULL COMMENT "",
        `k2` int(11) NOT NULL COMMENT "",
        `v1` int(11) SUM NOT NULL COMMENT ""
    ) ENGINE=OLAP
    AGGREGATE KEY(`k1`, `k2`)
    PARTITION BY RANGE(`k1`)
    (
        PARTITION p1 VALUES LESS THAN ('2019-05-31'),
        PARTITION p2 VALUES LESS THAN ('2019-06-30')
    )
    DISTRIBUTED BY HASH(`k2`) BUCKETS 8
    PROPERTIES (
        "colocate_with" = "group1"
    );
    
    CREATE TABLE `tbl2` (
        `k1` datetime NOT NULL COMMENT "",
        `k2` int(11) NOT NULL COMMENT "",
        `v1` double SUM NOT NULL COMMENT ""
    ) ENGINE=OLAP
    AGGREGATE KEY(`k1`, `k2`)
    DISTRIBUTED BY HASH(`k2`) BUCKETS 8
    PROPERTIES (
        "colocate_with" = "group1"
    );
    
  • 编写查询语句,并查看执行计划

    explain SELECT * FROM tbl1 INNER JOIN tbl2 ON (tbl1.k2 = tbl2.k2);
    

    HASH JOIN 处 colocate 显示为 true,代表优化成功。

  • 查看 Group

    SHOW PROC '/colocation_group';
    

    当 Group 中最后一张表彻底删除后(彻底删除是指从回收站中删除。通常,一张表通过DROP TABLE 命令删除后,会在回收站默认停留一天的时间后,再删除),该 Group 也会被自动删除。

  • 修改表 Colocate Group 属性

    ALTER TABLE tbl SET ("colocate_with" = "group2");
    

    如果该表之前没有指定过 Group,则该命令检查 Schema,并将该表加入到该 Group(Group 不存在则会创建)。

    如果该表之前有指定其他 Group,则该命令会先将该表从原有 Group 中移除,并加入新Group(Group 不存在则会创建)。

  • 删除表的 Colocation 属性

    ALTER TABLE tbl SET ("colocate_with" = "");
    
  • 其他操作:当对一个具有 Colocation 属性的表进行增加分区(ADD PARTITION)、修改副本数时,Doris 会检查修改是否会违反 Colocation Group Schema,如果违反则会拒绝。

Bucket Shuffle Join

Bucket Shuffle Join 是在 Doris 0.14 版本中正式加入的新功能。旨在为某些 Join 查询提供本地性优化,来减少数据在节点间的传输耗时,来加速查询。

(1)原理

Doris 支持的常规分布式 Join 方式包括了 shuffle join 和 broadcast join。这两种 join 都会导致不小的网络开销:

举个例子,当前存在 A 表与 B 表的 Join 查询,它的 Join 方式为 HashJoin,不同 Join 类型的开销如下:

  • Broadcast Join: 如果根据数据分布,查询规划出 A 表有 3 个执行的 HashJoinNode,那么需要将 B 表全量的发送到 3 个 HashJoinNode,那么它的网络开销是 3B,它的内存开销也是 3B。
  • Shuffle Join: Shuffle Join 会将 A,B 两张表的数据根据哈希计算分散到集群的节点之中,所以它的网络开销为 A + B,内存开销为 B。

在 FE 之中保存了 Doris 每个表的数据分布信息,如果 join 语句命中了表的数据分布列,使用数据分布信息来减少 join 语句的网络与内存开销,这就是 Bucket Shuffle Join,原理如下图:

SQL 语句为 A 表 join B 表,并且 join 的等值表达式命中了 A 的数据分布列。而 Bucket Shuffle Join 会根据 A 表的数据分布信息,将 B 表的数据发送到对应的 A 表的数据存储计算节点。Bucket Shuffle Join 开销如下:

  • 网络开销: B < min(3B, A + B)
  • 内存开销: B <= min(3B, B)

可见,相比于 Broadcast Join 与 Shuffle Join, Bucket Shuffle Join 有着较为明显的性能优势。减少数据在节点间的传输耗时和 Join 时的内存开销。相对于 Doris 原有的 Join 方式,它有着下面的优点:

  • 首先,Bucket-Shuffle-Join 降低了网络与内存开销,使一些 Join 查询具有了更好的性能。尤其是当 FE 能够执行左表的分区裁剪与桶裁剪时。
  • 其次,同时与 Colocate Join 不同,它对于表的数据分布方式并没有侵入性,这对于用户来说是透明的。对于表的数据分布没有强制性的要求,不容易导致数据倾斜的问题。
  • 最后,它可以为 Join Reorder 提供更多可能的优化空间。

(2)使用

  • 设置 Session 变量,从 0.14 版本开始默认为 true

    show variables like '%bucket_shuffle_join%';
    set enable_bucket_shuffle_join = true;
    

    在 FE 进行分布式查询规划时,优先选择的顺序为 Colocate Join -> Bucket Shuffle Join -> Broadcast Join -> Shuffle Join。但是如果用户显式 hint 了 Join 的类型,如:

    select * from test join [shuffle] baseall on test.k1 = baseall.k1;
    

    则上述的选择优先顺序则不生效。

  • 通过 explain 查看 join 类型

    EXPLAIN SELECT SUM(example_site_visit.cost) 
    FROM example_site_visit
    JOIN example_site_visit2
    ON example_site_visit.user_id = example_site_visit2.user_id;
    

    在 Join 类型之中会指明使用的 Join 方式为:BUCKET_SHUFFLE。

(3)注意事项

  • Bucket Shuffle Join 只生效于 Join 条件为等值的场景,原因与 Colocate Join 类似,它们都依赖 hash 来计算确定的数据分布。
  • 在等值 Join 条件之中包含两张表的分桶列,当左表的分桶列为等值的 Join 条件时,它有很大概率会被规划为 Bucket Shuffle Join。
  • 由于不同的数据类型的 hash 值计算结果不同,所以 Bucket Shuffle Join 要求左表的分桶列的类型与右表等值 join 列的类型需要保持一致,否则无法进行对应的规划。
  • Bucket Shuffle Join 只作用于 Doris 原生的 OLAP 表,对于 ODBC,MySQL,ES 等外表,当其作为左表时是无法规划生效的。
  • 对于分区表,由于每一个分区的数据分布规则可能不同,所以 Bucket Shuffle Join只能保证左表为单分区时生效。所以在 SQL 执行之中,需要尽量使用 where 条件使分区裁剪的策略能够生效。
  • 假如左表为 Colocate 的表,那么它每个分区的数据分布规则是确定的,Bucket Shuffle Join 能在 Colocate 表上表现更好。

四种 Shuffle 方式对比

Shuffle方式 网络开销 物理算子 适用场景
BroadCast N * T® Hash Join / Nest Loop Join 通用
Shuffle T(S) + T® Hash Join 通用
Bucket Shuffle Hash Join Join条件中存在左表的分布式列,且左表执行时为单分区
Colocate 0 Hash Join Join条件中存在左表的分布式列,且左右表同属于一个Colocate Group

N : 参与 Join 计算的 Instance 个数

T(关系) : 关系的 Tuple 数目

上面这 4 种方式灵活度是从高到低的,它对这个数据分布的要求是越来越严格,但 Join 计算的性能也是越来越好的。

Runtime Filter

Runtime Filter 是在 Doris 0.15 版本中正式加入的新功能。旨在为某些 Join 查询在运行时动态生成过滤条件,来减少扫描的数据量,避免不必要的 I/O 和网络传输,从而加速查询。

(1)原理

Runtime Filter 在查询规划时生成,在 HashJoinNode 中构建,在 ScanNode 中应用。

举个例子,当前存在 T1 表与 T2 表的 Join 查询,它的 Join 方式为 HashJoin,T1 是一张事实表,数据行数为 100000,T2 是一张维度表,数据行数为 2000,Doris join 的实际情况是:

显而易见对 T2 扫描数据要远远快于 T1,如果我们主动等待一段时间再扫描 T1,等 T2将扫描的数据记录交给 HashJoinNode 后,HashJoinNode 根据 T2 的数据计算出一个过滤条件,比如 T2 数据的最大和最小值,或者构建一个 Bloom Filter,接着将这个过滤条件发给等待扫描 T1 的 ScanNode,后者应用这个过滤条件,将过滤后的数据交给 HashJoinNode,从而减少 probe hash table 的次数和网络开销,这个过滤条件就是 Runtime Filter,效果如下:

如果能将过滤条件(Runtime Filter)下推到存储引擎,则某些情况下可以利用索引来直接减少扫描的数据量,从而大大减少扫描耗时,效果如下:

可见,和谓词下推、分区裁剪不同,Runtime Filter 是在运行时动态生成的过滤条件,即在查询运行时解析 join on clause 确定过滤表达式,并将表达式广播给正在读取左表的ScanNode,从而减少扫描的数据量,进而减少 probe hash table 的次数,避免不必要的 I/O 和网络传输。

Runtime Filter 主要用于优化针对大表的 join,如果左表的数据量太小,或者右表的数据量太大,则 Runtime Filter 可能不会取得预期效果。

(2)使用

  • 指定 RuntimeFilter 类型

    set runtime_filter_type="BLOOM_FILTER,IN,MIN_MAX";
    
  • 建表

    CREATE TABLE test (t1 INT) DISTRIBUTED BY HASH (t1) BUCKETS 2 PROPERTIES("replication_num" = "1");
    INSERT INTO test VALUES (1), (2), (3), (4);
    CREATE TABLE test2 (t2 INT) DISTRIBUTED BY HASH (t2) BUCKETS 2 PROPERTIES("replication_num" = "1");
    INSERT INTO test2 VALUES (3), (4), (5);
    
  • 查看执行计划

    EXPLAIN SELECT t1 FROM test JOIN test2 where test.t1 = test2.t2;
    

    可以看到:

    HASH JOIN生成了 ID 为 RF000 的 IN predicate,其中test2.t2的 key values 仅在运行时可知,在 OlapScanNode 使用了该 IN predicate 用于在读取test.t1`时过滤不必要的数据。

  • 通过 profile 查看效果

    set enable_profile=true;
    SELECT t1 FROM test JOIN test2 where test.t1 = test2.t2;
    

    查看对应 fe 节点的 webui,可以查看查询内部工作的详细信息:

    http://hadoop1:8030/QueryProfile/

    • 可以看到每个 Runtime Filter 是否下推、等待耗时、以及 OLAP_SCAN_NODE 从prepare 到接收到 Runtime Filter 的总时长。

      RuntimeFilter:in:
       - HasPushDownToEngine: true
       - AWaitTimeCost: 0ns
       - EffectTimeCost: 2.76ms
      
    • 在 profile 的 OLAP_SCAN_NODE 中可以查看 Runtime Filter 下推后的过滤效果和耗时。

       - RowsVectorPredFiltered: 9.320008M (9320008)
       - VectorPredEvalTime: 364.39ms
      

(3)具体参数说明

  • 大多数情况下,只需要调整 runtime_filter_type 选项,其他选项保持默认即可:

    包括 BLOOM_FILTER、IN、MIN_MAX(也可以通过数字设置),默认会使用 IN,部分情况下同时使用 Bloom Filter、MinMax Filter、IN predicate 时性能更高,每个类型含义如下:

    • Bloom Filter: 有一定的误判率,导致过滤的数据比预期少一点,但不会导致最终结果不准确,在大部分情况下 Bloom Filter 都可以提升性能或对性能没有显著影响,但在部分情况下会导致性能降低。

      ①Bloom Filter 构建和应用的开销较高,所以当过滤率较低时,或者左表数据量较少时,Bloom Filter 可能会导致性能降低。

      ②目前只有左表的 Key 列应用 Bloom Filter 才能下推到存储引擎,而测试结果显示 Bloom Filter 不下推到存储引擎时往往会导致性能降低。

      ③目前 Bloom Filter 仅在 ScanNode 上使用表达式过滤时有短路(short-circuit)逻辑,即当假阳性率(实际是假但误辨为真的情况)过高时,不继续使用 Bloom Filter,但当Bloom Filter 下推到存储引擎后没有短路逻辑,所以当过滤率较低时可能导致性能降低。

    • MinMax Filter: 包含最大值和最小值,从而过滤小于最小值和大于最大值的数据,MinMax Filter 的过滤效果与 join on clause 中 Key 列的类型和左右表数据分布有关。

      ①当 join on clause 中 Key 列的类型为 int/bigint/double 等时,极端情况下,如果左右表的最大最小值相同则没有效果,反之右表最大值小于左表最小值,或右表最小值大于左表最大值,则效果最好。

      ②当 join on clause 中 Key 列的类型为 varchar 等时,应用 MinMax Filter 往往会导致性能降低。

    • IN predicate: 根据 join on clause 中 Key 列在右表上的所有值构建 IN predicate,使用构建的 IN predicate 在左表上过滤,相比 Bloom Filter 构建和应用的开销更低,在右表数据量较少时往往性能更高。

      ①默认只有右表数据行数少于 1024 才会下推(可通过 session 变量中的runtime_filter_max_in_num 调整)。

      ②目前 IN predicate 已实现合并方法。

      ③当同时指定 In predicate 和其他 filter ,并且 in 的 过 滤 数 值 没 达 到runtime_filter_max_in_num 时,会尝试把其他 filter 去除掉。原因是 In predicate 是精确的过滤条件,即使没有其他 filter 也可以高效过滤,如果同时使用则其他 filter 会做无用功。目前仅在 Runtime filter 的生产者和消费者处于同一个 fragment 时才会有去除非 in filter 的逻辑。

  • 其他查询选项通常仅在某些特定场景下,才需进一步调整以达到最优效果。通常只在性能测试后,针对资源密集型、运行耗时足够长且频率足够高的查询进行优化。

    • runtime_filter_mode: 用于调整 Runtime Filter 的下推策略,包括 OFF、LOCAL、GLOBAL三种策略,默认设置为 GLOBAL 策略
    • runtime_filter_wait_time_ms: 左表的 ScanNode 等待每个 Runtime Filter 的时间,默认1000ms
    • runtime_filters_max_num: 每个查询可应用的 Runtime Filter 中 Bloom Filter 的最大数量,默认 10
    • runtime_bloom_filter_min_size: Runtime Filter 中 Bloom Filter 的最小长度,默认 1048576(1M)
    • runtime_bloom_filter_max_size: Runtime Filter 中 Bloom Filter 的最大长度,默认 16777216(16M)
    • runtime_bloom_filter_size: Runtime Filter中Bloom Filter的默认长度,默认2097152(2M)
    • runtime_filter_max_in_num: 如果 join 右表数据行数大于这个值,我们将不生成 IN predicate,默认 1024

(4)注意事项

  • 只支持对 join on clause 中的等值条件生成 Runtime Filter,不包括 Null-safe 条件,因为其可能会过滤掉 join 左表的 null 值。

  • 不支持将 Runtime Filter 下推到 left outer、full outer、anti join 的左表;

  • 不支持 src expr 或 target expr 是常量;

  • 不支持 src expr 和 target expr 相等;

  • 不支持 src expr 的类型等于 HLL 或者 BITMAP;

  • 目前仅支持将 Runtime Filter 下推给 OlapScanNode;

  • 不支持 target expr 包含 NULL-checking 表达式,比如 COALESCE/IFNULL/CASE,因为当 outer join 上层其他 join 的 join on clause 包含 NULL-checking 表达式并生成 Runtime Filter 时,将这个 Runtime Filter 下推到 outer join 的左表时可能导致结果不正确;

  • 不支持 target expr 中的列(slot)无法在原始表中找到某个等价列;

  • 不支持列传导,这包含两种情况:

    一是例如 join on clause 包含 A.k = B.k and B.k = C.k 时,目前 C.k 只可以下推给B.k,而不可以下推给 A.k;

    二是例如 join on clause 包含 A.a + B.b = C.c,如果 A.a 可以列传导到 B.a,即 A.a和 B.a 是等价的列,那么可以用 B.a 替换 A.a,然后可以尝试将 Runtime Filter 下推给 B(如果 A.a 和 B.a 不是等价列,则不能下推给 B,因为 target expr 必须与唯一一个 join 左表绑定);

  • Target expr 和 src expr 的类型必须相等,因为 Bloom Filter 基于 hash,若类型不等则会尝试将 target expr 的类型转换为 src expr 的类型;

  • 不支持 PlanNode.Conjuncts 生成的 Runtime Filter 下推,与 HashJoinNode 的eqJoinConjuncts 和 otherJoinConjuncts 不同,PlanNode.Conjuncts 生成的 Runtime Filter 在测试中发现可能会导致错误的结果,例如 IN 子查询转换为 join 时,自动生成的 join on clause将保存在 PlanNode.Conjuncts 中,此时应用 Runtime Filter 可能会导致结果缺少一些行

Join Reorder

Join Reorder 功能可以通过代价模型自动帮助调整 SQL 中 Join 的顺序,以帮助获得最优的 Join 效率。 可通过会话变量开启:

set enable_cost_based_join_reorder=true

原理:

数据库一旦涉及到多表 Join,Join 的顺序对整个 Join 查询的性能是影响很大的。假设有三张表 Join,参考下面这张图,左边是 a 表跟 b 张表先做 Join,中间结果的有 2000 行,然后与 c 表再进行 Join 计算。

接下来看右图,把 Join 的顺序调整了一下。把 a 表先与 c 表 Join,生成的中间结果只有 100,然后最终再与 b 表 Join 计算。最终的 Join 结果是一样的,但是它生成的中间结果有 20 倍的差距,这就会产生一个很大的性能 Diff 了。

Doris 目前支持基于规则的 Join Reorder 算法。它的逻辑是:

  • 让大表、跟小表尽量做 Join,它生成的中间结果是尽可能小的。
  • 把有条件的 Join 表往前放,也就是说尽量让有条件的 Join 表进行过滤
  • Hash Join 的优先级高于 Nest Loop Join,因为 Hash join 本身是比 Nest Loop Join 快很多的。

Join的优化原则

  • 在做 Join 的时候,要尽量选择同类型或者简单类型的列,同类型的话就减少它的数据 Cast,简单类型本身 Join 计算就很快。
  • 尽量选择 Key 列进行 Join, 原因前面在 Runtime Filter 的时候也介绍了,Key 列在延迟物化上能起到一个比较好的效果。
  • 大表之间的 Join ,尽量让它 Co-location ,因为大表之间的网络开销是很大的,如果需要去做 Shuffle 的话,代价是很高的。
  • 合理的使用 Runtime Filter,它在 Join 过滤率高的场景下效果是非常显著的。但是它并不是万灵药,而是有一定副作用的,所以需要根据具体的 SQL 的粒度做开关。
  • 涉及到多表 Join 的时候,需要去判断 Join 的合理性。尽量保证左表为大表,右表为小表,然后 Hash Join 会优于 Nest Loop Join。必要的时可以通过 SQL Rewrite,利用sHint 去调整 Join 的顺序。

去重

HLL近似去重

在实际的业务场景中,随着业务数据量越来越大,对数据去重的压力也越来越大,当数据达到一定规模之后,使用精准去重的成本也越来越高,在业务可以接受的情况下,通过近似算法来实现快速去重降低计算压力是一个非常好的方式,Doris 提供的 HyperLogLog(简称 HLL)是一种近似去重算法。

HLL 的特点是具有非常优异的空间复杂度 O(mloglogn) , 时间复杂度为 O(n), 并且计算结果的误差可控制在 1%—2% 左右,误差与数据集大小以及所采用的哈希函数有关。

HLL 是基于 HyperLogLog 算法的工程实现,用于保存 HyperLogLog 计算过程的中间结果,它只能作为表的 value 列类型、通过聚合来不断的减少数据量,以此来实现加快查询的目的,基于它得到的是一个估算结果,误差大概在1%左右,hll 列是通过其它列或者导入数据里面的数据生成的,导入的时候通过 hll_hash 函数来指定数据中哪一列用于生成 hll 列,它常用于替代 count distinct,通过结合 rollup 在业务上用于快速计算uv等。

  • HLL_UNION_AGG(hll):此函数为聚合函数,用于计算满足条件的所有数据的基数估算
  • HLL_CARDINALITY(hll):此函数用于计算单条hll列的基数估算
  • HLL_HASH(column_name):生成HLL列类型,用于insert或导入的时候

创建:

create table test_hll(
    dt date,
    id int,
    name char(10),
    province char(10),
    os char(10),
    pv hll hll_union
)
Aggregate KEY (dt,id,name,province,os)
distributed by hash(id) buckets 10
PROPERTIES(
    "replication_num" = "1",
    "in_memory"="false"
);
  1. 使用 HLL 去重的时候,需要在建表语句中将目标列类型设置成HLL,聚合函数设置成HLL_UNION
  2. HLL类型的列不能作为 Key 列使用
  3. 用户不需要指定长度及默认值,长度根据数据聚合程度系统内控制

导入数据:

curl --location-trusted -u root: -H "label:label_test_hll_load" \
    -H "column_separator:," \
    -H "columns:dt,id,name,province,os, pv=hll_hash(id)" -T test_hll.csv http://fe_IP:8030/api/demo/test_hll/_stream_load

数据如下:

2022-05-05,10001,测试01,北京,windows
2022-05-05,10002,测试01,北京,linux
2022-05-05,10003,测试01,北京,macos
2022-05-05,10004,测试01,河北,windows
2022-05-06,10001,测试01,上海,windows
2022-05-06,10002,测试01,上海,linux
2022-05-06,10003,测试01,江苏,macos
2022-05-06,10004,测试01,陕西,windows

查询:

HLL 列不允许直接查询原始值,只能通过 HLL 的聚合函数进行查询。

  • 求总的PV

    mysql> select HLL_UNION_AGG(pv) from test_hll;
    +---------------------+
    | hll_union_agg(`pv`) |
    +---------------------+
    |                   4 |
    +---------------------+
    1 row in set (0.00 sec)
    
    等价于:
    mysql> SELECT COUNT(DISTINCT pv) FROM test_hll;
    +----------------------+
    | count(DISTINCT `pv`) |
    +----------------------+
    |                    4 |
    +----------------------+
    1 row in set (0.01 sec)
    
  • 求每一天的PV

    mysql> select HLL_UNION_AGG(pv) from test_hll group by dt;
    +---------------------+
    | hll_union_agg(`pv`) |
    +---------------------+
    |                   4 |
    |                   4 |
    +---------------------+
    2 rows in set (0.01 sec)
    

BITMAP精准去重

Doris原有的Bitmap聚合函数设计比较通用,但对亿级别以上bitmap大基数的交并集计算性能较差。排查后端be的bitmap聚合函数逻辑,发现主要有两个原因。一是当bitmap基数较大时,如bitmap大小超过1g,网络/磁盘IO处理时间比较长;二是后端be实例在scan数据后全部传输到顶层节点进行求交和并运算,给顶层单节点带来压力,成为处理瓶颈。

解决思路是:将bitmap列的值按照range划分,不同range的值存储在不同的分桶中,保证了不同分桶的bitmap值是正交的。当查询时,先分别对不同分桶中的正交bitmap进行聚合计算,然后顶层节点直接将聚合计算后的值合并汇总,并输出。如此会大大提高计算效率,解决了顶层单节点计算瓶颈问题。

使用场景:符合对bitmap进行正交计算的场景,如在用户行为分析中,计算留存,漏斗,用户画像等。

  • 人群圈选:

    select orthogonal_bitmap_intersect_count(user_id, tag, 13080800, 11110200) from user_tag_bitmap where tag in (13080800, 11110200);
     注:1308080011110200代表用户标签
    
  • 计算user_id的去重值:

    select orthogonal_bitmap_union_count(user_id) from user_tag_bitmap where tag in (13080800, 11110200);
    
  • bitmap交并差集合混合计算:

    select orthogonal_bitmap_expr_calculate_count(user_id, tag, '(833736|999777)&(1308083|231207)&(1000|20000-30000)') from user_tag_bitmap where tag in (833736,999777,130808,231207,1000,20000,30000);
    注:10002000030000等整形tag,代表用户不同标签
    
    select orthogonal_bitmap_expr_calculate_count(user_id, tag, '(A:a/b|B:2\\-4)&(C:1-D:12)&E:23') from user_str_tag_bitmap where tag in ('A:a/b', 'B:2-4', 'C:1', 'D:12', 'E:23');
     注:'A:a/b', 'B:2-4'等是字符串类型tag,代表用户不同标签, 其中'B:2-4'需要转义成'B:2\\-4'
    

使用步骤:建表,增加hid列,表示bitmap列值id范围, 作为hash分桶列

(1)建表

建表时需要使用聚合模型,数据类型是 bitmap , 聚合函数是 bitmap_union

CREATE TABLE `user_tag_bitmap` (
  `tag` bigint(20) NULL COMMENT "用户标签",
  `hid` smallint(6) NULL COMMENT "分桶id",
  `user_id` bitmap BITMAP_UNION NULL COMMENT ""
) ENGINE=OLAP
AGGREGATE KEY(`tag`, `hid`)
COMMENT "OLAP"
DISTRIBUTED BY HASH(`hid`) BUCKETS 3

表schema增加hid列,表示id范围, 作为hash分桶列。

注:hid数和BUCKETS要设置合理,hid数设置至少是BUCKETS的5倍以上,以使数据hash分桶尽量均衡。

(2)数据导入

LOAD LABEL user_tag_bitmap_test
(
    DATA INFILE('hdfs://abc')
    INTO TABLE user_tag_bitmap
    COLUMNS TERMINATED BY ','
    (tmp_tag, tmp_user_id)
    SET (
        tag = tmp_tag,
        hid = ceil(tmp_user_id/5000000),
        user_id = to_bitmap(tmp_user_id)
    )
)
注意:5000000这个数不固定,可按需调整
...

数据格式:

11111111,1
11111112,2
11111113,3
11111114,4
...

注:第一列代表用户标签,由中文转换成数字

load数据时,对用户bitmap值range范围纵向切割,例如,用户id在1-5000000范围内的hid值相同,hid值相同的行会分配到一个分桶内,如此每个分桶内到的bitmap都是正交的。可以利用桶内bitmap值正交特性,进行交并集计算,计算结果会被shuffle至top节点聚合。

注:正交bitmap函数不能用在分区表,因为分区表分区内正交,分区之间的数据是无法保证正交的,则计算结果也是无法预估的。

(3)使用

  • bitmap_orthogonal_intersect:求bitmap交集函数

    语法:orthogonal_bitmap_intersect(bitmap_column, column_to_filter, filter_values)

    select BITMAP_COUNT(orthogonal_bitmap_intersect(user_id, tag, 13080800, 11110200)) from user_tag_bitmap  where tag in (13080800, 11110200);
    

    参数:第一个参数是Bitmap列,第二个参数是用来过滤的维度列,第三个参数是变长参数,含义是过滤维度列的不同取值。

    说明:查询规划上聚合分2层,在第一层be节点(update、serialize)先按filter_values为key进行hash聚合,然后对所有key的bitmap求交集,结果序列化后发送至第二层be节点(merge、finalize),在第二层be节点对所有来源于第一层节点的bitmap值循环求并集。

  • orthogonal_bitmap_intersect_count:求bitmap交集count函数,语法同原版intersect_count,但实现不同

    语法:orthogonal_bitmap_intersect_count(bitmap_column, column_to_filter, filter_values)

    参数:第一个参数是Bitmap列,第二个参数是用来过滤的维度列,第三个参数开始是变长参数,含义是过滤维度列的不同取值

    说明:查询规划聚合上分2层,在第一层be节点(update、serialize)先按filter_values为key进行hash聚合,然后对所有key的bitmap求交集,再对交集结果求count,count值序列化后发送至第二层be节点(merge、finalize),在第二层be节点对所有来源于第一层节点的count值循环求sum

  • orthogonal_bitmap_union_count:求bitmap并集count函数,语法同原版bitmap_union_count,但实现不同。

    语法:orthogonal_bitmap_union_count(bitmap_column)

    参数:参数类型是bitmap,是待求并集count的列

    说明:查询规划上分2层,在第一层be节点(update、serialize)对所有bitmap求并集,再对并集的结果bitmap求count,count值序列化后发送至第二层be节点(merge、finalize),在第二层be节点对所有来源于第一层节点的count值循环求sum

  • orthogonal_bitmap_expr_calculate:求表达式bitmap交并差集合计算函数。

    语法:orthogonal_bitmap_expr_calculate(bitmap_column, filter_column, input_string)

    参数:第一个参数是Bitmap列,第二个参数是用来过滤的维度列,即计算的key列,第三个参数是计算表达式字符串,含义是依据key列进行bitmap交并差集表达式计算。表达式支持的计算符:& 代表交集计算,| 代表并集计算,- 代表差集计算, ^ 代表异或计算,\ 代表转义字符

    说明:查询规划上聚合分2层,第一层be聚合节点计算包括init、update、serialize步骤,第二层be聚合节点计算包括merge、finalize步骤。在第一层be节点,init阶段解析input_string字符串,转换为后缀表达式(逆波兰式),解析出计算key值,并在map<key, bitmap>结构中初始化;update阶段,底层内核scan维度列(filter_column)数据后回调update函数,然后以计算key为单位对上一步的map结构中的bitmap进行聚合;serialize阶段,根据后缀表达式,解析出计算key列的bitmap,利用栈结构先进后出原则,进行bitmap交并差集合计算,然后对最终的结果bitmap序列化后发送至第二层聚合be节点。在第二层聚合be节点,对所有来源于第一层节点的bitmap值求并集,并返回最终bitmap结果

  • orthogonal_bitmap_expr_calculate_count:求表达式bitmap交并差集合计算count函数, 语法和参数同orthogonal_bitmap_expr_calculate。

    语法:orthogonal_bitmap_expr_calculate_count(bitmap_column, filter_column, input_string)

    说明:查询规划上聚合分2层,第一层be聚合节点计算包括init、update、serialize步骤,第二层be聚合节点计算包括merge、finalize步骤。在第一层be节点,init阶段解析input_string字符串,转换为后缀表达式(逆波兰式),解析出计算key值,并在map<key, bitmap>结构中初始化;update阶段,底层内核scan维度列(filter_column)数据后回调update函数,然后以计算key为单位对上一步的map结构中的bitmap进行聚合;serialize阶段,根据后缀表达式,解析出计算key列的bitmap,利用栈结构先进后出原则,进行bitmap交并差集合计算,然后对最终的结果bitmap的count值序列化后发送至第二层聚合be节点。在第二层聚合be节点,对所有来源于第一层节点的count值求加和,并返回最终count结果。

SQL 函数

(1)查看函数名:

show builtin functions in test_db;

(2)查看函数具体信息,比如查看 year 函数具体信息:

show full builtin functions in test_db like 'year';

官网:https://doris.apache.org/zh-CN/sql-reference/sql-functions/date-time-functions/convert_tz.html

支持自定义的UDF和UDAF函数,具体可看官网。

Laterval view 语法

通 过 Lateral View 语 法 , 我 们 可 以 使 用 explod_bitmap 、 explode_split 、explode_jaon_array 等 Table Function 表函数,将 bitmap、String 或 Json Array 由一列展开成多行,以便后续可以对展开的数据进行进一步处理(如 Filter、Join 等)。

  • 创建测试表:

    CREATE TABLE test3 (k1 INT,k2 varchar(30)) 
    DISTRIBUTED BY HASH (k1) BUCKETS 2 
    PROPERTIES("replication_num" = "1");
    INSERT INTO test3 VALUES (1,''), (2,null), (3,','), 
    (4,'1'),(5,'1,2,3'),(6,'a,b,c');
    
  • 设置参数开启

    set enable_lateral_view=true;
    
  • explode_bitmap:展开一个 bitmap 类型

    select k1, e1 from test3 lateral view explode_bitmap(bitmap_from_string("1")) tmp1 as e1 order by k1, e1;
    
  • explode_split:将一个字符串按指定的分隔符分割成多个子串

    select k1, e1 from test3 lateral view explode_split(k2, ',') tmp1 
    as e1 order by k1, e1;
    
  • explode_json_array:展开一个 json 数组

    select k1, e1 from test3 lateral view explode_json_array_int('[1,2,3]') tmp1 as e1 order by k1, e1;
    select k1, e1 from test3 lateral view explode_json_array_double('[1.0,2.0,3.0]') tmp1 as e1 order by k1, e1;
    select k1, e1 from test3 lateral view explode_json_array_string('[1,"b",3]') tmp1 as e1 order by k1, e1;
    

猜你喜欢

转载自blog.csdn.net/qq_44766883/article/details/131353496
今日推荐