Showing posts with label foreign keys. Show all posts
Showing posts with label foreign keys. Show all posts

Tuesday, September 02, 2025

Partition Pruning/Elimination on Reference Partitioned Tables

I discussed locally partitioning the unique index on a reference partitioned table in a previous blog.  Having implemented it, I wanted to confirm what happens when I execute a single-table query on the reference partitioned table.  

It is not possible to specify predicates on the partitioning key columns, because they are on the foreign key table.  However, provided that a query specifies predicates on all the foreign key columns, the database can still prune/eliminate partitions, and it does not probe every partition.

Single Table Query

This example uses the same example tables as the previous blog.  The journal header table, PS_JRNL_HEADER, is the parent and therefore foreign key of the journal line table, PS_JRNL_LN.

CREATE TABLE PS_JRNL_HEADER 
(BUSINESS_UNIT VARCHAR2(5 CHAR) NOT NULL
,JOURNAL_ID VARCHAR2(10 CHAR) NOT NULL
,JOURNAL_DATE DATE NOT NULL
,UNPOST_SEQ NUMBER NOT NULL
…
,CONSTRAINT PS_JRNL_HEADER PRIMARY KEY (BUSINESS_UNIT, JOURNAL_ID, JOURNAL_DATE, UNPOST_SEQ)
) 
PARTITION BY RANGE (fiscal_year) INTERVAL (1)
(PARTITION FISCAL_YEAR_2016 VALUES LESS THAN (2017))
/

CREATE TABLE PS_JRNL_LN 
(BUSINESS_UNIT VARCHAR2(5 CHAR) NOT NULL
,JOURNAL_ID VARCHAR2(10 CHAR) NOT NULL
,JOURNAL_DATE DATE NOT NULL
,UNPOST_SEQ NUMBER NOT NULL 
,JOURNAL_LINE NUMBER(9,0) NOT NULL
,LEDGER VARCHAR2(10 CHAR) NOT NULL
…
,CONSTRAINT PS_JRNL_LN PRIMARY KEY (BUSINESS_UNIT, JOURNAL_ID, JOURNAL_DATE, UNPOST_SEQ, JOURNAL_LINE, LEDGER)
,CONSTRAINT PS_JRNL_LN_FK FOREIGN KEY (BUSINESS_UNIT, JOURNAL_ID, JOURNAL_DATE, UNPOST_SEQ) REFERENCES PS_JRNL_HEADER 
)
PARTITION BY REFERENCE(PS_JRNL_LN_FK)
…
/
My single-table query has literal equality predicates on each of the foreign key columns.
select *
from ps_jrnl_ln
where business_unit = '12345'
and journal_id = 'XX12345678'
and journal_date = TO_DATE('25/05/2021','DD/MM/YYYY')
and unpost_seq = 0
/
This is the resulting execution plan
Plan hash value: 2773029334
 
-------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                                  | Name       | Rows  | Bytes | Cost (%CPU)| Time     | Pstart| Pstop |
-------------------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                           |            |     1 |   277 |     5   (0)| 00:00:01 |       |       |
|   1 |  PARTITION REFERENCE SINGLE                |            |     1 |   277 |     5   (0)| 00:00:01 |   KEY |   KEY |
|   2 |   TABLE ACCESS BY LOCAL INDEX ROWID BATCHED| PS_JRNL_LN |     1 |   277 |     5   (0)| 00:00:01 |   KEY |   KEY |
|   3 |    SORT CLUSTER BY ROWID BATCHED           |            |     1 |       |     4   (0)| 00:00:01 |       |       |
|*  4 |     INDEX RANGE SCAN                       | PS_JRNL_LN |     1 |       |     4   (0)| 00:00:01 |   KEY |   KEY |
-------------------------------------------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
 
   4 - access("BUSINESS_UNIT"='12345' AND "JOURNAL_ID"='XX12345678' AND "JOURNAL_DATE"=TO_DATE(' 2021-05-25 
              00:00:00', 'syyyy-mm-dd hh24:mi:ss') AND "UNPOST_SEQ"=0)
  • The execution plan only mentions PS_JRNL_LN. There is no mention of visiting PS_JRNL_HEADER, but this doesn’t mean it didn’t happen. 
  • At line 1, PARTITION REFERENCE SINGLE indicates that the database accessed a single partition; it did not scan multiple partitions. 
  • The partition start/stop values for table and index accesses are all ‘KEY’.  This indicates that the partition was only determined during execution rather than earlier parsing. Usually, when we specify literal values in a SQL statement, we expect to see literal partition start/stop values.  Here, the partition is looked up for the foreign key values provided, so we get key values during execution. 

What is going on here?  Oracle visits the foreign key table PS_JRNL_HEADER, looking up the foreign key, which is also its primary key, and is the subject of a unique index.   It determines the partition in the foreign key table from the ROWID in the index (even though the index cannot be locally partitioned because it does not contain the partitioning key column).  There is a 1:1 relationship of partitions between the reference partitioned table and its foreign key table.  Thus, Oracle also determines which partition to query in the reference partitioned table, and hence the partition start/stop values mentioned in the execution plan are ‘KEY’ because they are determined at execution time.

Trace Test

To confirm this, I traced the query. The buffer cache was flushed before the test, so that I would see the physical I/O for each block accessed. 
ALTER SYSTEM FLUSH BUFFER_CACHE; 
ALTER SESSION SET tracefile_identifier=DMK1_JRNL_LN_LOOKUP; 
exec dbms_monitor.session_trace_enable(waits => true, binds => true);

select *
from ps_jrnl_ln
where business_unit = '12345'
and journal_id = 'XX12345678'
and journal_date = TO_DATE('25/05/2021','DD/MM/YYYY')
and unpost_seq = 0
/

exec dbms_monitor.session_trace_disable;
Specifying TRACEFILE_IDENTIFIER makes it easy to correctly identify the trace file in v$diag_trace_file.
SELECT * FROM v$DIAG_TRACE_FILE
WHERE trace_filename like '%DMK%'
ORDER BY modify_time desc
/
Then it can be queried from v$diag_trace_file_contents and spooled to a local file (see also Obtaining Trace Files without Access to the Database Server).
clear screen
set pages 0 lines 200 echo off
spool DMK_JRNL_LN_LOOKUP.trc
SELECT payload FROM v$diag_trace_file_contents
WHERE trace_filename = 'xxxxarcx2_ora_235305_DMK1_JRNL_LN_LOOKUP.trc'
ORDER BY line_number
/
spool off

Database Objects and IDs

The trace mentions three object IDs.  I have looked them up in the DBA_OBJECTS view for convenience.
SELECT object_id, object_type, object_name, subobject_name 
FROM dba_objects WHERE objecT_id IN(574371,600163, 574522)
/

OBJECT_ID OBJECT_TYPE          OBJECT_NAME        SUBOBJECT_NAME
---------- -------------------- ------------------ ------------------------------------------------
    574371 INDEX                PS_JRNL_HEADER
    574522 TABLE PARTITION      PS_JRNL_LN         FISCAL_YEAR_2021_ACCOUNTING_PERIOD_07
    600163 INDEX PARTITION      PS_JRNL_LN         FISCAL_YEAR_2021_ACCOUNTING_PERIOD_07

Trace File

  1. 3 blocks are read from object 574371.  This is the primary key index PS_JRNL_HEADER, on the table of the same name.  Oracle is looking up the partition on the foreign key on JRNL_LN to get the partition in the reference table.  Only the table is partitioned; this index is not, but Oracle can get the partition from the row ID in the index.  Curiously, Oracle still performs this lookup if the index on the reference partitioned table is not partitioned.  Thus, this is an overhead of reference partitioning, not of whether the index is partitioned, but the foreign key is only looked up once for each foreign key, and then the blocks will be in the buffer cache.
  2. There is one multi-block and two single-block reads from the index PS_JRNL_LN, but only from one index partition; FISCAL_YEAR_2021_ACCOUNTING_PERIOD_07.
  3. Finally, Oracle looks up table rows by ROWID.  They are all in the table partition that has the same name as the index partition, and this required two single block reads.
Trace file /u01/app/oracle/diag/rdbms/xxxxarcx/xxxxarcx2/trace/xxxxarcx2_ora_235305_DMK1_JRNL_LN_LOOKUP.trc


*** TRACE CONTINUED FROM FILE
/u01/app/oracle/diag/rdbms/xxxxarcx/xxxxarcx2/trace/xxxxarcx2_ora_235305_DMK0_JRNL_LN_LOOKUP.trc ***

=====================
PARSING IN CURSOR #140550623318264 len=169 dep=0 uid=130 oct=3 lid=130 tim=1987367209651 hv=686856243 ad='61aa57d40' sqlid='db2cyj8ng161m'
select *
from ps_jrnl_ln
where business_unit = '12345'
and journal_id = 'XX12345678'
and journal_date = TO_DATE('25/05/2021','DD/MM/YYYY')
and unpost_seq = 0

END OF STMT
PARSE #140550623318264:c=123,e=124,p=0,cr=0,cu=0,mis=0,r=0,dep=0,og=1,plh=2773029334,tim=1987367209651
WAIT #140550623318264: nam='gc cr grant 2-way' ela= 237 p1=97 p2=459395 p3=1 obj#=574371 tim=1987367210207
WAIT #140550623318264: nam='cell single block physical read: flash cache' ela= 358 cellhash#=4239709683 diskhash#=0 bytes=8192 obj#=574371 tim=1987367210612[1]
WAIT #140550623318264: nam='gc cr grant 2-way' ela= 110 p1=39 p2=474285 p3=1 obj#=574371 tim=1987367210865
WAIT #140550623318264: nam='cell single block physical read: flash cache' ela= 335 cellhash#=3429896051 diskhash#=0 bytes=8192 obj#=574371 tim=1987367211225
WAIT #140550623318264: nam='cell single block physical read: flash cache' ela= 330 cellhash#=4156894774 diskhash#=0 bytes=8192 obj#=574371 tim=1987367211636
EXEC #140550623318264:c=1481,e=2000,p=3,cr=3,cu=0,mis=0,r=0,dep=0,og=1,plh=2773029334,tim=1987367211709
WAIT #140550623318264: nam='SQL*Net message to client' ela= 3 driver id=1413697536 #bytes=1 p3=0 obj#=574371 tim=1987367211783
WAIT #140550623318264: nam='gc cr multi block grant' ela= 234 p1=69 p2=647975 p3=14 obj#=600163 tim=1987367212195
WAIT #140550623318264: nam='cell multiblock physical read' ela= 205 cellhash#=4156894774 diskhash#=0 bytes=32768 obj#=600163 tim=1987367212436[2]
WAIT #140550623318264: nam='cell single block physical read: xrmem cache' ela= 169 cellhash#=3429896051 diskhash#=0 bytes=8192 obj#=600163 tim=1987367212706
WAIT #140550623318264: nam='cell single block physical read: RDMA' ela= 44 cellhash#=4239709683 diskhash#=0 bytes=8192 obj#=600163 tim=1987367212817
WAIT #140550623318264: nam='gc cr grant 2-way' ela= 71 p1=39 p2=481918 p3=1 obj#=600163 tim=1987367212976
WAIT #140550623318264: nam='cell single block physical read: xrmem cache' ela= 137 cellhash#=4239709683 diskhash#=0 bytes=8192 obj#=600163 tim=1987367213151
WAIT #140550623318264: nam='cell single block physical read: RDMA' ela= 38 cellhash#=3429896051 diskhash#=0 bytes=8192 obj#=574522 tim=1987367213303[3]
WAIT #140550623318264: nam='cell single block physical read: RDMA' ela= 29 cellhash#=3429896051 diskhash#=0 bytes=8192 obj#=574522 tim=1987367213406
WAIT #140550623318264: nam='cell single block physical read: RDMA' ela= 28 cellhash#=3429896051 diskhash#=0 bytes=8192 obj#=574522 tim=1987367213502
WAIT #140550623318264: nam='cell single block physical read: RDMA' ela= 25 cellhash#=3429896051 diskhash#=0 bytes=8192 obj#=574522 tim=1987367213563
WAIT #140550623318264: nam='cell single block physical read: RDMA' ela= 26 cellhash#=3429896051 diskhash#=0 bytes=8192 obj#=574522 tim=1987367213620
WAIT #140550623318264: nam='cell single block physical read: flash cache' ela= 649 cellhash#=3429896051 diskhash#=0 bytes=8192 obj#=574522 tim=1987367214312
FETCH #140550623318264:c=1397,e=3255,p=13,cr=11,cu=0,mis=0,r=4,dep=0,og=1,plh=2773029334,tim=1987367215128
STAT #140550623318264 id=1 cnt=4 pid=0 pos=1 obj=0 op='PARTITION REFERENCE SINGLE PARTITION: KEY KEY (cr=14 pr=16 pw=0 str=1 time=5142 us cost=5 size=277 card=1)'
STAT #140550623318264 id=2 cnt=4 pid=1 pos=1 obj=574372 op='TABLE ACCESS BY LOCAL INDEX ROWID BATCHED PS_JRNL_LN PARTITION: KEY KEY (cr=11 pr=13 pw=0 str=1 time=3240 us cost=5 size=277 card=1)'
STAT #140550623318264 id=3 cnt=4 pid=2 pos=1 obj=0 op='SORT CLUSTER BY ROWID BATCHED (cr=4 pr=7 pw=0 str=1 time=1366 us cost=4 size=0 card=1)'
STAT #140550623318264 id=4 cnt=4 pid=3 pos=1 obj=600104 op='INDEX RANGE SCAN PS_JRNL_LN PARTITION: KEY KEY (cr=4 pr=7 pw=0 str=1 time=1331 us cost=4 size=0 card=1)'
WAIT #140550623318264: nam='SQL*Net message from client' ela= 155007 driver id=1413697536 #bytes=1 p3=0 obj#=574522 tim=1987367370548
CLOSE #140550623318264:c=14,e=13,dep=0,type=0,tim=1987367370652
=====================

Locally Partitioned Index 

In my scenario, I found that the number of levels in B-tree index in the local partitions was generally only 2 or 3, rather than 4 in the global non-partitioned version of that index (of course, this will vary from case to case). So local partitioning saved one or two I/Os per index probe. This quickly outweighs the lookup of the journal header table because there are many journal lines per journal header.
SELECT index_owner, partition_name, index_name, num_rows, distinct_keys, blevel, leaf_blocks, status
FROM dba_ind_partitions
WHERE index_name = 'PS_JRNL_LN'
ORDER by partition_position
/

INDEX_OWNE PARTITION_NAME                         INDEX_NAME           NUM_ROWS DISTINCT_KEYS     BLEVEL LEAF_BLOCKS STATUS  
---------- -------------------------------------- ------------------ ---------- ------------- ---------- ----------- --------
…
XXXXXXXXXX FISCAL_YEAR_2018_ACCOUNTING_PERIOD_01  PS_JRNL_LN           10189190      10189190          2       32008 USABLE
XXXXXXXXXX FISCAL_YEAR_2018_ACCOUNTING_PERIOD_02  PS_JRNL_LN            5368231       5368231          2       16970 USABLE
XXXXXXXXXX FISCAL_YEAR_2018_ACCOUNTING_PERIOD_03  PS_JRNL_LN            6713612       6713612          2       21132 USABLE
XXXXXXXXXX FISCAL_YEAR_2018_ACCOUNTING_PERIOD_04  PS_JRNL_LN            8500469       8500469          2       27128 USABLE
XXXXXXXXXX FISCAL_YEAR_2018_ACCOUNTING_PERIOD_05  PS_JRNL_LN            7901118       7901118          2       24862 USABLE
XXXXXXXXXX FISCAL_YEAR_2018_ACCOUNTING_PERIOD_06  PS_JRNL_LN           29785888      29785888          3       95734 USABLE
XXXXXXXXXX FISCAL_YEAR_2018_ACCOUNTING_PERIOD_07  PS_JRNL_LN           29978325      29978325          3       96377 USABLE
XXXXXXXXXX FISCAL_YEAR_2018_ACCOUNTING_PERIOD_08  PS_JRNL_LN            8470092       8470092          2       26743 USABLE
XXXXXXXXXX FISCAL_YEAR_2018_ACCOUNTING_PERIOD_09  PS_JRNL_LN           30393756      30393756          3       97669 USABLE
XXXXXXXXXX FISCAL_YEAR_2018_ACCOUNTING_PERIOD_10  PS_JRNL_LN           30649060      30649060          3       98537 USABLE
XXXXXXXXXX FISCAL_YEAR_2018_ACCOUNTING_PERIOD_11  PS_JRNL_LN            9340460       9340460          2       29597 USABLE
XXXXXXXXXX FISCAL_YEAR_2018_ACCOUNTING_PERIOD_12  PS_JRNL_LN           55420790      55420790          3      177193 USABLE  
…

TL;DR 

The single-table lookup of the reference partitioned table does benefit from partition pruning/elimination, although there is no predicate on the partitioning key, but only if there are predicates on each of the foreign key columns. The foreign key becomes a proxy for the predicate on the partitioning key. 
Thus, locally partitioning the primary key index on a reference partitioned table can be effective even if you do not additionally join the foreign key table.

Tuesday, November 13, 2018

Data Warehouse Design Mistakes 1: Lack of Foreign Key Constraints

This post is part of a series that discusses some common issues in data warehouses.

What is the Purpose of Foreign Keys?

  • In the context of relational databases, a foreign key is a column, or group of columns, on one table that uniquely identifies a row in another table.  So, a foreign key on a child record uniquely identifies a row on the parent table.
  • If a foreign key constraint is enforced (which they are when the foreign key constraint is enabled, which they are by default) then you cannot insert a child for which you cannot find a parent, and you cannot delete a parent for which children exist.  This guarantees that data is, and remains, referentially integral.
A foreign key constraint can also permit the Oracle optimizer to perform certain optimisations in executing a SQL query.  
  • If you are querying a child and parent table (or if you prefer a fact and dimension table) without referencing any attribute on the parent (or dimension), then the optimizer can rely on the foreign key constraint to know that the parent row will always be present and so omit the parent table from the query.  This is called 'foreign key join elimination', and I will discuss this in more detail below.
I think that documenting relationships between tables through foreign keys helps developers build sensible SQL code that follows the data model, performs better, and is easier to understand and therefore easier to maintain.
If you have foreign key constraints defined in an OLTP system, it is typical to see them enabled exactly because they enforce referential integrity.
In a data warehouse there is less emphasis on revalidating data, but saving effort though join elimination is important.  Unfortunately, it is all too common to see downstream data warehouses without foreign key constraints at all.  It seems to be very easy to find an excuse not to build them. While researching this blog I came across this blog posting that I thought had the ring of truth of about it.

"9 Reasons Why There Are No Foreign Keys in Your Database" (Piotr Kononow)

  1. Performance: degrades DML performance as foreign keys are validated
  2. Legacy data is not referentially integral in the first place.
  3. Full Table Reload.  Should disable, reload, and then re-enable and revalidate constraints.
  4. High-Level Framework doesn't create foreign keys.
  5. Cross-Database relations
  6. Database platform agnosticism (eg. PeopleSoft)
  7. Open for Change
  8. Lazy Architect
  9. Table relationships are not clear/revealed.
I'll add one more unacceptable excuse this list.
  • Extracting referentially integral data from an OLTP system into the data warehouse, so there is no need for more foreign keys to revalidate it there again.

Foreign Key Join Elimination

If you are querying a child and parent table, without referencing any attribute on the parent, then the optimizer can rely on the foreign key constraint to know that the parent row will always be present and so omit the parent table from the query.  
Let's start with a very simple demonstration, on the Sales History demo schema, of a query of the fact table (SALES), and three dimension tables (PRODUCTS, TIMES, CUSTOMERS).  I am referencing attribute columns on the PRODUCT and TIMES tables, but nothing on the CUSTOMERS table other than the primary key column CUST_ID in a join predicate.

Without Foreign Key Constraints

I have disabled the foreign key constraints between these tables to model the situation without foreign key constraints.
ALTER TABLE sales MODIFY CONSTRAINT sales_channel_fk DISABLE NOVALIDATE;
ALTER TABLE sales MODIFY CONSTRAINT sales_customer_fk DISABLE NOVALIDATE;
ALTER TABLE sales MODIFY CONSTRAINT sales_product_fk DISABLE NOVALIDATE;
ALTER TABLE sales MODIFY CONSTRAINT sales_promo_fk DISABLE NOVALIDATE;
ALTER TABLE sales MODIFY CONSTRAINT sales_time_fk DISABLE NOVALIDATE;
This is a query for certain products (Electronics) in a single year (1999).
SELECT  p.prod_category
,  t.fiscal_year
,  COUNT(*)
FROM  sales s
, products p
,  times t
, customers c
WHERE  s.time_id = t.time_id
AND  s.prod_id = p.prod_id
AND   t.fiscal_year = 2001
AND c.cust_id = s.cust_id
AND p.prod_category = 'Electronics'
AND p.prod_category = 'Software/Other'
GROUP BY p.prod_category
,  t.fiscal_year
ORDER BY 1
/
Throughout these tests, having set STATISTICS_LEVEL=ALL in order to collect additional runtime statistics, I have will extract execution plans, as follows
select * from table(dbms_xplan.display_cursor(null,null,'ADVANCED +ADAPTIVE PROJECTION +ALLSTATS LAST, IOSTATS'));
You can see that all 4 tables (or indexes on them) referenced in the SQL appear in the execution plan (in bold).
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                         | Name                 | Starts | E-Rows |E-Bytes| Cost (%CPU)| E-Time   | Pstart| Pstop | A-Rows |   A-Time   | Buffers | Reads  |  OMem |  1Mem | Used-Mem |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                  |                      |      1 |        |       |  1167 (100)|          |       |       |      1 |00:00:00.21 |    3386 |    472 |       |       |          |
|   1 |  SORT GROUP BY NOSORT             |                      |      1 |      1 |    44 |  1167   (3)| 00:00:01 |       |       |      1 |00:00:00.21 |    3386 |    472 |       |       |          |
|   2 |   NESTED LOOPS                    |                      |      1 |   2840 |   122K|  1167   (3)| 00:00:01 |       |       |   2840 |00:00:00.21 |    3386 |    472 |       |       |          |
|   3 |    VIEW                           | VW_GBF_35            |      1 |   2840 |   108K|  1167   (3)| 00:00:01 |       |       |   2840 |00:00:00.19 |     544 |    472 |       |       |          |
|   4 |     HASH GROUP BY                 |                      |      1 |   2840 |   138K|  1167   (3)| 00:00:01 |       |       |   2840 |00:00:00.19 |     544 |    472 |  1137K|  1137K| 1403K (0)|
|*  5 |      HASH JOIN                    |                      |      1 |  41362 |  2019K|  1163   (3)| 00:00:01 |       |       |  23678 |00:00:00.17 |     544 |    472 |  1695K|  1695K| 1571K (0)|
|   6 |       PART JOIN FILTER CREATE     | :BF0000              |      1 |    364 |  4368 |    16   (0)| 00:00:01 |       |       |    364 |00:00:00.01 |      55 |      0 |       |       |          |
|*  7 |        TABLE ACCESS FULL          | TIMES                |      1 |    364 |  4368 |    16   (0)| 00:00:01 |       |       |    364 |00:00:00.01 |      55 |      0 |       |       |          |
|*  8 |       HASH JOIN                   |                      |      1 |    165K|  6156K|  1146   (3)| 00:00:01 |       |       |  26637 |00:00:00.17 |     488 |    472 |  1572K|  1572K| 1390K (0)|
|*  9 |        VIEW                       | index$_join$_002     |      1 |     13 |   273 |     2   (0)| 00:00:01 |       |       |     13 |00:00:00.01 |       5 |      0 |       |       |          |
|* 10 |         HASH JOIN                 |                      |      1 |        |       |            |          |       |       |     13 |00:00:00.01 |       5 |      0 |  1355K|  1355K| 1376K (0)|
|* 11 |          INDEX RANGE SCAN         | PRODUCTS_PROD_CAT_IX |      1 |     13 |   273 |     1   (0)| 00:00:01 |       |       |     13 |00:00:00.01 |       1 |      0 |       |       |          |
|  12 |          INDEX FAST FULL SCAN     | PRODUCTS_PK          |      1 |     13 |   273 |     1   (0)| 00:00:01 |       |       |     72 |00:00:00.01 |       4 |      0 |       |       |          |
|  13 |        PARTITION RANGE JOIN-FILTER|                      |      1 |    918K|    14M|  1136   (2)| 00:00:01 |:BF0000|:BF0000|    296K|00:00:00.13 |     482 |    472 |       |       |          |
|  14 |         TABLE ACCESS FULL         | SALES                |      5 |    918K|    14M|  1136   (2)| 00:00:01 |:BF0000|:BF0000|    296K|00:00:00.13 |     482 |    472 |       |       |          |
|* 15 |    INDEX UNIQUE SCAN              | CUSTOMERS_PK         |   2840 |      1 |     5 |     0   (0)|          |       |       |   2840 |00:00:00.01 |    2842 |      0 |       |       |          |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

With Foreign Key Constraints

I have re-enabled the foreign key constraints
ALTER TABLE sales MODIFY CONSTRAINT sales_channel_fk enable validate;
ALTER TABLE sales MODIFY CONSTRAINT sales_customer_fk enable validate;
ALTER TABLE sales MODIFY CONSTRAINT sales_product_fk enable validate;
ALTER TABLE sales MODIFY CONSTRAINT sales_promo_fk enable validate;
ALTER TABLE sales MODIFY CONSTRAINT sales_time_fk enable validate;
Now the CUSTOMERS table is no longer referenced due to foreign key join elimination.  The nested loop operation (at line 2 of the previous plan) to join the CUSTOMERS table is no longer required because the query does not need anything from that dimension table.  The foreign key constraint tells the optimizer that there will always be a corresponding row for every SALES record.  Thus not joining to it does not change the result of the query.  There is one less operation requiring PGA memory, and the number of buffers required, and the optimizer cost has also dropped.
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                      | Name                 | Starts | E-Rows |E-Bytes| Cost (%CPU)| E-Time   | Pstart| Pstop | A-Rows |   A-Time   | Buffers | Reads  |  OMem |  1Mem | Used-Mem |
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT               |                      |      1 |        |       |  1163 (100)|          |       |       |      1 |00:00:00.87 |     544 |    472 |       |       |          |
|   1 |  SORT GROUP BY NOSORT          |                      |      1 |      1 |    45 |  1163   (3)| 00:00:01 |       |       |      1 |00:00:00.87 |     544 |    472 |       |       |          |
|*  2 |   HASH JOIN                    |                      |      1 |  41362 |  1817K|  1163   (3)| 00:00:01 |       |       |  23678 |00:00:00.85 |     544 |    472 |  1695K|  1695K| 1669K (0)|
|   3 |    PART JOIN FILTER CREATE     | :BF0000              |      1 |    364 |  4368 |    16   (0)| 00:00:01 |       |       |    364 |00:00:00.01 |      55 |      0 |       |       |          |
|*  4 |     TABLE ACCESS FULL          | TIMES                |      1 |    364 |  4368 |    16   (0)| 00:00:01 |       |       |    364 |00:00:00.01 |      55 |      0 |       |       |          |
|*  5 |    HASH JOIN                   |                      |      1 |    165K|  5346K|  1146   (3)| 00:00:01 |       |       |  26637 |00:00:00.79 |     488 |    472 |  1572K|  1572K| 1331K (0)|
|*  6 |     VIEW                       | index$_join$_002     |      1 |     13 |   273 |     2   (0)| 00:00:01 |       |       |     13 |00:00:00.01 |       5 |      0 |       |       |          |
|*  7 |      HASH JOIN                 |                      |      1 |        |       |            |          |       |       |     13 |00:00:00.01 |       5 |      0 |  1355K|  1355K| 1377K (0)|
|*  8 |       INDEX RANGE SCAN         | PRODUCTS_PROD_CAT_IX |      1 |     13 |   273 |     1   (0)| 00:00:01 |       |       |     13 |00:00:00.01 |       1 |      0 |       |       |          |
|   9 |       INDEX FAST FULL SCAN     | PRODUCTS_PK          |      1 |     13 |   273 |     1   (0)| 00:00:01 |       |       |     72 |00:00:00.01 |       4 |      0 |       |       |          |
|  10 |     PARTITION RANGE JOIN-FILTER|                      |      1 |    918K|    10M|  1136   (2)| 00:00:01 |:BF0000|:BF0000|    296K|00:00:00.43 |     482 |    472 |       |       |          |
|  11 |      TABLE ACCESS FULL         | SALES                |      5 |    918K|    10M|  1136   (2)| 00:00:01 |:BF0000|:BF0000|    296K|00:00:00.25 |     482 |    472 |       |       |          |
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
…
Note
-----
   - rely constraint used for this statement
From Oracle 12.2, when a constraint has been used to remove a table from an execution plan, you get a note in the execution plan "rely constraint used for this statement".  Though it doesn't tell you which table has been eliminated, nor due to which constraint.
Admittedly, the actual run time of the query has gone up because there is more work for the optimizer to do.  However, this is an unusually small test, so it has swamped the savings in not visiting the CUSTOMERS table.  Foreign key join elimination should generally deliver a performance improvement because the query processes less data.

With Disabled Reliable Constraints

If you don't want the overhead and complexity of enforced foreign key constraints, you can still get the benefit of foreign key join elimination.  It is possible to disable the constraint from SALES to CUSTOMER so that it is not enforced, but it can still tell the optimizer that it can RELY upon the referential integrity of the data as if the constraint were enforced.
ALTER TABLE sales MODIFY CONSTRAINT sales_customer_fk RELY DISABLE NOVALIDATE;
This behaviour has been available since constraints were introduced in Oracle 8 (c. 1998), but there has been a change in Oracle 12c.  Foreign key join elimination on disabled RELY constraints does not occur if QUERY_REWRITE_INTEGRITY is set to its default value of ENFORCED.  It must be set to either TRUSTED or STALE_TOLERATED.  However, bear in mind that setting it to STALE_TOLERATED also affects how materialized views can be used by query rewrite.
ALTER SESSION SET query_rewrite_integrity = TRUSTED;
And now we are back to the same plan as before without the CUSTOMERS table, and with the "rely constraint used for this statement" note.
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                      | Name                 | Starts | E-Rows |E-Bytes| Cost (%CPU)| E-Time   | Pstart| Pstop | A-Rows |   A-Time   | Buffers | Reads  |  OMem |  1Mem | Used-Mem |
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT               |                      |      1 |        |       |  1164 (100)|          |       |       |      1 |00:00:01.55 |     544 |    472 |       |       |          |
|   1 |  SORT GROUP BY NOSORT          |                      |      1 |      1 |    45 |  1164   (3)| 00:00:01 |       |       |      1 |00:00:01.55 |     544 |    472 |       |       |          |
|*  2 |   HASH JOIN                    |                      |      1 |  81133 |  3565K|  1164   (3)| 00:00:01 |       |       |    110K|00:00:01.46 |     544 |    472 |  1476K|  1476K| 1528K (0)|
|*  3 |    VIEW                        | index$_join$_002     |      1 |     26 |   546 |     2   (0)| 00:00:01 |       |       |     26 |00:00:00.01 |       5 |      0 |       |       |          |
|*  4 |     HASH JOIN                  |                      |      1 |        |       |            |          |       |       |     26 |00:00:00.01 |       5 |      0 |  1298K|  1298K| 1612K (0)|
|*  5 |      INDEX RANGE SCAN          | PRODUCTS_PROD_CAT_IX |      1 |     26 |   546 |     1   (0)| 00:00:01 |       |       |     26 |00:00:00.01 |       1 |      0 |       |       |          |
|   6 |      INDEX FAST FULL SCAN      | PRODUCTS_PK          |      1 |     26 |   546 |     1   (0)| 00:00:01 |       |       |     72 |00:00:00.01 |       4 |      0 |       |       |          |
|*  7 |    HASH JOIN                   |                      |      1 |    229K|  5369K|  1160   (3)| 00:00:01 |       |       |    246K|00:00:01.00 |     538 |    472 |  1695K|  1695K| 1683K (0)|
|   8 |     PART JOIN FILTER CREATE    | :BF0000              |      1 |    364 |  4368 |    16   (0)| 00:00:01 |       |       |    364 |00:00:00.01 |      55 |      0 |       |       |          |
|*  9 |      TABLE ACCESS FULL         | TIMES                |      1 |    364 |  4368 |    16   (0)| 00:00:01 |       |       |    364 |00:00:00.01 |      55 |      0 |       |       |          |
|  10 |     PARTITION RANGE JOIN-FILTER|                      |      1 |    918K|    10M|  1136   (2)| 00:00:01 |:BF0000|:BF0000|    296K|00:00:00.47 |     482 |    472 |       |       |          |
|  11 |      TABLE ACCESS FULL         | SALES                |      5 |    918K|    10M|  1136   (2)| 00:00:01 |:BF0000|:BF0000|    296K|00:00:00.28 |     482 |    472 |       |       |          |
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- 
…
Note
-----
   - rely constraint used for this statement

Multi-Column Foreign Key Join Elimination 

It has always been possible to create foreign keys on multiple columns, sometimes called composite foreign keys.  From Oracle 12.2, it is possible to get join elimination on composite foreign keys. Jonathan Lewis has published a blog with demonstration scripts.
ALTER TABLE child 
add constraint child_fk_parent foreign key (id_g, id_p) 
references parent (id_g, id);
However, he has also found a case where join elimination depends on the order of tables in the from clause – (currently unpublished) bug 22228669.  The workaround is to list parents in the from clause before their children.
The need for single column keys inevitably leads to meaningless keys, often generated from a sequence.  Another of Jonathan's blogs and its comments discusses the pros and cons. 
Updated 19th November: It depends how important foreign key join elimination is to you.  How much use a system makes of this feature will depend upon how it is written.  If you think that you may need to use this feature then, at least until this bug is resolved, I would stick with single column keys in Oracle 12.2.

Summary of Good Practice

  • Primary keys (with unique indexes) on all tables.
  • Define foreign key constraints on all dimension columns of fact tables referencing the primary or unique keys on the dimension tables through equality joins only. 
    • I think that even from Oracle 12.2 I would still avoid creating multi-column primary and foreign keys because there appears to be a bug with this feature.
  • If foreign key constraints are to be enforced, then the key columns should also be indexed avoid TM locking during DML operations.
  • Otherwise, in order to achieve join elimination mark the constraints as reliable:
  • ALTER TABLE … MODIFY CONSTRAINT … RELY NOVALIDATE DISABLE
    • From Oracle 12 you must also set QUERY_REWRITE_INTEGRITY to TRUSTED or STALE_TOLERATED.

Thursday, October 20, 2016

Refreshing Materialized Views with Referential Integrity Constraints

I have a number of tables on a reporting system which have referential integrity constraints, and whose contents are replicated from a primary system. I am going to create materialized views on these prebuilt tables to manage incremental refresh. However, the referential integrity means that some materialized view will have to be refreshed before others which refer to them.
The referential constraints can be queried from the DBA_/USER_CONSTRAINTS view. Clearly this defines a hierarchy of tables. I could construct a hierarchical query on this data I could determine the order in which to refresh the materialized views.  Naturally, I looked for someone who had done this already and I found this on the Ask Tom website: All Parent - Child tables in the database. It was written in 2001 for Oracle 8.1.6. I have used the same demonstration, and enhanced it some newer features. I ran my tests on 12c.
create table p ( x int primary key ); 
create materialized view log on p;
create table c0 ( x int primary key); 
create table c1 ( x primary key constraint c1_p  references p); 
create table c2 ( x primary key constraint c2_c1 references c1); 
create table c3 ( x primary key constraint c3_c2 references c2); 
create table c4 ( x primary key constraint c4_c2 references c2); 

create materialized view c0 on prebuilt table as select x from p;
create materialized view c1 on prebuilt table as select x from p;
create materialized view c2 on prebuilt table as select x from p;
create materialized view c3 on prebuilt table as select x from p;
create materialized view c4 on prebuilt table as select x from p;

insert into p select rownum from dual connect by level <= 42;
commit;
I have no problem refreshing a materialized view C0 without an referential constraints, but C4 has a constraint that refers to C2 that has not been refreshed so I get a parent key not found error.
exec dbms_mview.refresh('C0',method=>'F');
exec dbms_mview.refresh('C4',method=>'F');

ERROR at line 1:
ORA-12008: error in materialized view refresh path
ORA-02291: integrity constraint (SCOTT.C4_C2) violated - parent key not found
ORA-06512: at "SYS.DBMS_SNAPSHOT", line 2821
ORA-06512: at "SYS.DBMS_SNAPSHOT", line 3058
ORA-06512: at "SYS.DBMS_SNAPSHOT", line 3017
ORA-06512: at line 1

Part 1: Incremental Refresh 

I will start with the easier problem of incremental refresh. Related materialized views can be passed as a list to the DBMS_MVIEW.REFRESH procedure and will be refreshed in a single database transaction. Hierarchies of related tables are created by the aggregation of these constraints. I can calculate which materialized views to group together.
Let's start by creating a working storage table that contains a row for each materialized view.
DROP TABLE dmk_mview_refresh PURGE
/
CREATE TABLE dmk_mview_refresh AS 
SELECT owner, mview_name, mview_name refresh_group
FROM   dba_mviews
WHERE  owner = user
/
Ideally, I would like to be able to refresh each one independently. If I were using refresh groups, each materialized view would be in its own group, which for simplicity will named the same as the materialized view.
set pages 99 lines 200
column owner format a10
column mview_name format a10
column refresh_Group format a10
break on report
SELECT * FROM dmk_mview_refresh
ORDER BY 1,2,3;

OWNER      MVIEW_NAME REFRESH_GR
---------- ---------- ----------
SCOTT      C0         C0
SCOTT      C1         C1
SCOTT      C2         C2
SCOTT      C3         C3
SCOTT      C4         C4
This PL/SQL block works through each referential constraint that links any two materialized views, and determines the current refresh group for each materialized view and if they are not in the same group it moves all materialized views in the group of referring materialized view to the group of the referred to materialized view.
set serveroutput on
DECLARE
  l_owner VARCHAR2(30) := user;
  l_groupc VARCHAR2(30);
  l_groupr VARCHAR2(30);
BEGIN
  FOR i IN (
select mr.owner ownerr, mr.mview_name mview_namer
, r.constraint_name, r.status
,  mc.owner ownerc, mc.mview_name mview_namec
from dba_mviews mc
, dba_constraints r
, dba_constraints c
, dba_mviews mr
where mc.owner = l_owner
and r.owner = mc.owner
and r.table_name = mc.container_name
and r.constraint_type = 'R'
and c.owner = r.r_owner
and c.constraint_name = r.r_constraint_name
and mr.owner = c.owner
and mr.container_name = c.table_name
  ) LOOP
    dbms_output.put_line(i.ownerr||'.'||i.mview_namer||'->'||i.ownerc||'.'||i.mview_namec||' constraint '||i.constraint_name);

    SELECT refresh_group
    INTO   l_groupr
    FROM   dmk_mview_refresh
    WHERE  owner = i.ownerr
    AND    mview_name = i.mview_namer;

    SELECT refresh_group
    INTO   l_groupc
    FROM   dmk_mview_refresh
    WHERE  owner = i.ownerc
    AND    mview_name = i.mview_namec;

    IF l_groupc != l_groupr THEN
      UPDATE dmk_mview_refresh
      SET    refresh_group = l_groupc
      WHERE  refresh_group = l_groupr;

      dbms_output.put_line('Update '||l_groupr||'->'||l_groupc||' '||SQL%rowcount||' rows updated');
    END IF;

  END LOOP;
END;
/

SCOTT.C2->SCOTT.C4 constraint C4_C2
Update C2->C4 1 rows updated
SCOTT.C2->SCOTT.C3 constraint C3_C2
Update C4->C3 2 rows updated
SCOTT.C1->SCOTT.C2 constraint C2_C1
Update C1->C3 1 rows updated
You can see that C1, C2, C3 and C4 are now all in group C3. So they need to be refreshed in a single operation.
break on refresh_group skip 1
select * from dmk_mview_refresh
order by 3,1,2;

OWNER      MVIEW_NAME REFRESH_GR
---------- ---------- ----------
SCOTT      C0         C0

SCOTT      C1         C3
SCOTT      C2
SCOTT      C3
SCOTT      C4
I can use the LISTAGG() analytic function to simply generate the list and pass it to DBMS_MVIEW.REFRESH.
DECLARE
  l_t1 TIMESTAMP;
  l_t2 TIMESTAMP;
  l_tdiff NUMBER;
  l_module VARCHAR2(64);
  l_action VARCHAR2(64);
BEGIN 
  dbms_application_info.read_module(l_module,l_action);
  dbms_application_info.set_module('MV Group Refresh','Begin');
  FOR i IN (
select  refresh_group, listAgg(owner||'.'||mview_name, ',') WITHIN GROUP (ORDER BY mview_name) mview_list
from dmk_mview_refresh
GROUP BY refresh_Group
  ) LOOP
    dbms_application_info.set_action(i.refresh_group);
    l_t1 := SYSTIMESTAMP;

    dbms_output.put_line(l_t1||' Start Refresh MV group: '||i.mview_list);
    dbms_mview.refresh(i.mview_list,method=>'F');

    l_t2 := SYSTIMESTAMP;
    l_tdiff := 60*(60*(24*extract(day from l_t2-l_t1)+extract(hour from l_t2-l_t1))+extract(minute from l_t2-l_t1))+extract(second from l_t2-l_t1);
    dbms_output.put_line(l_t2||' End Refresh MV '||i.mview_list||':'||l_tdiff||' secs');
    commit;
  END LOOP;
  dbms_application_info.set_module(l_module,l_action);END;
/

PL/SQL procedure successfully completed.
And you can see they materialized views refresh without error.
20-OCT-16 09.50.43.119765 Start Refresh MV group: SCOTT.C0
20-OCT-16 09.50.43.382501 End Refresh MV SCOTT.C0:.262736 secs
20-OCT-16 09.50.43.383195 Start Refresh MV group: SCOTT.C1,SCOTT.C2,SCOTT.C3,SCOTT.C4
20-OCT-16 09.50.43.655243 End Refresh MV SCOTT.C1,SCOTT.C2,SCOTT.C3,SCOTT.C4:.272048 secs
If I wanted to schedule the refresh I could also create corresponding refresh groups with DBMS_REFRESH.

Part 2: Non-Atomic Full Refresh

The second question is how to fully refresh the materialized views. I will have to do this at least once when I instantiate the replication.  I have a real-world case where there is a large volume of data in these tables, so I also want to use non-atomic refresh to reduce the time taken to refresh, and the size of the final table (see also Complete Refresh of Materialized Views: Atomic, Non-Atomic and Out-of-Place). I cannot use the same list approach as above. I can disable constraints to facilitate the refresh, but I won't be able to re-enable them until the data is integral. Of course I could disable all the constraints, refresh all the materialized views and then re-enable all the constraints.
ALTER TABLE c4 MODIFY CONSTRAINT c4_c2 DISABLE;
exec dbms_mview.refresh('C4',method=>'C',atomic_refresh=>FALSE);
ALTER TABLE c4 MODIFY CONSTRAINT c4_c2 ENABLE;
                                 *
ERROR at line 1:
ORA-02298: cannot validate (SCOTT.C4_C2) - parent keys not found
So in this case I need to refresh the materialized views on which C4 depends first, and so on.
column mview_name format a10
column table_name format a10
column constraint_name format a10
select mr.owner, mr.mview_name, r.table_name
, r.constraint_name, r.status
,  mc.owner, mc.mview_name, c.table_name
from dba_mviews mr
, dba_constraints r
, dba_constraints c
, dba_mviews mc
where mr.owner = user
and mr.owner = r.owner
and mr.container_name = r.table_name
and r.constraint_type = 'R'
and c.owner = r.r_owner
and c.constraint_name = r.r_constraint_name
and mc.owner = c.owner
and mc.container_name = c.table_name
order by 1,2

OWNER      MVIEW_NAME TABLE_NAME CONSTRAINT STATUS   OWNER      MVIEW_NAME TABLE_NAME
---------- ---------- ---------- ---------- -------- ---------- ---------- ----------
SCOTT      C2         C2         C2_C1      ENABLED  SCOTT      C1         C1
SCOTT      C3         C3         C3_C2      ENABLED  SCOTT      C2         C2
SCOTT      C4         C4         C4_C2      ENABLED  SCOTT      C2         C2
I need to tree walk the hierarchy of foreign keys to determine which tables need to be refreshed first.
column path format a20
with x as (
select /*+MATERIALIZE*/ mr.owner ownerr, mr.mview_name mview_namer, r.table_name table_namer
, r.constraint_name, r.status
,  mc.owner ownerc, mc.mview_name mview_namec, c.table_name table_namec
from dba_mviews mc
, dba_constraints r
, dba_constraints c
, dba_mviews mr
where mr.owner = user
and mr.owner = r.owner
and mr.container_name = r.table_name
and r.constraint_type = 'R'
and c.owner = r.r_owner
and c.constraint_name = r.r_constraint_name
and mc.owner = c.owner
and mc.container_name = c.table_name
)
select level mylevel, ownerr, mview_namer, ownerc, mview_namec
,       sys_connect_by_path(mview_namer,'/') path
from x
connect by nocycle prior mview_namer = mview_namec
              and  prior ownerr      = ownerc
order by 1,2,3
/
I need to start with the tables with the lowest maximum level first.
   MYLEVEL OWNERR     MVIEW_NAMER OWNERC     MVIEW_NAMEC PATH
---------- ---------- ----------- ---------- ----------- --------------------
         1 SCOTT      C2          SCOTT      C1          /C2
         1 SCOTT      C3          SCOTT      C2          /C3
         1 SCOTT      C4          SCOTT      C2          /C4
         2 SCOTT      C3          SCOTT      C2          /C2/C3
         2 SCOTT      C4          SCOTT      C2          /C2/C4
Any materialized view not picked up in the tree walk is given level 0, and now I can calculate the refresh order in a single SQL statement and execute the refreshes in a single PL/SQL block.
set serveroutput on 
DECLARE
  l_t1 TIMESTAMP;
  l_t2 TIMESTAMP;
  l_tdiff NUMBER;
  l_module VARCHAR2(64);
  l_action VARCHAR2(64);
  l_sql CLOB;
BEGIN 
  dbms_application_info.read_module(l_module,l_action);
  dbms_application_info.set_module('MV Group Refresh','Begin');
  FOR i IN (
with x as ( /*obtain constraints and identify table in referred constraint*/
select /*+MATERIALIZE*/ mr.owner ownerr, mr.mview_name mview_namer, r.table_name table_namer
, r.constraint_name, r.status
,  mc.owner ownerc, mc.mview_name mview_namec, c.table_name table_namec
from dba_mviews mc
, dba_constraints r
, dba_constraints c
, dba_mviews mr
where mr.owner = user
and mr.owner = r.owner
and mr.container_name = r.table_name
and r.constraint_type = 'R'
and c.owner = r.r_owner
and c.constraint_name = r.r_constraint_name
and mc.owner = c.owner
and mc.container_name = c.table_name
), y as ( /*tree walk constraints*/
select level mylevel, ownerr, mview_namer
from x
connect by nocycle prior mview_namer = mview_namec
              and  prior ownerr      = ownerc
union /*add all MVs at level 0*/
select 0, owner, mview_name
from  dba_mviews
where owner = user
)
select  ownerr owner, mview_namer mview_name
,  max(mylevel) mview_level
from y
group by ownerr, mview_namer
order by mview_level
  ) LOOP
    dbms_application_info.set_action(i.mview_name);

    FOR j IN ( /*disable enabled constraints*/
      SELECT r.owner, r.table_name, r.constraint_name
      FROM   dba_constraints c
      ,      dba_constraints r
      WHERE  c.owner = i.owner
      AND    c.table_name = i.mview_name
      AND    c.constraint_Type IN ('U','P')
      and    r.constraint_type = 'R'
      and    c.owner = r.r_owner
      and    c.constraint_name = r.r_constraint_name
      and    r.status = 'ENABLED'
    ) LOOP
      dbms_application_info.set_action('Disable '||j.constraint_name);
      l_sql := 'ALTER TABLE '||user||'.'||j.table_name||' MODIFY CONSTRAINT '||j.constraint_name||' DISABLE';
      dbms_output.put_line(l_sql);
      EXECUTE IMMEDIATE l_sql;
    END LOOP;

    l_t1 := SYSTIMESTAMP;
    dbms_output.put_line(l_t1||' Start Refresh MV '||i.mview_name||' ('||i.mview_level||')');

    dbms_mview.refresh(i.mview_name,method=>'C',atomic_refresh=>FALSE);
    l_t2 := SYSTIMESTAMP;
    l_tdiff := 60*(60*(24*extract(day from l_t2-l_t1)+extract(hour from l_t2-l_t1))+extract(minute from l_t2-l_t1))+extract(second from l_t2-l_t1);
    dbms_output.put_line(l_t2||' End Refresh MV '||i.mview_name||':'||l_tdiff||' secs');
    commit;
    
    FOR j IN ( /*reenable disabled constraints*/
      SELECT r.owner, r.table_name, r.constraint_name
      FROM   dba_constraints c
      ,      dba_constraints r
      WHERE  c.owner = i.owner
      AND    c.table_name = i.mview_name
      AND    c.constraint_Type IN ('U','P')
      and    r.constraint_type = 'R'
      and    c.owner = r.r_owner
      and    c.constraint_name = r.r_constraint_name
      and    r.status = 'DISABLED'
    ) LOOP
      dbms_application_info.set_action('Disable '||j.constraint_name);
      l_sql := 'ALTER TABLE '||user||'.'||j.table_name||' MODIFY CONSTRAINT '||j.constraint_name||' ENABLE';
      dbms_output.put_line(l_sql);
      EXECUTE IMMEDIATE l_sql;
    END LOOP;

  END LOOP;
  dbms_application_info.set_module(l_module,l_action);
END;
/
Constraints and disabled before the refresh and re-enabled afterwards. The maximum tree level of the materialized view is shown in brackets.
20-OCT-16 10.32.21.985520 Start Refresh MV C0 (0)
20-OCT-16 10.32.22.497711 End Refresh MV C0:.512191 secs
ALTER TABLE SCOTT.C2 MODIFY CONSTRAINT C2_C1 DISABLE
20-OCT-16 10.32.22.728865 Start Refresh MV C1 (0)
20-OCT-16 10.32.23.174410 End Refresh MV C1:.445545 secs
ALTER TABLE SCOTT.C2 MODIFY CONSTRAINT C2_C1 ENABLE
ALTER TABLE SCOTT.C3 MODIFY CONSTRAINT C3_C2 DISABLE
ALTER TABLE SCOTT.C4 MODIFY CONSTRAINT C4_C2 DISABLE
20-OCT-16 10.32.23.437297 Start Refresh MV C2 (1)
20-OCT-16 10.32.23.873448 End Refresh MV C2:.436151 secs
ALTER TABLE SCOTT.C3 MODIFY CONSTRAINT C3_C2 ENABLE
ALTER TABLE SCOTT.C4 MODIFY CONSTRAINT C4_C2 ENABLE
20-OCT-16 10.32.24.240742 Start Refresh MV C3 (2)
20-OCT-16 10.32.24.714952 End Refresh MV C3:.47421 secs
20-OCT-16 10.32.25.007517 Start Refresh MV C4 (2)
20-OCT-16 10.32.25.408507 End Refresh MV C4:.40099 secs

PL/SQL procedure successfully completed.

Sunday, October 28, 2007

TM locking: Checking for Missing Indexes on Foreign Key Constraints

Recently, I was working on a packaged application purchased from a third-party vendor. It is one of those platform agnostic systems that started life on Microsoft SQL Server, and has been ported to Oracle. I spend a lot of my time working with PeopleSoft, so I had a certain sense of déjà vu. However, this application uses referential integrity.

The application was upgraded, and simultaneously Oracle was upgraded to 10g and then exhibited TM contention. It had probably been suffering from TM contention while running on Oracle 9i, but we hadn't realised because Oracle9i only reports 'enqueue'.

From 10g, there are no less that 208 different enqueue wait events, that show the type of lock that the process is waiting for, and sometimes additional information. In my case it was event 175. Events can be listed from v$event_name.

SELECT event#, name FROM v$event_name
WHERE UPPER(name) LIKE 'ENQ: TM%'
/
EVENT#     NAME
---------- --------------------
175 enq: TM - contention

With a little help from my friends I came to realise that the cause of this contention was that the system had foreign key constraints on columns that were not indexed. Having found one example of this, I realised that I needed a way to check the entire data model. The result was the following SQL and PL/SQL script.

REM fk_index_check.sql
REM 19.10.2007

Uncommenting the following section will produce a test case that should build two indexes.

/*--------------------------------------------------------------
ALTER TABLE EMP_TAB DROP CONSTRAINT MGR_FKEY;
ALTER TABLE EMP_TAB DROP CONSTRAINT DEPT_FKEY;
DROP TABLE Emp_tab;
DROP TABLE DEPT_TAB;

CREATE TABLE Dept_tab (
setid   NUMBER(3),
deptno  NUMBER(3),
dname   VARCHAR2(15),
loc     VARCHAR2(15)
--CONSTRAINT dname_ukey UNIQUE (Dname, Loc),
--CONSTRAINT loc_check1
--CHECK (loc IN ('NEW YORK', 'BOSTON', 'CHICAGO'))
,CONSTRAINT Dept_pkey PRIMARY KEY (setid,deptno)
)
/
CREATE TABLE Emp_tab (
empno    NUMBER(5) CONSTRAINT emp_pkey PRIMARY KEY,
ename    VARCHAR2(15) NOT NULL,
job      VARCHAR2(10),
mgr      NUMBER(5) CONSTRAINT mgr_fkey REFERENCES emp_tab,
hiredate DATE,
sal      NUMBER(7,2),
comm     NUMBER(5,2),
setid    NUMBER(3),
deptno   NUMBER(3) NOT NULL,
CONSTRAINT dept_fkey FOREIGN KEY (setid,deptno)
REFERENCES dept_tab (setid,deptno) ON DELETE CASCADE
)
/
/*------------------------------------------------------------*/
set serveroutput on buffer 1000000000

GFC_FK_INDEX_CHECK is a working storage script that is to hold results of the tests on each foreign key.

DROP TABLE gfc_fk_index_check
/
CREATE TABLE gfc_fk_index_check
(owner             VARCHAR2(30) NOT NULL
,table_name        VARCHAR2(30) NOT NULL
,constraint_name   VARCHAR2(30) NOT NULL
,r_owner           VARCHAR2(30) NOT NULL
,r_table_name      VARCHAR2(30) NOT NULL
,r_constraint_name VARCHAR2(30) NOT NULL
,i_index_owner     VARCHAR2(30)
,i_index_name      VARCHAR2(30)
,i_status          VARCHAR2(30) DEFAULT 'UNKNOWN'
,i_column_list     VARCHAR2(300)
,CONSTRAINT gfc_fk_index_check_pk
PRIMARY KEY(table_name, constraint_name)
)
/
TRUNCATE TABLE gfc_fk_index_check
/

First the script populates the working storage table with all the referential integrity constraints that reference a primary key constraint.

INSERT INTO gfc_fk_index_check
(owner, table_name, constraint_name
,r_owner, r_constraint_name, r_table_name)
SELECT c.owner, c.table_name, c.constraint_name
,      c.r_owner, c.r_constraint_name
,      r.table_name r_table_name
FROM   all_constraints c
,      all_constraints r
WHERE  c.constraint_Type = 'R'
AND    r.owner = c.r_owner
AND    r.constraint_name = c.r_constraint_name
AND    r.constraint_Type = 'P'
AND    r.owner = user
/

This PL/SQL routine checks each foreign key constraint in the table for each constraint it looks up the referring columns in all_cons_columns and builds a dynamic query that SELECTs the owner and name of an index with the same columns in the same position. The name of that index and the column list is stored on the working storage table. Depending upon how many rows that query returns, a status string is written to the table: No Index/Index Found/Multiple Indexes

DECLARE
l_counter     NUMBER;
l_column_list VARCHAR2(200);
l_sql1        VARCHAR2(4000);
l_sql2        VARCHAR2(4000);
l_tmp1        VARCHAR2(20);
l_tmp2        VARCHAR2(20);
l_alias       VARCHAR2(3);
l_oldalias    VARCHAR2(3);
l_index_owner VARCHAR2(30);
l_index_name  VARCHAR2(30);
l_status      VARCHAR2(30);
BEGIN
FOR a IN (SELECT * FROM gfc_fk_index_check) LOOP
 l_counter := 0;
 l_column_list := '';
 l_sql1 := 'SELECT i1.index_owner, i1.index_name';
 l_sql2 := '';
 FOR b IN (SELECT *
        FROM  all_cons_columns c
        WHERE c.owner = a.owner
        AND   c.constraint_name = a.constraint_name
        AND   c.table_name = a.table_name
        ORDER BY position) LOOP
  l_counter := l_counter + 1;
  l_oldalias := l_alias;
  l_alias := ' i'||TO_CHAR(l_counter);
  IF l_counter > 1 THEN
   l_sql1 := l_sql1||', '; 
   l_sql2 := l_sql2
         ||' AND '||l_oldalias||'.index_owner='
                  ||l_alias   ||'.index_owner'
         ||' AND '||l_oldalias||'.index_name='
                  ||l_alias   ||'.index_name'
         ||' AND ';
   l_column_list := l_column_list||',';
  ELSE
   l_sql1 := l_sql1||' FROM ';
   l_sql2 := l_sql2||' WHERE';
  END IF;
  l_sql1 := l_sql1||'all_ind_columns'||l_alias;
  l_sql2 := l_sql2
            ||l_alias||'.TABLE_OWNER='''||b.owner||''''
   ||' AND '||l_alias||'.TABLE_NAME='''||b.table_name||''''
   ||' AND '||l_alias||'.COLUMN_NAME='''||b.column_name||''''
   ||' AND '||l_alias||'.COLUMN_POSITION='''||b.position||'''';
  l_column_list := l_column_list||b.column_name;
 END LOOP;
--   dbms_output.put_line(l_sql1);
--   dbms_output.put_line(l_sql2);
--   dbms_output.put_line(l_column_list);
 l_status := a.i_status;
 l_index_owner := '';
 l_index_name := '';
 BEGIN
  EXECUTE IMMEDIATE l_sql1||l_sql2
               INTO l_index_owner, l_index_name;
  l_status := 'Index Found';
 EXCEPTION
  WHEN NO_DATA_FOUND THEN l_status := 'No Index';
  WHEN TOO_MANY_ROWS THEN l_status := 'Multiple Indexes';
 END;
 UPDATE gfc_fk_index_check
 SET    i_status = l_status
 ,      i_index_owner = l_index_owner
 ,      i_index_name  = l_index_name
 ,      i_column_list = l_column_list
 WHERE  owner = a.owner
 AND    table_name = a.table_name
 AND    constraint_name = a.constraint_name;
END LOOP;
COMMIT;
END;
/

This query produces a simple report on each foreign key constraint.

set lines 90 head on feedback on echo on
column owner             format a20
column table_name        format a30
column constraint_name   format a30
column r_owner           format a20
column r_constraint_name format a30
column r_table_name      format a30
column i_index_owner     format a20
column i_index_name      format a30
column i_status          format a30
column i_column_list     format a80
spool fk_index_check
SELECT g.owner, g.table_name, g.constraint_name
,      g.r_owner, g.r_table_name, g.r_constraint_name
,      g.i_index_owner, g.i_index_name, g.i_status
,      g.i_column_list
FROM   gfc_fk_index_check g
/
spool off

This query is similar to the last, but it produces a report of just largest tables that lack indexes on FK constraints. It show tables more than 10000 rows (according to the CBO statistics), or at least the top 20. These are likely to be most severe offenders.

spool fk_index_by_size
SELECT * from (
SELECT g.owner, g.table_name, g.constraint_name
,      g.r_owner, g.r_table_name, g.r_constraint_name
,      g.i_index_owner, g.i_index_name, g.i_status
,      /*t.temporary, t.partitioned, */ t.num_rows
,      g.i_column_list
FROM   gfc_fk_index_check g, all_tables t
WHERE  t.table_name = g.table_name
AND    t.owner = g.owner
AND    g.i_status = 'No Index'
ORDER BY num_rows desc
) WHERE rownum <= 20 or num_rows >= 10000
/
spool off

This query generates a script constraint create index DDL statements that will build the missing indexes. The index will have the same name as the foreign key constraint to which it relates.

set head off trimout on trimspool on feedback off verify off timi off echo off lines 200
spool fk_index_build.sql
SELECT 'CREATE INDEX '||g.owner||'.'||g.constraint_name
      ||' ON '||g.owner||'.'||g.table_name
      ||' ('||g.i_column_list||');' build_indexes
FROM   gfc_fk_index_check g, all_tables t
WHERE  t.table_name = g.table_name
AND    t.owner = g.owner
AND    g.i_status = 'No Index'
ORDER BY t.num_rows
/
spool off
set lines 90 head on feedback on echo on

The test script correctly reports (in fk_index_check.LST) that there are two foreign keys that require supporting indexes

OWNER           TABLE_NAME           CONSTRAINT_NAME
--------------- -------------------- --------------------
R_OWNER         R_TABLE_NAME         R_CONSTRAINT_NAME
--------------- -------------------- --------------------
I_INDEX_OWNER   I_INDEX_NAME         I_STATUS
--------------- -------------------- --------------------
I_COLUMN_LIST
---------------------------------------------------------
SYSADM          EMP_TAB              MGR_FKEY
SYSADM          EMP_TAB              EMP_PKEY
                                    No Index
MGR

SYSADM          EMP_TAB              DEPT_FKEY
SYSADM          DEPT_TAB             DEPT_PKEY
                                    No Index
SETID,DEPTNO

It produces another script fk_index_build.sql that will build the missing indexes.

CREATE INDEX SYSADM.MGR_FKEY ON SYSADM.EMP_TAB (MGR);
CREATE INDEX SYSADM.DEPT_FKEY ON SYSADM.EMP_TAB (SETID,DEPTNO);

When I ran this test script on my problem application, it identified over 200 missing indexes on 900 foreign key constraints, and since building the indexes on tables where I have seen TM locking, I haven't seen any TM locking contention.

The script can be downloaded from the Go-Faster website at http://www2.go-faster.co.uk/scripts.htm#fk_index_check.sql

Caveat: Just because you can index a foreign key, doesn't mean that you should. See
http://www.jlcomp.demon.co.uk/faq/fk_ind.htmlThis query produces a simple report on each foreign key constraint.