Slow Concurrent INSERTs in Oracle: Reverse Key Index vs Scalable Sequence, Measured on 19c

Tested on: Oracle Database 19c Enterprise Edition 19.27, 16 CPUs | Level: Intermediate to Advanced

Eight sessions insert rows into a table whose primary key comes from a sequence. Nothing is wrong with the SQL, yet the load is twice as slow as it should be. The cause is one hot index block that every session fights over. In this post I reproduce the problem on 19c, measure it, and compare the two standard fixes, a reverse key index and a scalable sequence, including the trade-offs most articles skip. Every number below comes from a real lab run.

Results at a glance



Metric (8 sessions × 50,000 rows)Normal B-treeReverse keyScalable sequence
Elapsed time9.2 s4.0 s4.0 s
Buffer busy waits on the PK index261,9009,2322,358
enq: TX - index contention waits9,29215035
Index leaf blocks after load1,5151,0242,677
Index range scan on ID possible?YesNoYes, but IDs are not in time order

Both fixes made the load 2.3 times faster and removed 96 to 99 percent of the index waits. They are not interchangeable, though. The reverse key index gave up range scans, and the scalable sequence produced the largest index and 34-digit keys.

The problem: a right-hand hot block

A sequence produces ascending numbers, so every new key belongs at the far right edge of the B-tree index. With one session that is efficient. With many sessions inserting at the same time, they all need to change the same rightmost leaf block. Only one session can modify a block at a time, so the others queue:

  • buffer busy waits: a session waits because another session is changing the block it needs.
  • enq: TX - index contention: when that leaf block is full, one session splits it, and the others wait for the split to finish.

Adding CPUs does not help, because the work is serialised on one block. The fix is to stop all sessions from inserting into the same place.

How to spot it in production

Find the segments with the most buffer busy waits since instance startup. This uses V$SEGMENT_STATISTICS, which needs no extra license:

SELECT owner, object_name, object_type, value AS buffer_busy_waits
FROM   v$segment_statistics
WHERE  statistic_name = 'buffer busy waits'
ORDER  BY value DESC
FETCH  FIRST 10 ROWS ONLY;

A primary key index on a sequence column at the top of this list, together with enq: TX - index contention in your top wait events, is the classic signature.

If you are licensed for the Diagnostics Pack, Active Session History shows the object for recent waits:

SELECT o.owner, o.object_name, a.event, COUNT(*) AS samples
FROM   v$active_session_history a
JOIN   dba_objects o ON o.object_id = a.current_obj#
WHERE  a.sample_time > SYSTIMESTAMP - INTERVAL '1' HOUR
AND    a.event IN ('buffer busy waits', 'enq: TX - index contention')
GROUP  BY o.owner, o.object_name, a.event
ORDER  BY samples DESC
FETCH  FIRST 10 ROWS ONLY;
License note: V$ACTIVE_SESSION_HISTORY, AWR and ASH reports require the Oracle Diagnostics Pack. Use the V$SEGMENT_STATISTICS query if you are not licensed for it.

The lab

Three identical tables, each with a numeric primary key filled from a cached sequence. Only the key design changes:

-- A: normal B-tree index (the problem)
CREATE TABLE t_a (
  id         NUMBER        NOT NULL,
  created_at TIMESTAMP     DEFAULT SYSTIMESTAMP NOT NULL,
  payload    VARCHAR2(100)
);
CREATE UNIQUE INDEX t_a_pk ON t_a (id);
ALTER TABLE t_a ADD CONSTRAINT t_a_pk PRIMARY KEY (id) USING INDEX t_a_pk;
CREATE SEQUENCE seq_a CACHE 1000;

-- B: reverse key index
CREATE UNIQUE INDEX t_b_pk ON t_b (id) REVERSE;
CREATE SEQUENCE seq_b CACHE 1000;

-- C: normal index, scalable sequence (Oracle 18c and later)
CREATE UNIQUE INDEX t_c_pk ON t_c (id);
CREATE SEQUENCE seq_c CACHE 1000 SCALE EXTEND;

Each worker session inserts 50,000 rows and commits every 100 rows:

CREATE OR REPLACE PROCEDURE load_rows (p_label VARCHAR2, p_rows PLS_INTEGER) AS
BEGIN
  FOR i IN 1 .. p_rows LOOP
    IF p_label = 'A' THEN
      INSERT INTO t_a (id, payload) VALUES (seq_a.NEXTVAL, RPAD('x', 100, 'x'));
    ELSIF p_label = 'B' THEN
      INSERT INTO t_b (id, payload) VALUES (seq_b.NEXTVAL, RPAD('x', 100, 'x'));
    ELSE
      INSERT INTO t_c (id, payload) VALUES (seq_c.NEXTVAL, RPAD('x', 100, 'x'));
    END IF;
    IF MOD(i, 100) = 0 THEN
      COMMIT;
    END IF;
  END LOOP;
  COMMIT;
END;
/

For each design, a driver procedure takes a snapshot of V$SYSTEM_EVENT and V$SYSSTAT, starts 8 sessions at once with DBMS_SCHEDULER, waits for all of them to finish, and takes a second snapshot. The difference is the cost of that design alone:

FOR j IN 1 .. 8 LOOP
  DBMS_SCHEDULER.CREATE_JOB(
    job_name   => 'HOTJOB' || p_label || j,
    job_type   => 'PLSQL_BLOCK',
    job_action => 'BEGIN load_rows(''' || p_label || ''', 50000); END;',
    enabled    => TRUE,
    auto_drop  => TRUE
  );
END LOOP;

All three runs completed with no failed sessions and loaded exactly 400,000 rows each.

Result 1: the index is the bottleneck

System-wide waits during each run (deltas from V$SYSTEM_EVENT and V$SYSSTAT):

METRIC                                       NORMAL_BTREE    REVERSE_KEY   SCALABLE_SEQ
------------------------------------------ -------------- -------------- --------------
buffer busy waits - seconds                          13.8            1.2            0.9
buffer busy waits - waits                       286,528.0       50,073.0       39,980.0
elapsed seconds                                       9.2            4.0            4.0
enq: TX - index contention - seconds                  4.3            0.8            0.0
enq: TX - index contention - waits                9,292.0          150.0           35.0
latch: cache buffers chains - seconds                 0.0            0.0            0.0
latch: cache buffers chains - waits                 645.0          737.0          396.0
leaf node 90-10 splits                              567.0            0.0          204.0
leaf node splits                                  1,514.0        1,023.0        2,676.0

With the normal index, sessions spent 13.8 seconds in buffer busy waits and 4.3 seconds in index contention, during a load that took only 9.2 seconds of wall-clock time. Most of each session's time was spent waiting, not inserting.

V$SEGMENT_STATISTICS shows exactly which segment suffered:

OBJECT_NAM BUFFER_BUSY_WAITS  ITL_WAITS
---------- ----------------- ----------
T_A                    33355          0
T_A_PK                261900          0
T_B                    51165          0
T_B_PK                  9232         35
T_C                    47174          0
T_C_PK                  2358         10

In the normal design, 89 percent of buffer busy waits were on the index T_A_PK, not the table. The table sits in a tablespace with automatic segment space management (ASSM, the default for new tablespaces), which already spreads concurrent inserts across different blocks. The index cannot do that, because the key value decides where each entry goes.

Result 2: both fixes work, and the bottleneck moves

Index buffer busy waits fell from 261,900 to 9,232 with the reverse key index (96 percent less) and to 2,358 with the scalable sequence (99 percent less). Index contention waits fell from 9,292 to 150 and 35.

Look at the table rows, though: buffer busy waits on the tables went up, from 33,355 to 51,165 and 47,174. With the index fixed, sessions insert faster and now meet each other on table blocks instead. That is the normal pattern in performance work: removing one bottleneck exposes the next. At this scale it is harmless, but on a much busier system the table is where you would look next.

How a scalable sequence spreads the inserts

A scalable sequence adds a 6-digit prefix to each value: 3 digits for the instance and 3 digits for the session. Each session therefore inserts into its own region of the index instead of all sessions sharing the right edge.

ID_TEXT
----------------------------------------
1013720000000000000000000000000551
1013720000000000000000000000000552
1013720000000000000000000000000553

PREFIX    ROW_COUNT
-------- ----------
101187        50000
101251        50000
101312        50000
101372        50000
101428        50000
101666        50000
101726        50000
101841        50000

Eight sessions produced exactly eight prefixes with 50,000 rows each. In 101187, 101 is the instance part and 187 comes from the session ID. On RAC, the instance part also stops different instances from fighting over the same block, which is what scalable sequences were designed for.

The trade-offs

Reverse key index: no range scans

A reverse key index stores the bytes of each key in reverse order, so consecutive values land in different leaf blocks. The price is that the index no longer keeps keys in order, so the optimizer cannot use it for a range predicate:

-- Normal index: SELECT * FROM t_a WHERE id BETWEEN 1000 AND 1100
| Id  | Operation                           | Name   |
|   0 | SELECT STATEMENT                    |        |
|   1 |  TABLE ACCESS BY INDEX ROWID BATCHED| T_A    |
|*  2 |   INDEX RANGE SCAN                  | T_A_PK |
   2 - access("ID">=1000 AND "ID"<=1100)

-- Reverse key index: SELECT * FROM t_b WHERE id BETWEEN 1000 AND 1100
| Id  | Operation         | Name |
|   0 | SELECT STATEMENT  |      |
|*  1 |  TABLE ACCESS FULL| T_B  |
   1 - filter("ID"<=1100 AND "ID">=1000)

Equality lookups (WHERE id = :x) still use the index. Range queries on the key, such as batch jobs that process ID ranges or pagination by ID, become full table scans. Also, inserts now land all over the index, so the whole index needs to stay in the buffer cache. On a very large index that does not fit, you trade contention for physical reads.

Scalable sequence: bigger keys, bigger index

INDEX_NAME     BLEVEL LEAF_BLOCKS ROWS_PER_LEAF
---------- ---------- ----------- -------------
T_A_PK              2        1515           264
T_B_PK              2        1024           391
T_C_PK              2        2677           149

The scalable sequence index was the largest: 1.8 times the normal index, with only 149 entries per leaf block. The keys are 34 digits long, so each index entry is much wider. Check three things before using it:

  • Application data types. A 34-digit number does not fit in a 64-bit integer (Java long, BIGINT, NUMBER(18)). Every application, ORM, ETL tool and downstream system that reads the key must handle it. SCALE NOEXTEND with a smaller MAXVALUE keeps values shorter, but leaves fewer digits for the counter.
  • No time ordering. A higher ID no longer means a newer row. Never use the key for "latest rows" logic.
  • Index size. Expect a larger index and plan storage and buffer cache for it.

A surprise: the normal index was not compact either

The textbook says an ascending key index fills its blocks almost completely, because full right-hand blocks split 90-10. Under concurrency that did not happen: only 567 of 1,514 leaf splits were 90-10 splits. With 8 sessions, sequence values reach the index slightly out of order, so many splits became 50-50 splits, and those half-empty blocks are never filled again. The result was 264 entries per leaf, against 391 for the reverse key index. Under concurrent load, "ascending keys give a compact index" does not hold.

Which fix should you use?

SituationRecommendation
Key is only used for equality lookups (id = :x) and joinsReverse key index. Smallest index in this test and no application change.
You need range scans on the key, or you run RACScalable sequence, if every consumer can handle 34-digit values.
Application uses 64-bit integer keys and you cannot change itReverse key index, or a hash-partitioned global index (needs the Partitioning option).
Low concurrency, only a few inserting sessionsKeep the normal index. Measure first, because the fix has costs.
Also check the sequence itself. This lab used CACHE 1000. A sequence with NOCACHE or a small cache adds a second bottleneck, enq: SQ - contention, which none of the index fixes solve. On RAC, also avoid ORDER unless you truly need it.

Caveats

  • One run on a 16-CPU lab database in NOARCHIVELOG mode with 8 sessions. Your absolute numbers will differ. The direction of the results is what matters.
  • More sessions or more CPUs usually make the normal index worse and the gap bigger.
  • Changing a production primary key index needs an outage or online rebuild plan, and testing against your real query mix, not only the insert load.

Clean-up

The whole lab lives in one schema. To remove it, as SYSDBA:

DROP USER hot_demo CASCADE;

Summary

A sequence-based primary key under concurrent inserts turns the rightmost index leaf block into a queue. On 19c with 8 sessions, that single block caused 261,900 buffer busy waits and doubled the load time. A reverse key index or a scalable sequence removed 96 to 99 percent of those waits and made the load 2.3 times faster. Choose by how the key is used: reverse key if you never range-scan it, scalable sequence if you do and every consumer can handle 34-digit values. In both cases, measure first, because each fix has a cost.

Tamilselvan Perumal is an Oracle ACE Apprentice and Senior DBA with 12+ years in banking databases, working on database performance and benchmarking. Connect on LinkedIn.