yamakenji blog

「失敗から学ぶRDBの正しい歩き方」を読む

2025/01/02

2025/01/02

失敗から学ぶRDBの正しい歩き方を読んだのでまとめていく

2章 失われた事実

過去の事実(値・過程)が失われると例外処理やトラブル時の状況把握で情報が不足してしまう。 作業ログやエンプラ系の実務ではよく発生する。データの履歴を保存することが大事。 ようは、事実が発生した後に、その事実を修正するための処理を行うことができるか、ステータスが変わっている時にその遷移を追うことができるかなどを考える必要がある。 ただし、履歴の保存はデータ量が増加したときのパフォーマンスとのトレードオフになる。履歴を持つ以外にも遅延レプリケーションを使ったり、Elasticsearchなどの分析ツールに保存するということもできる。

3章やり過ぎたJOIN

不要なJOINとJOINの回数が増えると計算量が増加する。 JOINの高速化のためのアルゴリズムとして大きく3つ挙げられている

  • Nested Loop Join (NLJ)
  • Hash Join
  • Sort Merge Join

JOINの回数を減らすためには、JOINの回避やJOINの条件を見直すことが大事。 特にJOINするテーブルは先にデータを小さくしてからJOINなどをすることを検討する。

4章効かないINDEX

INDEXとは、テーブルからデータを高速に取り出すためのRDBMSの仕組み。だいたいがB-Treeというデータ構造を使っている。 INDEXを効かせるためには、適切なカラムを選択することが大事。また、INDEXを貼るカラムのデータ型も重要。 ケースによってはINDEXを使ってくれないことも

  • 検索結果が多く、全体の件数がそもそも少ない
  • 条件として使ってない
  • カーディナリティが低い
  • 曖昧な検索
  • 統計情報と実際のテーブルで乖離がある

カーディナリティとは、カラムのユニークな値の割合のこと。カーディナリティが高いほどINDEXを効かせやすい。 INDEXを利用するためには、次の2つの条件が必要

  1. 検索結果がテーブル全体の20%未満
  2. 検索対象のテーブルが十分に大きい

INDEXを貼りまくると、更新系のクエリが遅くなる。複雑な割合INDEXを設定しすぎると、オプティマイザが不適切な選択をする場合があるらしい。 これらはMENTORの原則に基づいて対応したらいいらしい。

  • Measure(測定): スロークエリログやDBのパフォーマンスなどのモニタリング
  • Explain(解析): クエリの実行計画を確認、遅くなっている原因の追及
  • Normalize(指名): ボトルネックの原因を特定
  • Test(試験): インデックスの追加や削除などの改善を行い、パフォーマンスの変化を確認
  • Optimize(最適化): パフォーマンスの最適化を行う
  • Rebuild(再構築): 統計情報やインデックスを定期的に再構築

また、INDEXを設計する際には3つの質問をすることが大事

  1. このテーブルは数年後、何行くらいになると想定されるか
  2. このINDEXは複合INDEXでまとめる、または単一のINDEXで十分絞り込めるかどうか
  3. 今このINDEXが必要かどうか、後ほどでも問題ないのではないか

5章 フラグの闇

削除という「状態」を持たせるのがアンチパターン。削除フラグを導入することで以下のような問題が発生しやすくなる

  • クエリの複雑化
  • UNIQUE制約が使えない
  • カーディナリティが低くなる

アンチパターンの回避策として、「事実のみを保存する」が挙げられている。例えば、削除済み用のテーブルを作成するなど。 会員の状態を表すステータスを持つ場合もよくある。これらはそれらの状態を表すデータ型として持つのが良くないらしい。 かといってどうすればいいのかの言及があまりなかったように見える。 絶対に持たせたらダメというふうには書かれていなかったので、適宜注意しながら利用していくのだろう。

  • テーブルが関連するテーブルの親になることがなく、データを取得する際に頻繁にJOIN対象になることがない
  • UNIQUE制約が不要で、外部キーでデータの整合性担保する必要がない

6章 ソートの依存

RDBMSのクエリ実行には処理の評価順序があり、ORDER BYはSELECTの後に実行される。 ページネーション時にLIMITとOFFSETを使うと、OFFSETが大きくなると処理が遅くなる。これは、OFFSETが大きくなると、OFFSET分のデータを取得してからLIMIT分のデータを取得するため。 OFFSETを使わずにページネーションを行う方法として、WHERE句で条件を指定して取得する方法がある。 例えば、次のページとして渡すのを最後に表示されたidを使うことで絞り込みが早くなる。しかし、途中のページを表示していたときにデータが追加されたり削除されたりすると表示されずに飛ばされる行が発生してしまう。 これらの解決策として、

  • アプリケーション側で全取得してソートする
  • ソート済みの結果をキャッシュして利用する
  • NoSQLなどを利用してソートする

どちらにせよ、適材適所で設計していく必要があり、ページネーションは難しい

9章 強すぎる制約

制約を強くすることでデータの整合性を保つことができるが、反面柔軟性を失ってしまう。 例えば、仕様変更をしたいときに、制約が強すぎると変更が難しくなる。データ型を変更する必要が出てきたときに、レコード数が膨大の時にロックが長時間かかってしまい、参照も更新もできなくなってしまう。

MySQLの外部キー制約は、外部キー制約の子テーブルを更新すると、親テーブルの共有ロックを自動的にとるらしく、デッドロックの温床になる。 対応として、排他ロックを取ることで回避できるがパフォーマンスのボトルネックとなる。 ビジネスロジックや状態を持たない範囲で適切に制約をつけることが大事 制約に段階をつけるとするならば、

  • 制約なし: なんでも入るよ
  • 弱い制約: NOT NULL, UNIQUE, 外部キーなどのデータ構造を守る必要最低限の制約
  • 強い制約: CHECK、EXCLUDEなど。制約は一般的な事実の範囲に収める(ex. 都道府県は47個しかない)
  • 強すぎる制約: システムの仕様やビジネス・ルールに基づいて記述される状態。変更が難しい

いい塩梅が難しそう

10章 転んだ後のバックアップ

バックアップは大きく3つに分けられる

  • 論理バックアップ
  • 物理バックアップ
  • ポイントインタイムリカバリ(PITR)

バックアップの設計をするときは、どの手法を採用するにしても次の指針について考える必要がある

  • RPO (Recovery Point Objective): データの損失を許容できる範囲
  • RTO (Recovery Time Objective): システムの復旧にかかる時間
  • RLO (Recovery Level Objective): リカバリの粒度 RTOとRLOによって、バックアップの設計が決まる。

バックアップを正しく行うために、次のようなポイントは押さえておきたい

  • バックアップが正しく行われていることを毎回確認し、失敗した時に気づける
  • リストアを定期的に行う
  • 手順書をまとめる

11章 見られないエラーログ

エラーログ出力の設計として如何含まれていると良さそう

  • いつ、誰が、どこに、どこから、なにをしたか、どうなったか

12章 監視されないデータベース

RDBMSのメトリクスとして何を監視したら良さそうか?

  • クエリの実行量
  • 実際に読み込まれているレコードの量
  • インデックスヒット率
  • デッドロックの有無
  • テンポラリファイルの作成の有無
  • ロックの量と時間

13章 知らないロック

MySQLのロックの特徴としてギャップロックとネクストキーロックがある。 MySQLのロックの粒度は通常行ロック。ギャップロックは、

  • INDEX値を持つ行との間にあるギャップ
  • 先頭のINDEX値を持つ行の前に存在するギャップ
  • 末尾のINDEX値を持つ行の後に存在するギャップ ネクストキーロックは、行ロックとその行の直前のギャップロックの組み合わせ

14章 ロックの功罪

よく聞くACID特性

  • Atomicity(原子性): トランザクションは全て成功するか全て失敗するか
  • Consistency(一貫性): トランザクション前後でデータの整合性が保たれる
  • Isolation(独立性): トランザクションは互いに影響を与えない
  • Durability(永続性): トランザクションが成功したら、データは永続的に保存される

トランザクション分離レベルの種類として次の4つがある。

  • READ UNCOMMITTED: 他のトランザクションがコミットしていないデータを読むことができる
  • READ COMMITTED: 他のトランザクションがコミットしたデータのみを読むことができる
  • REPEATABLE READ: トランザクション中に読んだデータは他のトランザクションによって変更されない
  • SERIALIZABLE: トランザクション中に読んだデータは他のトランザクションによって変更されない

また、それぞれに発生する現象がある

  • Dirty Read: 他のトランザクションがコミットしていない変更内容が見えてしまう現象
  • Fuzzy Read: 他のトランザクションのコミットしていないデータは見えないが、トランザクション途中に他のトランザクションがコミットした変更は見えてしまう現象
  • Phantom Read: 他のトランザクションがコミットしたデータが見えてしまう現象
  • Lost Update: 2つのトランザクションが同じデータを更新し、片方のトランザクションが上書きされてしまう現象

MySQLの場合、REPEATABLE READがデフォルトで、Phantom Readは基本的には発生しない。ただし、SELECT...FOR UPDATEでロックを取得している時には直近にコミットされたレコードを返す。

20章 フレームワーク依存症

フレームワークを利用することで、開発の生産性を高めることができる反面、フレームワーク独自の制約を受け入れる必要があり、依存すればするほどRDBMSを縛り付けることとなる。 特に、テーブル設計がライブラリに依存してしまった場合にテーブル設計に悪影響を及ぼすことがある。 フレームワーク・ライブラリと上手に付き合うための方法として次のようなものが紹介されている

  • 独自型への制約
  • 漏れのある抽象化

データベースの寿命はアプリケーションより長い。データベースにフレームワーク都合の問題を一度持たせると、その問題と長く付き合うことになってしまうため、トレードオフを常に意識して設計をしていく必要がある。