Samstag, April 30, 2016

Materialized View Fast Refresh und Data Clustering

Zur Abwechslung kann ich hier mal wieder eine selbst erlebte Geschichte erzählen: am letzten Wochenende habe ich ältere historischen Daten aus den Aggregationstabellen eines Data Warehouses (11.2) entfernt, was den Neuaufbau diverser Materialized Views erforderlich machte. Diese Materialized Views sind auf verschiedenen Ebenen der Zeitachse definiert: für Wochen, Monate und Jahre, wobei die beiden ersten Gruppen range partitioniert sind, während für die Jahres-Ebene keine Partitionierung eingerichtet wurde: vermutlich, weil in der Regel nur wenige Jahre in den Aggregationen vorgehalten werden sollen. Meine Annahme war, dass sich die Löschungen moderat positiv auf die Performance folgender Fast Refresh Operationen auswirken sollten, da das Datenvolumen insgesamt merklich reduziert wurde. Tatsächlich liefen die Fast Refresh Operationen aber nach dem Neuaufbau deutlich langsamer als zuvor - obwohl die Ausführungspläne strukturell unverändert blieben. Dazu zunächst der Ausführungsplan und ein paar Zahlen:

Plan hash value: 956726641
----------------------------------------------------------------------------------------------------------------
| Id  | Operation                         | Name                       | Rows  | Bytes | Cost (%CPU)| Time     |
----------------------------------------------------------------------------------------------------------------
|   0 | MERGE STATEMENT                   |                            |       |       |   648 (100)|          |
|   1 |  MERGE                            | MV_YEARLY_XXX              |       |       |            |          |
|   2 |   VIEW                            |                            |       |       |            |          |
|   3 |    NESTED LOOPS OUTER             |                            |   119 | 17731 |   648   (1)| 00:00:08 |
|   4 |     VIEW                          |                            |   119 | 12376 |   291   (1)| 00:00:04 |
|   5 |      SORT GROUP BY                |                            |   119 | 11067 |   291   (1)| 00:00:04 |
|   6 |       TABLE ACCESS FULL           | MLOG$_MV_MONTHLY_XXX       |   119 | 11067 |   290   (0)| 00:00:04 |
|   7 |     MAT_VIEW ACCESS BY INDEX ROWID| MV_YEARLY_XXX              |     1 |    45 |     3   (0)| 00:00:01 |
|   8 |      INDEX UNIQUE SCAN            | I_SNAP$_MV_YEARLY_XXX      |     1 |       |     2   (0)| 00:00:01 |
----------------------------------------------------------------------------------------------------------------

with
statistics as (
select /* exclude */
       st.sql_id
     , st.parsing_schema_name
     , st.module
     , trunc(sn.begin_interval_time) snap_date
     , count(*) no_snapshots
     , round(sum(st.elapsed_time_delta/1000000)) ela_sec_total
     , sum(st.executions_delta) execs_total
     , sum(st.rows_processed_delta) rows_processed_total
     , sum(st.buffer_gets_delta) buffer_gets
  from dba_hist_sqlstat st
  join dba_hist_snapshot sn
    on st.snap_id = sn.snap_id and st.instance_number = sn.instance_number
 where sn.begin_interval_time >= trunc(sysdate) - 14
 group by st.parsing_schema_name
        , st.module
        , st.sql_id
        , trunc(sn.begin_interval_time)
)
,
basedata as (
select /*+ materialize */ st.sql_id
  from dba_hist_sqltext st
 where upper(st.SQL_TEXT) like '%&sql_pattern%'
   and st.sql_text not like '%/* exclude */%'
)
select st.snap_date
     , max(st.sql_id) keep (dense_rank last order by snap_date) last_sql_id
     , sum(ela_sec_total) ela_sec_total
     , sum(execs_total) execs_total
     , sum(rows_processed_total) rows_processed_total
     , round(sum(rows_processed_total)/case when sum(execs_total) > 0 then sum(execs_total) else null end, 0) rows_per_exec
     , round(sum(buffer_gets)/case when sum(execs_total) > 0 then sum(execs_total) else null end, 0) buffer_gets_per_exec
     , round(sum(ela_sec_total)/case when sum(execs_total) > 0 then sum(execs_total) else null end, 2) ela_sec_per_exec
  from basedata bd
  join statistics st
    on bd.sql_id = st.sql_id
 group by st.snap_date
 order by st.snap_date;

SNAP_DATE           LAST_SQL_ID   LAST_PLAN_HASH_VALUE ELA_SEC_TOTAL EXECS_TOTAL ROWS_PROCESSED_TOTAL ROWS_PER_EXEC BUFFER_GETS_PER_EXEC ELA_SEC_PER_EXEC
------------------- ------------- -------------------- ------------- ----------- -------------------- ------------- -------------------- ----------------
16.04.2016 00:00:00 3gqhk0g658b7c            956726641          1075         153              1052262          6878                32921             7.03
18.04.2016 00:00:00 3gqhk0g658b7c            956726641           176          26               251987          9692                45211             6.77
19.04.2016 00:00:00 03zqa2r6v9hxp            956726641          1268         294              2355310          8011                36819             4.31
20.04.2016 00:00:00 03zqa2r6v9hxp            956726641           848          83               715340          8619                42348            10.22
21.04.2016 00:00:00 03zqa2r6v9hxp            956726641           864         133               771265          5799                28422             6.50
22.04.2016 00:00:00 03zqa2r6v9hxp            956726641           700          84               665256          7920                38072             8.33
23.04.2016 00:00:00 avavqbxkq8qpr            956726641         10026         171              1361158          7960                32848            58.63
24.04.2016 00:00:00 avavqbxkq8qpr            956726641          1030          31               214286          6912                29321            33.23
26.04.2016 00:00:00 avavqbxkq8qpr            956726641          9473         321              2763663          8610                35883            29.51
27.04.2016 00:00:00 avavqbxkq8qpr            956726641          3402         183              1153780          6305                27824            18.59

Hier sind mehrere Punkte, die einer Erklärung bedürfen:
  • der Execution Plan ist meiner Einschätzung nach plausibel: bei durchschnittlich weniger als 10K geänderten Datensätzen in einer Tabelle mit 160M rows ist der NL Join mit dem Zugriff über den eindeutigen I_SNAP$-Index eine sinnvolle Wahl.
  • die unterschiedlichen sql_id-s für die haben damit zu tun, dass die intern im Rahmen des Fast Refresh für Materialized Views verwendeten Statements einen OPT_ESTIMATE-Hint enthalten, der von den aktuellen Statistiken der Materialized View abhängt, was ich gelegentlich hier vermerkt hatte.
  • der Neuaufbau der MViews erfolgte am 23.04. und erhöhte die durchschnittlichen Laufzeiten von unter 10 sec. auf Werte von 20-30 sec., was angesichts der Häufigkeit der Refresh-Operationen recht massive Auswirkungen hatte - insbesondere, weil hier mehrere ähnliche MViews betroffen waren.
  • die dabei zu erledigende Arbeit ist weitgehend unverändert, jedenfalls, wenn man nach den Buffer Gets und der Anzahl der Datensätze geht.
Also ein Fall von "die Query läuft langsamer, obwohl sich nichts verändert hat"? Nun, das nicht gerade: immerhin wurden die Objekte komplett neu aufgebaut, was auf jeden Fall eine Änderung der physikalischen Struktur des Segments mit sich brachte. Leider hatte ich mir die Daten vor dem Neuaufbau nicht genau angesehen (sondern nur die DDL dazu) und konnte daher nichts über die ursprüngliche Organisation der Daten sagen. Nach dem Neuaufbau war jedenfalls zu erkennen, dass die Sortierung nach dem Jahr erfolgt war, die weitere Anordnung aber nicht der Reihenfolge der Spalten des I_SNAP$-Index entsprach (so dass dieser auch einen sehr schlechten Clustering Factor besaß). Für die Refresh Operationen wiederum nahm ich an, dass sie auf jeden Fall nach der zweiten Spalte des Index gruppiert sein müssten: nämlich einer Filialnummer. Daraus ergab sich dann die Überlegung, die Materialized Views der Jahres-Ebene nochmals neu aufzubauen: diesmal aber mit einem ORDER BY in der Definition. Dieses ORDER BY spielt nur eine Rolle bei der initialen Anlage der MView und sorgt für ein entsprechendes physikalisches Clustering in den Datenblöcken, das im Lauf der Zeit durch folgende DML-Operationen allmählich nachlassen wird (und hat natürlich auch keinen verlässlichen Einfluss auf die Sortierung von Abfrageergebnissen). Die Idee dabei war, dass sich auf diese Weise die Zahl der physical reads reduzieren lassen könnte, weil die Wahrscheinlichkeit steigt, dass mehrfach bearbeitete Blöcke noch im Buffer Cache vorliegen, wenn sie benötigt werden.

Nach dem Neuaufbau ergaben sich dann folgende Werte (gegenüber der innitialen Analysequery ergänzt um die phsical reads:

SNAP_DATE  LAST_SQL_ID   HASH_VALUE ELA_SEC_TOTAL EXECS_TOTAL ROWS_PROCESSED_TOTAL ROWS_PER_EXEC BUFFER_GETS_PER_EXEC DISK_READS_PER_EXEC ELA_SEC_PER_EXEC
---------- ------------- ---------- ------------- ----------- -------------------- ------------- -------------------- ------------------- ----------------
16.04.2016 3gqhk0g658b7c  956726641          1075         153              1052262          6878                32921                1286             7.03
18.04.2016 3gqhk0g658b7c  956726641           176          26               251987          9692                45211                1132             6.77
19.04.2016 03zqa2r6v9hxp  956726641          1268         294              2355310          8011                36819                 715             4.31
20.04.2016 03zqa2r6v9hxp  956726641           848          83               715340          8619                42348                1887            10.22
21.04.2016 03zqa2r6v9hxp  956726641           864         133               771265          5799                28422                1171             6.50
22.04.2016 03zqa2r6v9hxp  956726641           700          84               665256          7920                38072                1578             8.33
23.04.2016 avavqbxkq8qpr  956726641         10026         171              1361158          7960                32848                6048            58.63
24.04.2016 avavqbxkq8qpr  956726641          1030          31               214286          6912                29321                5662            33.23
26.04.2016 avavqbxkq8qpr  956726641          9473         321              2763663          8610                35883                3783            29.51
27.04.2016 avavqbxkq8qpr  956726641          3402         183              1153780          6305                27824                3948            18.59
28.04.2016 f1za0dqt3r803  956726641           134          50               433547          8671                37853                1606             2.68
29.04.2016 f1za0dqt3r803  956726641           433         204              1395873          6843                30404                1009             2.12
30.04.2016 f1za0dqt3r803  956726641            99          28               299080         10681                48064                1850             3.54

Sichtbar ist hier vor allem Folgendes:
  • der Plan ist immer noch der gleiche und auch die Werte für die Buffer Gets und verarbeiteten Datensätze bleiben im gleichen Bereich.
  • die durchschnittliche Anzahl der disk reads sinkt wieder von 4-6K auf unter 2K.
  • die durchschnittlichen Laufzeiten liegen jetzt zwischen 2-4 sec: also sogar deutlich niedriger als vor dem ersten Neuaufbau der Mviews
Das ist zunächst sehr zufriedenstellend. Aber was kann man daraus lernen? Vielleicht Folgendes: Das physikalische Clustering von Daten kann - insbesondere in Data Warehouse Umgebungen - einen ziemlich entscheidenden Einfluss auf die Performance von Abfragen und DML Operationen haben. Manchmal lohnt es sich hier durchaus, über eine physikalische Reorganisation nachzudenken, um die Anzahl kostspieliger physikalischer Zugriffe zu reduzieren. Dabei stellt sich dann natürlich die Frage nach der Sortierung für dieses Clustering, die von den jeweiligen Zugriffen abhängt. Außerdem ist zu überlegen, ob sich die Mühe lohnt, da die Sortierung sich im Lauf der Zeit wieder ändern wird, sofern man nicht mit read-omly Daten zu tun hat.

Freitag, April 22, 2016

Extended Statistics und CHAR Spalten

Vor Problemen bei der Verwendung von Extended Statistics - also Statistiken für mehrere (in der Regel korrelierte) Spalten, die intern über eine virtuelle Spalte abgebildet werden - warnt Jonathan Lewis in seinem jüngsten Sratchpad-Artikel. Der Hintergrund des Problems ist folgender: für eine einzelne CHAR-Spalte (in diesem Fall CHAR(2)) mit Histogram ist der Optimizer klug genug zu erkennen, das Blanks, die am Ende eines zum Vergleich herangezogenen Literals angehängt werden, keinen Einfluß auf die Selektivität des Prädikats haben: col = 'X' und col = 'X ' liefern demnach die gleiche Kardinalität im Ausführungsplan. Wiederholt man diesen Versuch mit Ergänzung einer zweiten Spalte im WHERE, wobei über beide Spalten der Bedingung Extended Statistics erzeugt wurden, dann liefert nur die Variante mit col = 'X ' das richtige Ergebnis. Jonathan Lewis vermutet, dass der Optimizer in diesem Fall "vergisst", dass ein CHAR-Wert im Spiel ist, wenn die Funktion sys_op_combined_hash() aufgerufen wird, mit der die Werte der virtuellen Spalte der Column Group erzeugt werden, so dass der HASH-Vergleich kein Ergebnis liefert. Laut Aussage des Oracle Supports (in den Kommentaren) handelt es sich um einen Bug. Da Column Groups in 12c unter entsprechenden Voraussetzungen automatisch erzeugt werden, sollte man sich dieses Problems bewusst sein. Allerdings mag ich CHAR ohnehin nicht besonders, so dass der Typ in meinen Tabellen nicht auftaucht.

Mittwoch, April 13, 2016

Mustererkennung mit MATCH_RECOGNIZE in 12c

Keith Laker hat im Data Warehouse Insider Blog eine Artikelserie begonnen, die sich mit der Verwendung des MATCH_RECOGNIZE Features in 12c beschäftigt. Ich will jetzt nicht behaupten, dass ich die Artikel umfassend wiedergeben würde (ja nicht einmal, dass ich sie komplett gelesen hätte), aber sie wären sicherlich ein sehr nützlicher Einstieg, sollte ich gelegentlich auf die Idee kommen, das Feature irgendwo einzusetzen. In erster Näherung greife ich hier aber nur die Zusammanfassungs-Abschnitte am jeweiligen Artikelende ab:
Sollten noch weitere Artikel folgen, würde ich sie ergänzen. Selten habe ich eine armseligere Kommentierung von Links erstellt...

    Montag, April 11, 2016

    Neue Transformation "Group-by and Aggregation Elimination" in 12.1.0.2

    Im Blog der Optimizer-Entwickler hat Nigel Bayliss eine Optimizer-Transformation vorgestellt, die in 12.1.0.2 eingeführt wurde und den Namen "Group-by and Aggregation Elimination" trägt. Was die Transformation leistet, ist Folgendes: wenn eine Query mit Gruppierung und aggregierenden Funktionen auf einer Inline-View basiert, die ihrerseits wieder eine Gruppierung und aggregierende Funktionen enthält, dann kann der Optimizer die beiden Gruppierungsschritte zusammenfassen. Das klingt so simpel, wie es in den Beispielen des Artikels auch aussieht: in den Plänen erscheint dann nur noch eine (HASH) GROUP BY Operation (an Stelle von zwei Operationen des Typs). Obwohl die Transformation sehr harmlos aussieht, ist die zugehörige Logik allerdings sehr komplex, was zum Auftreten von Bugs führte (die Mike Dietrich in seinem Blog gelegentlich erwähnte), die nzwischen aber durch einen Patch behoben sein sollten.

    Sonntag, April 10, 2016

    STRING_SPLIT im SQL Server 2016

    Vor einiger Zeit habe ich in der Sektion database ideas bei OTN folgenden Wunsch geäußert: a string splitting function like SPLIT_PART in postgres. SPLIT_PART erhält als Argumente einen String und einen Delimiter, zerlegt den String an den Positionen der Delimiter-Zeichen in Substrings und liefert den n-ten Teilstring:

    SELECT SPLIT_PART('A;B;C;D', ';', 2);
    split_part
    -----------
    B

    Das ist sicherlich keine höhere Magie und kann in SQL auf verschiedenen Wegen erreicht werden (etwa durch den Einsatz regulärer Ausdrücke), aber ich war in der Vergangenheit oft genug in Situationen, in denen mir eine solche built-in-Funktion geholfen hätte, dass ich ihre Ergänzung in Oracle für wünschenswert halte.  Für den Vorschlag gab es 21 positive und 4 negative Stimmen (was bei OTN schon eine recht rege Beteiligung ist) und ich vermute mal, dass die Resonanz vielleicht noch positiver ausgefallen wäre, wenn ich im Titel auf "postgres" verzichtet hätte.

    Heute habe ich dann im Blog von Ozar Unlimited einen Artikel von Erik Darling gelesen, der auf die Funktion STRING_SPLIT hinweist, die im SQL Server 2016 zur Verfügung steht und die folgendermaßen beschreiben wird: "splits input character expression by specified separator and outputs result as a table." Dort gibt's das jetzt also auch schon.

    Mittwoch, April 06, 2016

    Massendatenlöschungen

    Ich werde nachlässig: auf der Suche nach meiner Zusammenfassung der ersten beiden Teile von Jonathan Lewis AllThingsOracle-Serie zu Massendatenlöschungen ist mir aufgefallen, dass ich dazu keine Zusammenfassungen geschrieben habe. Aber das lässt sich ja ändern: 

    Massive Deletes – Part 1: beschäftigt sich zunächst mit strategischen und taktischen Fragen bei der Löschung von Massendaten. Auf der strategischen Ebene liegen Fragen nach der Zielsetzung der Löschung: was verspricht man sich davon und lohnt sich der Aufwand überhaupt und schafft die Löschung nicht vielleicht neue Probleme. Auf taktischer Ebene liegen Fragen der Verfügbarkeit von Ressourcen, der Möglichkeit einer Downtime zu Wartungszwecken etc. Dazu liefert der Autor zwei Szenarien in denen Partitioninierung - bzw. ihr Fehlen - und globale Indizes eine Rolle spielen. Ausgehend davon werden zwei übliche Gründe für Löschungen und drei Standard-Muster aufgeführt. Die Gründe sind:
    • Verbesserung der Perfromance
    • Reduzierung der Storage-Verwendung
    Bei den Pattern werden folgende Fälle aufgeführt:
    • Löschung von Daten auf Basis einer Eingangszeit
    • Löschung von Daten auf Basis des Endzeitpunkts der Bearbeitung
    • Löschung der Daten einer bestimmten Kategorie
    In Zusammenhang mit diesen Mustern steht die Frage, wie Oracle die Daten auf Blockebene speichert. Dabei führt die "Löschung von Daten auf Basis einer Eingangszeit" dazu, dass komplette Blöcke in den ersten Extents einer Tabelle frei werden, die dann wieder von (erneut gut geclusterten) neuen Daten belegt werden. Bei den beiden anderen Mustern kann sich die physikalische Clusterung der Daten deutlich verändern, da die "Löschung der Daten einer bestimmten Kategorie" vermutlich aus allen Blöcken etwa den gleichen Anteil entfernt, während bei der "Löschung von Daten auf Basis des Endzeitpunkts der Bearbeitung" eine gewisse Korrelation zur zeitlichen Entwicklung vorliegt,die aber weniger stark ist als bei den Eingangszeitpunkten. Diese physikalische Clusterung der Daten kann sich dann wieder massiv auf die Effektivität von Indizes auswirken (Stichwort: clustering factor). Außerdem spielt die Menge der Indizes natürlich eine große Rolle bei der Löschung selbst, da diese Indizes während der Löschung verwaltet werden müssen.

    Massive Deletes – Part 2: beginnt mit der Beobachtung, dass die Frage, wo massive Datenlöschungen beginnen, angesichts sehr unterschiedlicher Hardware schwer zu definieren ist. Im Beispiel wird eine Tabelle von 1,6G erzeugt, die 10M rows für 10 Jahre enthält (1M pro Jahr). Auf dieser Basis werden die drei im ersten Teil skizzierten Szenarien durchgespielt. Die Effekte der Löschung werden mit einer Query überprüft, die über die Anzahl der rows pro Block gruppiert. Für die "Löschung von Daten auf Basis einer Eingangszeit" ergeben sich auch nach der Löschung sehr dicht gepackte Blöcke. Für die Variante "Löschung von Daten auf Basis des Endzeitpunkts der Bearbeitung" bleiben relativ viele Blöcke mit einem deutlich niedrigeren Füllgrad stehen, von denen allerdings die überwiegende Mehrheit nicht für neue Inserts verwendet werden würden. Auch für die Indizes ergibt sich ein ähnliches Bild - allerdings ist die zugehörige Analyse-Query deutlich komplexer: jedenfalls wird der Füllgrad der Indizes der Indizes reduziert. Abhängig von der Verteilung der Einträge kann die Maintenance bei der Löschung sehr teuer werden, so dass es unter Umständen sinnvoller sein könnte, den Index vor der delete Operation als unusable zu markieren (oder zu löschen) und anschließend neu aufzubauen.

    Massive Deletes – Part 3: erläutert die Bereiche, in denen eine Löschung - wie jede andere DML-Operation - einen Overhead hervorruft, und da der Herr Lewis das sehr präzise zusammenfasst, zitiere ich hier extensiv:
    • redo: "every change to a data block generates redo – a description of the new information that has been written to the block plus an overhead of a few tens of bytes: and if you’ve got four indexes on the table then you may be changing 5 blocks for every row you delete."
    • undo: "Every time you delete a row you “preserve” the row by writing into the undo segment a description of how to re-insert that row, and how to “undelete” the index entries. Each undo entry has an overhead of a few tens of bytes and each undo entry IS a change to a data block so (redo second thoughts) generates even more redo. Combining the two sources of redo, a single row delete with four affected indexes could produce around 1KB of redo, plus the size of the row and index entries deleted."
    • I/O effects: hier kürze ich ab: Blöcke müssen von der Platte und in den Cache gelesen werden - und umgekehrt wieder geschrieben.
    • Concurrency: auch hier versuche ich's mit Verkürzung: aufgrund Oracles MVCC-Verfahrens muss mit zusätzlichen redo und undo Effekten gerechnet werden.
    Weiterhin wird erklärt, dass die Optimizer-Kosten eines delete denen eines entsprechenden select rowid from ... entsprechen. Für das delete hat der Optimizer im gegebenen Fall die Auswahl zwischen drei Strategien: FULL TABLE SCAN, INDEX FAST FULL SCAN (der aber erst seit 12c ohne Hint-Verwendung berücksichtigt wird) und INDEX RANGE SCAN. In zwei Beispielen wird nun ausgeführt, wie die Ressourcen-Nutzung in Hinsicht auf redo und undo für verschiedene Zugriffs-Varianten aussieht (abhängig von der Anzahl der vorhandenen Indizes) - einmal für eine Löschung gut geclusterter Daten und einmal für Daten, die stark verteilt sind. Interessant ist dabei vor allem folgender Punkt:
    It’s not a well-known phenomenon, but Oracle uses a completely different strategy for deleting through an index range/full scan from the strategy it uses when delete through a tablescan or index fast full scan. For the tablescan/index fast full scan Oracle deletes a row from the table then updates each index in turn, before moving on to delete the next row.  For the index range scan / full scan Oracle deletes a table row but records the rowid and the key values for each of the indexes that will need to be updated – then carries on to the next row without updating any indexes. When the table delete is complete Oracle sorts all the data that it has accumulated by index, by key, and uses bulk updates to do delayed maintenance on the indexes. Since bulk updates result in one undo record and one redo change vector per block updated (rather than one per row updated) the number of redo entries can drop dramatically with a corresponding drop in the redo and undo size.
    Insofern kann die Löschung über die den Index hinsichtlich der Index-Maintenance also grundsätzlich effizienter sein als die Verwendung von FTS (bzw. dem eng verwandten IFFS). Allerdings erfordert der Index Range Scan (bzw. der verwandte Index Full Scan) zusätzliche Sortierungen. Für die unterschiedlichen Pattern ergibt sich dabei jeweils eine sehr unterschiedliche Effektivität der Verfahren: während für die gut geclusterten Daten der Index Range Scan sehr performant erfolgt, ist bei den in der Tabelle stark verteilten Daten der FTS die bessere Wahl. Dabei ist die Arbeit zur Änderung der Tabelle unter Umständen im einen Fall höher, während die Index-Maintenance in diesem Fall höher wird und umgekehrt. Weniger gut schneidet in vielen Fällen der Index Fast Full Scan, der in 12c unter Umständen sehr häufig ausgewählt werden kann, weil er für select rowid from ... Queries eine sehr günstige Wahl ist.

    Da Jonathan Lewis im dritten Artikel bereits weitere Beiträge angekündigt hat, werde ich hier vermutlich später noch Ergänzungen vornehmen.