1. はじめに
結構な長編となっております。。
最近バックエンド開発に本格的に参画し始めリレーショナルDBに関わるようになりました。
そこでキャッチアップのために入門学習しました。学習の中で『達人に学ぶDB設計』を読んだので、インプットの定着と、個人的に役に立ちそうな情報の読み返し用に記事にまとめてみました。
同じようにDB入門中の方の参考になれば幸いです。
2. 基礎編
DOA
-
DOA(Data Oriented Approarch : データ中心アプローチ)
プログラムよりも前にデータの設計から始めるという、近年のソフトウェア開発の主流の考え方。 -
POA(Process Oriented Approarch : プロセス中心アプローチ)
「プロセス=プログラミング」で、プログラミングがデータ設計に先立つという考え方。
POAによりプロセス単位でデータ設計を行うと、別々のDBに同じデータが入るという冗長性が生じるなど不都合が発生する。
DOAであれば複数のプログラムで1つのDBを共有でき冗長性を排除できるため、仕様変更も比較的柔軟に対応できるメリットがある。
3層スキーマ
下記3種類のスキーマがあり、DB設計のステップに密接に関連する。
-
外部スキーマ
「ユーザーから見たデータベース」の姿。
後述の概念スキーマを、利用者ごとの目的に応じた見方に加工したもの。
リレーショナルモデルのビューやネットワークモデルのサブスキーマが外部スキーマに相当する。 -
概念スキーマ
「開発者から見たデータベース」で、概念スキーマの設計を論理設計
と呼ぶ。
DB設計の中心となるスキーマで、ER図や正規化などは論理設計で取り扱うもの。
E-R図の作成、表定義、正規化が概念スキーマに相当する。 -
内部スキーマ
「DBMSから見たデータベース」で、内部スキーマの設計を物理設計
と呼ぶ。
概念スキーマで定義された論理データモデルを、DBMS内の記憶装置上にどう格納するかを定義する。
ファイル編成やインデックスの設定が内部スキーマに相当する。
論理設計と物理設計
論理設計のステップ
-
1.エンティティの抽出
作るシステムにどのようなエンティティがあるか(どのようなデータを扱いたいか)を考える。(=テーブル
の定義)
エンティティは「実体」と訳すが、物理的実体に限らず行為などを示すこともある。 -
2.エンティティの定義
1で抽出したエンティティにどのようなデータを保持するかを決める。(=列
の定義) -
3.正規化
データの更新が整合的に行えるように、エンティティのフォーマットを整理する。 -
4.ER図の作成
正規化を行うとエンティティ(=テーブル)が細かく分割されるため、エンティティ同士の関係が分かりづらくなる。そこで開発者が把握しやすいよう、ER図を作成する。
ER図 = 「エンティティの見取り図」
物理設計のステップ
-
1.テーブル定義
論性設計で作られた概念スキーマを、実際にDBMSに格納するテーブルに変換する。 -
2.インデックス定義
インデックスはなくても機能的には問題ないが、パフォーマンスの最適化を目的にインデックスを定義する。 -
3.ハードウェアのサイジング
キャパシティとパフォーマンスの2つの点で決める。-
キャパシティ
十分な容量サイズを持ったストレージを選定する。サービス終了時にデータ量がどの程度になるか見積もりが必要だが、実際問題難しい。
そこで下記いずれかを考慮しておく。- 安全率を大きくとり余裕を持たせたストレージを選定
- 後で容量が不足しても、簡単に容量追加できる構成にしておく(拡張性の保持)
-
パフォーマンス
DBのパフォーマンスというと、サーバのCPU,メモリを考えがちだが、実際のところDBの性能問題の8割はディスクI/Oによって起きる。
そのため、ストレージのサイジングもパフォーマンスにとって重要。
性能要件の指標は下記2つ。-
処理時間
特定の処理が何秒以内に終了するか -
スループット
単位時間あたりにどれだけ処理できるか。1秒あたりの仕事量=TPS
-
処理時間
-
キャパシティ
-
4.ストレージの冗長構成決定
RAIDによってシステムの信頼性と性能を上げる。RAIDの詳細は省略。 -
5.ファイルの物理配置決定
最近のDBMSでは自動的にファイルの物理配置が決められるため、エンジニアが意識しなくても良くなってきた。
DBに格納されるファイルは下記5種類。I/O量が高いファイルほど、別種のファイルとディスク(RAIDグループ)を分割した方が良い。- データファイル(I/O量:最大)
- インデックスファイル(I/O量:高)
- システムファイル(I/O量:低)
- 一時ファイル(I/O量:高)
- ログファイル(I/O量:中)
正規化
正規化の目的
正規化を意識せず設計されたDBでは、下記の問題が起きる。
- 冗長性:一つの情報が複数のテーブルに存在し、無駄なデータ領域・面倒な更新処理を発生させる。
- 非一貫性:冗長なデータを持っていることにより、更新処理のタイムラグによってデータに不整合が生じる。
これらを解決するための方法論が正規化
である。
第3正規形までの定義
-
第1正規形
1つのセルには1つの値(スカラ値
)しか含まないこと。配列などを含んではいけない。
第1正規形でないことによる問題は、- 主キーを決められないこと(主キーが各列の値を一意に特定できない)
- テーブルの意味やレコードの単位をすぐに理解できないこと
-
第2正規形
部分関数従属
を排除して完全関数従属とする。
部分関数従属とは、複合主キーが設定されたテーブルにおいて、一部の主キー列のみに対して従属する列があること。
第2正規形でないことによる問題は、- テーブルにデータを登録できないケースがあること
- 運用を誤ると、部分関数従属している列と主キー列の対応が、レコードごとにマチマチになってしまうこと。
-
第3正規形
推移的関数従属
を排除する。
推移的関数従属とは、主キー以外の列に従属する列がテーブル内に存在すること。
正規化と非正規化
基本的に正規化はすべきだが、パフォーマンスの面でデメリットもある。
-
検索スピードの悪化
正規化によりテーブルを分割していくと、検索時にSQL分の中で結合(JOIN)が必要になる。
結合はSQLの処理の中でも高コストなため、多用するとSQLの速度低下を引き起こす。
一方、更新処理については正規化した方が早い。(正規化されたテーブルでは冗長性がなく、1レコードの更新で済むため)
正規化の次数が低いほど検索SQLのパフォーマンスは高いがデータ整合性は低い。逆に正規化していくほどパフォーマンスは低下する代わりにデータ整合性が高くなるというトレードオフがある。
ただし、原則的に非正規化はとるべきではない。他の手段でパフォーマンス向上を図り、それでもどうしようもない時の最後の手段として非正規化する。
- 非正規化のリスク
- 更新不整合の危険
- 更新パフォーマンスの低下
-
データのリアルタイム性の低下
例えば、サマリデータ列(商品数など)を作り非正規化すると、バッチ処理により一定間隔でサマリデータを更新する必要があるが、リアルタイムに整合しているデータではなくなる。 -
設計変更が発生したときの手戻りが大きい
データモデルの変更は、コードベースの修正に比べて改修コストが非常に大きいことを意識して論理設計しなくてはいけない。
3. インデックス
インデックスとは何ぞや、というところは省略する。
インデックスの利点
-
アプリケーション透過的
つまり、アプリケーションのコードに影響を与えない。非正規化
がアプリに大きく改修が必要になるのに対し、インデックスの作成はDBのスコープ内で菅ん結するため、簡単にパフォーマンスの改善を期待できる。 -
データ透過的
つまり、DBテーブルに格納されているデータの中身に影響を与えない。そのためインデックスの作成に合わせて論理設計の修正など不要。 -
性能改善効果が大きい
インデックスの性能はデータ量に対し線形より緩くしか劣化しない(持続性が高い)ため、多くの場合で性能改善効果を見込める。
B-treeインデックスの利点
インデックスといえば基本的にB-treeインデックスのことを指し、最も汎用的に使える。
その理由は、下記5点でバランス良く性能が高いため。
-
均一性
キーとなる列について、どのように値を指定しても検索速度にバラつきが小さい。
その理由は、B-treeが平衡木(平べったい木)であるため。ただし、長期間運用して更新が重なれば木のバランスは悪くなりバラつきが出るため、後述の再編成が必要になる。 -
持続性
nをデータ量とすると、インデックスの性能はB-treeでO(log n)
となる。
対して、フルスキャンはO(n)で性能が劣化していくため、データ量が多いほどB-treeはフルスキャンより有利になる。
-
処理汎用性
検索だけでなく、挿入・更新・削除のコストも早い。(検索と同じくO(log n)
) -
非等値性
等号(=)に限らず、不等号やBETWEENを使った検索にも早い。
この理由は、B-treeが最初にキー値をソートするため、リーフノードを一つに絞れなくとも特定ノードより「左」「右」で探索範囲を絞れるため。 -
親ソート性
ソートはDB的にかなりコストが高いわりに多用される。
B-treeではキー値をソートして保持するため、インデックスが存在する列をソートする場合、ソート処理をスキップでき高速化ができる。
ちなみに下記のような処理で実は、暗黙にソートが実行されている。- 集約関数(COUNT, SUM, AVG, MAX, MIN)
- ORDER BY句
- 集合演算(UNION, INTERSECT, EXCEPT)
- OLAP関数(RANK, ROW_NUMBER)
B-treeインデックスの設計方針
下記指針の上から順に従ってBインデックスを作成する。
-
大規模なテーブルに作成する
上記フルスキャン/B-treeの検索速度のグラフを見ると、データ量が少ないとフルスキャンの方が早いことがわかる。
目安として、レコード数が1万件以下の場合はインデックスの効果が発揮されないため、それ以上のレコードが格納されるテーブルを対象とすること。
(ストレージ・サーバの性能等にもよるため、閾値は環境によって変わる) -
カーディナリティの高い列に作成する
カーディナリティとは、その列に格納される値がどのくらいの種類の多さを持つかということ。(例えば性別列なら男性女性不詳など少ないが、口座番号などは無数にありカーディナリティが高いと言える)
目安としては、列に特定の値で検索した時に 5%以下に絞り込める カーディナリティがある列だとインデックスを張る意味があるといえる。(日付なら1/365*100=0.3%なので十分カーディナリティ高い)
※ カーディナリティ高くても、格納される値が特定の値に集中するような列ではB-treeの検索性能安定しないため注意。 -
SQLでWHERE句の選択条件、または結合条件に使用される列に作成する
当たり前だが、SQLで検索条件や結合条件に使用されない列にインデックスを張っても無意味。
ただし、SQLでインデックスが使用されるか注意するポイントがある。(本記事では省略)
B-treeインデックスの注意事項
-
主キー・Uniqueキーの列には作成不要
これらの列には内部的にB-treeインデックスが作られている。そのため二重にインデックス作成しても無意味。 -
更新性能を劣化させてしまう
B-treeインデックスは作成すればするほど、そのテーブルに対する更新性能は劣化する。
(インデックスはテーブルとは別のオブジェクトとしてDBMS内部に保持されているため、インデックス列の値が変更されると、そのオブジェクト内に保持している値も変更が必要なため) -
定期的に再編成が必要
「均一性」であったように、テーブルのデータが更新されていくと平衡木が崩れ性能が劣化する。そのため定期的にインデックス再編成してメンテナンスすることで性能維持が必要。
インデックスの再編成
上記の再編成は具体的にどのような操作をするか?
-
一度インデックスを削除して、もう一度作成する
簡単な方法だが、インデックスを削除後作成時に何らかの理由でエラーが発生すると、その間インデックスがないというリスクがある。 -
DBMSの再編成機能を使う
製品によりDDLが異なる。
○ PostgreSQL
REINDEX <インデックス名>;
○ MySQL
OPTIMIZE TABLE <テーブル名>;
MySQLではインデックスの再編成のためにテーブルごと再編成する。
※ 同一テーブル内の別のインデックスも再編成される
その他のインデックス
B-treeインデックス以外にも下記の種類のインデックスがある。
1. ビットマップインデックス
それぞれのキー値と対応するビットマップ(0,1の配列)を作成するインデックス。
メリット
- カーディナリティの低い(キーが取りうる値が少ない)列に対しても検索性能が良い
- ANDやOR条件でもインデックス利用できる。(B-treeとは異なり、NULL値の検索もできるため)
デメリット
- 更新時の性能が悪い → 更新が多いシステムでは不向き
2. ハッシュインデックス
メリット
- 等値検索では非常に高速
デメリット
- 等値検索以外ではインデックス利用できない。範囲検索、LIKE述語での検索不可。
4. 統計情報
統計情報とは
ものすごくざっくり言うと、カタログマネージャ
から統計情報を受け取ったオプティマイザ
が、複数の実行計画の中から最適な実行計画を選んで、実際にテーブルにアクセスする。
詳細は省きますが、詳しくはこちらのQiitaが分かりやすかったです。
統計情報の設計指針
オプティマイザの適切な実行計画選定を助けることと、パフォーマンスの観点の両面から統計情報収集のタイミング検討が必要。
1. いつ収集するか?
-
データが大規模に更新された後
INSERT/UPDATE/DELETEによって大きく更新された場合、それまでの古い統計情報と最新のテーブルの状態に齟齬が生じる。そのためオプティマイザが最適ではない実行計画を選定する恐れがあるため、大規模な更新後はなるべく早く統計情報の収集するべき。 -
夜間帯(利用者が少ない時間帯)
統計情報の収集は、それなりんいリソースを消費し長時間かかる処理である。そのためテーブルの規模が大きい場合、日中のようなアクセスが多い時間帯に収集すると、本来の処理を阻害するリスクがある。
したがって、システムの利用者が少ない時間帯に収集することで、システム処理の妨げになることを避ける。
2. どこを収集するか?
データ更新が少ないテーブルに対して頻繁に統計情報収集をするのは、DBMSのパフォーマンスを劣化させるだけなのでよろしくない。
そのため、収集が必要なテーブル(更新処理が多いテーブル)の検討が必要。
※ 一時テーブルの取り扱いには注意!
5. バッドノウハウとグレーノウハウ
論理設計のバッドノウハウ
そもそもバッドノウハウがなぜ悪いのか? 理由は下記の通りで開発の生産性や拡張性を著しく下げてしまうため。
- 可読性の低下:バッドノウハウは人間の直感に反するものであるため、エンジニアの設計に対する理解を妨げてしまう
- 設計変更の難しさ:開発の後工程の段階になってモデルを修正することは難しい
- データ構造がコードを決めるから(DOA):DBの設計がうまくいっていないとコードをいくら綺麗にしても取り返せない
バッド1: 単一参照テーブル
単一参照テーブル
とはあらゆるタイプのマスタテーブルを、一つのテーブルにまとめたもの。
列名が「コード」「名前」など同じ構造をしているテーブル同士はまとめることができる、という発想から作られる。
下記の通りメリットよりデメリットが大きいため、単一参照テーブルは利用するべきではない。
- メリット
- マスタテーブルの数が減るため、ER図がシンプルになる
- コード検索のSQLを共通化できる
- デメリット
- 多くの種類の値を格納するため、かなり大きめの可変長文字列型で宣言する必要がある
- 一つのテーブルに多くのレコードを格納するため、検索のパフォーマンスが悪化する
- コードタイプやコード値を間違えて指定しても場合によってはエラーが起きず、バグに気づきにくい
- ER図がすっきりするが正確さはなくなるため、かえって可読性を下げる恐れあり
バッド2: 水平分割とパーティション
水平分割
とは、レコード単位でテーブルを分割すること。
レコードが増えるほどディスクI/Oが増大しパフォーマンスが劣化するため、テーブル単位のデータ量(レコード数)を減らす目的で水平分割をとる。
しかし、下記のデメリットがある
- 論理的な意味合いがなくシステムを複雑にする
- 拡張性に乏しい
例えば年度別で水平分割した場合、年度をまたいだ検索などするときテーブルを跨ぐ必要がある。また一年ごとにテーブル数が増えるため、その度にアプリケーションの改修が必要となる。
水平分割はDOAの理念に反する。
水平分割の代替手段として、パーティション
がある。
パーティションとは、テーブルを分割することなくパーティションキーを軸として物理的に格納領域を分割する機能。
nがパーティション数とすると、SQLがアクセスするデータ量を1/nに減らすことができ、検索パフォーマンスを上げられる。
バッド3: 垂直分割と集約
垂直分割
は列をテーブルを分割すること。
メリデメは水平分割とほぼ同じ。
垂直分割の代替手段として、集約
があり、下記2種類に分けられる。
-
列の絞り込み
既存のテーブルはそのまま残しておき、頻繁に参照される列だけを持った新しいテーブルを追加で作成する。
このようにして切り出される小規模なテーブルをデータマート
という。
データマートの利点は、オリジナルのテーブルを意味的に破壊することなくパフォーマンスを上げられる点。
一方、オリジナルテーブルからのデータ同期タイミングには注意する。更新頻度が多いほどデータ精度は上がる一方、更新処理の負荷が上がるためかえってパフォーマンスを悪化させるリスクがある。同期タイミングは十分検討すること。 -
サマリテーブル
集約関数によって処理した後のデータが頻繁に参照されるシステムであれば、集約関数によってレコードを集約した状態でテーブルを切り出しておく(サマリテーブル
)。これにより単純なSELECT文で集約後の値を参照でき、パフォーマンス向上を狙える。
データマートと同じくオリジナルテーブルからの同期頻度には注意すること。
バッド4: 不適切なキーの設計
主キー、外部キーは基本的に不変である必要がある。
これらのキーに「名前」などを設定し、型を可変長文字列(VARCHAR)とすると不変性が崩れる。
固定長文字列(CHAR)の「コード(ID)」列をキーとすることが望ましい。
バッド5: ダブルマスタ
同じ役割を果たすはずのマスタテーブルが複数存在すること。システム統廃合で別々のシステムを一つにしたとき等に発生する。
統合時にダブルマスタを生み出さないために、名寄せ
の作業が必要になる。
名寄せ
とは、「似通った名前を寄せ集めて統合すること」という意味で、具体的には人名や企業名の表記揺れを解消して名称を統一すること。
名寄せ自体もコストのかかる作業であるため、最近では事前に統一された入力フォーマットを用意して、最初から名寄せが発生しないデータを格納させることも意識されている。
論理設計のグレーノウハウ
グレー1: 代理キー
RDSではテーブルごとに主キーが必要となる。しかし人間の扱うデータは重複や欠落も多く、運用中に事情が変わることもあるため、自然キーからふさわしい主キーが決められないことがある。そんなとき、代理キーを用いることで解決可能。
代理キー(サロゲートキー)
とは、自動割り当ての連続した通し番号のカラムを人工的に作り出し、自然キーの代理で主キーとなるキー。
便利なキーだが、デメリットとしてそもそも論理的には不要なキーで論理モデルをわかりづらくするため、原則的に代理キーは使うべきではない。
できるだけ自然キーによる解決を図るべき。
自然キーだけで解決するために、履歴管理のために時間を表す列を追加する方法(タイムスタンプ
orインターバル
)がある。詳細は省略。
グレー2: 列持ちテーブル
列持ちテーブル(繰り返し項目テーブル)
は、同一レコード内で配列を表現したい場合に使う。
列持ちテーブルは第一正規形を崩すことになるが、直感的にわかりやすいというメリットもあり一概に悪いとは言えない。
↓列持ちテーブルの例
社員ID | 社員名 | 子1 | 子2 | 子3 |
---|---|---|---|---|
001 | 織田 信長 | 信雄 | 信忠 | |
002 | 豊臣 秀吉 | |||
003 | 徳川 家康 | 信康 | 亀姫 | 秀忠 |
列持ちテーブルには下記のような欠点がある。
- 列の増減が難しい:途中から列を増やす場合はアプリケーションの変更コストもかかるため、拡張性が低い
- 無用のNULLを格納することになる:NULLはSQL文の結果を混乱させる要因となるため、極力Nullableな列は避けるべき
グレー3: アドホックな集計キー
DWH/BI分野の論理設計でよく必要性が高くなる。集計を行うためのキーを追加する。
(例えば、都道府県ごとのレコードの中に地域別の集計をするための「地方コード」をアドホックなキーとして追加する。)
アドホックな集計キー
により簡単にSQLで集計できるようになるが、このようなキーを増やしていくとテーブルが肥大化してパフォーマンスを劣化させる原因となる。
これを解決する手段は、
- キーを別テーブルに分割する
- テーブル自体には手を加えず、アドホックキーを追加したビューを作成する
- SELECT文のGROUP BYの中でCASE式を使いアドホックキーを作り出し、読み替えを行う。
グレー4: 多段ビュー
ビュー
とは、SELECT文を保存して一種のテーブルのように扱えるものだが、複雑なSELECT分をビュー化しておくことでアプリケーションレイヤでのプログラミングを簡単化できる利点がある。
非常に有用な機能だが、パフォーマンスに悪影響を与える・濫用するとかえって設計と実装を複雑化させてしまうリスクがある。
ビューから直接実際のテーブルにアクセスする(SELECT)するのであれば良いが、ビューが別のビューにアクセスするような構成(これが多段ビュー
)にすると、依存関係が発生し仕様が複雑化するため極力使うべきではない。
6. 番外編
SQLを考える時のコツ
テーブルを結合(JOIN)するような複雑なSELECT文を作る時、初心者は文の先頭であるSELECT句から考えがち。(私もそうでした。。)
しかし、SELECT句は列を抽出するものであり最後に決まる要素であるため、効率的ではない。
SQLの処理の順番に合わせて、下記の順番に考えるとスムーズ。
1. FROM句:テーブルの選択
2. WHERE句:選択テーブルからレコード絞り込み
3. GROUP BY句:グループ化
4. HAVING句:集計結果に対する絞り込み
マテリアライズドビュー
「実体化されたビュー」と訳される通り、実データを保持するビュー機能でほぼテーブルのようなもの。
テーブルのスナップショット、つまりテーブルのある時点における断面を実装した機能ともいえる。
マテリアライズドビュー
へのアクセス時にSELECTは実行されずパフォーマンス面で通常のビューより有利。
また、マテリアライズドビュー内に主キーやインデックスの作成も可能。
一方、欠点は下記。
- リフレッシュ管理(実テーブルとのデータ同期管理)が必要
- ストレージを消費する
データの制約はDB/コードのどちらで持つべきか?
DBに格納されるデータが満たすべき様々な条件(ビジネスロジック)に関して。
DBのCHECK制約等で実現すべきであって、アプリケーションコードでチェックすべきではないという考え方もあるが、現実問題では下記のような困難が発生する。
-
複雑なビジネスロジックを実装できない
「ある列のデータが100以下の数値である」のような単純な制約ならDBで実装できる。
一方、「テーブルAの列aはテーブルBの列bより小さい」といった実際に十分発生しそうなロジックでもDBでの実装は困難。
標準SQLの表明(Assertion)
という機能により検討もできるが、DBMS製品のサポートが遅れており現実的ではない。 -
エラーハンドリングが難しい
DBで制約をかけ不正なデータがINSERTされた時に返却されるエラーメッセージはDBMS側で定義されており、柔軟性に欠ける。そのせいでユーザフレンドリーなエラーメッセージの実装が困難であり、サービスの満足度を下げるリスクがある。
ところで制約といえば主キー
・外部キー
・NOT NULL
などもあるが、これらはDBMSがSQLが実行する際に最適なアクセスパスを決定する上で必要な情報であり、これらの制約はDB側で実装するべき。
結論:
主キー、外部キー、NOT NULL制約などRDSで基本的なルールはDB側で実装する。
それ以外の制約はアプリケーション側で実装する。
一時テーブル
一時テーブル
とは、一時的なデータを保持するためのテーブルであり、トランザクションやセッション等のスパンで自動削除される。
下記メリットがある。
- ビューと異なりアクセス時にSELECT文を実行せずリソース節約できる
- マテリアライズドビューと異なり自動削除されることからストレージの節約にもなる
一方、主にパフォーマンスの面で下記見落としがちな欠点があるため、取り扱う際は注意が必要。
-
統計情報収集によるリスク
統計情報収集のタイミングを誤れば、古い状態の実データと大きく乖離した一時テーブル(場合によっては空っぽ)の統計情報を使った実行計画をDBMSが組み立てることになり、検索パフォーマンスを十分に発揮しない恐れがある。
それなら一時テーブルにINSERTされた後に統計情報収集すれば良いが、収集自体にもコストがあるため「統計情報収集+一時テーブル利用」のセットで処理コストを考える必要がある。 -
I/O性能に優れるデータファイルを利用できない
一時テーブルのデータ格納先はデータファイルではなく一時ファイルである。
基本的にデータファイルに最も優れたI/O性能のディスク(RAIDグループ)を割り当てると思うが、一時テーブルではこの性能の恩恵にあずかれず、一時ファイル用の性能が割り当てられたディスクでの処理となる。