3月30日 理解索引(3)索引访问的方式
设计索引是专业性很强的工作,一般来说索引设计最好有专业DBA参与,不过现实中,绝大多数系统的索引设计都是由开发人员完成的。由于开发人员对索引的原理不甚了解,因此在大多数软件系统中索引设计方面都存在很大的问题。Oracle公司有一门培训课程叫做《开发DBA》,这门课程的主要目的是让开发人员理解一些Oracle的基本原理与概念,从而在设计应用系统的时候可以依据这些原理,避免出现一些严重的问题。在开发人员中进行类似《开发DBA》这样课程的培训,是数据库优化工作中十分重要的环节,而这个环节往往被我们所忽视。实际上,在研发团队进行适当的培训,可以大量节省的成本。
今天我们要讨论的话题是如何设计索引,这是很多DBA都很想了解的。实际上昨天我们也讨论到了设计索引的一些基本的方法。索引的设计要遵循一些最基本的原则,首先,索引的设计必须是有针对性的,索引的目的是快速定位数据,因此每个索引都必须符合快速定位数据的要求。比如说我们想让下面的一条语句执行的更快一些,消耗更小的资源:
select emp_name,emp_id,sal from emp where emp_id=11023
首先我们需要分析一下emp这张表,emp表是企业的职员表,有数万条记录,而每个职员都有唯一的EMP_ID对应,那么如果我们在EMP_ID上创建了索引,那么这个SQL就可以通过索引快速定位到EMP_ID,然后通过索引中的ROWID直接找到EMP表中的相关记录,并取出所需要的数据。这种情况下,在EMP_ID上创建一个索引就是十分必要的。我们再来看下一个语句:
select emp_id,sal from emp where emp_id=11023 and emp_name like 'John%'
在这个语句中,WHERE条件有两个,一个是emp_id=11023,另外一个是emp_name likje 'John%'。这个时候oracle该如何来选择合适的执行计划呢?我们讨论的前提是使用CBO优化器(RBO优化器在索引选择的时候智能程度很低,出现执行计划错误的机会要比CBO大得多,因此建议在有可能的情况下,尽可能使用CBO),在使用CBO的情况下,CBO会根据索引的统计数据计算每个索引访问路径的成本,从而选择一种开销较小的执行计划。在这个案例中,我们可以简单的来分析一下。如果通过EMP_ID来查找相关的数据,EMP_ID是主键,根据EMP_ID=11023可以唯一定位到一条记录,然后我们可以取出这条记录的SAL,EMP_NAME和EMP_ID这三个字段的值,将EMP_NAME字段的值和WHERE 条件中的emp_name like 'John%'进行比较,如果这条记录符合这个条件,就返回这一条记录,如果这条记录不符合,那么就没有记录返回了。
下面我们来分析一下另外一条访问路径,如果我们使用EMP_NAME上索引,首先找到所有的emp_name like 'John%'的记录,也许我们能找到10条记录,通过索引中提供的10条记录的ROWID,再去访问这张表,把emp_id,sal这两个字段的值取出来,然后我们再通过另外一个过滤条件emp_id=11023进行筛选,把符合条件的记录找出来。很明显,我们可以发现这条访问路径的开销要比第一条略高,因为我们先查出了10条记录,然后再通过过滤器过滤掉了其中的9条,而第一种执行计划只检索了一条记录就获得了结果。
第三种访问路径就是我们所说的全表扫描,如果在EMP_NAME和EMP_ID上都没有创建索引,那么我们必须对这张有几万条记录的表从头到尾扫描一遍,找到所有符合上面两个过滤条件的记录,并把对应的EMP_ID和SAL值检索出来。
这三条访问路径到底哪个开销更小呢?实际上ORACLE的CBO优化器会自动计算访问路径的成本,并且选择最佳的路径来执行这个SQL。实际上我们的例子里包含Oracle访问数据的三种常见的方法,第一种是索引唯一性检索,通过唯一性索引定位到某条记录,读出该记录后通过emp_name这个过滤条件进行筛选,最后获得符合条件的所有记录。第二条路径是通过emp_name上的索引找出所有符合条件的记录,这种查找方式是找到符合条件的第一条记录,然后顺着叶节点链按照升序或者降序的方式扫描出所有符合条件的记录的ROWID,然后依次将表中的相关数据块读出来,根据其他的过滤条件进行筛选,找到符合条件的记录,这种方式也就是我们常说的索引范围扫描。第三种方法是直接读取表的数据,将数据读出后,根据emp_id和emp_name两个条件进行过滤,获得所需要的数据。
另外还有一种数据访问路径,在这个案例中看不到,这种方法是Oracle 9i开始支持的。比如我们有一个复合索引IDX_ID_NAME,这个索引包含两个字段EMP_ID和EMP_NAME,而有这样一个SQL:
select emp_id,sal from emp where emp_name= 'John'
这种情况下,我们可以通IDX_ID_NAME来查找所需要的数据。由于这个索引中,EMP_NAME不是这个索引的第一个字段,因此在扫描索引的时候无法像普通的UNIQUE 或者RANGE SCAN一样,通过一次定位,然后顺着叶节点链进行扫描。这种扫描必须是跳跃式的,因为这个索引的前导字段是EMP_ID,因此扫描的时候,对于每个EMP_ID,都需要做一次定位,然后通过叶节点链查找到所有符合条件的记录。Oracle也给这种扫描方式起了一个很形象的名字:INDEX SKIP SCAN,中文翻译一般使用“索引跳跃式扫描”。INDEX SKIP SCAN是一种跳跃式的扫描方式,因此分段的数量对于扫描的成本影响很大,本案例中EMP_ID是主键,在这种情况下,使用INDEX SKIP SCAN的成本实际上是比较高的,其成本开销甚至会高于索引全扫描。
刚才我们又引入了另外一种索引访问的方式,索引全扫描。索引全扫描有两种不同的方式。一种被称为索引全扫描(INDEX FULL SCAN),另外一种被称为索引快速全扫描(INDEX FAST FULL SCAN)。为什么要使用索引全扫描呢?我们先看一个SQL:
select emp_name from emp where emp_name is not null
在emp_name上有一个索引,我们知道索引中包含了所有emp_name不为空的值,因此这种情况下,只要对整个索引进行一次扫描就可以完成这个SQL了,而不需要对表做全表扫描。一般来说,对于字段较多的表,索引的大小会远小于表的大小,因此全索引扫描的成本会远小于全表扫描的大小。上述这个例子中,可以使用索引快速扫描,对索引的扫描可以根据该索引的extent来进行,采用多块读的方式进行。因此在这类操作中,我们可以看到会话会大量的出现db file scattered read等待。我们经常看到db file scattered read等待就说系统中出现了全表扫描,其实这种说法并不准确,因为索引快速全扫描也会使用多块读的方式来扫描。
如果我们对SQL进行一些小的修改:
select emp_name from emp where emp_name is not null order by emp_name
那么SQL的执行路径可能会有所改变,不使用索引快速全扫描。索引快速全扫描是根据EXTENTS的顺序的,不是按照EMP_NAME的值的大小进行的,因此这样扫描出来的emp_name数据不是按照emp_name排序的,如果要排序,必须对索引的扫描完成后再进行一次排序。这样的执行计划可能不如另外一种索引扫描的方式-索引全扫描,索引全扫描和索引快速全扫描不同的地方是,索引全扫描是根据叶节点链来进行的。进行索引全扫描首先要从根开始,找到叶节点链上的第一个数据块,然后沿着叶节点链进行扫描,由于叶节点链是根据索引键值排序的,因此这样扫描出来的数据本身就是排序的,数据读出后不需要再次排序。这种扫描方式和索引快速全扫描相比,首先要找到索引的根,然后通过枝节点找到第一个叶节点,然后再顺着叶节点链扫描整个索引。索引全扫描的IO成本比索引快速全扫描要大很多,读取根节点和叶节点的成本相对不大,不过由于顺着叶节点链扫描整个索引的时候无法使用多块读,而只能使用单块读,因此这种扫描方式的IO开销要远大于索引快速全扫描。这种索引扫描,我们如果对会话进行跟踪,会发现大量的db file sequential read等待。
在这里,大家可能会有一个疑问,带ORDER BY的SQL语句是否一定使用索引全扫描成本小于索引快速全扫描呢?实际上这也不能一概而论,关键要看排序操作的成本是否大于索引单块扫描比索引多块扫描两种扫描方式之间的IO成本差。
谈到这里,我们已经基本了解了索引访问的主要的方式,实际上还有一种索引的访问操作我们还没有讨论,就是位图索引的访问。由于位图索引的结构十分特殊,位图索引没有枝节点,是一种平面结构,因此如果我们想要通过位图索引进行扫描,只有一种扫描方式,这种访问方式类似于普通索引的索引快速全扫描,就是将索引按照EXTENTS,通过多块读进行扫描,这是因为位图索引的每个数据块中都可能包含我们所需要的键值。