1.2 段中块的使用
在讲解本节主题前,我们先来理清一个概念,什么是段。在Oracle中,表和段是两个截然不同的概念。表从逻辑上说明表的形式,比如表有几列,每列的类型、长度,这些信息都属于表。而段只代表存储空间,比如,上节中提到的区,就是属于段。一个段中至少要包含一个区。
Oracle中,每个对象都有一个ID值,表有表的ID,段有段的ID。在DBA_OBJECTS数据字典视图中,object_id列是表ID,data_object_id列是段ID,下面查看了某个表的表ID和段ID:
SQL> create table lhb.table_lhb2 (id int,name varchar2(20)) tablespace tbs_ts2;
Table created.
SQL> select object_id,data_object_id from dba_objects where owner='LHB' and
object_name='TABLE_LHB2';
OBJECT_ID DATA_OBJECT_ID
---------- --------------
13039 13039
从上面信息可知,这里创建了一个表TABLE_LHB2,初始情况下,它的表ID和段ID是一样的,都是13039。
表ID一旦创建,就不会再改变。但段ID是会变化的,比如,当Truncate表时,Oracle会将表原来的段删除,再为表新建一个段。也就是将表原来的存储空间释放,再重新分配新的区。这个过程完毕后,表就换了一个段,所以,表ID不变,但段ID却变了。如下所示:
SQL> insert into lhb.table_lhb2 values(1,'abc');
1 row created.
SQL> commit;
Commit complete.
SQL> truncate table lhb.table_lhb2;
Table truncated.
SQL> select object_id,data_object_id from dba_objects where owner='LHB' and
object_name='TABLE_LHB2';
OBJECT_ID DATA_OBJECT_ID
---------- --------------
13039 13040
可以看到,在Truncate表后,OBJECT_ID不变,DATA_OBJECT_ID变了。基本上,每Truncate一次,段ID都会加1。
注意,上面的测试是在Oracle 11GR2中做的,如果是在Oracle 10g中,创建表后不需要插入一行,直接Truncate,就可以观察到段ID的变化。
1.2.1 块中空间的使用
一个块的大小最常见是8KB。对于这8KB空间的使用,网上已经有很多描述,这里简单说一下。块中信息分两部分:管理信息和用户数据,其中,管理信息包括块头的SCN、ITL槽等。
块的结构相信很多人也研究过,下面讨论一个常见问题:如果删除了一行,再回滚,行的位置会变吗?
测试如下:
SQL> select dbms_rowid.ROWID_RELATIVE_FNO(rowid) fno,dbms_rowid.rowid_block_
number(rowid) block_id,dbms_rowid.ROWID_ROW_NUMBER(rowid) row_id,id from lhb.
table_lhb2;
FNO BLOCK_ID ROW_ID ID
---------- ---------- ---------- ----------
5 517 0 1
5 517 12<---(删除此行再回滚)
5 517 23
5 517 34
这里使用了一个包,dbms_rowid,它的作用是从ROWID中将对象ID、文件号、块号、行号分解出来。或者把对象ID、文件号、块号、行号合并成ROWID,具体使用方法这里不再列出,可以参考Oracle官方文档PL/SQL Reference,其中有详细的说明。这里,使用它的第一种功能,从ROWID中解析出块号、行号等信息。如果向lhb.table_lhb2表中依次插入ID为1、2、3、4的4行数据,观察ROW_ID列,可以看到,这4行的行编号分别是0、1、2、3。
下面将ID为2的行(行编号是1)删除,再回滚,然后再次查看。
SQL> delete lhb.table_lhb2 where id=2;
1 row deleted.
SQL> rollback;
Rollback complete.
SQL> select dbms_rowid.ROWID_RELATIVE_FNO(rowid) fno,dbms_rowid.rowid_block_
number(rowid) block_id,dbms_rowid.ROWID_ROW_NUMBER(rowid) row_id,id from lhb.
table_lhb2;
FNO BLOCK_ID ROW_ID ID
---------- ---------- ---------- ----------
5 517 0 1
5 517 12<---(回滚后行号不变)
5 517 23
5 517 34
结果不变,ID为2的行,还是在行号为1的位置。
道理很简单,删除某行,其实只是在行上加个删除标志,声明此行所占的空间可以被覆盖。在没有提交时,事务加在行上的锁并没有释放,此行虽然已经打上了删除标志,但空间仍不会被其他行覆盖。而删除行的回滚,其实就是将被删除的行重新插入一次。但回滚时的插入和普通插入一行还是有一定区别的。因为被删除行的空间不会被覆盖,所以回滚时的插入,不需进行寻找空间的操作,而是行原来在哪儿,就还插入到那里。这也就是它和普通插入的区别。
因此,删除的回滚,不会改变行原来的位置。
但如果删除后提交再插入呢?行的位置肯定就会发生变化了。
1.2.2 典型问题:堆表是有序的吗
曾经有位开发人员跟我聊到,他曾做过测试,插入几万行,删掉,再插入,发现原来Oracle中堆表是按插入顺序安排行的位置的,而且这个测试他做了好多遍,都是这个结果。现在他们有个应用,显示数据时,要求先插入的行在前,后插入的行在后,其实Oracle已经帮他们实现了这个功能。
事实上,堆表是无序的,堆表的特点就是无序、插入快速。
Oracle在插入行时是如何在数据块内查找可用空间的呢?这有点类似于上节中提到的区的分配过程。Oracle会在数据块中设立一个标记位,记录空间使用到哪儿了。
块中用户数据所占空间是从下往上分配的。假设,在8192字节的块中插入了5行,每行100字节,也就是说,空间已经使用到了(8192-500)7692字节处,那么,标记位的值就是7692。
如果删除了其中一行并提交,标记位的值不会变,还是7692。再重新插入被删除行,或插入新行,将会从7692处向上查找可用空间,删除行释放出的空间不会被使用。
当标记位的值越来越小,向上到达管理性信息的边界时,标记位会再变为8192。
我们可以测试一下。
SQL> delete lhb.table_lhb2 where id=2;
1 row deleted.
SQL>commit;
Commit complete.
SQL> insert into lhb.table_lhb2 values (2,'ABC');
1 row created.
SQL> commit;
Commit complete.
SQL> select dbms_rowid.ROWID_RELATIVE_FNO(rowid) fno,dbms_rowid.rowid_block_
number(rowid) block_id,dbms_rowid.ROWID_ROW_NUMBER(rowid) row_id,id from lhb.
table_lhb2;
FNO BLOCK_ID ROW_ID ID
---------- ---------- ---------- ----------
5 517 0 1
5 517 2 3
5 517 3 4
5 517 4 2<------删除提交后再插入,
被分配到了新的位置
在上面的测试中,先删除ID为2的行,提交后接着又插入ID为2的行。不过,新插入的行并没有使用刚刚删除行的空间。
如果只测试到这一步,很容易得出结论,行的位置就是插入顺序。但别忘了,我们只在一个块内进行了观察,查找了可用空间。在众多的块中,Oracle是如何选择要向哪个块中插入的呢?情况会不会有变化呢?我们还不知道。
所以,现在还不能完全回答“堆表是有序的吗”这个问题,继续向下看,据说ASSM对插入的影响是巨大的,那接下来看看ASSM。
1.2.3 ASSM与L3、L2、L1块的意义
ASSM的目的是大并发插入,这应该是DBA要掌握的基本知识。在输入输出能力满足的情况下,使用ASSM就能有大并发插入吗?这可不一定。工具再好,还要看我们如何使用工具。
在了解ASSM的使用注意事项之前,先来分析一下ASSM。为什么Oracle对外宣称ASSM可以支撑大并发插入应用呢?
ASSM的整体结构是3层位图块+数据块,即共4层的树状结构。
第一层位图块称为L3块,一个L3块中可以存放多个L2块的地址,一个L2块中可以存放多个L1块地址,一个L1块中可以存放多个数据块地址,如图1-2所示。
图1-2 ASSM的整体结构
第一个L3块一般是段头。如果段头中存放了太多L2块的信息,空间不足,Oracle会再分配第二个L3块。当然,段头中会有第二个L3块的地址。如果第二个L3块空间也用完了,会再分配第三个。第二个L3块中会存放第三个L3块的地址。通常情况下,一个L3块就够了。有两个L3块就已经是非常罕有的情况了,基本上不会出现需要3个L3块的情况。
Oracle是如何使用4层树状结构(3层位图块+数据块)来确定向哪个块中插入的呢?
第一步,查找数据字典(就是dba_segments数据字典视图的基表),确定段头位置。
第二步,在段头中找到第一个L2块位置信息。
第三步,到L2块中根据执行插入操作进程的PID号,做HASH运算,得到一个随机数N,在L2中,找到第N个L1块的位置信息。
第四步,到第三步中确定的L1块中,再根据执行插入操作进程的PID号,做HASH运算,得到一个随机数M,在L1中找到第M号数据块。
第五步,向第M号数据块中插入。
L3块中虽然可以有多个L2块,但插入操作不会选择多个L2块,每次只会选择同一个L2块。直到这个L2块下面的所有数据块都被插满了,才会选择下一个L2块。
在L2中选择某个L1的时候,就是随机的了。不同Session,只要有可能,就会被分配到不同的L1中。在L1中找数据块时也是一样。
现在我们可以回答这个问题了:Oracle为什么宣称ASSM可以支持大并发插入。
假设一个L2中有100个L1,每个L1中有64个数据块,可以算一下,100×64,如果Oracle的随机算法真的够随机,如果有6400个进程一起执行插入操作,Oracle会随机地将它们分配到6400个数据块中。
Oracle的随机算法一向都是值得信赖的。
所以,在Oracle的所有资料中,都宣称ASSM可以支撑大并发插入。
但实际情况往往不像想象中的这么简单。
1.2.4 值得注意的案例:ASSM真的能提高插入并发量吗
这个案例很有代表性,如果不深入到细节中,很容易在中途得出错误的结论。下面详细描述思考过程,希望能给大家带来些启发。
曾经遇到过这样的应用,要求对用户的登录、退出行为做记录。此部分的逻辑很简单,用户每登录一次应用,向数据库中一个日志表中插入一行,退出应用的时候再向日志表中插入一行。
此日志表是个日分区表,每天一个分区。每天大约会插入千万行,除了插入并发很高以外,就没有其他的大并发操作。另外,每天晚上会将当天的数据推送到数据仓库,在数据仓库中再进行分析、对比。
项目上线后,有些用户反映登录变慢了。而且,只有上午八九点钟左右的时候慢,过了这一段时间就没有用户反映有问题。经过对比AWR,发现变慢是不定时的,从8点开始,到9点左右为止,在半小时一次的报告中,偶尔会有那么一两份AWR会显示Buffer Busy Waits比较高,然后就正常了。
看到这个情况,很容易让人认为是某个时间段有很多人一起在访问同一张表,其他时间又不一起访问了。究竟是不是这么回事呢?
先来确定一下等待是针对哪个对象。通过V$SEGMENT_STATISTICS,查找STATISTIC_NAME列为buffer busy waits的,或者,查看V$ACTIVE_SESSION_HISTORY中的历史等待事件,根据P1、P2列的值,就可以定位争用是针对哪个对象的。
根据文件号、块号查找的结果来看,绝大多数的Buffer Busy Waits都出现在日志表上。
日志表每天分区的数据量最高接近千万行,就按每天1000万行算,除以3600×24,平均每秒116个并发插入。当然,还要考虑高低峰的问题,晚上应用基本上没什么人用的,这几百万行大部分都是白天插入的。所以,再乘个2,每秒232的插入量,这是最高的了。也并不是很多,这点量和Oracle宣称的ASSM支持的高并发插入相比,应该不会有Buffer Busy Waits。
但无论如何,Buffer Busy Waits是产生了,有可能以主机的硬件来论,现在已经是并发插入量的极限了。但奇怪的是,这种情况每天只会在刚上班后不久(8~9点)出现,其他时段正常。
难道是刚上班时向日志表的插入量高?
但统计的结果显示,白天有好几个时段,日志表的插入量都很大,并不是早上上班时段特别大,有时下午还会比上午插入的稍多些,但没有发现下午日志表上有Buffer Busy Waits,下午也从来没人反映过慢,而且整库的压力上下午基本差不多。
如果全天都有Buffer Busy Waits,我想我也会放弃进一步调查。但有时下午的插入量多,反而没有等待。那说明ASSM是足以支撑这个量级的并发插入的。想解决问题的话,第一步是定位问题,这我们都知道。可如何定位这个问题呢?
遇到这样的疑难杂症,一般的方法是在测试环境中详细地分析相关操作,甚至可以使用DTrace加MDB/GDB这种底层分析工具。总之,只有清楚地了解底层操作,才能分析出问题在哪儿。
如何发现现在遇到的这个问题出在哪儿呢?
很简单,还是从最基本的测试做起。先建一个表,验证一下Oracle插入时,是否会随机地选择块。如下所示:
SQL> drop tablespace tbs_ts1 INCLUDING CONTENTS;
Tablespace dropped.
SQL> create tablespace tbs_ts1 datafile '/u01/Disk1/tbs_ts1_01.dbf' size 50m reuse
uniform size 1m;
Tablespace created.
SQL> create table table1(id int,name varchar2(20)) tablespace tbs_ts1;
Table created.
由于线上环境表空间区大小是1MB,因此在测试环境,我也创建了个区大小为1MB的表空间。
在Oracle 10g以后,Oracle默认的表空间类型就是ASSM了,所以,不需要专门指定了。
接着,在tbs_ts1表空间中创建一个测试表TABLE1,下面来看看它的区占用情况。
SQL> select extent_id, file_id, block_id, blocks from dba_extents where
owner='LHB' and segment_name='TABLE1' order by extent_id;
EXTENT_ID FILE_ID BLOCK_ID BLOCKS
---------- ---------- ---------- ----------
0 4 128 128
可以看到,TABLE1在4号文件中,第一个区开始自128号块处。可以DUMP一下128号块看看,它是一个L1块。129号块也是一个L1块,130号块是L2块,131号块是段头,也是L3块。
128号和129号块中,各自有64个数据块信息。这一点,可以通过DUMP来确认。
下面,插入一行,试试看这一行将被插入哪个块中。
SQL> insert into table1 values(1,'AAAAAA');
1 row created.
SQL> commit;
Commit complete.
SQL> select dbms_rowid.ROWID_RELATIVE_FNO(rowid) fno,dbms_rowid.rowid_block_
number(rowid) block_id,dbms_rowid.ROWID_ROW_NUMBER(rowid) row_id,id from lhb.table1;
FNO BLOCK_ID ROW_ID ID
---------- ---------- ---------- ----------
4 155 0 1
在插入这一行并提交后,可以用之前介绍过的语句,查看这一行的位置。可以看到,它被插入在了4号文件155号块中。换个会话再插入一行试试。
SQL> insert into table1 values(2,'BBBBBB');
1 row created.
SQL> commit;
Commit complete.
SQL> select dbms_rowid.ROWID_RELATIVE_FNO(rowid) fno,dbms_rowid.rowid_block_
number(rowid) block_id,dbms_rowid.ROWID_ROW_NUMBER(rowid) row_id,id from lhb.table1;
FNO BLOCK_ID ROW_ID ID
---------- ---------- ---------- ----------
4 155 0 1
4 156 0 2
在另一个会话中,插入了ID为2的行,它被插入在了156号块中。
不同的会话,Oracle会将行插入到不同块中。Oracle是根据PID计算出的随机数,随机地将行插入在不同的块中。只要PID不一样,行就会被插入在不同的块中。在PID一样的情况下,行会被插入在同一块中。
比如,在第一个会话中再插入一个ID为3的行。
SQL> insert into table1 values(3,'AAAAAA');
1 row created.
SQL> commit;
Commit complete.
SQL> select dbms_rowid.ROWID_RELATIVE_FNO(rowid) fno,dbms_rowid.rowid_block_
number(rowid) block_id,dbms_rowid.ROWID_ROW_NUMBER(rowid) row_id,id from lhb.table1;
FNO BLOCK_ID ROW_ID ID
---------- ---------- ---------- ----------
4 155 0 1
4 155 1 3
4 156 0 2
ID为3的行也被插入到155号块中。因为它和ID为1的行是在同一会话中插入的,会话对应进程的PID相同,两行就被插入了同一个块中。
另外,我们可以发现,后插入的ID为3的行,在显示时被排在先插入的ID为2的行前了。这说明堆表中行的排列也并非是插入顺序。
现在我们终于可以对前面提出的“堆表是有序的吗”问题给出一个明确的回复了。那就是:完全无序。因为插入时有个根据PID计算随机数的过程,这就会导致行被插入哪个块是随机的。因此,堆表是无序的。
继续观察行被插入的位置。但如果我们老是通过sqlplus lhb/a建立一个会话,在会话中插入,这样太麻烦了,还是写个脚本吧。
$ cat assm_test.sh
sqlplus lhb/a <<EOF
insert into lhb.table1 values($1,'aaabbbcccd');
commit;
exec dbms_lock.sleep(10000);
EOF
关于Shell脚本的编写,这里不再解释。下面只说一点,为什么最后要加如下语句:
exec dbms_lock.sleep(10000);
如果没有这个暂停操作,会话将立即结束。在Oracle中,如果前一个会话结束,下一个会话马上建立,则下一个会话将会有和前一个会话相同的Session ID和PID(注意,PID不是SPID,PID是Oracle对进程的编号)。如果两个会话的PID相同,行将被插入在同一块中。所以,这里专门加个“暂停”操作,让会话停10000秒后再退出。这样,再新建一个会话,它将有一个新的PID。
按如下方法,将上述脚本执行10次:
./assm_test.sh 4&
./assm_test.sh 5&
…
加一个&,表示放在后台执行,要不然要等10000秒才能结束。
查看一下这些行都被插到哪儿了。
SQL> select dbms_rowid.ROWID_RELATIVE_FNO(rowid) fno,dbms_rowid.rowid_block_number
(rowid) block_id,dbms_rowid.ROWID_ROW_NUMBER(rowid) row_id,id from lhb.table1;
FNO BLOCK_ID ROW_ID ID
---------- ---------- ---------- ----------
4 155 0 1
4 155 1 3
4 156 0 2
4 157 0 4
4 158 0 5
4 159 0 6
4 160 0 8
4 161 0 7
4 162 0 9
4 163 0 10
4 164 0 11
4 165 0 13
4 166 0 12
13 rows selected.
还是很平均的,每个块一行。我们看一下ROW_ID列,这是行在块中的行号。除了刚才做测试的ID为3的行,其他行都是块中的第一行(行号为0)。
平均是很平均,但我们应该也注意到了一个问题,在后面所做的10次插入,虽然这10行的确被插到了10个块中,但是,这些块未免有点太集中了。
table1表现在共有128个块,块编号从128到255。但这些行都被插到了155~166号块中。
这应该是Oracle的算法不够随机吧。
一开始我觉得,是区不够多,只有一个区,128个块,Oracle选择面太窄了。我们知道,表在扩展时,也都是一个区一个区地扩展的。每次占满了128个块后,再扩展下一个区。但下一个区也还是128块,还是只在128个块中选择。由于随机算法不够随机,导致在128选一时,很多行被同时插到了同一个块中,这时,就会出现Buffer Busy Waits。
一切都是合乎情理,我马上将发现告知应用方。解决方案就是,在晚上数据库空闲时,为日志表手动分很多个区。
第二天,客户依然反映,运行速度慢。查看数据库,还是有Buffer Busy Waits。
为什么?
看来是第一次的实验做得不够彻底。为什么使用的块是155号、156号、157号等,这么有顺序,而且不够分散呢?
继续前面的测试。这次,我调用./assm_test.sh N&,每10次观察一下行的分配情况,终于发现了问题。
SQL> select dbms_rowid.ROWID_RELATIVE_FNO(rowid) fno,dbms_rowid.rowid_block_number
(rowid) block_id,dbms_rowid.ROWID_ROW_NUMBER(rowid) row_id,id from lhb.table1;
FNO BLOCK_ID ROW_ID ID
---------- ---------- ---------- ----------
4 132 0 39
……………………
4 159 0 6
4 159 1 67
4 160 0 8
4 160 1 66
4 161 0 7
4 161 1 68
4 162 0 9
4 162 1 69
……………………
4 190 0 38
4 191 0 37
69 rows selected.
一共插入了69行,最小的块号是132。这个可以理解,因为表的第一个块编号是128,128号、129号块是L1,130号是L2,131号是段头兼L3。第一个可用数据块是从132开始的。但是到150号块后,就开始有重复,两行被插入同一块中。还有一点就是,最大使用的块是191号。用192-128,正好等于64。
继续分析下去有个关键点,要看之前对细节的挖掘程度了。前面我们一再地提过,对于1MB大小的区,每个区最前面的两个块,大多数情况下是L1块。在8KB的块大小下,1MB共128个块,两个L1,正好每个L1记录64个数据块。
好了,答案基本上已经浮出水面。
Oracle只使用了第一个L1块中的数据块,而没有使用第二个L1中的块。
其实还有一个知识点,如果不具备,可能分析就到这里为止了。前面也提过了,Oracle在L3、L2、L1、数据块中这个树状图中选择要插入的块时,从L3中选择L2并不是随机的,每次都只选某一个。但从L2中选择L1是随机的。关于这一点,我已经做了测试。
现在L2中有两个L1,会什么Oracle只选择第一个L1呢?
你想到原因了吗?
我是这样想到原因的,我曾经做过直接路径插入的测试,这个测试验证了如果进行直接路径插入,每次会在高水点之上分配空间,如果提交,则修改高水点。如果不提交,则不修改高水点,通过这种方式可减少UNDO的耗用。而普通的插入则是在高水点之下寻找空间。
我们一直没有提过高水点。直接路径都是在高水点之上插入的,那么间接路径呢?肯定是在高水点之下了。
好,答案已经见分晓了。高水点肯定在第192号块。因为第二个L1块中的数据块,都在高水点之上,因此,第二个L1块中的数据块不会被插入算法选择到。
DUMP一下段头验证一下吧。
Extent Control Header
---------------------------------------------------------------
Extent Header:: spare1: 0 spare2: 0 #extents: 1 #blocks: 128
last map 0x00000000 #maps: 0 offset: 2716
Highwater:: 0x010000c0 ext#: 0 blk#: 64 ext size: 128
代码中加下划线的就是高水点了。0x010000c0,这个是DBA(Data Block Address,数据块地址)。它的前10个二进制位是文件号,后面的是块号。0x010000c0也就是4号文件192号块。
看来Oracle的高水点每次向后移动时,是以L1块中的数据块数量为单位的啊。
水落石出了,原来是高水点太低的问题。
Oracle只告诉我们,ASSM可以增大插入并发量,但没告诉我们,并发插入量还要受高水点限制。
以前曾经有人讨论过在MSSM表空间中高水点的移动规则,而ASSM下高水点的推移规则还很少有人注意过。
当在区中插入第一行时,高水点移到区的第一个L1块中最大的数据块后。这句话有点绕,还是以我们的测试表TABLE1为例吧:插入第一行时,高水点移到了第一个L1块(128号块)中最大的数据块后,128号块中最大的数据块是191,那么高水点就是192了,其实也就是第二个L1块中的第一个数据块。
简单总结一下,高水点的移动,在ASSM下,是以L1中数据块的数量为准的。
如果块大小是8KB,区大小是1MB,L1中有64个数据块,高水点就是以64个块为单位,依次往后挪的。也就是说,我们的并发插入,每次都只是向64个块中插入。可以想象,如果同时有100个进程插入,但只有64个块接收,将有36个进程不得不和另一个进程同时向一个块中插入。
两个进程同时修改一个块,会有什么等待时间呢?Buffer Busy Waits(当然也会有少量的Cache Buffer Chain Latch)。
问题已经找到一大半了,ASSM表空间仍有可能因为高水点不高,可用于插入的块不多,造成Buffer Busy Waits。但另一半问题隐藏得更深,为什么只会在刚上班那会儿出现这个等待,而其他时间则没有呢?
注意,白天的时候,压力是差不多的。有时下午比上午还要高。
要解答这个问题,就看你对Oracle的内部机制有多大的好奇心了。
我挖掘出这个问题纯属意外。
其实在发现了高水点问题后,我建议使用抬高高水点的方式解决争用问题。
当然,抬高高水点后,将对全表扫描不利。全表扫描只扫描高水点之下的块,如果高水点太高,要扫描的块也多了。
但这个日志型应用,平常没有全表扫描,只有在每天晚上向数据仓库传数据时,需要全表扫描。因此,对全表扫描的影响不是主要考虑的因素。
如何抬高呢?手动分配区是无法抬高高水点的。只有一种方法,先插入行再删除。
因为日志表是一个日分区表,按照日期,每天一个分区。考虑到每天的插入量不会高于1000万行,因此决定对未来的每个分区,先插入1000万行,再用Delete删除。
具体的方案是这样的,先使用APPEND向一张中间表中插入1000万行,采用直接路径方式,这样产生的UNDO量较少。再用Delete慢慢删除,根据ROWID来删除,一次删除一个区的所有行,然后提交。将整个表删除完后,高水点就已经被抬高了,但表中是没有行的。再使用分区交换命令,将被抬高高水点的中间表交换到日志表中。
这种方法听起来有点不太规范,但没办法,暂时只能这样解决了。
实事上,我用上面的方式调高了几个分区的高水点,第二天观察,果然在全天任意时候,都不再有Buffer Busy Waits了。
其实如此交差也可以,就是加分区的时候麻烦点。若用脚本实现,只是在Delete的时候慢点,不占太多回滚段就不会有任何问题。
但还有一个问题一直困扰着我,但这个问题和应用已经无关了,我只是好奇:一个L1中有64个数据块,64这个数字是固定的吗?
我分别用40KB(5个8K的块,已经是Oracle中最小的区了)、1MB、10MB、30MB大小的区测试,40KB的区中,一个L1中可以只有5个数据块,是最少的。但1MB、10MB、30MB的区,都是一个L1中有64个块。64个块应该就是L1中数据块的最大数量了。
Oracle的系统管理区大小是随着段的不断变大而不断变大的,L1会不会也是这样呢?我决定再试一下。
用手动分配区的命令,为TABLE1多分配些区。我为TABLE1每次分配30MB空间,每次DUMP一下最后一个区的第一个块(每个区第一个块通常都是L1块)。
当分配的总空间到90MB时,我发现L1中的数据块数量从64增加到了256个。测试如下:
SQL> drop tablespace tbs_ts1 INCLUDING CONTENTS;
Tablespace dropped.
SQL> create tablespace tbs_ts1 datafile '/u01/Disk1/tbs_ts1_01.dbf' size 100m
=reuse uniform size 1m;
Tablespace created.
SQL> create table table1(id int,name varchar2(20)) tablespace tbs_ts1;
Table created.
SQL> alter table table1 allocate extent (size 90m);
Table altered.
SQL> set pagesize 1000
SQL> select extent_id, file_id, block_id, blocks from dba_extents where
owner='LHB' and segment_name='TABLE1' order by extent_id;
EXTENT_ID FILE_ID BLOCK_ID BLOCKS
---------- ---------- ---------- ----------
0 4 128 128
1 4 256 128
2 4 384 128
…………………………
88 4 11392 128
89 4 11520 128
90 4 11648 128
91 rows selected.
上面删除了表空间,重新建了个全空的,区大小1MB。又创建了个新表,TABLE1,手动分配90MB空间。它一共有91个区。
分别DUMP一下第128号块和11520号块。以下是4号文件128号块的DUMP结果:
mapblk 0x00000000 offset: 0
--------------------------------------------------------
DBA Ranges :
--------------------------------------------------------
0x01000080 Length: 64 Offset: 0
0:Metadata 1:Metadata 2:Metadata 3:Metadata
4:unformatted 5:unformatted 6:unformatted 7:unformatted
8:unformatted 9:unformatted 10:unformatted ………………
……………………
60:unformatted 61:unformatted 62:unformatted 63:unformatted
--------------------------------------------------------
可以看到,这个L1*有64个数据块。以下是11520号块的DUMP结果:
--------------------------------------------------------
DBA Ranges :
--------------------------------------------------------
0x01002d00 Length: 128 Offset: 0
0x01002d80 Length: 128 Offset: 128
0:Metadata 1:unformatted 2:unformatted 3:unformatted
4:unformatted 5:unformatted 6:unformatted 7:unformatted
…………………………
252:unformatted 253:unformatted 254:unformatted 255:unformatted
--------------------------------------------------------
在这个L1块中,数据块的数量增加到了256个。
这证明了L1块中记录的数据块个数也是随着表的不断增大而增多的。
这个证明有何意义呢?还记得上面遇到的问题吧,每天总是在刚上班时会有Buffer Busy Waits,而其他时间则没有。现在有答案了。
因为日志表每天一个分区,也就是每天一个段。刚上班时,段还比较小,L1块中只有64个数据块,因此并发插入每次都只针对64个块。随着表增大,当表超过90MB时,一个L1就有256个数据块了,即使所有并发都只针对一个L1中的数据块,256个块也足以支撑这套应用的所有并发了。因此,每天总是在最开始不长一段的时间内,会有Buffer Busy Waits,再往后就正常了。
这个奇怪的问题终于找到了原因。其实我研究L1中数据块的数量,本来只是为了满足好奇心,没想到可以查找出这个问题的原因。
更进一步,可以再试一下不同区大小、不同段大小下,L1块中数据块的数量。
我测试的结果是,10MB区大小,从第4个区开始,L1块中数据块的数量就已经是256个了。10MB的区好像有点大了,我只测试了一下4MB或8MB的区,在段大小超过64MB后,L1块中数据块的数量会达到256个。
好,研究得差不多了。可问题该如何解决呢?方法还和刚才一样,先插入,再删除。只不过,不需要插入1000万行了。我选择建立8MB区大小的表空间,日志表新的分区都建到新表空间中。每个分区只需插入50万行再删除就可以了。
只需要将前8个分区,插入满行,再删除,将高水点推到第8个分区后,因为第8个分区后,每个L1块中都是256个数据块,足够支撑并发插入量了。
该问题终于有了一个比较好的解决方案。但后面经过观察又发现,在L1块上出现了争用,但不严重,没有造成反应延迟。Oracle的高水点每次以L1块中数据块的量为单位向后扩,始络会有问题。如果同一时刻的并发超过了256个,一样会有争用,而且,这么大的量,L1块的竞争也会大大加剧。这样的话,解决方法只有一个,就是像我最初的方案一样,插入很多行(比如1000万行),将高水点拉得很高,再删除。
好了,ASSM的问题就说到这儿。看来随便建个ASSM表空间,再建个表上去,就想支撑大并发插入,这种想法有点简单了。
实际案例就先介绍到这儿。希望通过这个案例读者能有所收获。
补充一句:对Oracle越熟悉,面临的疑难杂症就越少。
关于表空间和存储结构,还有两个疑问:全表扫描时,Oracle是如何找到表的块在哪儿的?索引扫描Oracle是如何找到Root块的?
1.2.5 段头与Extent Map
上一节提到了,段头是第一个L3块,就是说段头中包含L3信息。其实,段头中的重要信息,除了L3外,还有Extent Map,将其直译过来就是区地图。
顾名思义,区地图就是记录一个段中所有区都在哪儿的地图。全表扫描操作,就是按图索骥,按区地图逐个读取所有区。
让我们来看看区地图是什么样子,同时,也模拟一下全表扫描的执行流程。
第一步,确定段头位置。
SQL> select header_file ,header_block from dba_segments where segment_name='TABLE1';
HEADER_FILE HEADER_BLOCK
----------- ------------
4 131
当然,Oracle肯定不会读dba_segments这个数据字典视图,它会读dba_segments低层seg$这样的数据字典表。会先到共享池中的字典缓存中查找seg$相关的行,如果没有找到,再到Buffer Cache中读seg$相关的块,如果还没有,就到磁盘上SYSTEM表空间中读seg$表。
当找到TABLE1的段头位置时,Oracle会读取它里面的区地图,我们来DUMP一下。
执行下面的命令DUMP:
exit
sqlplus / as sysdba
alter system dump datafile 4 block 131;
就是先退出sqlplus,再重新连接,然后去DUMP。因为同一服务器进程会把DUMP信息写到一个DUMP文件中。如果你DUMP多次,被会写进一个文件,这样观察起来不方便。我退出再登录,服务器进程会换一个的,SPID也会不同,这样DUMP信息会被写到不同的文件中,便于查看。
下面就是段头中的区地图信息:
Extent Map
-----------------------------------------------------------------
0x01000080 length: 128
0x01000100 length: 128
0x01000180 length: 128
…………
0x01002d00 length: 128
0x01002d80 length: 128
第一个区,开始自0x01000080处,前10个二进制位是文件号,后面是块号,前面已经提到过的,也就是4号文件128号块处。这个区的大小是128个块,最后一个区,开始自4号文件11648号块处(就是最后一行0x01002d80),大小也是128个块。
我们已经看到区地图了,很简单是吧?但全表扫描时Oracle读取的并不是这里的区地图,还要往下看。
Auxillary Map
--------------------------------------------------------
Extent 0 : L1 dba: 0x01000080 Data dba: 0x01000084
Extent 1 : L1 dba: 0x01000100 Data dba: 0x01000102
Extent 2 : L1 dba: 0x01000180 Data dba: 0x01000182
………………
Extent 89 : L1 dba: 0x01002d00 Data dba: 0x01002d01
Extent 90 : L1 dba: 0x01002d00 Data dba: 0x01002d80
--------------------------------------------------------
在上面的信息中,出现了Auxillary Map,直译过来是辅助地图。这一部分信息更详细。L1 dba: 0x01000080,说明了此区内第一个L1块开始的地方,即4号文件的128号块。Data dba: 0x01000084,说明用户数据开始的地方,即132号块。这里说明了真正的用户数据开始自哪里,Oracle全扫描时,是按照“Data dba:*******”后的DBA查找区的。但这里没有区长度,所以,上面那部分区地图信息还是要读的。
另外,我们看最后两行:
Extent 89 : L1 dba: 0x01002d00 Data dba: 0x01002d01
Extent 90 : L1 dba: 0x01002d00 Data dba: 0x01002d80
这两行的L1 Dba一样,都是0x01002d00,即4号文件11520号块。为什么这样?因为11520号块中有256个数据块,所以这两个区只需要有一个L1块就行了。可以观察一下从什么地方开始两个区只要一个L1块,这里是从8192号块开始的。
Extent 61 : L1 dba: 0x01001f00 Data dba: 0x01001f02
Extent 62 : L1 dba: 0x01001f80 Data dba: 0x01001f82
Extent 63 : L1 dba: 0x01002000 Data dba: 0x01002001
Extent 64 : L1 dba: 0x01002000 Data dba: 0x01002080
可以看到,61号、62号区,还各自有不同的L1号块,而63号、64号区,已经只有63区头的一个L1块了。63号区也就是第64个区,每个区1MB,也就是当段大小超过64MB时,一个L1将放存256个数据块。
好了,这就是区地图,通过研究它,全表扫描操作的流程我们应该也都清楚了。很简单,找到段头,读取区地图信息,根据区地图的顺序,读取每一个区。所以,全表扫描的显示顺序,就是区地图中区的顺序,其实也就是dba_extents中区的顺序。
下面再来看一下全表扫描的逻辑读。
SQL> drop tablespace tbs_ts2 INCLUDING CONTENTS;
Tablespace dropped.
SQL> create tablespace tbs_ts2 datafile '/u01/Disk1/tbs_ts2_01.dbf' size 20m reuse
uniform size 40k;
Tablespace created.
SQL> drop table table2;
Table dropped.
SQL> create table table2(id int,name varchar2(20)) tablespace tbs_ts2;
Table created.
SQL> insert into table2 values(1,'ABC');
1 row created.
SQL> commit;
Commit complete
SQL> set autot trace
SQL> select * from table2;
……………………
Statistics
----------------------------------------------------------
0 recursive calls
0 db block gets
4 consistent gets
0 physical reads
0 redo size
594 bytes sent via SQL*Net to client
520 bytes received via SQL*Net from client
2 SQL*Net roundtrips to/from client
0 sorts (memory)
0 sorts (disk)
1 rows processed
注意,做观察逻辑读的测试时,对测试SQL语句select * from table2,要反复多次执行。
这里重新创建了一个TBS_TS2表空间,它的区大小只有40KB,也就是5个块。然后建了一个表,随便插入一行,插入的这一行将会使高水点被抬升到区的最后一个块之后。
这个区只有5个块,前三个块分别是L1、L2和段头,可以存放用户数据的只有第4、5两个块,那么高水点将在第5个块之后。
为什么逻辑读是4次呢?全表扫描,要跳过L1、L2,只读段头和高水点下的所有块,也就是读段头和第4、5个块。但是段头要读两次,所以,逻辑读为4。至于段头读两次的原因,根据前面DUMP的段头来看,段头中的Extent Map、Auxillary Map信息是分开存放的,要一次读Extent Map,一次读Auxillary Map,所以就要读两次了。
如何确定段头读两次的问题呢?Oracle 10G以前的版本,可以观察Latch的Gets次数,但在11GR2后,就只有使用DTrace跟踪才能知道了。本书后面章节会有些这方面的内容,我们会逐步深入到Oracle内部,揭开Oracle之谜。
1.2.6 索引范围扫描的操作流程
索引范围扫描,网上已经有很多讨论了,就是按照根、枝、叶的顺序读取。叶块的地址在枝块,枝块地址在根块。找到枝块就可以找到叶块,找到根块就可以找到枝块。那么,如何找到根块呢?
其实很简单,在Oracle中,根块永远在索引段头的下一个块处。因此,索引扫描是不必读取索引段头的。先在数据字典表中找到段头位置,块号加1就是根块位置了。
对索引范围扫描时的逻辑读,可以做如下测试:
SQL> insert into table1 select rownum,'abcde' from dba_objects;
12691 rows created.
SQL> commit;
Commit complete.
SQL> create index table1_id on table1(id) tablespace tbs_ts1;
Index created.
SQL> exec dbms_stats.gather_table_stats('LHB','TABLE1');
PL/SQL procedure successfully completed.
SQL> select BLEVEL from dba_INDEXES where index_name='TABLE1_ID' and owner='LHB';
BLEVEL
----------
1
上面先向表中插入了10000多行,再创建了一个1层高的索引,索引只有Root块和叶块。
下面看看索引访问一次的逻辑读:
SQL> set autot trace
SQL> select * from table1 where id=10;
Statistics
----------------------------------------------------------
0 recursive calls
0 db block gets
4 consistent gets
0 physical reads
0 redo size
596 bytes sent via SQL*Net to client
520 bytes received via SQL*Net from client
2 SQL*Net roundtrips to/from client
0 sorts (memory)
0 sorts (disk)
1 rows processed
将测试SQL语句select * from table1 where id=10多执行几次,观察到的逻辑读为4。这4次逻辑读分别是:Root块一次,叶块两次,数据块一次。
叶块之所以需要两次,是因为索引是非唯一的。第一次读叶块是为了取出目标行ROWID,第二次读叶块是判断此叶块中还有没有满足条件的行。
如果建成了唯一索引,不需要判断叶块是否还有满足条件的行,叶块就只需要读一次,一共只需要3次逻辑读。
SQL> drop index table1_id ;
Index dropped.
SQL> create UNIQUE index table1_id on table1(id) tablespace tbs_ts1;
Index created.
SQL> set autot trace
SQL> select * from table1 where id=10;
Statistics
----------------------------------------------------------
0 recursive calls
0 db block gets
3 consistent gets
0 physical reads
0 redo size
460 bytes sent via SQL*Net to client
509 bytes received via SQL*Net from client
1 SQL*Net roundtrips to/from client
0 sorts (memory)
0 sorts (disk)
1 rows processed
表空间和存储结构这就说到这儿。本章中的例子,都是在Oracle 11GR2中做的,在Oracle 10g中做同样例子时的注意事项也都随例子说明了。
另外,本章的测试都是以8KB块大小为例的,其他块大小下的情况,留给读者亲自动手测试。