{"id":73161,"date":"2015-06-17T10:26:39","date_gmt":"2015-06-17T10:26:39","guid":{"rendered":"https:\/\/www.red-gate.com\/simple-talk\/uncategorized\/basics-of-the-cost-based-optimizer-part-2\/"},"modified":"2021-07-14T13:07:24","modified_gmt":"2021-07-14T13:07:24","slug":"basics-of-the-cost-based-optimizer-part-2","status":"publish","type":"post","link":"https:\/\/www.red-gate.com\/simple-talk\/databases\/oracle-databases\/basics-of-the-cost-based-optimizer-part-2\/","title":{"rendered":"Basics of the Cost Based Optimizer &#8211; Part 2"},"content":{"rendered":"<p>In the <a href=\"https:\/\/allthingsoracle.com\/basics-of-the-cost-based-optimizer-pt-1\/\">first installment<\/a> of this series I gave an informal description of how the optimizer would consider the possibilities when choosing an execution path for a simple two-table join between an <em><strong>orders<\/strong> <\/em>table and an <em><strong>order_lines<\/strong><\/em> table that I had described.<\/p>\n<p>In that installment I supplied the DDL for the tables and its indexes; in this installment I\u2019m going to take a look at the data I created, show you a few of the relevant numbers, and then show how the numbers affect the optimizer\u2019s choice of path. The arguments will still be fairly informal, though.<\/p>\n<p>The key point of this installment is that though the optimizer can do some very clever things there are still flaws in the default model which you may be able to identify through simple queries and resolve through precisely targeted changes to configuration.<\/p>\n<h3>The data<\/h3>\n<p>Here\u2019s the code I used to populate the two tables:<\/p>\n<pre>-- create sequence s1 cache 10000;\r\n-- variable m_ord_id number\r\n\r\ninsert into orders values (\r\n        1000000 + s1.nextval,\r\n        trunc(dbms_random.value(100000,200000)),\r\n        case\r\n                when s1.nextval &lt; 257143\r\n                        then sysdate + (365 * (s1.nextval \/  257143)- 3 * 365)\r\n                when s1.nextval &lt; 771429\r\n                        then sysdate + (365 * (s1.nextval \/  771429)- 2 * 365)\r\n                        else sysdate + (365 * (s1.nextval \/ 1800000)- 1 * 365)\r\n        end,\r\n        rpad('x',100)\r\n)\r\nreturning order_id into :m_ord_id\r\n;\r\n\r\ninsert into order_lines\r\nselect\r\n        :m_ord_id,\r\n        rownum,\r\n        trunc(dbms_random.value(100000,102000)),\r\n        trunc(dbms_random.value(1,5)),\r\n        trunc(dbms_random.value(1,100)),\r\n        rpad('x',80)\r\nfrom \r\n        dual\r\nconnect by\r\n        level &lt;= (select mod(ceil(2.80 * ln(abs(dbms_random.normal))),11) from dual)\r\n;\r\n\r\ncommit;\r\n\r\nexecute dbms_lock.sleep(0.01)\r\n<\/pre>\n<p>I created 18 concurrent sessions and executed the code 100,000 times from each of the sessions. This should have given me exactly 1.8M rows but I found a few SQL*Plus sessions crashing randomly so launched a couple of extra sessions and ended up with a few more rows than originally planned. The number of rows ended up at 1,810,995 rows in <em><strong>orders<\/strong><\/em> and 2,166,518 rows in <em><strong>order_lines<\/strong><\/em> for an average of 1.196 order lines per order, with the following breakdown:<\/p>\n<pre>select\r\n        N, count(*) \"Orders with N order_lines\"\r\nfrom\r\n        (\r\n        select\r\n                order_id, count(*)      N\r\n        from\r\n                order_lines\r\n        group by\r\n                order_id\r\n        )\r\ngroup by\r\n        N\r\norder by\r\n        N\r\n;\r\n\r\n         N Orders with N order_lines\r\n---------- -------------------------\r\n         1                   1534136\r\n         2                    204382\r\n         3                     66344\r\n         4                      6079\r\n         5                        54\r\n\r\n<\/pre>\n<p>It\u2019s worth making a couple of points about the insert statements. The <em><strong>case<\/strong><\/em> statement for <em><strong>orders<\/strong><\/em> models the number of orders doubling each year over the last 3 years; the <em><strong>ln()<\/strong><\/em> (natural logarithm) call in the <em><strong>level<\/strong><\/em> for <em><strong>order_lines<\/strong><\/em> is a convenient way of getting a typical \u201creal-world\u201d pattern of order lines per order (and minutes per phone-call, cars per family, etc.). The final point to bear in mind is that the tables are located in a tablespace defined to use automatic (bitmap) segment space management \u2013 the tablespace is also using system managed extent allocation, but that doesn\u2019t really have a significant impact in this case.<\/p>\n<p>The fact that I inserted a pause of 1\/100th of a second between each order helped to reduce the problems of the <a href=\"https:\/\/jonathanlewis.wordpress.com\/2009\/09\/19\/index-itls\/\"><em><strong>&#8220;ITL explosion&#8221;<\/strong><\/em><\/a>, though the pattern of activity still managed to produce some space inefficiencies on index leaf block splits. The run-time to load the data was about 20 minutes &#8211; but if you don&#8217;t have a large SGA and fast discs you may find you lose a lot of extra time on <em>&#8220;db file sequential read&#8221;<\/em> waits as you maintain the index <em><strong>order_lines(<\/strong><strong>product_id)<\/strong><\/em>, and <em>&#8220;log file parallel write&#8221;<\/em> waits (and associated session <em>&#8220;log file sync&#8221;<\/em> waits) as the redo streams out to disc.<\/p>\n<h3>Getting Started<\/h3>\n<p>We\u2019ll be addressing the main query in the next installment, but I want to start by running a couple of simpler queries to see how the optimizer thinks our data should be handled. Our original query was for product 101234 (an attribute of <em><strong>order_lines<\/strong><\/em>) over the last seven days (an attribute of <em><strong>orders<\/strong><\/em>), but I\u2019ll start with a query against just the order lines \u2013 let\u2019s just sum the value of all order lines for that product &#8211; a query that has to visit exactly the data we&#8217;re interested in, but conveniently returns only one row:<\/p>\n<pre>SQL&gt; set autotrace traceonly explain\r\nSQL&gt; select  sum(unit_price * quantity) \r\n  2  from    order_lines \r\n  3  where   product_id = 101234\r\n  4  ;\r\n\r\n-------------------------- ------------------------------------------------------------------\r\n| Id  | Operation                    | Name        | Rows  | Bytes | Cost (%CPU)| Time     |\r\n--------------------------------------------------------------------------------------------\r\n|   0 | SELECT STATEMENT             |             |     1 |    11 |  1064   (1)| 00:00:13 |\r\n|   1 |  SORT AGGREGATE              |             |     1 |    11 |            |          |\r\n|   2 |   TABLE ACCESS BY INDEX ROWID| ORDER_LINES |  1083 | 11913 |  1064   (1)| 00:00:13 |\r\n|*  3 |    INDEX RANGE SCAN          | ORL_FK_PRD  |  1083 |       |     4   (0)| 00:00:01 |\r\n--------------------------------------------------------------------------------------------\r\n\r\nPredicate Information (identified by operation id):\r\n---------------------------------------------------\r\n   3 - access(\"PRODUCT_ID\"=101234)\r\n\r\n<\/pre>\n<p>From a business perspective we were expecting something like 1,000 order lines for each product and that (within reasonable limits) is what the optimizer predicted. Based on our knowledge of the business we were also expecting those rows to be spread fairly evenly across the three years which, moving a little closer to the database technology tells us that we might typically expect every row to be in a separate block \u2013 and that (approximately) is what the optimizer\u2019s estimated cost is telling us.<\/p>\n<p>The optimizer has decided that the resources needed to get the data are approximately equivalent to physically reading 1,064 blocks from the database files (4 for the index plus a further 1060 for the table). Given that the execution plan is an <em><strong>index range scan<\/strong><\/em> following by a <em><strong>table access by index rowid<\/strong><\/em> we know that this equivalence is a direct representation of the fact that the optimizer really does expect the run-time engine to do 1,064 single block reads.<\/p>\n<p>Now let\u2019s look at the other end of our original query \u2013 orders for the last 7 days. To execute the original query we would need the <em><strong>order_id<\/strong><\/em> to join to the <em><strong>order_lines<\/strong><\/em> table, so we\u2019ll select the <em><strong>order_id<\/strong><\/em> in this test but use it to get an approximate count of the number of orders in the date range by finding the difference between the min and max ids in the range and adding 1.<\/p>\n<pre>SQL&gt; set autotrace traceonly explain\r\nSQL&gt; select  max(order_id) - min(order_id) + 1 \r\n  2  from    orders \r\n  3  where   date_ordered between trunc(sysdate) - 7 and trunc(sysdate)\r\n  4  ;\r\n\r\n---------------------------------------------------------------------------------------------\r\n| Id  | Operation                | Name             | Rows  | Bytes | Cost (%CPU)| Time     |\r\n---------------------------------------------------------------------------------------------\r\n|   0 | SELECT STATEMENT         |                  |     1 |    14 |  4115   (2)| 00:00:50 |\r\n|   1 |  SORT AGGREGATE          |                  |     1 |    14 |            |          |\r\n|*  2 |   FILTER                 |                  |       |       |            |          |\r\n|*  3 |    VIEW                  | index$_join$_001 | 11526 |   157K|  4115   (2)| 00:00:50 |\r\n|*  4 |     HASH JOIN            |                  |       |       |            |          |\r\n|*  5 |      INDEX RANGE SCAN    | ORD_DATE_ORD     | 11526 |   157K|    35   (0)| 00:00:01 |\r\n|   6 |      INDEX FAST FULL SCAN| ORD_PK           | 11526 |   157K|  5083   (1)| 00:01:01 |\r\n---------------------------------------------------------------------------------------------\r\n\r\nPredicate Information (identified by operation id):\r\n---------------------------------------------------\r\n   2 - filter(TRUNC(SYSDATE@!)&gt;=TRUNC(SYSDATE@!)-7)\r\n   3 - filter(\"DATE_ORDERED\"&gt;=TRUNC(SYSDATE@!)-7 AND\r\n              \"DATE_ORDERED\"&lt;=TRUNC(SYSDATE@!)) \r\n   4 - access(ROWID=ROWID) \r\n   5 - access(\"DATE_ORDERED\"&gt;=TRUNC(SYSDATE@!)-56 AND\r\n              \"DATE_ORDERED\"&lt;=TRUNC(SYSDATE@!))\r\n)\r\n\r\n<\/pre>\n<p>(The query identified 20,000 rows while the estimated rowcount was 11,500 \u2013 but that discrepancy is consistent with the prediction we made in part 1 about the effect of the number of orders doubling each year.)<\/p>\n<p>The big surprise though \u2013 showing simultaneously how smart and how ignorant the optimizer can be \u2013 is the strange execution plan that does a <a href=\"https:\/\/jonathanlewis.wordpress.com\/2010\/11\/22\/index-join\/\"><em><strong>hash join between two indexes<\/strong><\/em><\/a> to get the result. It\u2019s very clever that the optimizer has found a plan that doesn\u2019t need to visit the table, but it\u2019s surprising that the optimizer thinks this plan is more efficient than visiting the table through a simple index range scan; after all we know that the last 7 days of orders are packed into the last \u201cfew\u201d blocks in the table \u2013 in fact we can check that this is true:<\/p>\n<pre>select count(*)\r\nfrom (\r\n        select distinct\r\n                dbms_rowid.rowid_relative_fno(rowid),\r\n                dbms_rowid.rowid_block_number(rowid)\r\n        from orders\r\n        where date_ordered between trunc(sysdate) - 7 and trunc(sysdate)\r\n        )\r\n;\r\n\r\n  COUNT(*)\r\n----------\r\n       447\r\n<\/pre>\n<p>The data we want is scattered across just 447 blocks and we might find similar figures if we sample a few other week-long intervals, so it seems reasonable to expect the optimizer to work out that it should take something in the order of 500 blocks to get all the data it needs \u2013 so why does it do a <em><strong>fast full scan<\/strong><\/em> on the primary key index at a cost of 5,083 (and don\u2019t ask why 5,083 + 35 seems to sum to 4,115 \u2013 sometimes the optimizer does strange things that you don\u2019t need to investigate right away \u2013 even in 11.2.0.4) which is about 10 times the cost that the &#8220;obvious&#8221; plan should be.<\/p>\n<p>We can answer the critical question by reminding ourselves of two things \u2013 the tablespace is bitmap managed (ASSM) and I had 18 concurrent processes inserting data. The point of ASSM is to reduce contention on DML, particularly on concurrent inserts, by pointing different sessions at different blocks that are typically, though not necessarily, spread over a batch of 16 consecutive blocks. This is a very good strategy for avoiding <em>\u201cbuffer busy waits\u201d<\/em> (and the more extreme <em>\u201cgc buffer busy waits\u201d<\/em> that you would get using RAC) but it does mean that at a very fine level of detail the data appears (to the optimizer) to be far more scattered than it really is. We can show the effect with another simple query \u2013 reporting the file and block id for a batch of consecutive orders.<\/p>\n<pre>SQL&gt; select\r\n  2          order_id,\r\n  3          dbms_rowid.rowid_relative_fno(rowid) file_id,\r\n  4          dbms_rowid.rowid_block_number(rowid) block_id\r\n  5  from    orders\r\n  6  where   date_ordered &gt;  trunc(sysdate) - 56\r\n  7  and     rownum &lt;= 20\r\n  8  ;\r\n\r\n  ORDER_ID    FILE_ID   BLOCK_ID\r\n---------- ---------- ----------\r\n   2667873          6      29360\ta\r\n   2667874          6      28853\t b\r\n   2667875          6      29503\t      f\r\n   2667876          6      29207\t  c\r\n   2667877          6      29567\r\n   2667878          6      29272\t     e\r\n   2667879          6      29823\r\n   2667880          6      29272\t     e\r\n   2667881          6      27952\t    d\r\n   2667882          6      29104\r\n   2667883          6      27952\t    d\r\n   2667884          6      28981\r\n   2667885          6      29616\r\n   2667886          6      29503\t      f\r\n   2667887          6      28917\r\n   2667888          6      29360\ta\r\n   2667889          6      29207\t  c\r\n   2667890          6      28853\t b\r\n   2667891          6      29280\r\n   2667892          6      29207\t  c\r\n\r\n<\/pre>\n<p>As you can see I\u2019ve picked 20 orders ordered by <em><strong>order_date<\/strong><\/em> which has given me (not coincidentally) 20 consecutive order IDs; but the data is scattered over 13 different blocks in a randomized fashion. This means that as Oracle walks the index to fetch the data it often jumps from one table block to another \u2013 and a measure of this \u201cjumping\u201d activity is used as an important detail when the optimizer decides how efficient a particular indexed access path might be.<\/p>\n<p>When you gather index stats, Oracle uses a function called <em><strong>sys_op_countchg()<\/strong><\/em> to count the number of jumps to \u201ca different table block\u201d that would occur as it moves in order through the entire index. This number appears in view <em><strong>user_indexes<\/strong><\/em> as the <em><strong>clustering_factor<\/strong><\/em>. The higher the <em><strong>clustering_factor<\/strong><\/em> the more random (physical) reads the optimizer thinks it would have to do to access the table data in index order.<\/p>\n<p>Historically, though, Oracle would fail to notice that a jump to \u201cthe next required block from the table\u201d was taking it back to a table block that had appeared in its very recent history so, walking the sample above, it would see 20 different table blocks where we see only 13. In 12c, with a backport to 11.2.0.4, we can set a \u201ctable preference\u201d to tell Oracle to remember recent history as it is calculating the <em><strong>clustering_factor<\/strong><\/em>. For example, if we set the preference to 8, Oracle <strong><em>wouldn\u2019t<\/em><\/strong> remember that it had recently seen block 29360 (labelled a) the second time it reached it, so it would count it a second time; on the other hand it <em><strong>would<\/strong><\/em> remember visiting blocks 29272 and 27952 and count them only once each. Block 29207 (labelled c) is a particularly useful example \u2013 when Oracle reaches it the 2nd time it will have forgotten the first visit, so it will count the block a second time, but when it reaches it the 3rd time it will remember the previous (2nd) visit so won\u2019t increment the count.<\/p>\n<p>ASSM tends to scatter inserts over 16 blocks (which makes 16 a good default value for this option), but in my case I connected 18 sessions to the database and kept those sessions alive for a long time doing single row inserts with commits continuously; so I might expect to see a pattern that\u2019s a little worse than typical \u2013 a scattering effect that\u2019s broadly spread over 18 blocks. Because of this I\u2019m going to set the table preference to tell Oracle to remember 18 steps of history then gather index stats again and see what happens<\/p>\n<pre>begin\r\n        dbms_stats.set_table_prefs (user,'orders','table_cached_blocks',18);\r\n        dbms_stats.gather_table_stats(user,'orders');\r\n\r\n        dbms_stats.set_table_prefs (user,'order_lines','table_cached_blocks',18);\r\n        dbms_stats.gather_table_stats(user,'order_lines');\r\nend;\r\n\/\r\n\r\nselect  table_name, index_name, sample_size, num_rows, clustering_factor \r\nfrom    user_indexes\r\norder by \r\n        table_name, index_name\r\n;\r\n<\/pre>\n<p>Here are the details of the clustering factors before and after adjusting the \u201chistory\u201d setting:<\/p>\n<pre>TABLE_NAME           INDEX_NAME           SAMPLE_SIZE   NUM_ROWS CLUSTERING_FACTOR\r\n-------------------- -------------------- ----------- ---------- -----------------\r\nORDERS               ORD_DATE_ORD              448630    1913634           1738979\r\n                     ORD_PK                   1810995    1810995           1643965\r\n\r\nORDER_LINES          ORL_FK_PRD               2166518    2166518           2114283\r\n                     ORL_PK                    425450    2164008           1658659\r\n\r\n\r\nTABLE_NAME           INDEX_NAME           SAMPLE_SIZE   NUM_ROWS CLUSTERING_FACTOR\r\n-------------------- -------------------- ----------- ---------- -----------------\r\nORDERS               ORD_DATE_ORD              412815    1760865            103293\r\n                     ORD_PK                   1810995    1810995             66790\r\n\r\nORDER_LINES          ORL_FK_PRD               2166518    2166518           2114283\r\n                     ORL_PK                    426762    2170681            115868\r\n\r\n<\/pre>\n<p>As you can see, three of the indexes report a much smaller clustering factor once we instruct Oracle to allow for the effects of concurrency and ASSM scattering of data. The absence of change for the <em><strong>orl_fk_prd<\/strong><\/em> index shouldn\u2019t come as a surprise since this is the index on the product ID and we know that individual products really are very randomly scattered throughout the entire history of the table.<\/p>\n<p>So what does the reduced (and more realistic) <em><strong>clustering_factor<\/strong><\/em> do to the execution plan of our simple query against the orders table? Here\u2019s the new plan:<\/p>\n<pre>----------------------------------------------------------------------------------------------\r\n| Id  | Operation                     | Name         | Rows  | Bytes | Cost (%CPU)| Time     |\r\n----------------------------------------------------------------------------------------------\r\n|   0 | SELECT STATEMENT              |              |     1 |    14 |   691   (1)| 00:00:09 |\r\n|   1 |  SORT AGGREGATE               |              |     1 |    14 |            |          |\r\n|*  2 |   FILTER                      |              |       |       |            |          |\r\n|   3 |    TABLE ACCESS BY INDEX ROWID| ORDERS       | 11526 |   157K|   691   (1)| 00:00:09 |\r\n|*  4 |     INDEX RANGE SCAN          | ORD_DATE_ORD | 11526 |       |    32   (0)| 00:00:01 |\r\n----------------------------------------------------------------------------------------------\r\n\r\nPredicate Information (identified by operation id):\r\n---------------------------------------------------\r\n   2 - filter(TRUNC(SYSDATE@!)&gt;=TRUNC(SYSDATE@!)-7)\r\n   4 - access(\"DATE_ORDERED\"&gt;=TRUNC(SYSDATE@!)-7 AND\r\n              \"DATE_ORDERED\"&lt;=TRUNC(SYSDATE@!))\r\n<\/pre>\n<p>The plan is the one we might have expected based on our knowledge of the data. We have to access a \u201csmall\u201d number of table blocks by single block read after the index range scan; the optimizer\u2019s estimate is approximately 691 \u2013 32 = 659 single block reads, which doesn\u2019t quite match our precise knowledge for the specific set of data, but with the adjusted <em><strong>clustering_factor<\/strong><\/em> the optimizer has got a much more realistic estimate of the work involved.<\/p>\n<p>For comparative purposes we could reset the <em><strong>table_blocks_cached<\/strong><\/em> preference to 1, gather stats again, and see what we get if we force Oracle to take this path with the <em><strong>\/*+ index(orders (date_ordered)) *\/<\/strong><\/em> hint:<\/p>\n<pre>----------------------------------------------------------------------------------------------\r\n| Id  | Operation                     | Name         | Rows  | Bytes | Cost (%CPU)| Time     |\r\n----------------------------------------------------------------------------------------------\r\n|   0 | SELECT STATEMENT              |              |     1 |    14 | 11109   (1)| 00:02:14 |\r\n|   1 |  SORT AGGREGATE               |              |     1 |    14 |            |          |\r\n|*  2 |   FILTER                      |              |       |       |            |          |\r\n|   3 |    TABLE ACCESS BY INDEX ROWID| ORDERS       | 11526 |   157K| 11109   (1)| 00:02:14 |\r\n|*  4 |     INDEX RANGE SCAN          | ORD_DATE_ORD | 11526 |       |    35   (0)| 00:00:01 |\r\n----------------------------------------------------------------------------------------------\r\nPredicate Information (identified by operation id):\r\n---------------------------------------------------\r\n   2 - filter(TRUNC(SYSDATE@!)&gt;=TRUNC(SYSDATE@!)-7)\r\n   4 - access(\"DATE_ORDERED\"&gt;=TRUNC(SYSDATE@!)-7 AND\r\n              \"DATE_ORDERED\"&lt;=TRUNC(SYSDATE@!))\r\n<\/pre>\n<p>Without fixing the clustering factor, the optimizer\u2019s estimate for the plan involving a simple index range scan was 11,109 \u2013 indicating an estimate of 11,074 distinct table blocks (11,109 \u2013 35) needing to be read. If you compare 11,074 with 659 (the costs \/ estimates of table block reads) and 1,738,979 with 103,293 (the two <em><strong>clustering_factors<\/strong><\/em>) you\u2019ll see that in both cases the ratio is about 16.8 (with some variation due to different sample sizes)<\/p>\n<h2>Summary<\/h2>\n<p>We&#8217;ve examined a couple of queries that will eventually lead us to the table join that we\u2019re interested in. Because they are single-table queries we have a good idea of how many rows are involved, how many blocks are relevant, and how effective the available indexes should be.<\/p>\n<p>Since we\u2019re expecting a simple table access by index range scan we know that the optimizer\u2019s estimated cost for each query should be close to the number of distinct blocks it thinks it will have to visit (which it equates with single block physical reads), and we have a good idea of how many blocks that should be.<\/p>\n<p>However, the optimizer chose an unexpected path for one of the queries with a much higher cost estimate than our well-informed estimate and, since we can use some simple SQL to prove that our estimation method was appropriate, we know that the optimizer is making an invalid assumption somewhere in its calculations.<\/p>\n<p>For index range scans and index full scans a significant contributor to the cost calculation for visiting the table is the index\u2019s <em><strong>clustering_factor<\/strong><\/em> and we knew that our <em><strong>orders<\/strong><\/em> and <em><strong>order_lines<\/strong><\/em> data are fairly well clustered by date; however our knowledge of ASSM and the application (particularly the degree of concurrency) tells us that while the big picture shows well-clustered data, the very fine detail shows a localized scattering effect which Oracle\u2019s traditional algorithm has exaggerated enormously.<\/p>\n<p>Fortunately a recent version of Oracle allowed us to configure a \u201ctable preference\u201d to address this particular problem and, after we set this preference to match our pattern of concurrent activity, we can see after the next call to gather stats that this has affected the <em><strong>clustering_factor<\/strong><\/em> of all the indexes in exactly the way we might hope \u2013 i.e. reduced it dramatically for 3 of them and left the fourth unchanged.<\/p>\n<p>With the corrected <em><strong>clustering_factor<\/strong><\/em> the optimizer has switched to the path we expect, with a cost that is a good match for our expectation.<\/p>\n<p>In the next installment we\u2019ll move on to looking at the join that we\u2019re really interested in, and see another of the problems that the optimizer runs into when it doesn&#8217;t understand our data as well as we do.<\/p>\n<p><a href=\"http:\/\/jonathanlewis.wordpress.com\/cbo-series\/\">&#8211;&gt; Catalogue of current articles in CBO series.<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>In the first installment of this series I gave an informal description of how the optimizer would consider the possibilities when choosing an execution path for a simple two-table join between an orders table and an order_lines table that I had described. In that installment I supplied the DDL for the tables and its indexes; in this installment I\u2019m going&hellip;<\/p>\n","protected":false},"author":101205,"featured_media":0,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"footnotes":""},"categories":[143533],"tags":[48372,5038,48383],"coauthors":[],"class_list":["post-73161","post","type-post","status-publish","format-standard","hentry","category-oracle-databases","tag-cbo","tag-clustering","tag-cost-based-optimizer"],"acf":[],"_links":{"self":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts\/73161","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/users\/101205"}],"replies":[{"embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/comments?post=73161"}],"version-history":[{"count":1,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts\/73161\/revisions"}],"predecessor-version":[{"id":91654,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/posts\/73161\/revisions\/91654"}],"wp:attachment":[{"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/media?parent=73161"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/categories?post=73161"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/tags?post=73161"},{"taxonomy":"author","embeddable":true,"href":"https:\/\/www.red-gate.com\/simple-talk\/wp-json\/wp\/v2\/coauthors?post=73161"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}