正規化のすすめ – 性能編(その1 – データベースのI/Oにまつわる誤解)

データベースのI/Oにまつわる誤解

 今回は、非正規形が実際は遅いことを説明するために、I/Oにまつわる誤解について解説していきたいと思います。

ここから先の説明はPostgreSQLおよびOracleをベースとしています。
MySQLやSQLServerなど別のDBMSでも大筋は同じはずですが、詳しくは個別にご確認ください。

 前回の記事で、次の理解は間違いであると述べましたが、今回は赤字の部分について説明していきます。

非正規化すると、必要な情報が1レコードの中に揃っている
   ↓
1レコードに情報が揃っているので、1回のI/Oで必要な情報が全部取れる(JOINがない)
   ↓
1テーブルだけが対象なのでSQLの記述が少なく済む(シンプル)

 ここで言うI/Oとは、データベースとストレージ(HDDやSSDなど)間の読み書きを指します。
 ここで間違っている点は、1回のI/Oの単位を「レコード」としていることです。イメージとして次の図のようになりますが、これは誤りです。

間違っているI/Oのイメージ

 実際には、1回のI/Oの最小単位は「ページ」ないし「ブロック」です

ページ:  PostgreSQLにおけるI/Oの最小単位(8KB固定)
ブロック: OracleにおけるI/Oの最小単位(2~32KB。デフォルト8KB)

 各レコードは、ページ/ブロックを境界とする領域に連続的に詰めて格納されます。

 1レコードのサイズが1ページ/ブロックに収まる場合、1回のI/Oではその中の複数のレコードが一括で取得されます。

レコードサイズが小さい場合のI/Oイメージ

 1レコードのサイズが1ページ/ブロックに収まらない場合、1レコードのデータを分割して別の領域に格納した状態となるため、複数回のI/Oが必要になります
 この動作は、PostgreSQLとOracleで少し異なります。

  • PostgreSQLでは、1ページに収まらないレコードや2KBを超えるカラムをTOASTという別の領域に、
    2KBごとのチャンクに分割して格納します。
  • OracleにはTOASTの仕組みはなく、1ブロックに収まらないデータを複数のブロックに分けて格納します。レコードを分割して格納するという点は同じです。
レコードサイズが大きい場合のI/Oイメージ(PostgreSQL)

 実際にはキャッシュ・インデックス・WALの動作なども加味する必要がありますが、この動作が基本です。

 以上の仕組みは、レコードのサイズがI/Oの発生回数に大きな影響を与えることを意味しています。

  • 1レコードのサイズがページ/ブロックのサイズより小さければ、1ページ/ブロックに複数のレコードを格納できるため、1回のI/Oでより多くの情報を取得できることになります。
    これは特に、シーケンシャルなアクセスをする場合に効率的です。
  • 逆に1レコードのサイズがページ/ブロックのサイズを超えると、TOASTや複数のブロックを読み込む必要があり、I/Oの回数が増えます。
    たとえ1レコードの中で取得したい情報が1カラムだけだったとしても、常に1レコード全体を読み込むため、複数のI/Oが発生します。
    これは効率的とは言えない状態です。

 正規形でJOINによるI/Oが発生することばかり強調されますが、レコードサイズが大きくなることでもI/Oは増えるのです。

I/Oの重要性

 なぜデータベースのI/Oがこんな面倒な仕組みなのか、疑問に思われると思いますので、その理由を解説します。

 これは、ファイルシステムとストレージの特性に合わせて最適化した結果です。
 HDDやSSDの特性として、メモリのように任意の1byteを読み書きできるわけではなく、以下を物理的な最小単位として読み書きを行います。

  • HDDの場合はセクタ(512byteや4KB)
  • SSDの場合はページ(4KBや16KB)

 これに合わせてファイルシステムは、ブロックを最小単位(主に4KB)として読み書きを行います。

 ストレージ-ファイルシステム-データベースの間でこの単位がうまい具合に揃っている状態では、パフォーマンスは良好です。これが崩れると、無駄なヘッダの移動や読み書きなどでオーバーヘッドが発生します。
 SSDの場合では比較的ランダムアクセスに強いものの、このオーバーヘッドを無視できるほどではありません。一般的にHDDやSSDのI/Oにかかる時間は、CPU処理やメモリアクセスと比べて1,000倍から10,000,000倍といった差になりますので、この点は性能に大きく影響します。


 非正規化を行うと、カラム数が増えることでレコードサイズが増大します。大きなシステムでは、1レコードに数百から数千のカラムを持つテーブルになる状況も珍しくありません。
 そのようなテーブルでは、1レコードあたり数百回といったI/Oが発生することになります。この負荷はJOINの負荷を上回るだけでなく、すべてのSQLでの性能低下につながります。

 ここまで理解すれば、レコードサイズの増大に伴うI/Oの増加が、いかに性能上不利となるかが分かると思います。

非正規化の例え

 この問題をより具体的にイメージするために、データベースを倉庫に例えてみます。

  • 1つのカラムは、商品を構成する1個のパーツを表す。
  • 1つのレコードは、複数のパーツが集まってできた1つの商品を表す。
    商品を構成するパーツはまとめられ、パッケージングされる。
    ただし一定(8KB)以上大きい場合は取り回ししづらいので、n個組のように複数のパッケージに分けられる。
  • 1ページ/ブロックは、商品を集めて格納する段ボール箱を表す。
    ただし段ボール箱は追加したり空きを再利用するので、n個組となった商品のパッケージが、同じ段ボール箱に格納されるとは限らない。
  • 1つのテーブルは、商品を入れた段ボール箱を置くための棚を表す。
    商品が倉庫のどこにあるかは、検索可能なようにインデックス化される。
    倉庫から商品を出し入れするときは段ボール箱の単位で運び、所定の作業場所で開封しなければならない。
  • あなたはデータベースであり、倉庫番である。
    指示を受けて上記のルールに則り商品を出し入れする役目である。

 正規化した場合では、商品を構成するパーツが最小限となり、パッケージが小さく済みます。
 1つの段ボール箱に複数の商品を収められる状態であれば、1回の運搬でより多くの商品を運ぶことができます。また、段ボール箱を1つ開けるだけで目的の商品やパーツを出し入れできます。
 この状態での作業は、たとえ多種類の商品を取り扱う(=JOINが発生する)場合においても最小限の労力で済むため、効率的です。

 一方で非正規化した場合は、商品を構成するパーツが多くなり、1つの商品がn個組に分割された状態が発生しやすくなります。
 仮に1つの商品が10個の段ボール箱に分割されたとすると、あなたは1つの商品を運び出すために倉庫全体を回って目的の段ボール箱を探し出し、10回に分けて運搬・開封する必要があります。さらに目的が1つのパーツだけだったならば、9回の運搬と開封の作業は無駄でしかありません。

正規化のイメージ
非正規化のイメージ

 こうして人の動きに置き換えてみると、非正規化が大きな無駄と重労働を生み出すことがイメージできるのではないでしょうか?

 非正規形が性能的に有利に働くのは、1レコード全体を読み込んでも無駄がない場合に限られます。この条件では、複数のテーブルをJOINするよりいくらか速い可能性もあります。
 しかしそのような限定的な条件のために、他のすべての性能を犠牲にする必要があるでしょうか?

 レコードサイズは最小限に保つべきです。そのための最適な形が、正規形です。

 次回は、非正規化により実際にどれくらい性能が低下するのか、測定と検証を行いたいと思います。


1つ星2つ星3つ星4つ星5つ星 (まだ評価がありません)
タイトルとURLをコピーしました