はじめましての方ははじめまして、そうでない方はこんにちは。社内では「じぇっと」と呼ばれています。なぜそう呼ばれているかは追々お伝えできればいいかなと思います。
1. きっかけ — 「使いにくそう」と思っていたJSON型に、業務でSELECTした話
MySQLのJSON型について、正直ずっと「なんとなく」の理解で済ませていました。「スキーマレスで柔軟だけど、結局ふつうのカラムより扱いにくいんだろうな」というくらいの、触ったことのない人が持ちがちな先入観です。
きっかけは、業務であるJSON型カラムを使ったクエリを目にする機会があったことでした。テーブルの形を業務とは関係ない形に抽象化すると、こんなイメージです。イベントごとに参加者へ聞きたい項目が違う参加申込みを1つのテーブルで扱っていて、回答の部分だけJSON型カラムに逃がしてあるという設計です。
CREATE TABLE entries (
id INT PRIMARY KEY AUTO_INCREMENT,
event_id INT,
name VARCHAR(255),
answers JSON
);
INSERT INTO entries (event_id, name, answers) VALUES
(1, '山田太郎', '{"tshirt_size": "L"}'),
(2, '佐藤花子', '{"dietary_restriction": "vegetarian"}');
あるイベントならTシャツのサイズを、別のイベントなら食事制限を——イベントごとに列を増やしていたらキリがない入力項目を、JSON型カラム1つに寄せてある形です。
このテーブルから、たとえば「あるイベントの参加者のTシャツサイズだけ一覧で見たい」というとき、こう書けます。
SELECT name, answers->>'$.tshirt_size' AS tshirt_size
FROM entries
WHERE event_id = 1;
これを知ったときに、思っていたより素直に読めて驚きました。「JSON型=取り扱い注意な特殊カラム」というイメージだったのが、->> で値を取り出すだけならただの関数呼び出しと大差ない。この「思ったより使いやすそうだった」という気づきをきっかけに、JSON型をちゃんと学び直すことにしました。この記事はその過程で分かったメリット・デメリットのまとめです。
2. メリットとして分かったこと — 検索・関数まわりは思ったよりシンプル
-> と ->> の違い
JSON型カラムから値を取り出す方法はいくつかありますが、まず押さえるべきはこの2つです。
| 演算子 | 実体 | 返る値 |
|---|---|---|
-> |
JSON_EXTRACT() のエイリアス |
JSON値(文字列ならダブルクオート付き) |
->> |
-> の結果をさらに JSON_UNQUOTE() したもの |
クオートなしの生の値 |
SELECT answers->'$.tshirt_size' AS with_quote, -- "L"
answers->>'$.tshirt_size' AS without_quote -- L
FROM entries
WHERE id = 1;
->> は WHERE 句・ORDER BY・GROUP BY などどこでも使えるので、実務でJSON値を文字列や数値として扱いたい場面はだいたい ->> で足ります。冒頭のエピソードで「思ったより使いやすい」と感じた正体はここで、JSON_EXTRACT(answers, '$.tshirt_size') を answers->>'$.tshirt_size' と書けるだけの、見た目以上の話ではありませんでした。
なぜ読み取りが軽く感じるのか
JSON型カラムに格納された値は、テキストのままではなく内部的にバイナリ形式に変換されて保存されています。これにより、値を読むたびにテキストをパースし直す必要がなく、キーや配列インデックスで直接該当箇所にアクセスできます。「JSON=都度パースして重そう」というイメージも、この記事を書くまで持っていた誤解のひとつでした。
3. デメリット・注意点として学び直したこと
使ってみた感触は良かった一方、公式ドキュメントを読み直すと「知らずに使っていたら踏んでいたかもしれない」注意点がいくつもありました。
3-1. JSON型カラムは直接インデックスできない
WHERE answers->>'$.tshirt_size' = 'L' のような検索を繰り返すなら、当然インデックスが欲しくなります。しかしJSON型カラムは、他のバイナリ型と同様に直接インデックスを作成できません。
回避策は、値を取り出す**生成列(generated column)**を作り、そちらにインデックスを張ることです。
CREATE TABLE entries (
id INT PRIMARY KEY AUTO_INCREMENT,
event_id INT,
name VARCHAR(255),
answers JSON,
tshirt_size VARCHAR(10) GENERATED ALWAYS AS (answers->>'$.tshirt_size') VIRTUAL,
INDEX idx_tshirt_size (tshirt_size)
);
MySQL 8.0.26以降であれば、JSON_VALUE() を使うことで中間の可視カラムを作らずに直接インデックスを定義する「functional index」も使えます。
CREATE INDEX idx_tshirt_size ON entries ((JSON_VALUE(answers, '$.tshirt_size')));
なお配列の各要素を検索したい場合(例: 参加者が選んだ複数セッションの配列から MEMBER OF() で絞り込む)は、MySQL 8.0.17以降のmulti-valued indexという別の仕組みがありますが、こちらは並べ替えや主キーには使えないなど制約があります。
3-2. 部分更新は条件付きの最適化
JSON_SET() / JSON_REPLACE() / JSON_REMOVE() でJSON値の一部だけを更新すると、MySQLはドキュメント全体を書き直さず、その場で書き換える「部分更新(partial update)」を試みます。ただし、これは無条件の最適化ではありません。
-
JSON_SET/JSON_REPLACE/JSON_REMOVEのみを使っていること(代入や別カラムからのコピーは対象外) - 更新元と更新先が同一カラムであること
- 既存の値の置換のみ(新しいキーの追加や配列の拡張は対象外)
- 置換後の値のサイズが元の値以下であること
条件を外れると、内部的には結局ドキュメント全体が書き直されます。「JSON_SETを使っていれば自動的に速い」わけではない、という点は覚えておく価値がありました。
3-3. 比較・バリデーションまわりの落とし穴
- 比較は大文字小文字を区別します(
utf8mb4_bin相当)。JSON_ARRAY('x') = JSON_ARRAY('X')はfalseになります。 - JSONリテラルの
null/true/falseは小文字必須です。NullはJSONとして無効扱いになります。 -
BETWEENやIN()はJSON値の比較にまだ対応していません。範囲検索したい場合はCAST()などで明示的に型変換する必要があります。 - JSON型にはスキーマがないため、キーの有無や値の型はアプリケーション側で保証するのが基本です。どうしてもDB側で制約したい場合は、MySQL 8.0.17以降の
JSON_SCHEMA_VALID()をCHECK制約に組み込むことで、スキーマに沿わないドキュメントの挿入を防げます。
ALTER TABLE entries
ADD CONSTRAINT chk_answers CHECK (
JSON_SCHEMA_VALID(
'{"type": "object", "required": ["agreed_to_terms"]}',
answers
)
);
4. 結局いつ使うべきか
学び直してみて、判断基準はシンプルに整理できました。
| 向いている場面 | 向いていない場面 |
|---|---|
| エントリーごとに入力項目が異なるエンティティ(今回の参加申込みのような例) | 深い階層構造に対して頻繁にクエリする(正規化した方が数十〜数百倍速いこともある) |
| ログ・外部APIレスポンスの保存 | 厳密なスキーマ・整合性が必要なデータ |
| 設定データなど、読み書きの中心がキー単位のアクセスであるもの | 分析・集計・レポート用途(正規化テーブルの方が得意) |
冒頭のエピソードのように「トップレベルの属性を ->> で取り出して表示する」くらいの使い方であれば、JSON型は見た目通りシンプルで扱いやすいです。一方で、検索条件に使う・大量データに対して更新を繰り返す・厳密な整合性が欲しい、といった要求が出てきたら、生成列でのインデックス付与やスキーマ検証を組み合わせるか、素直に正規化したテーブルに寄せるかを検討する、という判断でよさそうです。
「なんとなく難しそう」というイメージだけでJSON型を避けていたら、この学び直しの機会はなかったと思います。得意な場面で使えば十分実用的、ただし内部の仕組み(インデックス・部分更新・比較ルール)を知らずに使うと痛い目を見る——というのが率直な結論です。
参考
- MySQL 8.0 リファレンスマニュアル — 11.5 JSON データ型
- MySQL 8.0 リファレンスマニュアル — セカンダリインデックスと生成列
- MySQL 8.0 リファレンスマニュアル — JSON 値の属性を返す関数
- MySQL 8.0 リファレンスマニュアル — JSON スキーマ検証関数
- MySQL :: WL#8955: Add support for multi-valued indexes
- The MySQL JSON data type — PlanetScale
- Storing JSON in Your Databases: Tips and Tricks For MySQL Part Two — Percona