Search code examples
oracledatabase-designindexinghashmapdatabase-performance

Constant-time index for string column on Oracle database


I have an orders table. The table belongs to a multi-tenant application, so there are orders from several merchants in the same table. The table stores hundreds of millions of records. There are two relevant columns for this question:

  • MerchantID, an integer storing the merchant's unique ID
  • TransactionID, a string identifying the transaction

I want to know whether there is an efficient index to do the following:

  • Enforce a unique constraint on Transaction ID for each Merchant ID. The constraint should be enforced in constant time.
  • Do constant time queries involving exact matches on both columns (for instance, SELECT * FROM <table> WHERE TransactionID = 'ff089f89feaac87b98a' AND MerchantID = 24)

Further info:

  1. I am using Oracle 11g. Maybe this Oracle article is relevant to my question?
  2. I cannot change the column's data type.
  3. constant time means an index performing in O(1) time complexity. Like a hashmap.

Solution

  • Hash clusters can provide O(1) access time, but not O(1) constraint enforcement time. However, in practice the constant access time of a hash cluster is worse than the O(log N) access time of a regular b-tree index. Also, clusters are more difficult to configure and do not scale well for some operations.

    Create Hash Cluster

    drop table orders_cluster;
    drop cluster cluster1;
    
    create cluster cluster1
    (
        MerchantID number,
        TransactionID varchar2(20)
    )
    single table hashkeys 10000; --This number is important, choose wisely!
    
    create table orders_cluster
    (
        id number,
        MerchantID number,
        TransactionID varchar2(20)
    ) cluster cluster1(merchantid, transactionid);
    
    --Add 1 million rows.  20 seconds.
    begin
        for i in 1 .. 10 loop
            insert into orders_cluster
            select rownum + i * 100000, mod(level, 100)+ i * 100000, level
            from dual connect by level <= 100000;
            commit;
        end loop;
    end;
    /
    
    create unique index orders_cluster_idx on orders_cluster(merchantid, transactionid);
    
    begin
        dbms_stats.gather_table_stats(user, 'ORDERS_CLUSTER');
    end;
    /
    

    Create Regular Table (For Comparison)

    drop table orders_table;
    
    create table orders_table
    (
        id number,
        MerchantID number,
        TransactionID varchar2(20)
    ) nologging;
    
    --Add 1 million rows.  2 seconds.
    begin
        for i in 1 .. 10 loop
            insert into orders_table
            select rownum + i * 100000, mod(level, 100)+ i * 100000, level
            from dual connect by level <= 100000;
            commit;
        end loop;
    end;
    /
    
    create unique index orders_table_idx on orders_table(merchantid, transactionid);
    
    begin
        dbms_stats.gather_table_stats(user, 'ORDERS_TABLE');
    end;
    /
    

    Trace Example

    SQL*Plus Autotrace is a quick way to find the explain plan and track I/O activity per statement. The number of I/O requests is labeled as "consistent gets" and is a decent way of measuring the amount of work done. This code demonstrates how the numbers were generated for other sections. The queries often need to be run more than once to warm things up.

    SQL> set autotrace on;
    SQL> select * from orders_cluster where merchantid = 100001 and transactionid = '2';
    
    no rows selected
    
    
    Execution Plan
    ----------------------------------------------------------
    Plan hash value: 621801084
    
    ------------------------------------------------------------------------------------
    | Id  | Operation         | Name           | Rows  | Bytes | Cost (%CPU)| Time     |
    ------------------------------------------------------------------------------------
    |   0 | SELECT STATEMENT  |                |     1 |    16 |     1   (0)| 00:00:01 |
    |*  1 |  TABLE ACCESS HASH| ORDERS_CLUSTER |     1 |    16 |     1   (0)| 00:00:01 |
    ------------------------------------------------------------------------------------
    
    Predicate Information (identified by operation id):
    ---------------------------------------------------
    
       1 - access("MERCHANTID"=100001 AND "TRANSACTIONID"='2')
    
    
    Statistics
    ----------------------------------------------------------
              0  recursive calls
              0  db block gets
             31  consistent gets
              0  physical reads
              0  redo size
            485  bytes sent via SQL*Net to client
            540  bytes received via SQL*Net from client
              1  SQL*Net roundtrips to/from client
              0  sorts (memory)
              0  sorts (disk)
              0  rows processed
    
    SQL>
    

    Find Optimal Hashkeys, Trade-Offs

    For optimal read performance all the hash collisions should fit in one block (all Oracle I/O is done per block, usually 8K). Getting the ideal storage right is tricky and requires knowing the hash algorithm, storage size (not the same as the block size), and number of hash keys (the buckets). Oracle has a default algorithm and size so it is possible to focus on only one attribute, the number of hash keys.

    More hash keys leads to fewer collisions. This is good for TABLE ACCESS HASH performance as there is only one block to read. Below are the number of consistent gets for different hashkey sizes. For comparison an index access is also included. With enough hashkeys the number of blocks decreases to the optimal number, 1.

    Method          Consistent Gets (for transactionid = 1, 20, 300, 4000, and 50000)
    Index           4,  3,  3,  3,  3
    Hashkeys 100    1, 31, 31, 31, 31
    Hashkeys 1000   1,  3,  4,  4,  4
    Hashkeys 10000  1,  1,  1,  1,  1
    

    More hash keys also lead to more buckets, more wasted space, and a slower TABLE ACCESS FULL operation.

    Table type      Space in MB
    HeapTable       24MB
    Hashkeys 100    26MB
    hashkeys 1000   30MB
    hashkeys 10000  81MB
    

    To reproduce my results, use a sample query like select * from orders_cluster where merchantid = 100001 and transactionid = '1'; and change the last value to 1, 20, 300, 4000, and 50000.

    Performance Comparison

    Consistent gets are predictable and easy to measure, but at the end of the day only the wall clock time matters. Surprisingly, the index access with 4 times more consistent gets is still faster than the optimal hash cluster scenario.

    --3.5 seconds for b-tree access.
    declare
        v_count number;
    begin
        for i in 1 .. 100000 loop
            select count(*)
            into v_count
            from orders_table
            where merchantid = 100000 and transactionid = '1';
        end loop;
    end;
    /
    
    --3.8 seconds for hash cluster access.
    declare
        v_count number;
    begin
        for i in 1 .. 100000 loop
            select count(*)
            into v_count
            from orders_cluster
            where merchantid = 100000 and transactionid = '1';
        end loop;
    end;
    /
    

    I also tried the test with variable predicates but the results were similar.

    Does it Scale?

    No, hash clusters do not scale. Despite the O(1) time complexity of TABLE ACCESS HASH, and the O(log n) time complexity of INDEX UNIQUE SCAN, hash clusters never seem to outperform b-tree indexes.

    I tried the above sample code with 10 million rows. The hash cluster was painfully slow to load, and still under-performed the index on SELECT performance. I tried to scale it up to 100 million rows but the insert was going to take 11 days.

    The good news is that b*trees scale well. Adding 100 million rows to the above example only require 3 levels in the index. I looked at all DBA_INDEXES for a large database environment (hundreds of databases and a petabyte of data) - the worst index had only 7 levels. And that was a pathological index on VARCHAR2(4000) columns. In most cases your b-tree indexes will stay shallow regardless of the table size.

    In this case, O(log n) beats O(1).

    But WHY?

    Poor hash cluster performance is perhaps a victim of Oracle's attempt to simplify things and hide the kind of details necessary to make a hash cluster work well. Clusters are difficult to setup and use properly and would rarely provide a significant benefit anyway. Oracle has not put a lot of effort into them in the past few decades.

    The commenters are correct that a simple b-tree index is best. But it's not obvious why that should be true and it's good to think about the algorithms used in the database.