データベース」カテゴリーアーカイブ

「オプティマイザ統計の保留」の検証(序章②)

自動化の落とし穴

グローバル社会は24時間眠らない。

「9時から5時まで働いて、土日は完全休日。」正月からスーパーが開いている現代ではそんな古き良き時代の働き方は皆無と言えるかもしれません。

私の場合、以前某官公庁で大型汎用計算機の運用に携わっていた頃は、朝7時に当番が早めに出勤してコンピュータを立ち上げ、夕方18時くらいには電源を落として帰るような運用をしていました。(当然土日は休みです。)

しかし、DBAとして本格的に働き始めた2000年ごろから、24時間止められない運用とその運用を支える仕組みを直に担当するようになりました。

24×365(24時間365日)運用というものは携わった者でなければ理解できないシビアな世界です。ちょうどこの頃はインターネットをビジネスで使うということが当たり前のようになっていった時期であり、世界規模(グローバル)にビジネスを展開することが多くの企業で求められ始めた頃でもありました。

私はスペシャリストとしてオンコールで対応する立場でしたが、運用エンジニアは3交代シフトで24時間365日運用を行うような体制でした。

この会社は主に日本国内の企業を対象にしていましたが、それでも24時間システムを維持管理する必要があります。

特にグローバルに事業を展開している企業はより高いレベルでサービスを提供するため、例えばOracle社のサポートは当時日本、米国、英国に拠点を持ち、サポート案件を拠点間でハンドリングすることで、シームレスで高度なサポートを提供しているというようなことを聞きました。

その他にも外国為替市場のように、業界全体がグローバルに広がっている場合の運用もシビアです。

あるFX会社におけるパフォーマンス問題

Oracle10gは2004年にR1がリリースされましたが、以下は比較的早い時期にOracle9iから10gR1にアップグレードした、とあるFX(外国為替証拠金取引)会社で実際に起きた事例を若干の脚色を加えて再現したものです。

FXにおける繁忙時間帯は夜の21時です。なぜなら世界の主要取引市場である東京、ロンドン、ニューヨーク市場はそれぞれ朝7時に開かれます。ニューヨーク市場は元々の規模が大きことや、東京・ロンドン市場の取引傾向の影響を受けるために、日本国内のFX個人投資家にとっても14時間の時差があるニューヨーク市場の動向は非常に気になるのです。仕事から帰ってきたサラリーマンが自宅のパソコンで取引を始める時間帯ということもあり、21時からの数時間は日本のFX業者にとって最もクリティカルな時間帯なのです。

問題は21時にニューヨーク市場が開いて、トランザクション数が次第に伸びてきた22時過ぎに起きました。

さっきまで何の問題もなかったOLTPの取引処理が全く無応答になってしまったのです。

秒単位以下で変動する為替レートに対しリアルタイムに処理が完了しなければならないのに、この状況は致命的でした。

注文や約定と呼ばれる処理が遅れてしまっているため、焦った個人投資家がさらに取引処理を行おうとしたのかもしれません。滞留したトランザクションが膨大になり、状況はさらに悪化していきました。

このようなタイミングでアプリケーションの変更を行うはずがありません。性能劣化の原因が不明のまま時間だけが過ぎていきます。

ついに運用責任者がデータベースの強制再起動を決断しました。

未処理のトランザクションはすべてロールバックされ、せっかくの注文処理が無効になってしまいました。
そればかりか、レートが大きく変動したことによる損失を少しでも小さくするための決済処理が無効になってしまったため、投資家によっては大きな損失が発生してしまったかもしれません。

ほとんどの投資家はネット上で取引を行っているため、深刻なシステム障害はネットの掲示板等で瞬時に広まります。業者にとって投資家に対する補償等だけでなく、悪い評判が拡散してしまうということも大変にダメージが大きなことなのです。

障害の真相

後日、障害の原因を特定することができたのですが、それは唖然とするものでした。

まず初めに障害の背景となった事情について触れておきます。

Oracle10gからルール・ベース・オプティマイザ(RBO)がサポートされなくなりました。RBOで開発していた頃は、FROM句の後に記述するテーブルの順序を入れ替えただけで実行計画がガラッと変わり、パフォーマンスが大きく異なることがよくありました。

具体的には、レコード件数の多い順に記述するような原則で

FROM TBL-A(件数大),TBL-B(件数中),TBL-C(件数小)

のような感じで記述します。(この最適な順番を見つけるのが開発者の腕の見せどころでもありました。)
従って、テーブル件数が大きく変動するような場合は、なかなか開発者の意図した結果とならず苦労したという経験は、ベテランのOracle開発者であれば誰でも1つや2つ持っているのではないでしょうか?

ところが、Oracle10g 以降でコスト・ベース・オプティマイザ(CBO)のみになってから、世界は大きく変わりました。データ件数が変動しても統計情報を正しく取得していれば、最適な実行計画を必要に応じて算出してくれる、という理想のオプティマイザがCBOなのです。

前回のルート検索の例で言うと、渋滞情報をリアルタイムに収集して場合に応じた経路を示してくれるという機能がCBOに求められているのです。
つまり、地図上の最短距離だけでルートを示してくれるのがRBOで、渋滞情報を加味したルートを示すのがCBOなのです。

また、カーナビの渋滞情報が変わることに対するルート計算は比較的柔軟に再実行できるような気がしますが、実行計画算出(解析)は負荷の高い処理故、Oracleでは解析済みSQL文を共有することでその負荷がなるべく小さくなるような仕組みになっています。
つまり、渋滞がされた後でもずっと迂回路を案内してしまうような難しさをCBOは併せ持っているのです。

しかも、「正しく統計情報が取得されている」状態でなければ最適な実行計画は算出されないのです。Oracle社はこれに対してテーブル・インデックスの統計情報をデフォルトで自動的に収集する機能を実装しました。

収集の対象となるテーブル・インデックスを特定する条件はありますが、自動統計情報収集処理は10gの場合毎日22時に起動されています。

ほとんどの企業にとって夜の22時はオンライン業務が終了し、バッチ処理も終わっている頃、というのが自動統計情報収集の仕組みを作った開発者の頭の中にあったかどうかはわかりませんが、デフォルト設定がそのようになっているというのは事実です。

日本のFX業者にとってこの仕様が最悪の結果をもたらす可能性があるということは残念ながらあまり知られていませんでした。

詳細は関知していないのですが、このパフォーマンス問題が発生した経緯や背景事情を以下の通り想像を交えてまとめてみます。

  1. 繁忙時間帯が夜21時過ぎから日付が変わるあたりまでということは前述の通りですが、ニューヨーク市場がクローズされてから東京市場がオープンされる翌朝7時までのごく短い間にバッチ処理は終了するようになっています。
  2. 通常の問題のない日においては22時に自動統計情報収集処理が起動されていましたが、実行計画に影響を与えるようなデータの変動がバッチ処理で行われなかったため、統計情報が大きく変更され実行計画が突然入れ替わってしまうようなことはありませんでした。
  3. 問題の起きた日は、朝のバッチ処理でデータの大きな変動がありました。
  4. 21時の時点では前日の統計情報により良好な実行計画でOLTPが動いていました。
  5. 22時に通常と同じように自動統計情報収集処理が起動され、結果として良好な実行計画が変更されてしまいました。
  6. 実行計画算出処理(SQL文解析処理)は元々CPUリソースを消費するものでもあるので、それがOLTPに対して少なからず影響を与えていたというのも事実です。

再発防止策は?

障害の大きな原因として考えられるのは以下の2つです。

  • 22時という極めてクリティカルな時間帯であるにも関わらず、自動統計情報収集処理がデフォルトのまま起動されてしまった。
  • 実行計画に影響を与えるようなデータの変更があり、実行計画が変更されてしまった。

このケースでは「実行計画が意図せず変更されてしまった。」という問題に対する対策としては、

  1. 自動統計情報収集処理を業務影響の少ない時間帯に変更する。
  2. 統計情報収集処理を必要最小限の実行にとどめ、毎日は実施しない。
  3. 実行計画に影響を与えることが予めわかっているテーブルは統計情報が変更されないようにロックする。

のような項目を実施したと聞いています。

変動するデータに応じて最適な実行計画を立案する仕組みが、Oracle10g以降の「自動化」機能で実現されたわけですが、自動化というのは決して万能ではありません。

上の対策はある意味せっかくの自動化を意図的に殺すことで、(最適ではないかもしれないけれど)安定した運用を実現するものです。

私は「自動化の落とし穴」というテーマで、今までこの事例をいろいろな機会において紹介してきたのですが、反面自動化を選択するか否かの二者択一という考え方で本当に良いのかということをずっと考えてきました。

つまり、自動化と非自動化の中間に「半自動化」という概念があっても良いのではないかというのがその結論であり、次回紹介する「オプティマイザ統計の保留」というあまり注目されていない機能の特徴でもあります。

次回へ続く

「オプティマイザ統計の保留」の検証(序章①)

オプティマイザの正体

「HOW型」「WHAT型」コンピュータとは?

コンピュータを大きく分類すると「HOW型」と「WHAT型」という2つのタイプに分けることができるという説があります。

これは私が勝手に言っていることではなく1980年代にTRONを提唱した東大・坂村健教授の言葉です。(新版 TRONで変わるコンピュータ P.44〜、TRONプロジェクト Wikipedia)

「HOW型」というのは、現在使われているコンピュータつまりノイマン型コンピュータと呼ばれるコンピュータのことで、コンピュータがどのように振る舞うかを人間がプログラムという形で指示するものです。コンピュータが行う複雑な処理の一つ一つを厳密に定義しなければならないためプログラムを作るのは大変ですが、いったんプログラムができてしまえばコンピュータはそれを忠実に実行するだけというものです。

コンピュータ制御された車を考えると、「次の角を左に曲がれ。」とか「国道1号線を東京方面に進め。」等の指示を次々に与えながら目的地に誘導していくのが「HOW(どうやってやる)型」です。

一方「WHAT型」というのは1980年代当時研究されていた第五世代コンピュータとか、人工知能専用コンピュータに該当するもので、コンピュータ・カーの例で言うと「道路の左側を車線に沿って走れ。」とか「赤信号では止まること。」のように基本的なルール(専門的には知識ベース)だけを先に教えておいて、「何処何処へ行け!」という指示だけで目的地に向かわせるのが「WHAT(何をする)型」です。

第五世代コンピュータは当時最高の頭脳を結集させたプロジェクトだったようですが、結局は成果を出すことができずに終了してしまいました。まだコンピュータのパワーが非力だったということが最大の原因だったと思いますが、とりあえずムチャクチャに動いて(プログラムなしでコンピュータを動かすのは大変な事)最終的に結果を出せば良いというアプローチにやはり無理があったようです。

オプティマイザは「HOW型」でもあり「WHAT型」でもある

オプティマイザはリレーショナル・データベースの中で最も重要な機能と言っても過言ではありません。正しい結果をより早く返すためのアプローチを最適化する(optimize)機能・プログラムがオプティマイザ(optimizer)です。

それは当然「HOW型」コンピュータの上で動くプログラムですが、「WHAT型」しての性格も色濃く持っています。それはSQL(Structured Query Language)がまさに「(求める)結果=WHAT」の構造を記述するものだからです。

これはカーナビで経路検索をすることに似ています。例えば「日本橋から横浜ランドマークタワー」まで車で行きたい場合、Google Mapで検索すると

  1. 首都高速1号羽田線 と 首都高速神奈川1号横羽線 経由:35.1km、46分(31分)
  2. 首都高速3号渋谷線 と 第三京浜道路 経由:41.8km、49分(41分)
  3. 第二京浜/国道1号線 経由:35.2km、1時間8分(50分)

という結果が返ってきました。

GoogleMapの例(イメージと記事の内容は異なります。)

この2点間を結ぶ道は無数にあります(遠回りして新宿駅経由のルートでも目的地には着くことができます)が

  1. どんなにお金がかかっても最短時間で着くことができるルート
  2. 有料道路でも若干安いルート
  3. 有料道路を使わないルート

というような基準でそれぞれのルートを算出し(文字通り計算で求め)たのが上の結果です。ちなみにこれらは渋滞情報も加味されていて(カッコ)内は渋滞なしの場合の所要時間です。(これは実に興味深いことなので次回で取り上げます。)

カーナビで検索されたルートに該当するのが「アクセスパス」です。オプティマイザが最終的に1つに決定したアクセスパスに従って、実際のデータにアクセスされ、加工され、結果が返されます。

言い換えると、オプティマイザの役割は最適なアクセスパスを算出するところまでで、実際の物理的なI/Oやメモリ間操作などはオプティマイザの関知するところではありません。運転前にルートを検索することと実際にそのルートに従って車を運転することが違うことに相当します。

オプティマイザの計算量は膨大

オプティマイザは前述のように、決められたアルゴリズムに従って最適解を得ると言った面では「HOW型」と言えますが、ユーザの求める結果を実現するための「アクセスパス」を最終的に1つに決定するということでは「WHAT型」です。

2点間を結ぶルート検索であれば選択すべき経路はそれほど多くないのですが、複数のテーブルから求める結果を得るということは想像以上に大変なことです。

A、Bという2つのテーブルを結合して結果を得る場合、最初にAテーブルにアクセスしてその結果を基にBテーブルにアクセスすることを「A→B」と表現すると、A→BとB→Aという2通りのアクセスパスが存在します。

さらにA、B、Cの3つのテーブルでは、A→B、A→C、B→A、B→C、C→A、C→Bの6通りになります。(結合は原則的に2つのテーブルあるいは結果セット同士になります。)

テーブル数が増えるごとにアクセスパスは多くなり、簡単に説明すると(テーブル数)!:テーブル数の階乗となります。つまり10個のテーブルを結合するSQL文の場合は実に 3,628,800通りとなってしまいます。この中から最適なものを1つだけ選択しなければならないのでオプティマイザの計算量は膨大なものとなってしまいます。

実は、Oracleのオプティマイザは300万通り以上の組み合わせを律儀に評価するようなことはしません。「OPTIMIZER_MAX_PERMUTATIONS」というOracle8から導入された初期化パラメータによって評価する組み合わせの上限値が決められています。(これは解析時間を短縮するための苦肉の策と思われます。)

Oracle8と8iではこの値は「80,000」でしたが、9i以降では「2,000」となり、さらに10g以降では隠しパラメータ「_optimizer_max_permutations」となったため基本的に変更しないパラメータとなってしまいました。

SQL> select
  2   a.ksppinm  "Parameter"
  3  ,b.ksppstvl "Value"
  4  from
  5   x$ksppi  a
  6  ,x$ksppcv b
  7  where a.indx    = b.indx
  8  and   a.ksppinm like '%optimizer_max_permutations%';

Parameter                      Value
------------------------------ ----------
_optimizer_max_permutations    2000

7個以上のテーブルを結合するとアクセスパス算出が不十分になる?

「OPTIMIZER_MAX_PERMUTATIONS(または _OPTIMIZER_MAX_PERMUTATIONS)」パラメータが2,000であることの意味を考えてみましょう。

前述のとおり複数テーブルを結合する組み合わせの数は「(テーブル数)!」となります。テーブル数6の場合6!=720、7の場合7!=5,040であるので、7つ以上のテーブルを結合する場合、すべての組み合わせを評価して真に最適なアクセスパスを算出する前に、オプティマイザが評価を諦めてしまう可能性があります。

以前、ある企業のコンサルティングを行った際「テーブルの結合は5つまでとする。」というルールを定めているのを目にしたことがあります。
これは恐らく本パラメータを意識したルールでなかなか興味深い考え方だなと記憶しているのですが、原則的には間違った発想だと思います。

一般的に5つのテーブルを結合するような複雑なクエリーを書くことは珍しいかもしれませんが

.....
from
 emp  e1
,emp  e2
,emp  e3
,dept d
.....

のように、FROM句の後に同じテーブルを複数記述するようなことは簡単にできてしまうので、5つという制限は意味のない足かせになるかもしれませんし、そもそも結合を減らすためにせっかく正規化したテーブルを非正規化するようなことは本末転倒です。

それではもし、正規化された7つ以上のテーブルをどうしても結合しなければならない場合はどうしたらよいでしょうか?

  • LEADINGヒントやORDEREDヒントにより、FROM句の後に記述された順にテーブルが結合されるようオプティマイザに情報を与える。
    • デメリット:テーブル順を間違えると悪い結果をもたらす。統計情報が変動した場合どうする?
  • PL/SQLでカーソルを定義し(例えば4テーブルのSELECT文)、カーソル・ループの中で残りのテーブルを参照する。
    • デメリット:想像のとおりプログラムが複雑になり、手間の割には成果が少ないかもしれない。
  • 一時的に_OPTIMIZER_MAX_PERMUTATIONSパラメータの値を変更する。
    • デメリット:解析済みSQLがキャッシュアウトされる度にパラメータが変更できるか?実行計画を固定化する高度なスキルが必要。

いろいろ考えられるのですが、一長一短ありでなかなか単純ではありません。(あえて言えば3番目が一番スマートでしょう。)

次回へ

「オプティマイザ統計の保留」というあまり注目されていない機能を取り上げ(検証し)ようとしているのですが、オプティマイザについて語ると脱線してしまってなかなかたどり着けません。

次回「序章②」として、オプティマイザを理解する上で前提として押さえておきたいことを説明し、次々回で検証に入りたいと思います。

NULLを排除した設計②

先日投稿した「NULLを排除した設計①」の続きです。

若干変更

前回の投稿から若干変更です。せっかくCLOBカラムを定義したので、I/O負荷を高くするため値を設定する際に4000バイト長のデータにして格納することにしました。(LOBカラムへの正規の値格納方法ではないのですが、簡単に4000バイトのデータを設定しました。)

SQL> update CUSTOMERS_1 set REMARKS = rpad('0',4000,'0') where mod(CUST_ID,5 ) = 0;

10000行が更新されました。

SQL> update CUSTOMERS_1 set REMARKS = rpad('1',4000,'1') where mod(CUST_ID,10) = 1;

5000行が更新されました。

SQL> update CUSTOMERS_1 set REMARKS = rpad('2',4000,'2') where mod(CUST_ID,20) = 2;

2500行が更新されました。

SQL> update CUSTOMERS_1 set REMARKS = rpad('3',4000,'3') where mod(CUST_ID,50) = 3;

1000行が更新されました。

SQL> commit;

コミットが完了しました。

SQL> select
  2   CUST_ID
  3  ,nvl(DBMS_LOB.SUBSTR(REMARKS, 10, 1),'Null') REMARKS
  4  ,DBMS_LOB.GETLENGTH(REMARKS) LENGTH
  5  from CUSTOMERS_1 where CUST_ID < 21
  6  order by CUST_ID;

   CUST_ID REMARKS             LENGTH
---------- --------------- ----------
         1 1111111111            4000
         2 2222222222            4000
         3 3333333333            4000
         4 Null
         5 0000000000            4000
         6 Null
         7 Null
         8 Null
         9 Null
        10 0000000000            4000
        11 1111111111            4000

   CUST_ID REMARKS             LENGTH
---------- --------------- ----------
        12 Null
        13 Null
        14 Null
        15 0000000000            4000
        16 Null
        17 Null
        18 Null
        19 Null
        20 0000000000            4000

20行が選択されました。

SQL> select
  2   nvl(DBMS_LOB.SUBSTR(REMARKS, 10, 1),'Null') REMARKS
  3  ,count(*)
  4  from
  5   CUSTOMERS_1
  6  group by
  7   DBMS_LOB.SUBSTR(REMARKS, 10, 1)
  8  order by
  9   1;

REMARKS           COUNT(*)
--------------- ----------
0000000000           10000
1111111111            5000
2222222222            2500
3333333333            1000
Null                 31500

パフォーマンス比較

テストは同じSQL文をそれぞれ3回連続で実行し、一番経過時間が短かった結果を採用しました。

まずは全件検索(結果:50,000件)

①-1 単純カラム追加(NULLを排除しない設計)

SQL> select
  2   CUST_ID
  3  ,CUST_FNAME
  4  ,CUST_LNAME
  5  ,SEX
  6  ,REMARKS
  7  from
  8   CUSTOMERS_1;

50000行が選択されました。

 経過: 00:00:30.41

実行計画
----------------------------------------------------------
Plan hash value: 2813274236

---------------------------------------------------------------------------------
| Id  | Operation         | Name        | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------------
|   0 | SELECT STATEMENT  |             | 50000 |  3857K|   140   (0)| 00:00:02 |
|   1 |  TABLE ACCESS FULL| CUSTOMERS_1 | 50000 |  3857K|   140   (0)| 00:00:02 |
---------------------------------------------------------------------------------

統計
----------------------------------------------------------
       0  recursive calls
       0  db block gets
  198074  consistent gets
  148000  physical reads
       0  redo size
24824948  bytes sent via SQL*Net to client
17644472  bytes received via SQL*Net from client
  124002  SQL*Net roundtrips to/from client
       0  sorts (memory)
       0  sorts (disk)
   50000  rows processed

②-1 テーブル追加(Nullを排除した設計)

SQL> select
  2   C.CUST_ID
  3  ,C.CUST_FNAME
  4  ,C.CUST_LNAME
  5  ,C.SEX
  6  ,R.REMARKS
  7  from
  8   CUSTOMERS_2 C left outer join CUST_2_REMARKS R
  9  on C.CUST_ID = R.CUST_ID;

50000行が選択されました。

 経過: 00:00:30.31

実行計画
----------------------------------------------------------
Plan hash value: 2466772454

------------------------------------------------------------------------------------------------
| Id  | Operation             | Name           | Rows  | Bytes |TempSpc| Cost (%CPU)| Time     |
------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT      |                | 50000 |  7373K|       |   361   (1)| 00:00:05 |
|*  1 |  HASH JOIN RIGHT OUTER|                | 50000 |  7373K|  2248K|   361   (1)| 00:00:05 |
|   2 |   TABLE ACCESS FULL   | CUST_2_REMARKS | 18500 |  2023K|       |    40   (0)| 00:00:01 |
|   3 |   TABLE ACCESS FULL   | CUSTOMERS_2    | 50000 |  1904K|       |    90   (0)| 00:00:02 |
------------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   1 - access("C"."CUST_ID"="R"."CUST_ID"(+))

統計
----------------------------------------------------------
       0  recursive calls
       0  db block gets
  198137  consistent gets
  148000  physical reads
       0  redo size
24824948  bytes sent via SQL*Net to client
17644472  bytes received via SQL*Net from client
  124002  SQL*Net roundtrips to/from client
       1  sorts (memory)
       0  sorts (disk)
   50000  rows processed

このクエリーはOUTER JOINで書くのがポイントです。

コスト値は単純カラム追加の方が半分以下なのですが、physical readsは両者全く同じで、経過時間もほとんど同じです。

②のコストが大きいのはハッシュ・ジョインのためですが、パフォーマンス的には遜色のない結果となっています。

IS NOT NULL検索はどうか?(結果:18,500件)

①-2 単純カラム追加(NULLを排除しない設計)


SQL> select
  2   CUST_ID
  3  ,CUST_FNAME
  4  ,CUST_LNAME
  5  ,SEX
  6  ,REMARKS
  7  from
  8   CUSTOMERS_1
  9  where REMARKS is not null;

18500行が選択されました。

経過: 00:00:24.06

実行計画
----------------------------------------------------------
Plan hash value: 2813274236

---------------------------------------------------------------------------------
| Id  | Operation         | Name        | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------------
|   0 | SELECT STATEMENT  |             | 18500 |  1427K|   143   (2)| 00:00:02 |
|*  1 |  TABLE ACCESS FULL| CUSTOMERS_1 | 18500 |  1427K|   143   (2)| 00:00:02 |
---------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   1 - filter("REMARKS" IS NOT NULL)

統計
----------------------------------------------------------
       0  recursive calls
       0  db block gets
  166771  consistent gets
  148000  physical reads
       0  redo size
20641347  bytes sent via SQL*Net to client
17297972  bytes received via SQL*Net from client
   92502  SQL*Net roundtrips to/from client
       0  sorts (memory)
       0  sorts (disk)
   18500  rows processed

②-2 テーブル追加(Nullを排除した設計)

SQL> select
  2   C.CUST_ID
  3  ,C.CUST_FNAME
  4  ,C.CUST_LNAME
  5  ,C.SEX
  6  ,R.REMARKS
  7  from
  8   CUSTOMERS_2 C inner join CUST_2_REMARKS R
  9  on C.CUST_ID = R.CUST_ID;

18500行が選択されました。

経過: 00:00:23.60

実行計画
----------------------------------------------------------
Plan hash value: 2209842270

---------------------------------------------------------------------------------------------
| Id  | Operation          | Name           | Rows  | Bytes |TempSpc| Cost (%CPU)| Time     |
---------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |                | 18500 |  2728K|       |   361   (1)| 00:00:05 |
|*  1 |  HASH JOIN         |                | 18500 |  2728K|  2248K|   361   (1)| 00:00:05 |
|   2 |   TABLE ACCESS FULL| CUST_2_REMARKS | 18500 |  2023K|       |    40   (0)| 00:00:01 |
|   3 |   TABLE ACCESS FULL| CUSTOMERS_2    | 50000 |  1904K|       |    90   (0)| 00:00:02 |
---------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   1 - access("C"."CUST_ID"="R"."CUST_ID")

統計
----------------------------------------------------------
       0  recursive calls
       0  db block gets
  166835  consistent gets
  148000  physical reads
       0  redo size
20641347  bytes sent via SQL*Net to client
17297972  bytes received via SQL*Net from client
   92502  SQL*Net roundtrips to/from client
       1  sorts (memory)
       0  sorts (disk)
   18500  rows processed

このクエリーはINNER JOINで書くのがポイントですが、全件検索と同様に②-1と較べて遜色ないというか、むしろ若干よい結果が出ました。(これは偶然だと思いますが。)

IS NULL検索はどうか?(結果:31,500件)

③-1 単純カラム追加(NULLを排除しない設計)

SQL> select
  2   CUST_ID
  3  ,CUST_FNAME
  4  ,CUST_LNAME
  5  ,SEX
  6  ,REMARKS
  7  from
  8   CUSTOMERS_1
  9  where REMARKS is null;

31500行が選択されました。

経過: 00:00:06.56

実行計画
----------------------------------------------------------
Plan hash value: 2813274236

---------------------------------------------------------------------------------
| Id  | Operation         | Name        | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------------
|   0 | SELECT STATEMENT  |             | 31500 |  2430K|   143   (2)| 00:00:02 |
|*  1 |  TABLE ACCESS FULL| CUSTOMERS_1 | 31500 |  2430K|   143   (2)| 00:00:02 |
---------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   1 - filter("REMARKS" IS NULL)

統計
----------------------------------------------------------
      0  recursive calls
      0  db block gets
  31745  consistent gets
      0  physical reads
      0  redo size
4184175  bytes sent via SQL*Net to client
 346972  bytes received via SQL*Net from client
  31502  SQL*Net roundtrips to/from client
      0  sorts (memory)
      0  sorts (disk)
  31500  rows processed

③-2 テーブル追加(Nullを排除した設計)


SQL> select
  2   CUST_ID
  3  ,CUST_FNAME
  4  ,CUST_LNAME
  5  ,SEX
  6  ,null REMARKS
  7  from
  8   CUSTOMERS_2
  9  where not exists (
 10      select
 11       1
 12      from
 13       CUST_2_REMARKS
 14      where CUSTOMERS_2.CUST_ID = CUST_2_REMARKS.CUST_ID
 15                   );

31500行が選択されました。

経過: 00:00:00.75

実行計画
----------------------------------------------------------
Plan hash value: 2401287113

----------------------------------------------------------------------------------------
| Id  | Operation          | Name              | Rows  | Bytes | Cost (%CPU)| Time     |
----------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |                   | 31500 |  1353K|    93   (4)| 00:00:02 |
|   1 |  NESTED LOOPS ANTI |                   | 31500 |  1353K|    93   (4)| 00:00:02 |
|   2 |   TABLE ACCESS FULL| CUSTOMERS_2       | 50000 |  1904K|    90   (0)| 00:00:02 |
|*  3 |   INDEX UNIQUE SCAN| PK_CUST_2_REMARKS |  6845 | 34225 |     0   (0)| 00:00:01 |
----------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   3 - access("CUSTOMERS_2"."CUST_ID"="CUST_2_REMARKS"."CUST_ID")

統計
----------------------------------------------------------
      0  recursive calls
      0  db block gets
   6386  consistent gets
      0  physical reads
      0  redo size
1577675  bytes sent via SQL*Net to client
  23561  bytes received via SQL*Net from client
   2101  SQL*Net roundtrips to/from client
      0  sorts (memory)
      0  sorts (disk)
  31500  rows processed

このクエリーはNOT EXIST句を使って書くのがポイントですが、テーブル分割した方が圧倒的にパフォーマンスがよいです!

しかし、実はこれには落とし穴があります。IS NULL条件なので結果のREMARKSカラムは必ずNullになります。③-1は以下のように書き換えが可能です。

SQL> select
  2   CUST_ID
  3  ,CUST_FNAME
  4  ,CUST_LNAME
  5  ,SEX
  6  ,null REMARKS
  7  from
  8   CUSTOMERS_1
  9  where REMARKS is null;

31500行が選択されました。

経過: 00:00:00.71

実行計画
----------------------------------------------------------
Plan hash value: 2813274236

---------------------------------------------------------------------------------
| Id  | Operation         | Name        | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------------
|   0 | SELECT STATEMENT  |             | 31500 |  2430K|   143   (2)| 00:00:02 |
|*  1 |  TABLE ACCESS FULL| CUSTOMERS_1 | 31500 |  2430K|   143   (2)| 00:00:02 |
---------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   1 - filter("REMARKS" IS NULL)

統計
----------------------------------------------------------
      0  recursive calls
      0  db block gets
   2531  consistent gets
      0  physical reads
      0  redo size
1577675  bytes sent via SQL*Net to client
  23561  bytes received via SQL*Net from client
   2101  SQL*Net roundtrips to/from client
      0  sorts (memory)
      0  sorts (disk)
  31500  rows processed

律儀にREMARKSカラムをクエリーに入れてしまうと、パフォーマンスが極端に悪くなります。

こうして見ると設計の違いでパフォーマンスに特筆すべき差はないので、「単純なカラム追加でよいではないか。」という意見もあるかと思いますが、Nullを排除した設計の利点を次回考察していきたいと思います。

(続く)

Oracleアーキテクチャをどのように理解するか

私がDBAになったきっかけ

私は20代に某官庁で大型汎用計算機の仕事に携わった経験があったのですが、その後の異動で30代半ばまでITとは全く別の世界で生きてきました。

元々コンピュータは自分に合っているという意識があったので、民間で本格的にITに携わってみたいと転職をしました。

最初はそれまでの所属官庁と関係の深いあるSIerに入社し、そこで初めてOracleデータベースに触りました。汎用計算機時代にやっと出始めた初期のRDBMSに少し携わる機会はあったのですが、COBOLから従来型のファイルシステムの代わりにRDBMSを利用するようなシステムの運用を行っており、民間で見たOracle7 はそれとは全くの別物でした。

その民間会社で恐る恐るOracleの経験をスタートし、約2年後に当時Oracle Master保有率の日本一を争っているような会社に転職しました。

その間はずっとアプリ開発者としてのキャリアを積んでいましたが、Unixも知らなければサーバに触ることも殆ど無く、GUIの開発ツールでPL/SQLのコードをひたすら書く毎日でした。

最初はテーブルのリレーションや実行計画に関する知識もなく、結合の仕方を間違えて発生した重複行を「DISTINCT」で無理矢理1行にまとめるようなおかしなことをやっていました。今思えば赤面モノです。

資格取得に熱心な会社でしたので、転職1年後くらいにOracle Master Plutinum for Oracle 8の資格を取得しました。その後しばらくしてあるネットワークに強い運用アウトソーシング会社からDBA常駐の依頼を受けDBA人生がスタートしました。

仕組みがわかると楽しい

アプリケーションの開発経験はそこそこ積んでいましたが運用経験は皆無です。今思えば無謀な挑戦でしたが、初日にたまたまSQLチューニングを頼まれる機会があり、何とかその場で解決できたので、専門家として少しは認めてもらえました。

ただし、Unix系の経験は殆どなし、サーバやネットワークに関しても同様でしたので、最初の同僚には「思わずめまいがした。」と言われました。

でも、Oracleのスペシャリストはほとんど居なかったので、それなりに期待もされて、ある日上司から社内セミナーでOracleについて話して欲しいと頼まれました。

引き受けてみたものの、何をどう話せばよいのか皆目わからず、日本オラクル社のサイトを探していたら「Oracleアーキテクチャ」の資料がアップされているのを見つけました。

Oracleについて人前で初めて話すというプレッシャーは相当なものでしたが、下手なことはできないとまずはその資料を徹底的に読みこなすことから始めました。

REDOやUNDO(当時はRBSでしたが)、あるいはリスナー経由の接続など、(資格を取っていたにも関わらず)それまで何となくしか理解できていなかったことが改めて明確に理解でき、実際に試すことが出来る環境があったので実地の経験も積んで、初めてOracleが動く仕組みを体系的に理解出来ました。

社内Oracleセミナーは、おかげさまで大変盛況で、半日×2回の講座を希望する人が多すぎてさらに追加の講座を実施するほどでした。

インターネット創世時代から活躍されているネットワーク・エンジニアの方も参加されたのですが、データベースというのはその会社では真空状態のようで、まだまだ未熟とは言えその日から社内的にもスペシャリストとして認知してもらえたように思います。

新人は何を学ぶか?

その後新入社員研修講師や新人向け勉強会のオブザーバをする機会が沢山ありました。未経験者がどのように興味を持ってOracleアーキテクチャを学べばよいのかということについて私なりのイメージが固まってきたので以下にまとめます。

  1. UPDATE文実行の裏でどのような仕組みが動いているかを考える。SELECT、INSERT、DELETEはUPDATEがわかれば理解できる。
  2. 単純なブロック更新の仕組みから発展させ、セッションの確立から、SQL文の解析等どんどん深堀りする。
  3. ユーザ管理のバックアップを理解することでリカバリを考慮した仕組みを理解する。
  4. 自由に再起動が出来る自分専用の検証環境を準備し、参考書等のスクリプト等を実地に確かめる。
  5. プロセスをKillしたり、ファイルを壊してみたり異常状態とそこからのリカバリを確かめる。
  6. 実行計画、統計情報の読み方を覚えて、どのようにSQL文が実行されるかを考える。
  7. OracleもOSの上で動いているものだという意識でOS、ハードウェアについても知識を広げる。

1番目のUPDATE文に関する内部処理の流れをスラスラ何も見ずにホワイトボードに描くことができれば、DBAとしての基礎が確実に備わっていると思います。

いったん筋が通った理解ができれば、そこにどんどん肉付けをして自信を深めることができます。

こういう私もまだ知らないことが沢山あることを自覚していますが、少なくとも初めて「わかった!」と思った時の感動を新人の方々にも味わってもらいたいと心から思います。

OracleマニュアルはEvernoteに取り込んで使おう

HTMLだったら何でも取り込める

またしても、Evernoteネタです。

それぐらいEvernoteは仕事の必需品です。

以前、iPadでOracleマニュアルを持ち出す という記事を書いたことがあります。Oracleマニュアルは私にとって商売道具であり愛読書でもあります。

今では紙でのOracleマニュアルを見ることはなくなりましたが、PDF、HTML、最近ではePub形式のマニュアルを無料で入手できるということは、紙マニュアルの時代を知るものとして隔世の感があります。

いろいろなフォーマットの中で知りたい場所がある程度わかっているものはやはりHTML形式に軍配が上がるのではないかと思いますが、Evernote はHTMLとの相性が抜群です。

Evernote Web Clipper

私は主にGoogle Chromeをメインのブラウザとして使っているのですが、ChromeにしろFierfoxにしろ「Evernote Web Clipper」という拡張機能(エクステンション)が提供されています。(インストール方法に関してはリンク先を参照して下さい。)

Evernote Web Clipper

Evernote Web Clipperをインストールするとブラウザに象のアイコンが表示されます。

拡張機能

クリップの仕方

クリップの実際

 

私はマニュアルのクリップを以下の要領で行っています。

  1. 取り込みたいマニュアルを表示させます。この場合ローカルディスクに保存したHTMLファイルではなくOTNサイトのマニュアルをインターネット経由で表示させます。(例は、Oracle Databaseリファレンス 11gR2 の V$SEGSTAT_NAMEの箇所です。)
  2. 取り込みたい箇所をドラッグして選択状態にします。
  3. Evernote Web Clipperのアイコンをクリックすると、上の写真のように「選択範囲」にマークがついた状態でダイアログが開きます。そのまま緑の「保存」ボタンをクリックするとEvernoteの新しいノートが作成されます。ノートのタイトルはTitleタグに記述されたものが自動的に指定されます。保存先はデフォルトノートブックですが、このタイミングで任意のノートブックを指定することも可能です。
  4. 作成されたノートは下の写真のようになります。保存先ノートブックを変更したり、必要なタグ付けをします。例では「Oracle:リファレンス」と「V$ビュー」というタグを付けています。
  5. マニュアルの記述だけではわかりにくい場合は、実際のSQL文を実行した結果を貼り付けたり、字の色を変えたりして自分だけの情報としてまとめます。

ノート

Evernoteにまとめておくと(Evernote Web Clipperがインストールしてあれば)、普通にGoogle検索をすると右側に自分のEvernote内の検索結果も同時に表示してくれます。まさに「自分だけのGoogle」です!

膨大なマニュアルの中から必要な部分を抜き出し、自分だけのコメントを付けて保存しておくと、記憶が定着するのに非常に有効です。

必要な情報をサッと取り出せる、こういうインフラを日頃から整えておけば仕事にきっと役立ちます。お試し下さい!

Google検索

NULLを排除した設計①

なぜNullを排除しなければならないのか?

テーブルを設計する際、Nullについてはあまり考慮されていないように思います。主キーは当然としてもむやみにNot Null制約を定義することは、テストデータの作り難さなどの理由でむしろ避けられているかもしれません。

ただし、緻密なテーブル設計を目的とした場合、Not Null制約によってNullデータを排除することは非常に重要です。

Nullには2つの意味がある

例えば

update CUSTOMERS set REMARKS = null where CUST_ID = 25000;

というDMLであたかも「Null」という値を設定できているような印象を持ってしまいますが、それではなぜ

select * from CUSTOMERS where REMARKS = null;

ではなく、

select * from CUSTOMERS where REMARKS is null;

というSQL文にしなければならないのでしょうか?プログラマのためのSQL 第4版 ジョー・セルコ著 には次のような一節があります。

「2値論理は、ONとOFFの2つしか状態を持たないバイナリコンピュータや多くの数学分野と極めて相性がいい。しかしSQLは3値論理を採用している。すなわち、TRUE、FALSE、UNKNOWNである。UNKNOWN という値は、比較述語や他の述語の戻り値として生じるものだが、れっきとした真理値であり、データがないことの目印であるNULLとは異なる。これが、SQLにおいて(x=NULL)ではなく(x IS [NOT] NULL) と書かねばならない理由である。NULL は値でも変数でもないので、値や変数にしか適用できない「=」という述語は適用できないのだ。」(P.266)

つまり、SQLでは「状態UNKNOWN」としてのNullと、「データがないことの目印」であるNullが混在しているということが誤解を生みやすくなっています。

Nullを許すことの問題点

以下のSQLを見てみましょう。

SQL> select 1/null from dual;

    1/NULL
----------
(Null)

変数の中にNullが紛れ込むと、どんなほとんどの場合(もちろん例外もありますが)関数の結果がNullになります。

その他、外部キーの参照先にNullデータがあると参照整合性制約の定義としては問題なくても値が取得できない問題が発生します。

プログラマのためのSQL 第4版 ジョー・セルコ著 には次のような記述もあります。

「優秀なSQLプログラマならば、NULLに対処するにあたり、まずはDDLにおいてこれを排除することに全力を傾けるだろう。すべてのテーブルのすべての列に原則としてNOT NULL 制約を付与し、正当な理由がある場合にだけNULLの使用を許可する。間違っても、こういう努力をしないまま、クエリで実装依存の変換関数に頼ればいいなどと考えないでほしい。 NULLは、SQL を知らない人々を混乱させるのだ。 加えて、NULLを使うのはコストの高い行為でもある。というのも、通常、NULLはそれが現れる列の行に余計なピットフラグを持つことで実装されている。これは前述のとおり、NULLが列自身の値ではないためだ。これによって、ストレージ要件や、インデックスおよび検索条件において本来なら必要なかったことを考慮する必要がある。」(P.272)

セルコ曰く「NULLを排除した設計」とはどういうものでしょうか?

この部分は重要だと思うので、簡単な例を挙げて説明します。

顧客テーブルを例にして

どこにでもあるような顧客テーブル(CUSTOMERS)を考えてみます。

テーブル構造は以下の様な簡単なもので、50,000件のテストデータが格納されています。

顧客テーブル

性別カラムには必ず「M」または「F」の値が格納されていてそれぞれ25,000件ずつになるようにデータを作成しています。

SQL> select SEX,count(*) from CUSTOMERS group by SEX;

SEX    COUNT(*)
---- ----------
M         25000
F         25000

備考欄を追加する

ここで、顧客毎に異なる特記事項を記録する必要が生じたことを考えてみましょう。特記事項は長くなりそうなのでCLOB型で記録する必要があるものとします。

さあ、どのように変更したらよいでしょうか?

1. 単純にカラムを追加する(Nullを排除しない設計)

それでは、よくあるパターンとしてカラム追加で対応する方法を考えてみます。

元のテーブルを丸ごとコピーしたCUSTOMERS_1テーブルに、CLOB型のREMARKSカラムを追加してみましょう。

顧客テーブル1

 

SQL> desc CUSTOMERS_1
 名前             NULL?    型
 ---------------- -------- ------------------
 CUST_ID          NOT NULL NUMBER(9)
 CUST_LNAME       NOT NULL VARCHAR2(30)
 CUST_FNAME       NOT NULL VARCHAR2(30)
 SEX              NOT NULL VARCHAR2(4)

SQL> alter table CUSTOMERS_1 add (REMARKS clob);

表が変更されました。

SQL> desc CUSTOMERS_1
 名前             NULL?    型
 ---------------- -------- -------------------
 CUST_ID          NOT NULL NUMBER(9)
 CUST_LNAME       NOT NULL VARCHAR2(30)
 CUST_FNAME       NOT NULL VARCHAR2(30)
 SEX              NOT NULL VARCHAR2(4)
 REMARKS                   CLOB

他のカラムはNot Null制約が付与されていますが、追加したばかりのREMARKSカラムはNullデータを許しています。

備考欄にデータを設定する

カラムを追加しただけではデータは「Null」なので、次のUpdate文でデータを設定します。


SQL> update CUSTOMERS_1 set REMARKS = ‘0' where mod(CUST_ID,5)  = 0;

10000行が更新されました。

SQL> update CUSTOMERS_1 set REMARKS = ‘1' where mod(CUST_ID,10) = 1;

5000行が更新されました。

SQL> update CUSTOMERS_1 set REMARKS = '2' where mod(CUST_ID,20) = 2;

2500行が更新されました。

SQL> update CUSTOMERS_1 set REMARKS = '3' where mod(CUST_ID,50) = 3;

1000行が更新されました。

SQL> commit;

コミットが完了しました。

SQL> select
  2   nvl(DBMS_LOB.SUBSTR(REMARKS, 1000, 1),'Null') REMARKS
  3  ,count(*)
  4  from
  5   CUSTOMERS_1
  6  group by
  7   DBMS_LOB.SUBSTR(REMARKS, 1000, 1)
  8  order by
  9   1;

REMARKS           COUNT(*)
--------------- ----------
0                    10000
1                     5000
2                     2500
3                     1000
Null                 31500

2. Nullを排除した設計

ジョー・セルコの言う「DDLにおいてこれを排除する」とは以下の様に備考欄を別テーブルに分割した構造です。

顧客テーブル2

REMARKSカラムにはNot Null制約を定義し、Nullデータを入力できないようにします。

DDLは以下のようになります。(ここでは便宜上CUSTOMER_1表を元にデータと一緒に作成しています。)

SQL> create table CUST_2_REMARKS as select CUST_ID,REMARKS from CUSTOMERS_1 where REMARKS is not null;

表が作成されました。

SQL> alter table CUST_2_REMARKS add constraint FK_CUST_ID_2 FOREIGN KEY (CUST_ID) REFERENCES CUSTOMERS_2 (CUST_ID);

表が変更されました。

SQL> alter table CUST_2_REMARKS modify REMARKS not null;

表が変更されました。

SQL> desc CUST_2_REMARKS
 名前              NULL?    型
 ----------------- -------- -------------
 CUST_ID           NOT NULL NUMBER(9)
 REMARKS           NOT NULL CLOB

パッと見た感じ、2つのテーブルをジョインしなければならないしパフォーマンス的には不利なような気もしますが、次回は両者のパフォーマンスの違いを見てみましょう。

(続く)

V$SQL_PLANでCRUD表モドキを作ってみる(番外編)

ピボットテーブルの縦横を入れ替える

テーブルの数とSQL文の数はたいていの場合後者の方が多いので、ピボットテーブルの横軸(列ラベル)にテーブル名(OBJECT_NAME)、縦軸(行ラベル)にSQL_IDを指定するのが自然だと思うのですが、列ラベルの高さは一番長いテーブル名の長さに合わせるため無駄に高くなってしまいます。

見やすさを追求するためには、下図のように列ラベルと行ラベルをドラッグ&ドロップで入れ替えてしまうというのも一つの工夫です。

PIVOT2

SQL_IDは13バイト全部を表示させる必要はありませんし、テーブル名が縦に並んでいる方が圧倒的に見やすいのではないでしょうか?

必要に応じてどちらを列ラベルに指定した方がよいかを考えて指定しましょう。

DBA_HIST_SQL*は上位◯個のSQL文しか格納しない

前回の投稿で本番業務への影響をできるだけ小さくするため、V$SQL_*の代わりにDBA_HIST_SQL_*を使った方がよいと書いたのですが、一つ注意点があります。

V$SQL_*の情報はすべてDBA_HIST_SQL*に保存されるのではなく、上位SQL(Top SQL)のみがフィルタリングされて格納されます。(これはDBMS_WORKLOAD_REPOSITORY.MODIFY_SNAPSHOT_SETTINGSで指定する「TOPNSQL」で変更することが可能です。デフォルト「30」)

AWRはパフォーマンス問題を解決するための情報を取得するので、問題となりそうな一部のSQLの情報しか保持しません。従ってDBA_HIST_SQL_*によって得られる情報は網羅性に欠けるということを念頭に置いて下さい。

V$SQL_PLANでCRUD表モドキを作ってみる③

ここからはExcelで

前回のクエリーの結果をMicrosoft Excelのピボットテーブルを利用して集計してみましょう。(Excel2010での方法を説明します。)
今回はある架空アプリケーションにおける実績データを対象とします。

一番簡単なのはSQL*Plusの実行結果を以下のようにExcelのシートにコピー&ペーストするやり方です。

Excelにコピー&ペースト

 データをスペースで区切る

データ>区切り位置 で「区切り位置指定ウィザード」を呼び出し、「スペースによって右または左に揃えられた固定長フィールドのデータ」を選択して、データを区切ります。

区切り位置指定ウィザード

ピボットテーブルで集計

データを区切ったら、A〜C列(CRUD, SQL_ID, OBJECT_NAME)を選択した状態から、挿入>ピボットテーブル で「ピボットテーブルの作成(ウィザード)」を呼び出します。配置する場所が「新規ワークシート」となっていることを確認し、「OK」をクリックします。

ピボットテーブルウィザード

右側にピボットテーブルのフィールドリストを指定する画面が出てくるので、リストのそれぞれを下のボックスにドラッグして指定します。

  • レポートフィルタ: CRUD
  • 列ラベル: OBJECT_NAME
  • 行ラベル: SQL_ID
  • 値: CRUD(データの個数となっていることを確認)

ピボットテーブル設定

これで完成ですが、見やすくするためにテーブル名を90°回転させて列幅を揃える等の整形をします。

CRUD表を使ってみる

作成したCRUD表(モドキですが)は縦にSQL(SQL_ID)、横にテーブル名が並んだ表で、それぞれの交差部分に「1」が立っていれば、そのSQL文で該当するテーブルを使っていることを意味します。

まず、下図のB1カラム(レポートフィルタ)で「R」を選択し、SELECT文を見てみましょう。

1つのSELECT文で参照するテーブル数

例えば、SQL_ID = 2p8c09m8rupuk のSQL文は「ABCD_BHA」「NIC」「STU3」という3つのテーブルをアクセスしています。一番右端の総計欄を見るとSQL文でアクセスしているテーブル数の合計がわかります。(表を横に見ます。)

R1

この合計数が多いSQLはジョインしているテーブルの数が多い可能性が高いため、注目する必要があるものです。

あるテーブルを使用しているSQL文とその数

逆にあるテーブルから関連するSQL文とその数を確認するには、表を縦に見ます。

R2

上図の例では、「EFGH_DAY_OUT」というテーブルは一番下の総計欄が「34」になっています。これは34種類の異なるSQL文で使用されていることを示しています。

この数が多いということは

  1. 多くのSQL文でアクセスされるホットなテーブルである。
  2. バインド変数を使わないために類似SQL文が多数存在している。

という2つの可能性を示しています。

いずれにせよ、パフォーマンス・チューニングで注目すべきポイントになりますので、AWRレポート等他の情報と突き合わせて問題の特定を行うようにしましょう。

また、このあるテーブルに関係するSQL文の一覧を得ることは、テーブル定義の変更で影響を与える対象を把握するのにも役立ちます。

SQLテキストと実行計画を確認する

一覧ではSQL文を表示させていません。SQL_IDが特定できたら以下のSQL文を実行してSQLテキストと実行計画を確認します。

set pages 1000
set lines 140
select plan_table_output from table(DBMS_XPLAN.DISPLAY_CURSOR('<SQL_ID>'));

 

応用編

インデックス – SQL

V$SQL_PLANを使えば、テーブルとSQLの関係だけでなく、インデックスとSQLの関係を分析することもできます。

例えば、あるインデックスの定義を変更しようとする場合、1つのSQLだけに注目してしまうと他のSQLに影響があることに気づかず新たな問題を引き起こしてしまうかもしれません。

そのような場合、インデックスとSQLの相関表が役に立ちます。

DBA_HIST_*を使う

V$SQL_PLANもX$SQLAREAもメモリ(SGA)上にキャッシュされている情報です。従って、共有プールの使用率が高い場合等にはどんどんキャッシュから追い出されてしまい欲しい情報が得られない場合があります。

もし、AWRが使える環境であれば

  • V$SQL_PLAN → DBA_HIST_SQL_PLAN
  • V$SQLAREA → DBA_HIST_SQLTEXT

に置き換えれば、AWR(SQL)レポート保存期間以内のデータが保証されますので、事後解析でも有用なデータが得られます。

念のためAWR版の情報取得SQLを以下に紹介します。

select
 decode(CRUD_TYPE_ID,2,'C'
                    ,3,'R'
                    ,6,'U'
                    ,7,'D') CRUD
,SQL_ID
,OBJECT_NAME
,OPTIONS
from
(
select distinct
 SQL_ID
,OBJECT_NAME
,decode(operation,'TABLE ACCESS',3
                 ,'UPDATE',6
                 ,'DELETE',7) CRUD_TYPE_ID
,OPTIONS
from
 DBA_HIST_SQL_PLAN --V$SQL_PLAN
where OPERATION in ('TABLE ACCESS','UPDATE','DELETE')
and   OBJECT_OWNER ='xxxx'
union all
select
 SQL_ID
,ltrim(
  replace(
   upper(
    substr(
     regexp_substr(DBMS_LOB.SUBSTR(SQL_TEXT, 1000, 1),'INTO[[:space:]]+[^[:space:]\(]+',1,1,'i')
        ,6)
         )
    ,'"')
      ) OBJECT_NAME
,COMMAND_TYPE CRUD_TYPE_ID
,null OPTIONS
from
 DBA_HIST_SQLTEXT --V$SQLAREA
where COMMAND_TYPE = 2
) I
,DBA_TABLES D
where I.OBJECT_NAME = D.TABLE_NAME
and   D.OWNER       = 'xxxx'
order by
 CRUD_TYPE_ID
,SQL_ID
,OBJECT_NAME
;

最後に注意事項

V$SQL_PLAN等は負荷の高い状況ではアクセスしないことをお勧めします。DBAが手動で負荷状況を見ながら注意深く実行して下さい。

従って、スクリプトを組んで定期的に自動実行するような運用は行わない方が望ましいと思います。

V$SQL_PLANでCRUD表モドキを作ってみる②

UPDATEとDELETE

UPDATE文とDELETE文の実行計画については以下の例のとおりです。

-----------------------------------------------------------------------------
| Id  | Operation          | Name   | Rows  | Bytes | Cost (%CPU)| Time     |
-----------------------------------------------------------------------------
|   0 | UPDATE STATEMENT   |        |     1 |     8 |     1   (0)| 00:00:01 |
|   1 |  UPDATE            | EMP    |       |       |            |          |
|*  2 |   INDEX UNIQUE SCAN| PK_EMP |     1 |     8 |     0   (0)| 00:00:01 |
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
| Id  | Operation          | Name   | Rows  | Bytes | Cost (%CPU)| Time     |
-----------------------------------------------------------------------------
|   0 | DELETE STATEMENT   |        |     1 |    26 |     1   (0)| 00:00:01 |
|   1 |  DELETE            | EMP    |       |       |            |          |
|*  2 |   INDEX UNIQUE SCAN| PK_EMP |     1 |    26 |     1   (0)| 00:00:01 |
-----------------------------------------------------------------------------

Operation=UPDATE or DELETE となる Name列が示すものが、対象のテーブル名です。

SELECTと同様にV$SQL_PLANから情報を取得するクエリーは以下のとおりです。

select distinct
 SQL_ID
,OBJECT_NAME
,decode(operation,'UPDATE',6
                 ,'DELETE',7) CRUD_TYPE_ID
from V$SQL_PLAN
where OPERATION in ('UPDATE','DELETE')
and   OBJECT_OWNER = 'xxxx'
order by
 OBJECT_NAME
,CRUD_TYPE_ID
,SQL_ID
;

SELECTも一緒に取得するように書きなおしたSQL文の実行例は以下のとおりです。

SQL> select distinct
   2   SQL_ID
   3  ,OBJECT_NAME
   4  ,decode(operation,'TABLE ACCESS',3
   5                   ,'UPDATE',6
   6                   ,'DELETE',7) CRUD_TYPE_ID
   7  ,OPTIONS
   8  from
   9   V$SQL_PLAN
  10  where OPERATION in ('TABLE ACCESS','UPDATE','DELETE')
  11  and   OBJECT_OWNER = 'SCOTT'
  12  order by
  13   OBJECT_NAME
  14  ,CRUD_TYPE_ID
  15  ,SQL_ID;

 SQL_ID        OBJECT_NAME  CRUD_TYPE_ID OPTIONS
 ------------- ------------ ------------ --------
 a2dk8bdn0ujx7 EMP                     3 FULL
 2r07krus3ums3 EMP                     6
 7hr1wxxb8hkqc EMP                     7 

INSERTは難しい

INSERT文も同じような要領で情報を取れると思ったのですが、なかなかやっかいです。

以下はINSERT文の実行計画です。

---------------------------------------------------------------------------------
| Id  | Operation                | Name | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------------
|   0 | INSERT STATEMENT         |      |     1 |    38 |     1   (0)| 00:00:01 |
|   1 |  LOAD TABLE CONVENTIONAL | EMP  |       |       |            |          |
---------------------------------------------------------------------------------

Operation=”LOAD TABLE CONVENTIONAL” となるName列を持ってくればよさそうな気がします。
しかし、いろいろなINSERT文を見ていて次のような実行計画に出くわしました。

--------------------------------------------------------------------------------------------------------------
| Id  | Operation                | Name                      | Rows  | Bytes |TempSpc| Cost (%CPU)| Time     |
--------------------------------------------------------------------------------------------------------------
|   0 | INSERT STATEMENT         |                           |       |       |       | 47068 (100)|          |
|   1 |  LOAD TABLE CONVENTIONAL |                           |       |       |       |            |          |
|*  2 |   HASH JOIN              |                           | 22794 |    44M|    44M| 47068   (1)| 00:09:25 |
|*  3 |    TABLE ACCESS FULL     | Txxxxxxxxxxxxxxxxxxxxxxxx | 22794 |    44M|       |  1922   (1)| 00:00:24 |
|   4 |    TABLE ACCESS FULL     | Mxxxxxxxxxxx              |  4861K|   148M|       | 32769   (1)| 00:06:34 |
--------------------------------------------------------------------------------------------------------------

これは実際の実行計画のテーブル名をマスクしたものですが、INSERT対象のテーブル名はどこにも出てきていないことがおわかりでしょうか?

つまり、実行計画からINSERT先のテーブルを特定することはできないのです。

正規表現を使う

そこでV$SQLAREAを使って情報を取得することにしました。SQL_TEXT列にSQLテキストの先頭1,000バイトが入っているので、そこから文字列操作でテーブル名を取り出す作戦です。

SUBSTR関数等いろいろ試してみたのですが、最終的に10g以降で使えるようになった正規表現機能で抜き出すことにしました。
以下が情報取得用のクエリーです。

select
 SQL_ID
,ltrim(
  replace(
   upper(
    substr(
     regexp_substr(SQL_TEXT,'INTO[[:space:]]+[^[:space:]\(]+',1,1,'i')
        ,6)
         )
    ,'"')
      ) OBJECT_NAME
,COMMAND_TYPE CRUD_TYPE_ID
,null OPTIONS
from V$SQLAREA
where COMMAND_TYPE = 2
order by
 SQL_ID
,OBJECT_NAME
;

解説:

  • 7行目がもっとも肝になる部分です。「INTO [テーブル名]」となる文字列をregexp_substr関数で抜き出します。
  • substr関数で抜き出した文字列の6バイト目以降をテーブル名として取り出します。(6行目)
  • upper関数でテーブル名を大文字に統一します。(5行目)
  • たまにテーブル名がダブルクォートで囲まれている場合があるのでreplace関数で取り除きます。これが最終的なOBJECT_NAMEとなります(4行目)

以下は実行例です。
V$SQLAREAにはテーブルの所有者を特定できる情報がないので、上記クエリーの結果とDBA_TABLESをジョインしてOWNER列で絞り込む条件を加えています。

SQL> select distinct
  2   SQL_ID
  3  ,OBJECT_NAME
  4  ,CRUD_TYPE_ID
  5  ,OPTIONS
  6  from
  7  (
  8  select
  9   SQL_ID
 10  ,ltrim(
 11    replace(
 12     upper(
 13      substr(
 14       regexp_substr(SQL_TEXT,'INTO[[:space:]]+[^[:space:]\(]+',1,1,'i')
 15          ,6)
 16           )
 17      ,'"')
 18        ) OBJECT_NAME
 19  ,COMMAND_TYPE CRUD_TYPE_ID
 20  ,null OPTIONS
 21  from
 22   V$SQLAREA
 23  where COMMAND_TYPE = 2
 24  ) I
 25  ,DBA_TABLES D
 26  where I.OBJECT_NAME = D.TABLE_NAME
 27  and   D.OWNER       = 'SCOTT'
 28  order by
 29   SQL_ID
 30  ,OBJECT_NAME
 31  ;

SQL_ID        OBJECT_NAME                    CRUD_TYPE_ID OPTIONS
------------- ------------------------------ ------------ --------
7071f28x901k7 EMP                                       2
gjvsdk24ufadk EMP                                       2

UNIONでまとめてみる

最後に、INSERT/SELECT/UPDATE/DELETE の結果をまとめてみましょう。24行目の「union all」に注目して下さい。

補足すると、CRUD_TYPE_IDをdecode関数で「C」「R」「U」「D」に変換しています。(4行目)
なぜ、このような面倒くさいことをやっているのかというと、最初から「C」「R」「U」「D」という文字列にすると、この順番にソートしてくれないのでCRUD_TYPE_IDという数値を使っているのです。(細かいですが私のこだわりです。)

それから、SQL*Plusコマンドを使って、CRUD別のSQL数も集計してみました。(1-2行目)

SQL> break on crud skip page
SQL> compute count of sql_id on crud
SQL> select
  2   decode(CRUD_TYPE_ID,2,'C'
  3                      ,3,'R'
  4                      ,6,'U'
  5                      ,7,'D') CRUD
  6  ,OBJECT_NAME
  7  ,SQL_ID
  8  ,OPTIONS
  9  from
 10  (
 11  select distinct
 12   SQL_ID
 13  ,OBJECT_NAME
 14  ,decode(operation,'TABLE ACCESS',3
 15                   ,'UPDATE',6
 16                   ,'DELETE',7) CRUD_TYPE_ID
 17  ,OPTIONS
 18  from
 19   V$SQL_PLAN
 20  where OPERATION in ('TABLE ACCESS','UPDATE','DELETE')
 21  and   OBJECT_OWNER = 'SCOTT'
 22  union all
 23  select
 24   SQL_ID
 25  ,ltrim(
 26    replace(
 27     upper(
 28      substr(
 29       regexp_substr(SQL_TEXT,'INTO[[:space:]]+[^[:space:]\(]+',1,1,'i')
 30          ,6)
 31           )
 32      ,'"')
 33        ) OBJECT_NAME
 34  ,COMMAND_TYPE CRUD_TYPE_ID
 35  ,null OPTIONS
 36  from
 37   V$SQLAREA
 38  where COMMAND_TYPE = 2
 39  ) I
 40  ,DBA_TABLES D
 41  where I.OBJECT_NAME = D.TABLE_NAME
 42  and   D.OWNER       = 'SCOTT'
 43  order by
 44   CRUD_TYPE_ID
 45  ,OBJECT_NAME
 46  ,SQL_ID
 47  ;

C OBJECT_NAME          SQL_ID        OPTIONS
- -------------------- ------------- ---------------
C EMP                  7071f28x901k7
  EMP                  gjvsdk24ufadk
*                      -------------
c                                  2

C OBJECT_NAME          SQL_ID        OPTIONS
- -------------------- ------------- ---------------
R EMP                  7071f28x901k7 BY INDEX ROWID
  EMP                  a2dk8bdn0ujx7 FULL
  EMP                  b84685tx0a4u0 BY INDEX ROWID
  EMP                  f93ucvwf6d1vh FULL
*                      -------------
c                                  4

C OBJECT_NAME          SQL_ID        OPTIONS
- -------------------- ------------- ---------------
U EMP                  2r07krus3ums3
*                      -------------
c                                  1

C OBJECT_NAME          SQL_ID        OPTIONS
- -------------------- ------------- ---------------
D EMP                  7hr1wxxb8hkqc
*                      -------------
c                                  1

どのテーブル(OBJECT_NAME)がどのSQL(SQL_ID)によってINSERT/SELECT/UPDATE/DELETEの対象になっているかが、この一覧からわかります。
(OPTIONSは単なるおまけですのでなくても構いません)

この例ではSCOTT.EMP表しか出てきていませんが、実際は非常に沢山の結果が返ってくるはずです。

次回は、MS Excelのピボットテーブルを使って CRUD表らしく加工してみます。

(続く)

V$SQL_PLANでCRUD表モドキを作ってみる①

パフォーマンス問題の犯人探し?

情報システムに問題(特にパフォーマンス問題)が発生した場合、運用を担っているインフラ・チームが真っ先にやり玉に挙げられることが多いようです。

そんな時のアプリ・チームの主張は

  • 自分たちはアプリケーションに一切手を加えていないので悪くない。悪いはずがない。
  • インフラ・チームが何かやらかしたのではないか?(過去に痛い経験があればなおさら)
  • 隠しパラメータみたいな秘密のスイッチがあるらしいから、それをうまく使って切り抜けてくれ!
  • インフラには高い金をつぎ込んでいるのだから何とかしろ!

というようなものが多いのではないでしょうか?

しかも、大抵の場合アプリ・チームの方が人数が多いので、多勢に無勢いつの間にか問題はインフラ・チームのせいになってしまっている、というようなことはよくあります。

最近は、新卒でインフラ・チームに配属されるというケースも多くなっていますが、ベテラン開発者から運用者に転向しているような技術者は総じてスキルのレベルが高いので(好意的に解釈して)重要な問題の解決を期待されているということの裏返しなのかもしれません。

ただし、インフラ・チームの名誉のために補足しますが、私はパフォーマンス問題の8〜9割近くはSQLつまりアプリケーションの問題のような印象を持っています。(なぜアプリケーションに手を加えていないのに問題が発生するのかは改めて触れたいと思います。)

DBAは問題解決の要

DBAは多くの場合インフラ・チームの一員として数えられていますが、データベースはミドルウェアと言いながらOSから見れば一種のアプリケーションであるので、DBAがアプリ・チームとの仲介役としてうまく機能できれば問題解決に対して非常に強い組織になります。

専任のDBAを置いている組織は少ないかもしれません。サーバ管理者が兼任していたりしてDBAの職務が曖昧になっている場合、インフラ・チームとアプリ・チームの溝が深くなってしまうことはよくあります。

DBA自らがアプリケーションを理解する

問題が発生してからアクションを起こすのでは遅すぎます。DBAは常日頃からアプリケーションに対して関心を持つ必要があります。理想的にはアプリ・チームの責任としてDBAが理解できるドキュメントを整備するのがベストですが、現実的にはなかなか難しいことが多いかと思います。

従って、DBA側から能動的に情報を収集することが必要です。DBAから見た最も身近なアプリケーションはSQL文です。Oracleでは実行されているSQL文に関するさまざまな情報をV$SQL*ビュー(Oracle11gR2であれば30種)から得ることができます。

V$SQL_PLAN

それではV$SQL_PLANを使用してSQL文とテーブルの相関表を作ってみましょう。あるSQL文がどのテーブルを使用しているか、あるいはあるテーブルはどのようなSQL文によって使用されているかを一覧にしたものです。これはDB設計におけるCRUD表に近いものですが、実際に動いているSQL文の情報を使って作るというのがポイントです。

 実行計画のおさらい(SELECT)

以下の簡単な実行計画の例で考えてみます。

-----------------------------------------------------------------------------------
| Id  | Operation                     |  Name        | Rows  | Bytes | Cost (%CPU)|
-----------------------------------------------------------------------------------
|   0 | SELECT STATEMENT              |              |     3 |   189 |    10  (10)|
|   1 |  NESTED LOOPS                 |              |     3 |   189 |    10  (10)|
|   2 |   NESTED LOOPS                |              |     3 |   141 |     7  (15)|
|*  3 |    TABLE ACCESS FULL          | EMPLOYEES    |     3 |    60 |     4  (25)|
|   4 |    TABLE ACCESS BY INDEX ROWID| JOBS         |    19 |   513 |     2  (50)|
|*  5 |     INDEX UNIQUE SCAN         | JOB_ID_PK    |     1 |       |            |
|   6 |   TABLE ACCESS BY INDEX ROWID | DEPARTMENTS  |    27 |   432 |     2  (50)|
|*  7 |    INDEX UNIQUE SCAN          | DEPT_ID_PK   |     1 |       |            |
-----------------------------------------------------------------------------------

まず中程の「Name」列は、その実行計画でアクセス(SELECT)されるテーブルあるいはインデックスとなります。テーブルなのかインデックスなのか名前から判断することもできますが確実なのは左隣りの「Operation」列が

  • TABLE ACCESS  で始まっているものが「テーブル」
  • INDEX で始まっているものが「インデックス」

です。

V$SQL_PLANからSELECT対象のテーブル一覧を取得する

V$SQL_PLANから情報を取得するクエリーを考えてみます。

select distinct
 SQL_ID
,OBJECT_NAME
,3 CRUD_TYPE_ID
,OPTIONS
from
 V$SQL_PLAN
where OPERATION like 'TABLE%'
and   OBJECT_OWNER = 'xxxx'
order by
 OBJECT_NAME
,CRUD_TYPE_ID
,SQL_ID;

8行目のWHERE条件でテーブルアクセスのオペレーションのみを取り出しています。
9行目ではテーブルの所有者で絞り込みをしています。

4行目で何をしているのかというと、V$SQL_PLANにはない V$SQL.COMMAND_TYPE に相当する情報をここで付加しています。
SELECTであれば「3」になります。この理由は追って説明します。

以下は実行例です。

SQL> select distinct
  2   SQL_ID
  3  ,OBJECT_NAME
  4  ,3 CRUD_TYPE_ID
  5  ,OPTIONS
  6  from
  7   V$SQL_PLAN
  8  where OPERATION like 'TABLE%'
  9  and   OBJECT_OWNER = 'SCOTT'
 10  order by
 11   OBJECT_NAME
 12  ,CRUD_TYPE_ID
 13  ,SQL_ID;

SQL_ID        OBJECT_NAME  CRUD_TYPE_ID OPTIONS
------------- ------------ ------------ --------
0cc0hqvr7kpgx BONUS                   3 FULL
30hdyzpfrzgbf BONUS                   3 FULL
3154rqzb8xudy DEPT                    3 FULL
678kpdu03jc8x DEPT                    3 FULL
1srhq04p4x0zz EMP                     3 FULL
a2dk8bdn0ujx7 EMP                     3 FULL
25tuw6725a9h7 SALGRADE                3 FULL
30p00zk91247c SALGRADE                3 FULL

8行が選択されました。

(続く)