首页 > 代码库 > 大表的主键创建优化技术(转一篇有深度的文章)

大表的主键创建优化技术(转一篇有深度的文章)

NO.1 【Jonathan lewis研究ORACLE ANSI JOIN】 


pk problem主键问题发表时间:2012-7-12
博文地址:http://jonathanlewis.wordpress.com/2012/07/12/pk-problem/

    这篇文章主要介绍如何对存在的表快速创建primarykey(unique key)的方法以及ORACLE内部处理的相关问题进行探讨。
    
    我在很早之前就说过(也许是上世纪的某个时候),若要对某个表增加primarykey(or unique key)约束,但是可能会存在重复数据的话,那么最佳实践就是先创建个non-unique索引(用online选项),然后增加约束的状态为enable novalidate,最后validate这个约束。例如:
    
create table t1
as
select
    *
from
    all_objects
;

create index t1_i1 on t1(object_id) online;
-- collect stats
alter table t1 add constraint t1_pk primarykey(object_id)  enable  novalidate;
alter table t1 modify constraint t1_pk validate;
    
    这个理论是非常简单的—online创建索引,lock的时间很短、enablenovalidate让新加入的数据满足约束,不验证历史数据,也减少了lock的时间、最后validate只是简单轮询索引来判断是否有重复的,如果要把重复的rowid找出来,可以用exceptions into语句放到异常表里(创建主键或unique约束有exceptionsinto语法)。
    
    enable novalidatevalidate过程,这个操作可以并行,并且不会阻塞对表的读写或DDL操作。

    在过去48小时里,这个问题引起了我的注意,也许过去的理解是错的,我回到8.1.7.4来运行ORACLE做这个验证的测试用例。

    这个测试,依赖于这个约束是单列还是多列、依赖于你是否增加了primary key或unique约束、依赖于这些列是否实现被声明为NOT NULL、依赖于ORACLE的版本。如果你想做详细的分析,有一些不同的方案,但是对于这个SQL,ORACLE在11.2.0.3下做验证的执行计划是:

select /*+all_rows ordered */
    A.rowid, :1, :2, :3
from
    TEST_USER.T1 A,
    (
    select /*+ all_rows */
        OBJECT_ID
    from
        TEST_USER.T1 A
    where  (OBJECT_ID is not null)
    group by
        OBJECT_ID
    having
        count(1) > 1
    ) B
where
    (A.OBJECT_ID = B.OBJECT_ID)
unionall
select
    /*+ all_rows ordered */
    A.rowid, :1, :2, :3
from
    TEST_USER.T1 A
where
    (OBJECT_ID is null)
;

-------------------------------------------------------------------------------------------
| Id  | Operation                 | Name  | Rows | Bytes |TempSpc| Cost (%CPU)| Time    |
-------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT          |       | 2706 | 81167 |       |   149  (3)| 00:00:02 |
|   1 | UNION-ALL                |       |      |       |       |            |          |
|*  2 |  HASH JOIN               |       |  2705| 81150 |  1536K|   149  (3)| 00:00:02 |
|   3 |   INDEX FAST FULL SCAN   | T1_I1 |54081 |   897K|       |   34   (0)| 00:00:01 |
|   4 |   VIEW                   |       | 2705 | 35165 |       |    36  (6)| 00:00:01 |
|*  5 |    FILTER                |       |      |       |       |            |          |
|   6 |     HASH GROUP BY        |       | 2705 | 13525 |       |    36  (6)| 00:00:01 |
|   7 |      INDEX FAST FULL SCAN| T1_I1 | 54081 |   264K|      |    34   (0)| 00:00:01 |
|*  8 |   FILTER                 |       |      |       |       |            |          |
|   9 |   INDEX FAST FULL SCAN   | T1_I1 |54081 |   897K|       |   34   (0)| 00:00:01 |
-------------------------------------------------------------------------------------------

PredicateInformation (identified by operation id):
---------------------------------------------------
   2 -access("A"."OBJECT_ID"="B"."OBJECT_ID")
   5 - filter(COUNT(*)>1)
   8 - filter(NULL IS NOT NULL)

    有几点需要说明一下:这个执行计划是使用"explain plan"和dbms_xplan.display做的,但是它与运行期的trace file中的计划一致。这个代码的含义是找出相同的object_id出现多次的所有rowid(如果找不到,则PK满足唯一条件),优化器标准算法对于"havingcount(*)>{const}"是按照数据量的5%进行估算cardinality(t1表有54081行,groupby出来2705行)。

另外union  all是另一个pk条件,object_id is not null,在这里是多余的,因为object_id已经有NOTNULL约束,观察第8行会看到有个FILTER操作,它会做短路条件判断,实际上第9行的计划不会发生。在SELECT部分有3个绑定变量,分别代表owner,table name,constraint name ----作为oracle exceptions table的列(这里我们没有要求做exceptions table例子,exceptionstable的例子可以参考sqlreferences的constraints部分文档,有4个列,rowid,owner,table_name,constraint,创建约束的时候,出异常将异常数据的相关信息放到exceptions表中)。


    我想我们能够很容易解释上面的代码,开发人员(只oracle自己的开发人员)可能是想极大化地实现代码重用。但是为什么使用了ordered hint使代码的效率非常差呢?结果是执行计划中对于任何大的索引都使用了那个hint进行固化。Oracle将index中的每个entry放到内存的hash table中(如果放的下的话),然后做大规模的group by运算,之后用这个聚合后的结果作为probe table来探测这个hash表。因此,就相当于做了将整个索引dump到temporarytablespace中两次---一次是做hash table,另一次是做group by,这个可能是我们非常希望避免的动作。

    如果我们希望在启用pk约束的时候,预计group by后的结果集不是非常小(重复的非常少),那么用小的结果集驱动做nestedloop,而不用hash join也是没有意义的(事实上,我们这个例子的分组结果集是很小的)。因为我们有一个合适的索引,分组能够走索引并消除排序,避免在temporary tablespace上做I/O动作,计划可能像:
    
    NESTEDLOOP
   Aggregated object_id having count(*) > 1

Index range scant1_i1


因为这个代码是封装在ORACLE里的,我们不可改变,但是在一些特别情况下,我们可能通过SQL计划基线来改变查询的计划,attaching that plan to this query。下面是通过OBJECT_ID分组,通过索引获取数据,并消除排序:

select * from (
    select /*+index(t1(object_id)) */
        object_id
    from
        t1
    where
        object_idis not null
    group by
        object_id
    having
        count(1)> 1
)
;
--------------------------------------------------------------------------------
| Id  |Operation              | Name  | Rows | Bytes | Cost (%CPU)| Time     |
--------------------------------------------------------------------------------
|   0 | SELECTSTATEMENT       |       | 2705 | 35165 |   121   (0)| 00:00:02 |
|   1 |  VIEW                  |       | 2705 | 35165 |   121   (0)| 00:00:02 |
|*  2 |   FILTER               |       |      |       |            |          |
|   3 |    SORT GROUP BY NOSORT|       | 2705 | 13525 |   121   (0)| 00:00:02 |
|   4 |     INDEX FULL SCAN    | T1_I1 | 54081 |   264K|  121   (0)| 00:00:02 |
--------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------
   2 -filter(COUNT(*)>1)

     在这个计划中,我们按照索引顺序进行轮询,对每个object_id进行count,并丢弃掉count=1的object_id。注意计划中的"SORT GROUP BY NOSORT",它消除了排序。如果不使用index hint,则计划会走"INDEX FAST FULL SCAN"和"HASH GROUP BY",这由你决定哪种方式更高效地产生一些小的不满足条件的结果。

    一旦有了这一步,我们可以尝试使用NESTED LOOP,将上面的结果作为驱动表,因为实际上GROUP BY的结果集非常小,而不是估算的5%。

    select
    --+ leading(ba) use_nl(a) index(a(object_id)) no_merge(b)
    a.rowid
from
    (
    select /*+index(t1 (object_id)) */
        object_id
    from
        t1
    where
        object_idis not null
    group by
        object_id
    having
        count(1)> 1
    )
    b ,
    t1  a
where
    a.object_id =b.object_id
;

---------------------------------------------------------------------------------
| Id  |Operation               | Name  | Rows | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------------
|   0 | SELECTSTATEMENT        |       | 2705 | 81150 |   121   (0)| 00:00:02 |
|   1 |  NESTED LOOPS           |       | 2705 | 81150 |   121   (0)| 00:00:02 |
|   2 |   VIEW                  |       | 2705 | 35165 |   121   (0)| 00:00:02 |
|*  3 |    FILTER               |       |      |       |            |          |
|   4 |     SORT GROUP BY NOSORT|       | 2705 | 13525 |   121   (0)| 00:00:02 |
|   5 |      INDEX FULL SCAN    | T1_I1 | 54081 |   264K|  121   (0)| 00:00:02 |
|*  6 |   INDEX RANGE SCAN      | T1_I1 |     1 |   17 |     0   (0)| 00:00:01 |
---------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------
   3 -filter(COUNT(*)>1)
   6 -access("A"."OBJECT_ID"="B"."OBJECT_ID")

   这正是我们想要的结果—对于第2行产生的row source的每一行,都会访问索引获取返回多行的结果(仅仅访问rowid),相关索引的leaf blocks,正好是我们刚刚group by轮询过的,因此不会产生随机I/O,这正式非常高效的计划。

    这里实际上有一个问题—-我撒了一个谎。

    通过剖析这个执行计划,实际上前面查询的inline view中的NOSORT操作实际上是不存在的。第4行应该是"SORT GROUP BY",我手动编辑成NOSORT---ORACLE实际上在做nested loop操作之前会对整个内联视图的结果集进行排序,我将隐含参数"_gby_hash_aggregation_enabled"参数设为false,阻止了默认的HASH GROUP BY操作,这样有点不好。通过观察cost,看到ORACLE做了这个NOSORT的动作(实际是SORT),成本是0,这让我很惊讶---因此我启用10046和10032 trace来观察到ORACLE会把结果集写到temporary tablespace,然后读它,之后会做预期数目的排序动作。

   这里,我没有发现任何通过hint的方式可以避免不需要的排序---因此似乎可以使用SQL计划基线来消除大数据量写到temporary tablespace的动作(注意,我仍然希望使用INDEX FAST FULL SCAN,它比默认的计划要好,因为只需要转储一次索引的内容到temporarytablespace中)。

    但是我们的方案可能不会完全落空,可能会使用一个nosort的中间结果集,比如像下面的计划一样:

with b as (
   select /*+ materialize index(t1(object_id))  */
       object_id
   from
       t1
   where
       object_id is not null
   group by
       object_id
    having
       count(1) > 1
)
select
   --+ leading(b a) use_nl(a) index(a(object_id))
   a.rowid
from
    b,
   t1  a
where
   a.object_id = b.object_id
;

----------------------------------------------------------------------------------------------------------
| Id | Operation                  |Name                        | Rows  | Bytes | Cost (%CPU)| Time  |
----------------------------------------------------------------------------------------------------------
|   0| SELECT STATEMENT           |                             |  2705 | 81150 |   123  (0)| 00:00:02 |
|   1|  TEMP TABLE TRANSFORMATION |                             |       |      |            |       |
|   2|   LOAD AS SELECT           | SYS_TEMP_0FD9D6608_4017FEF5 |       |      |            |       |
|*  3|    FILTER                  |                             |       |      |            |       |
|   4|    SORT GROUP BY NOSORT   |                             |  2705 | 13525 |   121  (0)| 00:00:02 |
|   5|      INDEX FULL SCAN       | T1_I1                       | 54081 |   264K|  121   (0)| 00:00:02 |
|   6|   NESTED LOOPS             |                             |  2705 | 81150 |     2  (0)| 00:00:01 |
|   7|    VIEW                    |                             | 2705 | 35165 |     2   (0)| 00:00:01 |
|   8|    TABLE ACCESS FULL      | SYS_TEMP_0FD9D6608_4017FEF5 |  2705 | 13525 |     2  (0)| 00:00:01 |
|*  9|    INDEX RANGE SCAN        | T1_I1                       |     1 |   17 |     0   (0)| 00:00:01 |
----------------------------------------------------------------------------------------------------------

Predicate Information (identified byoperation id):
---------------------------------------------------
   3 -filter(COUNT(*)>1)
   9 -access("A"."OBJECT_ID"="B"."OBJECT_ID")

    
    如果我们能够使用subquery factoring 重写这个SQL,然后使用materializehint来固化这个子查询(这里应该仅产生很小的结果集),这里的分组是nosort的,然后把结果集用于nestedloop的驱动表---这里我没有修改任何隐含参数。但是我们怎么重写封装在ORACLE内部的SQL呢?首先,针对这个SQL的计划,我取消原来的hint,然后用subquery factoring子句以及index、materialize hint改写:

with b as (
   select /*+ materialize index(t1 (object_id))  */
       object_id
   from
       t1
   where
       object_id is not null
   group by
       object_id
   having
       count(1) > 1
)
select
   A.rowid, :1, :2, :3
from
    t1A,
    B
where
   (a.object_id = b.object_id)
union all
select
   a.rowid, :1, :2, :3
from
    t1A
where
   object_id is null
;

----------------------------------------------------------------------------------------------------------
| Id | Operation                  |Name                        | Rows  | Bytes | Cost (%CPU)| Time  |
----------------------------------------------------------------------------------------------------------
|   0| SELECT STATEMENT           |                             |  2706 | 81167 |     2  (0)| 00:00:01 |
|   1|  TEMP TABLE TRANSFORMATION |                             |       |      |            |       |
|   2|   LOAD AS SELECT           | SYS_TEMP_0FD9D660D_4017FEF5 |       |      |            |       |
|*  3|    FILTER                  |                             |       |      |            |       |
|   4|     SORT GROUP BY NOSORT   |                             |  2705 | 13525 |   121  (0)| 00:00:02 |
|   5|      INDEX FULL SCAN       | T1_I1                       | 54081 |   264K|  121   (0)| 00:00:02 |
|   6|   UNION-ALL                |                             |       |      |            |       |
|   7|    NESTED LOOPS            |                             |  2705 | 81150 |     2  (0)| 00:00:01 |
|   8|     VIEW                   |                             |  2705 | 35165 |     2  (0)| 00:00:01 |
|   9|      TABLE ACCESS FULL     | SYS_TEMP_0FD9D660D_4017FEF5 |  2705 | 13525 |     2  (0)| 00:00:01 |
|* 10 |    INDEX RANGE SCAN       |T1_I1                       |     1 |   17 |     0   (0)| 00:00:01 |
|* 11 |   FILTER                  |                             |       |      |            |       |
|  12|     INDEX FAST FULL SCAN   | T1_I1                       | 54081 |   897K|   34   (0)| 00:00:01 |
----------------------------------------------------------------------------------------------------------

Predicate Information (identified by operationid):
---------------------------------------------------
   3 -filter(COUNT(*)>1)
  10 -access("A"."OBJECT_ID"="B"."OBJECT_ID")
  11 -filter(NULL IS NOT NULL)

     现在已经看到,计划完全改为我们预期的计划了。通过轮询索引来计划count(并且是NOSORT),然后作为nested loop的驱动表,在到索引里去查找,最后与非空结果进行UNION ALL,实际上因为object_id本身就是NOT NULL约束,所以FILTER会阻断object_id is null的查询(短路判断)。
    
    做了这么多,下面要做的就是让ORACLE在validate的过程中使用我们上面认为比较好的查询计划,而不是使用ORACLE自己的比较差的计划。
     

PLANB---DBMS_ADVANCED_REWRITE
    包DBMS_ADVANCED_REWRITE可以让ORACLE运行一个SQL的时候,关联另外的SQL,也就是实际执行的是另外的SQL,这个包的好处就是在不改变SQL的前提下,改变SQL的执行计划。
    当然,这个包的使用要特别注意,SQL必须高度相似,包括SQL里的一些使用的值,返回值、类型等都要一致。这里有一个例子:

begin
       sys.dbms_advanced_rewrite.declare_rewrite_equivalence(
                name             => ‘TEST_HINT‘,
                source_stmt      => ‘select * from t1 where n1 = 15‘,
                destination_stmt =>
                        ‘select /*+index(v1.t1) */ * from v1 where n1 = 15‘,
                validate         => false,
                rewrite_mode     => ‘general‘
        );
end;
/


    这个例子的意思就是当ORACLE执行source_stmt指定的语句的时候,实际执行的是destination_stmt中的含有hint的视图的语句。当然他们的结果和语义应该是完全一致的,从而达到改变SQL执行计划的目的。

    回到原始例子中,我们要做的就是让ORACLE本身生成的SQL等价转换为使用我们通过subquery factoring和hint固化的SQL(DBMS_ADVANCED_REWRITE是ORACLE10G特性),但是用DBMS_ADVANCED_REWRITE改写的时候报错:

ERROR at line 1:
ORA-30389: the source statement is not compatible withthe destination statement
ORA-32034: unsupported use of WITH clause
ORA-06512: at "SYS.DBMS_ADVANCED_REWRITE",line 29
ORA-06512: at "SYS.DBMS_ADVANCED_REWRITE",line 185
ORA-06512: at line 11
    
    可惜ORACLE的DBMS_ADVANCED_REWRITE包不支持用subquery factoring的改写,我想,只能等ORACLE发现这个问题,然后改进了。


总结
    多年以来,我一直相信ORACLE通过索引来验证主键或UNIQUE约束是非常聪明的,但是最后我发现ORACLE实际可能做了一个不高效的事---验证步骤将大的索引转储到temporary tablespace中两次。
    我尝试使ORACLE现有代码避免大的JOIN和聚合操作,通过构造一个走索引的GROUPBY,然后做nested loop操作的SQL PLAN BASELINE,但是ORACLE优化器做不到(在聚合中做INDEX FAST FULL SCAN,然后做NESTEDLOOP,可能比默认的要好)。
    因为我不能使用SQL PLAN BASELINE来得到我想要的执行计划,通过分析重写SQL来让聚合操作不使用临时表空间的可能性,之后通过物化subqueryfactoring来实现聚合操作。唯一的方法让ORACLE使用重写的SQL,只能通过DBMS_ADVANCED_REWRITE---但是这个包有不允许subquery factoring操作的限制。
    我做的对于18亿行的表这种验证步骤需要的临时表空间大于可获得的空间的操作没有什么价值。尽管一般的原则会发生改变,但是每次尝试应用这个方法的时候,具体的代码还得适合相应的表和索引。一般情况下,除非有很好的理由,否则排序应该是要避免的。我写了这么多的可能是由于a)因为我很好奇。
b)因为我认为写到blog里是很有意思的学习过程。






点评:

1)   了解了如何利用索引,快速创建主键或unique键的方法。在线创建加online选项是必须地,然后利用主键或uniquekey与索引的关系,通过novalidate到validate的过程快速创建pk或unique key。对于pk/unique key/索引的关系,详细见SQL文档。

 

2)   通过挖掘ORACLE在novalidate到validate的过程中的SQL:本文通过10046 trace挖掘的,然后来分析计划的缺点以及如何改进,找到创建索引效率低下的原因。最后发现,ORACLE对这个验证的过程处理,内部递归SQL的执行计划可能不是最优的,但是这问题ORACLE还并未很好滴解决,虽然通过分析使用subquery factoring和materialize hint,但是因为DBMS_ADVANCED_REWRITE这个包不支持subquery factoring,SO,只能等待ORACLE优化了。

 

3)   10g ORACLE提供的DBMS_ADVANCED_REWRITE这个包对不改变SQL,但是将原始SQL调整为目标SQL,这个功能很强大,当然ORACLE还提供类似的功能,比如OUTLINE,SQL PROFILE,PLANBASELINE等来帮助我们进行调优。

 

4)   其实可以不使用10046来抓取递归SQL,可以直接到V$SQL里,WHERE SQL_TEXT LIKE ‘%T1%‘,也可以捕获到。系统执行的SQL中的对象一般都是大写的。

JL这篇文章主要由一个问题引入,然后通过研究发现ORACLE内部校验PK的计划比较差,都是用HINT固定的,内部代码写的不是很好。JL一直认为ORACLE这个校验的内部计划应该是良好的,可惜ORACLE到现在还没有改良


实际上ORACLE内部执行的很多SQL都是通过HINT固定的,一些实用HINT的SQL如果对计划影响特别大的话,很可能会带来一些问题,这是必然的

还有,如果通过display_cursor会发现,ORACLE执行我们写的SQL的时候,也会有一些HINT,当然这个是经过解析分析后来加入的HINT,这样ORACLE才可以控制查询转换等操作

dingjun123@ORADB> SELECT * FROM table(dbms_xplan.display_cursor(NULL,NULL,‘outline‘));

PLAN_TABLE_OUTPUT
---------------------------------------------------------------------------------------------
SQL_ID  9cmt416a597rg, child number 0
-------------------------------------
select  * from t1 where object_id=10

Plan hash value: 1429545322

-------------------------------------------------------------------------------------
| Id  | Operation                   | Name  | Rows  | Bytes | Cost (%CPU)| Time     |
-------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT            |       |       |       |     2 (100)|          |
|   1 |  TABLE ACCESS BY INDEX ROWID| T1    |     1 |   158 |     2   (0)| 00:00:01 |
|*  2 |   INDEX RANGE SCAN          | T1_I1 |     1 |       |     1   (0)| 00:00:01 |
-------------------------------------------------------------------------------------

Outline Data
-------------

  /*+
      BEGIN_OUTLINE_DATA
      IGNORE_OPTIM_EMBEDDED_HINTS
      OPTIMIZER_FEATURES_ENABLE(‘11.2.0.1‘)
      DB_VERSION(‘11.2.0.1‘)
      ALL_ROWS
      OUTLINE_LEAF(@"SEL$1")
      INDEX_RS_ASC(@"SEL$1" "T1"@"SEL$1" ("T1"."OBJECT_ID"))
      END_OUTLINE_DATA
  */

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - access("OBJECT_ID"=10)

大表的主键创建优化技术(转一篇有深度的文章)