Dlaczego indeks w PL/SQL nie jest używany?

Czy kiedykolwiek zdarzyło Ci się trafić na problem, który wywołał u Ciebie uczucie jakby nocnego koszmaru, z którego nie możesz się obudzić? Ostatnio przejąłem stary kod PL/SQL, który ładował dość dużą ilość danych z jednej tabeli do drugiej z niewielką ilością transformacji. To był prosty przypadek: weź podzbiór danych z jednej tabeli i wrzuć do innej. Operacja ta była wykonywana w pętli, która iterowała po kilku tysiącach wierszy w zdefiniowanym kursorze:

DECLARE
  CURSOR c_cur IS
    SELECT id FROM table_t;

  r_cur c_cur%ROWTYPE;

BEGIN
  FOR r_cur IN c_cur LOOP

    INSERT INTO destination_t
    SELECT some_fields
      FROM source_t s
     WHERE s.id = r_cur.id;

  END LOOP;
END;

Tabela SOURCE_T zawierała ok. 20 milionów wierszy ale na kolumnie ID utworzony był indeks, więc całość powinna wykonać się w przyzwoitym czasie. Zgodnie z otrzymaną instrukcją uruchomiłem procedurę. Wielkim zaskoczeniem był fakt, że na drugi dzień, po 30 godzinach od startu, wykonane było dopiero ok. 40% całej roboty! Coś tam zdecydowanie było nie tak. Sprawdziłem plan wykonania dla działającego zapytania i okazało się, że w każdej iteracji wykonywany jest skan po pełnej tabeli! Uruchomiłem zbieranie statystyk dla tabeli i indeksu, ale niczego to nie zmieniło. Co więcej, okazało się, że to samo zapytanie wycięte z PL/SQLa na zewnątrz dawało plan wykonania, w którym indeks był używany! Dlaczego więc w PL/SQLu indeks leżał odłogiem?!

Zrobiłem nieco refaktoryzacji, zoptymalizowałem procedurę (tabela SOURCE_T skanowana była 2 razy przy każdej iteracji, raz jak pokazałem wyżej, drugi raz dla SELECT DISTINCT w innym zapytaniu). Zaczęło działać to szybciej, ale przewidywany czas zakończenia nie był satysfakcjonujący… Co gorsza, full table scan wciąż tam był… Naprawdę zacząłem dostawać szału… W końcu wezwałem na pomoc Ducha Świętego i wreszcie znalazłem rozwiązanie!

Powodem problemu była różnica typów w klauzuli WHERE. Okazało się, że r_cur.id to liczba a SOURCE_T.id było tekstem! Index był więc utworzony dla tekstów! Niestety, optymalizator oraclowy sobie z tym nie poradził. Mała zmiana:

WHERE s.id = TO_CHAR(r_cur.id)

załatwiła sprawę.

Dlaczego jednak plan wykonania był inny poza PL/SQLem? Narzędzie do jego analizy traktowało r_cur.id jako stałą i zakładało zgodność typów z SOURCE_T.id. Jednak w boju optymalizator nie potrafił powtórzyć tego założenia.

Nie pamiętam bym kiedykolwiek tak się cieszył z uzyskania poniższego planu wykonania:

INSERT STATEMENT 	
+ TABLE ACCESS BY INDEX ROWID	SOURCE_T
++  INDEX RANGE SCAN	IND_SRC_ID

Więc lekcja na dzisiaj:


ZAWSZE UŻYWAJ BEZPOŚREDNIEGO RZUTOWANIA TYPÓW W SQL!


To powyższe prawdziwe jest także w innych przypadkach. Kiedyś jeszcze o tym napiszę. Kończąc, zamieszczam kilka wycinków kodu, które przydadzą się do wyciągania informacji o uruchomionym zapytaniu SQL.

Wyciąganie informacji o aktualnie uruchomionym w sesji zapytaniu:

SELECT SQ.SQL_TEXT, S.*
  FROM V$SESSION S, V$SQL SQ
 WHERE S.SQL_ID = SQ.SQL_ID
   AND LOWER(S.USERNAME) = 'username'
   AND s.SID= <session ID>;

Wyciąganie informacji o długo trwających operacjach (np. skanowanie tabeli):

SELECT *
  FROM V$SESSION_LONGOPS L
 WHERE L.SID = <sid>
   AND SOFAR <> TOTALWORK -- daje tylko aktualnie trwające operacje
 ORDER BY LAST_UPDATE_TIME DESC;

Plan wykonania dla aktualnie działającego zapytania:

SELECT ID,
       PARENT_ID,
       LPAD(' ', LEVEL - 1) || OPERATION || ' ' || OPTIONS OPERATION,
       OBJECT_NAME
  FROM (SELECT ID, PARENT_ID, OPERATION, OPTIONS, OBJECT_NAME
          FROM V$SQL_PLAN SP, V$SESSION A, V$SQL B
         WHERE SP.ADDRESS = B.ADDRESS
           AND SP.HASH_VALUE = B.HASH_VALUE
           AND SP.CHILD_NUMBER = B.CHILD_NUMBER
           AND A.SQL_ADDRESS = B.ADDRESS
           AND a.SID= <sid>
           )
 START WITH ID = 0
CONNECT BY PRIOR ID = PARENT_ID;

Lista ostatnio uruchamianych zapytań:

SELECT sq.sql_id,
       sq.sql_fulltext,
       sq.sql_text,
       se.username,
       sq.last_active_time
  FROM v$sql sq, v$session se, v$open_cursor oc
 WHERE sq.sql_id = oc.sql_id
   AND se.saddr = oc.saddr
   AND se.sid = oc.sid
   AND LOWER(se.username) = 'username'
   AND se.sid = <sid>
 ORDER BY sq.last_active_time DESC;