MySQLの生成列(Generated Columns):JSONクエリを高速化しスキーマをクリーンに保つ

Database tutorial - IT technology blog
Database tutorial - IT technology blog

午前2時のデータベース障害

監視アラートが私の電話を鳴らしたのは午前2時のことでした。本番環境のデータベースのCPU使用率が99%に急上昇し、APIのレスポンスタイムは通常200ミリ秒のところ、10秒まで悪化していました。これは本番環境でのクエリ遅延における典型的な症状です。スロークエリログを確認したところ、そこには惨状が広がっていました。JSON列の奥深くに埋もれたビジネスロジックを使用して、250万行、5GB of テーブルをフィルタリングしていたのです。

クエリは以下のようなものでした。

SELECT * FROM orders WHERE JSON_EXTRACT(order_details, '$.status') = 'shipped';

MySQLはJSONドキュメント内の特定のパスに対してネイティブにインデックスを貼ることができないため、リクエストのたびにフルテーブルスキャンが強制されていました。エンジンは毎回、全行のJSONをパースしなければならなかったのです。これはよくあるパフォーマンス低下の要因ですが、まさに「生成列(Generated Columns)」が解決するために設計された問題です。

仮想型(Virtual)vs 格納型(Stored):2つの最適化手法

MySQLは生成データを処理するために2つの戦略を提供しています。どちらを選択するかによって、データが増大した際にシステムがスムーズにスケールするか、あるいは限界に達するかが決まります。

仮想生成列(Virtual Generated Columns)

仮想列はデフォルトのオプションです。ディスク上の余分なスペースを消費しません。代わりに、MySQLは行を読み取るたびにその場で値を計算します。「値を再計算するのは遅いのではないか」と心配になるかもしれませんが、大きな利点があります。それは、仮想列にはインデックスを作成できるということです。インデックスを作成すると、MySQLはインデックス値を物理的に保存します。これにより、実際の列データを二重に保持することなく、標準的なインデックスと同じ速度を得ることができます。

格納生成列(Stored Generated Columns)

格納列は、INSERTまたはUPDATEの実行時に値を計算し、その結果をディスクに書き込みます。通常の列のように動作しますが、管理はデータベースによって行われます。これは、非常に複雑でCPU負荷の高い計算に適した選択肢です。また、仮想フィールドを解釈できない古いレポーティングツールを使用している場合にも役立ちます。

2つのアプローチの比較

機能 仮想列 (Virtual) 格納列 (Stored)
ディスク使用量 低(インデックスのみ) 高(全データを保存)
書き込み速度 高速(書き込み時の計算なし) 低速(書き込みごとに計算)
読み取り速度 高速(インデックス時) 最速(常に物理データ)
最適な用途 JSONのインデックス作成、単純な計算 重いCPUロジック、インデックスなしの読み取り

推奨される戦略

本番環境のワークロードの約90%において、仮想生成列がより賢い選択です。これにより、.ibdファイルのサイズを肥大化させることなく、JSONフィールドにインデックスを貼ることができます。ディスク使用量を小さく抑えることは、バックアップ速度を維持し、メモリバッファプールの制限内に収めるために極めて重要です。

最近、レガシーなCSVデータセットを柔軟なスキーマ設計を実現するためにJSONベースのアーキテクチャに移行しました。初期データのインポートには、toolcraft.app/ja/tools/data/csv-to-jsonを使用しました。これは完全にブラウザ上で動作するため、機密データがマシンから離れることはありません。データがJSON構造になった後、DBeaverなどのツールを使用して仮想列を追加し、主要なフィールドを検索可能にしました。

実装:JSONの最適化

具体的な例を見てみましょう。プロフィール情報がJSONで保存されているusersテーブルがあり、頻繁に都市名(city)でフィルタリングする必要があると仮定します。

ステップ1:仮想列を定義する

生のJSONに頼るのではなく、ERD設計の段階で、都市名の文字列を抽出する仮想列を定義します。

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    profile_data JSON,
    -- 都市名を仮想フィールドとして抽出
    user_city VARCHAR(100) GENERATED ALWAYS AS (profile_data->>"$.address.city") VIRTUAL
);

ここで->>演算子が重要です。これはJSON_UNQUOTE(JSON_EXTRACT(...))の短縮形です。これにより、引用符付きのJSON値(””Chicago””)ではなく、きれいな文字列(”Chicago”)を取得できます。

ステップ2:インデックスを追加する

仮想列を定義しただけでは速度の問題は解決しません。フルテーブルスキャンを止めるには、インデックスを追加する必要があります。

CREATE INDEX idx_user_city ON users(user_city);

ステップ3:ビジネスロジックを自動化する

生成列は、アプリケーションとデータベースの間で値が食い違う「データドリフト」も防ぎます。例えば、最終価格を自動的に計算できます。

ALTER TABLE products 
ADD COLUMN final_price DECIMAL(10,2) 
GENERATED ALWAYS AS (base_price - (base_price * discount_percent / 100)) STORED;

このシナリオでは、STOREDを使用しました。これにより、エクスポート中にCPUに全行の再計算を強いることなく、数百万行にわたる財務レポートを実行できるようになります。

結果の検証

あの午前2時の障害の際、仮想列とインデックスを実装した後にクエリのEXPLAINを実行しました。結果は劇的に変わりました。アクセスタイプはフルテーブルスキャン(ALL)から、idx_user_cityを使用したrefルックアップへと変化しました。実行時間は8秒からわずか1.2ミリ秒に短縮されました。RDSのCPU負荷は即座に15%まで下がり、私はようやく眠りにつくことができました。

まとめ

MySQLでモダンなアプリを構築しているなら、JSONを単なるブラックボックスとして扱うのはやめましょう。頻繁にクエリを実行するフィールドには仮想生成列を使用してください。これにより、ORMから煩雑なJSON_EXTRACT呼び出しを排除し、アプリケーションコードをクリーンに保つことができます。そして何より, データベースの高速性を維持できます。インデックス作成には仮想型(Virtual)を、重い計算処理には格納型(Stored)を使い分けましょう。

Share: