散列连接

1)hash join的基本机制

首先,关于hash join的简单工作机制,tom有一个在线的video:http://asktom.oracle.com/~tkyte/hj/hj.html
上面讲的hash join的基本工作机制其实到也没有什么特别的地方,不过听听大师的声音倒是挺有趣的。

接下来我们就来介绍hash join。

假设我们有两个数据集合A和B,当我们对两个数据集做hash join,我们首先挑选一个数据集,比如是A,把A转变成为内存中的一个hash table,这个hash table和我们前面介绍过的single hash cluster table是很类似的,只不过single hash cluster table是在磁盘上,hash join的hash table是放在内存里。在这里,我们使用的是oracle内部的hash函数,hash函数的hash key就是A和B做join的那个列。

然后我们开始从第二个结果集B里面获取数据,对于B里的每一条数据使用相同的hash函数找到对应的A里数据在hash table里面的位置,在这个位置上检查能不能找到匹配的数据。

和single hash cluster table,由于使用了hash函数,hash join只能用在join条件是等于的条件下。

在这里,我们把数据集A称为build table(也就是A被build到内存中建立hash table),数据集合B叫做probe table(也就是说我们用B去probe hash table)。

我们刚刚介绍过nested loops,其实hash join就有点像outer table是普通表,inner table是一个single hash cluster table的nested loops,都是使用一张表上的数据,通过hash函数到另一个hash table里面寻找匹配的数据。不过不同的是hash join里面的hash table是放在用户使用的专有内存,即UGA里(10g以后hash join和sort使用的内存被分配到PGA里),而single hash cluster table最多只能cache到buffer cache里,而对buffer cache的过多访问会产生大量latch,这个问题在hash join里就不存在。

下面我们开始看一个hash join的例子:

在这个例子里注意一下我们使用workarea_size_policy = manual并且设置hash_area_size = 1048576,因为hash join的效率和我们分配给它的内存多少关系很大,所以为了能够精确的控制分配的内存数量,我们采用了手工的分配方式:

测试代码如下:
drop table probe_tab;
drop table build_tab;

create table probe_tab
initrans 3
nologging
as
with generator as (
select
rownum id
from all_objects
where rownum <= 3000
)
select
10000 + rownum id,
trunc(dbms_random.value(0,5000)) n1,
rpad(rownum,20) probe_vc,
rpad('x',500) probe_padding
from
generator v1,
generator v2
where
rownum <= 5000
;

alter table probe_tab add constraint pb_pk primary key(id);

create table build_tab
initrans 3
nologging
as
with generator as (
select
rownum id
from all_objects
where rownum <= 3000
)
select
rownum id,
10001 + trunc(dbms_random.value(0,5000)) id_probe,
rpad(rownum,20) build_vc,
rpad('x',500) build_padding
from
generator v1,
generator v2
where
rownum <= 5000
;

alter table build_tab add constraint bu_pk primary key(id);

alter table build_tab add constraint bu_fk_pb foreign key (id_probe) references probe_tab;

create index bu_fk_pb on build_tab(id_probe);

begin
dbms_stats.gather_table_stats(
user,
'build_tab',
cascade => true,
estimate_percent => null,
method_opt => 'for all columns size 1'
);
end;
/

begin
dbms_stats.gather_table_stats(
user,
'probe_tab',
cascade => true,
estimate_percent => null,
method_opt => 'for all columns size 1'
);
end;
/


begin

begin execute immediate 'alter session set workarea_size_policy = manual';
exception when others then null;
end;

begin execute immediate 'alter session set hash_area_size = 1048576';
exception when others then null;
end;

end;
/


set autotrace traceonly explain

select
bu.build_vc,
pb.probe_vc,
pb.probe_padding
from
build_tab bu,
probe_tab pb
where
bu.id between 1 and 500
and pb.id = bu.id_probe
;
Execution Plan
----------------------------------------------------------
0 SELECT STATEMENT Optimizer=CHOOSE (Cost=106 Card=500 Bytes=278500)
1 0 HASH JOIN (Cost=106 Card=500 Bytes=278500)
2 1 TABLE ACCESS (BY INDEX ROWID) OF 'BUILD_TAB' (Cost=42 Card=500 Bytes=15000)
3 2 INDEX (RANGE SCAN) OF 'BU_PK' (UNIQUE) (Cost=3 Card=2)
4 1 TABLE ACCESS (FULL) OF 'PROBE_TAB' (Cost=60 Card=5000 Bytes=2635000)

这里我故意建立这样的一个情况以纠正两个对于hash join的常见误解。

第一个误解是我们经常以为hash join需要对两个做join的表都做全表扫描,但是根据计划的第3步,我们看到是index range scan,也就是说,hash join并不会限制SQL的访问方法。

第二个误解是经常以为hash join会选择比较小的表做build table,但看看我的建表语句,这两个表其实是一样大的。所以并不是选择比较小的表,而是看那个表上得到的结果集比较小,就把哪个表作为build table。进一步说,Oracle是如何比较哪个表上的结果集比较小呢?为了说明这一点,我们需要把原来的SQL拆分成2部分:

select
bu.id
bu.build_vc,
bu.id_probe
from
build_tab bu
where
bu.id between 1 and 500
;

select
pb.probe_vc,
pb.probe_padding,
pb.id
from
probe_tab pb
;

oracle会根据这两个虚拟的查询的返回结果决定到底哪个表的返回结果集表较小,而这个结果集的大小则是 = 结果集的行数*user_tab_columns.sum(avg_col_len)计算得出的。

不过这里有一点是需要注意的,一般我们收集统计数据的方式都是dbms_stats,有的时候由于历史原因我们可能还在使用analyze,当在计算avg_col_len的时候,我们会发现dbms_stats计算出来的avg_col_len一般要比analyze计算的要大1,这是因为我们表里的列除了数据占用空间,列本身也是需要空间的,当计算avg_col_len的时候dbms_stats注意到了这一点,而analyze忽略了这一点。这可能导致同样的SQL在dbms_stats分析的系统上的执行计划,和analyze分析的系统上的执行计划不一样。

我们继续来看byte的计算:
查询user_tab_columns

SQL> select column_name, avg_col_len from user_tab_columns where table_name='PROBE_TAB';

COLUMN_NAME AVG_COL_LEN
-------------------- -----------
ID 5
N1 4
PROBE_VC 21
PROBE_PADDING 501

4 rows selected.

SQL> select column_name, avg_col_len from user_tab_columns where table_name='BUILD_TAB';

COLUMN_NAME AVG_COL_LEN
-------------------- -----------
ID 4
ID_PROBE 5
BUILD_VC 21
BUILD_PADDING 501

4 rows selected.

我们看到probe_tab的结果集是(5+21+521)*5000=2635000,
build_tab的结果集是(4+5+21)*500=15000,
所以把build_tab作为build table。

作为验证,我们把原来查询的select list做一下修改,就可以颠倒build table和probe table的位置:
select
bu.build_vc,
pb.probe_vc,
bu.build_padding
from
build_tab bu,
probe_tab pb
where
bu.id between 1 and 500
and pb.id = bu.id_probe;

Execution Plan
----------------------------------------------------------
0 SELECT STATEMENT Optimizer=ALL_ROWS (Cost=110 Card=500 Bytes=278000)
1 0 HASH JOIN (Cost=110 Card=500 Bytes=278000)
2 1 TABLE ACCESS (FULL) OF 'PROBE_TAB' (Cost=60 Card=5000 Bytes=130000)
3 1 TABLE ACCESS (BY INDEX ROWID) OF 'BUILD_TAB' (Cost=42 Card=500 Bytes=265000)
4 3 INDEX (RANGE SCAN) OF 'BU_PK' (UNIQUE) (Cost=3 Card=500)

2)hash join的模式


hash join有三种工作模式,分别是optimal模式,onepass模式和multipass模式,分别在v$sysstat里面有对应的统计信息:

SQL> select name, value from v$sysstat where name like '%workarea executions%';

NAME VALUE
---------------------------------------------------------------- ----------
workarea executions - optimal 21683
workarea executions - onepass 9
workarea executions - multipass 2

optimal hash join

optimal模式就是从build table上获取的结果集比较小,可以把整个hash table都建立在用户可以使用的内存区域里。下面这张图就是用来描述optimal的hash join的:

optimal hash join大致上分为以下几步:

1. 首先利用做连接的列上的hash函数,把build table的结果集做成内存里的hash table,这里的hash bucket总是2的n次方,比如1024或4096。可以简单的把hash table看做内存里的一个大正方形,你面有很多小格子,而build table上的数据则是分散的分布在这些格子里面,而这些小格子就是hash bucket。

2. oracle开始读取probe table, 针对每一条数据都对做连接的列上使用hash函数,定位对应build table的相同值的hash bucket,找到相应的hash bucket后就到那个位置是去看有没有匹配的数据。这个过程叫做probing hash table。

3. 在检查bush bucket的时候,如果bucket里面没有数据,那么马上丢掉probe table的这一行。如果Bucket里面有数据,就要进一步检查里面的数据是否和proble table当前的这个数据匹配,这一步是很必要的。在我们前面介绍filter和hash cluster table的时候我们提过,hash函数存在一个冲突的问题,也就是不同的hash key完全有可能对应到相同的hash bucket里,所以当我们为probe table上的一个值定位到了一个hash bucket的时候,我们需要做进一步的检查,来看看这个bucket里面哪些数据是我们需要的,哪些数据是我们不需要的。
在理想的情况下,我们希望每一个hash bucket里面最多只有一个hash key的数据,因此往往hash bucket的个数是要比hash key的个数要多的。

现在让我们回头看一看最开始的执行计划:
Execution Plan
----------------------------------------------------------
0 SELECT STATEMENT Optimizer=CHOOSE (Cost=106 Card=500 Bytes=278500)
1 0 HASH JOIN (Cost=106 Card=500 Bytes=278500)
2 1 TABLE ACCESS (BY INDEX ROWID) OF 'BUILD_TAB' (Cost=42 Card=500 Bytes=15000)
3 2 INDEX (RANGE SCAN) OF 'BU_PK' (UNIQUE) (Cost=3 Card=2)
4 1 TABLE ACCESS (FULL) OF 'PROBE_TAB' (Cost=60 Card=5000 Bytes=2635000)

我们知道整个过程中的第2,3,4步都只做了一次,所以cost加起来应该是60+42=102。这里最后cost的106,比我们预想的多了4。这一点我也不是非常清楚,毕竟hash join的具体过程要不我们想象的要复杂。对于这个现象我在设置不同的hash_area_size进行测试之后,发现有的时候多1,有的时候多5,无法一概而论。而且如果我们继续往下研究onepass hash join的时候,我们会发现调整内存对于hash join的cost计算有很奇怪的影响。


onepass hash join

我们知道optimal hash join发生在我们可以把整个hash table全部放在内存里的时候,从而所有的join操作都可以在内存里面完成,这是我们最理想的模式。
但是,当我们的内存无法放下整个hash table,我们就不得不在onepass模式下进行hash join。

我们可以把刚才的实验进行一下调整:
execute dbms_random.seed(0)

drop table build_tab;
drop table probe_tab;
create table probe_tab
initrans 3
nologging
as
with generator as (
select --+ materialize
rownum id
from all_objects
where rownum <= 3000
)
select
/*+ ordered use_nl(v2) */
10000 + rownum id,
trunc(dbms_random.value(0,5000)) n1,
rpad(rownum,20) probe_vc,
rpad('x',1000) probe_padding
from
generator v1,
generator v2
where
rownum <= 10000
;

alter table probe_tab add constraint pb_pk primary key(id);

create table build_tab
initrans 3
nologging
as
with generator as (
select --+ materialize
rownum id
from all_objects
where rownum <= 3000
)
select
/*+ ordered use_nl(v2) */
rownum id,
10001 + trunc(dbms_random.value(0,5000)) id_probe,
rpad(rownum,20) build_vc,
rpad('x',1000) build_padding
from
generator v1,
generator v2
where
rownum <= 10000
;

alter table build_tab add constraint bu_pk
primary key(id);

alter table build_tab add constraint bu_fk_pb
foreign key (id_probe) references probe_tab;

create index bu_fk_pb on build_tab(id_probe);


begin
dbms_stats.gather_table_stats(
user,
'build_tab',
cascade => true,
estimate_percent => null,
method_opt => 'for all columns size 1'
);
end;
/

begin
dbms_stats.gather_table_stats(
user,
'probe_tab',
cascade => true,
estimate_percent => null,
method_opt => 'for all columns size 1'
);
end;
/


begin
begin execute immediate 'alter session set workarea_size_policy = manual';
exception when others then null;
end;

begin execute immediate 'alter session set hash_area_size = 1048576';
exception when others then null;
end;

end;
/


set autotrace traceonly explain
select
/*+ ordered full(bu) full(pb) use_hash(pb) */
bu.build_vc,
bu.build_padding,
pb.probe_vc,
pb.probe_padding
from
build_tab bu,
probe_tab pb
where
bu.id between 1 and 2000
and pb.id = bu.id_probe
/

在这里我们在select list里面添加了bu.build_padding,这就导致build_table和probe_table的结果集都比我们的hash_area_size要大,这时候Oracle就会进行onepass hash join,执行计划如下:
Execution Plan
----------------------------------------------------------
0 SELECT STATEMENT Optimizer=ALL_ROWS (Cost=1127 Card=2000 Bytes=4114000)
1 0 HASH JOIN (Cost=1127 Card=2000 Bytes=4114000)
2 1 TABLE ACCESS (FULL) OF 'BUILD_TAB' (Cost=255 Card=2000 Bytes=2060000)
3 1 TABLE ACCESS (FULL) OF 'PROBE_TAB' (Cost=255 Card=10000 Bytes=10270000)



1. 首先,由于内存无法放下所有的hash table内容,那么就会导致有的hash bucket放在内存里,有的hash bucket放在磁盘上,但不管放在哪里,Oracle使用一个bitmap结构来反应这些bucket的状态,包括位置和是否有数据在里面。

2. 当我们的probe table对连接的列使用hash函数之后,先到bitmap上看看对应的bucket是不是为空,而过为空,这条数据就丢掉不管。如果不为空,还要看现在这个bucket是在内存里还是在磁盘上。如果是在内存里,就直接访问这个bucket并检查是否有数据匹配,如果有匹配就返回这条查询结果。第二种情况是如果要访问的这个bucket在磁盘上,这时候如果直接去磁盘上访问显然cost很大,所以oracle的处理方法是先把这个probe的数据放到一边不管。顺便一提的是这个probe的值首先是会放在内存里,如果以后积累了一定量的其他probe上的数据之后,oracle会把这些数据批量的写入到磁盘,这就是图上的dump probe partitions to disk。

3. 当我们把probe完整的扫描了一边之后,我们可能已经返回了一部分匹配的数据,但是我们现在在磁盘还有两部分没有处理的数据:build table的hash table的一部分数据和probe table的一部分数据,现在oracle就把这两部分数据重新做一次hash join(这时候会重新比较谁的结果集比较小,因此可能会出现原来的build table变成probe table,原来的probe table变为build table),然后返回最终的查询结果。这就是onepass hash join的大致过程。

关于onepass的cost计算是个很复杂的过程,除非我们使用10053和10104 event进行监控,否则很难弄清楚这里的cost到底是怎么计算的。但是在这里需要提醒大家的是,通常我们以为加大给用户分配的内存,会优化hash join的性能,是的,大体的趋势是这样,但是我们会发现如果调整的范围没有跨越onepass和optimal的分界线,也就是说如果一个hash join在拥有10M内存的时候走optimal,小于10M走onepass,如果我们把内存从5M调整到9M,我们可能会遇到性能不但没有提高反而降低的现象:


SQL> show parameter hash_area

NAME TYPE VALUE
------------------------------------ -------------------------------- ------------------------------
hash_area_size integer 1048576

SQL>
SQL>
SQL> set autotrace traceonly exp
SQL> select
/*+ ordered full(bu) full(pb) use_hash(pb) */
bu.build_vc,
bu.build_padding,
pb.probe_vc,
pb.probe_padding
from
build_tab bu,
probe_tab pb
where
bu.id between 1 and 2000
and pb.id = bu.id_probe
/

Execution Plan
----------------------------------------------------------
0 SELECT STATEMENT Optimizer=ALL_ROWS (Cost=1127 Card=2000 Bytes=4114000)
1 0 HASH JOIN (Cost=1127 Card=2000 Bytes=4114000)
2 1 TABLE ACCESS (FULL) OF 'BUILD_TAB' (Cost=255 Card=2000 Bytes=2060000)
3 1 TABLE ACCESS (FULL) OF 'PROBE_TAB' (Cost=255 Card=10000 Bytes=10270000)

SQL>
SQL> alter session set hash_area_size=2097152;

Session altered.

SQL> select
/*+ ordered full(bu) full(pb) use_hash(pb) */
bu.build_vc,
bu.build_padding,
pb.probe_vc,
pb.probe_padding
from
build_tab bu,
probe_tab pb
where
bu.id between 1 and 2000
and pb.id = bu.id_probe
/

Execution Plan
----------------------------------------------------------
0 SELECT STATEMENT Optimizer=ALL_ROWS (Cost=2926 Card=2000 Bytes=4114000)
1 0 HASH JOIN (Cost=2926 Card=2000 Bytes=4114000)
2 1 TABLE ACCESS (FULL) OF 'BUILD_TAB' (Cost=255 Card=2000 Bytes=2060000)
3 1 TABLE ACCESS (FULL) OF 'PROBE_TAB' (Cost=255 Card=10000 Bytes=10270000)


我们发现调大内存反而加大了cost,这是因为我们加大的内存还不足以让这个hash join运行在optimal模式下,而同时更多的内存反而会导致hash join内部处理机制的某些改变使cost更大了。尤其请大家注意的是,这个问题在PGA自动管理模式下是同样存在的。

multipass hash join

最后,如果我们的内存特别小或者相对而言需要hash的数据特别大,hash join就会以最恶劣的方式执行:multipass hash join。如果说onepass是只需要多从磁盘做一次probe table的读取,那么multipass就需要做多次读取,这往往发生在可用内存和数据量相差很大的情况下。multipass hash join是我们需要尽量避免的东西。

我们可以做个实验,在onepass的那个实验的基础上,减少hash_area_size的值:

execute dbms_random.seed(0)

drop table build_tab;
drop table probe_tab;


begin
begin execute immediate 'purge recyclebin';
exception when others then null;
end;

begin execute immediate 'begin dbms_stats.delete_system_stats; end;';
exception when others then null;
end;

begin execute immediate 'alter session set "_optimizer_cost_model"=io';
exception when others then null;
end;
end;
/

/*

rem
rem 8i code to build scratchpad table
rem for generating a large data set
rem

drop table generator;
create table generator as
select
rownum id
from all_objects
where rownum <= 3000
;

*/

create table probe_tab
initrans 3
nologging
as
with generator as (
select --+ materialize
rownum id
from all_objects
where rownum <= 3000
)
select
/*+ ordered use_nl(v2) */
10000 + rownum id,
trunc(dbms_random.value(0,5000)) n1,
rpad(rownum,20) probe_vc,
rpad('x',1000) probe_padding
from
generator v1,
generator v2
where
rownum <= 10000
;

alter table probe_tab add constraint pb_pk primary key(id);

create table build_tab
initrans 3
nologging
as
with generator as (
select --+ materialize
rownum id
from all_objects
where rownum <= 3000
)
select
/*+ ordered use_nl(v2) */
rownum id,
10001 + trunc(dbms_random.value(0,5000)) id_probe,
rpad(rownum,20) build_vc,
rpad('x',1000) build_padding
from
generator v1,
generator v2
where
rownum <= 10000
;

alter table build_tab add constraint bu_pk
primary key(id);

alter table build_tab add constraint bu_fk_pb
foreign key (id_probe) references probe_tab;

create index bu_fk_pb on build_tab(id_probe);


begin
dbms_stats.gather_table_stats(
user,
'build_tab',
cascade => true,
estimate_percent => null,
method_opt => 'for all columns size 1'
);
end;
/

begin
dbms_stats.gather_table_stats(
user,
'probe_tab',
cascade => true,
estimate_percent => null,
method_opt => 'for all columns size 1'
);
end;
/


begin
begin execute immediate 'alter session set workarea_size_policy = manual';
exception when others then null;
end;

begin execute immediate 'alter session set hash_area_size = 131072';
exception when others then null;
end;

end;
/


set autotrace traceonly
select
/*+ ordered full(bu) full(pb) use_hash(pb) */
bu.build_vc,
bu.build_padding,
pb.probe_vc,
pb.probe_padding
from
build_tab bu,
probe_tab pb
where
bu.id between 1 and 2000
and pb.id = bu.id_probe
/

Execution Plan
----------------------------------------------------------
0 SELECT STATEMENT Optimizer=ALL_ROWS (Cost=13531 Card=2000 Bytes=4114000)
1 0 HASH JOIN (Cost=13531 Card=2000 Bytes=4114000)
2 1 TABLE ACCESS (FULL) OF 'BUILD_TAB' (Cost=255 Card=2000 Bytes=2060000)
3 1 TABLE ACCESS (FULL) OF 'PROBE_TAB' (Cost=255 Card=10000 Bytes=10270000)

我们看到multipass的cost是很客观的。

最后,作为一个建议。我建议大家如果没有特殊的理由,尽量使用PGA的自动管理,而不是手动管理。因为首先自动管理可以合理使用我们有限的内存,使更多的hash join可以在optimal下完成,另外实验表明hash join所使用的内存在整个处理的阶段是会一直改变的,也就是说可能在最开始的时候需要很多内存,后来就只需要很少的内存,如果我们是运行在PGA自动管理模式下,oracle可以在hash join不需要太多内存做hash join的时候回收这些内存,而这在手动管理模式下是做不到的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值