課題:データが頻繁に変わるときのスキーマの硬直性
これはECアプリのproduct attributes機能を開発していたときに直面した問題です。商品の種類によって属性の構造がまったく異なります——ノートPCならRAM/CPU/SSD、衣類ならサイズ/色/素材、食品なら賞味期限/原材料といった具合です。従来のアプローチではEAV(Entity-Attribute-Value)テーブルでkey/valueペアを管理するか、商品の種類ごとに専用テーブルを作るかのどちらかでした。
どちらも問題があります。EAVテーブルはクエリが複雑になり、多段JOINが必要でデバッグも難しくなります。種類別テーブルでは新しい商品カテゴリを追加するたびにマイグレーションを書く必要があり、専任DBAのいない小規模チームには負担が大きいです。
チームの誰かがこの部分をMongoDBに移行しようと提案しました。一見合理的に聞こえますが、システム全体はすでにMySQLで動いていて——トランザクション、外部キー、レポートがすべてMySQLに依存しています。スタックにもう一つのデータベースエンジンを追加するのは不要な複雑性を生みます:監視が2箇所、バックアップも2箇所、オンコール担当も両方を把握しなければなりません。
原因:MySQLにこの機能がずっと前から存在していたことを知らなかった
実はMySQLにできないのではなく、この機能がすでに存在していることを知らなかっただけでした。MySQL 5.7からネイティブのJSON型が導入されており、MySQL 8.0ではMySQL Document StoreとX DevAPIがコアに統合されました。
MySQL Document StoreではMongoDBのコレクションに似たCollectionを作成し、スキーマを事前定義せずにJSONドキュメントを保存できます。内部ではInnoDB上で動作し、完全なACIDトランザクションをサポートしつつ、ドキュメント指向のクエリAPIも利用できます。X DevAPIはポート33060(通常のポート3306と並行して動作)で動き、同じセッション内でSQL従来型とドキュメントスタイルのCRUDの両方をサポートします。
解決策
方法1:通常のSQLテーブルでJSONカラムを使う
最もシンプルなアプローチ——productsテーブルにattributes JSONカラムを追加し、JSON_EXTRACT()または演算子->>でクエリします。単純なJSON読み書きで十分な場合はこれで問題ありません。ただし、ネストしたフィールドへのインデックスが多数必要になる場合や、バックエンドのコードがSQL文字列の連結ではなくドキュメントスタイルのAPIを使いたい場合は、次第に煩雑になってきます。
方法2:MySQL Document StoreとX DevAPIを使う
まず、X ProtocolをサポートするクライアントであるMySQL Shellをインストールします:
# Ubuntu/Debian
sudo apt install mysql-shell
# またはX DevAPIをサポートするPythonコネクターをインストール
pip install mysql-connector-python
X Protocol(ポート33060)で接続してコレクションを作成します:
# MySQL ShellをX Protocolで接続
mysqlsh root@localhost:33060 --js
// MySQL Shell(JavaScriptモード)内
var db = session.createSchema('product_catalog');
var products = db.createCollection('products');
// ドキュメントを追加 — 事前のスキーマ定義不要
products.add({
name: "Laptop Dell XPS 15",
category: "laptop",
attributes: {
cpu: "Intel Core i7-13700H",
ram: "32GB DDR5",
storage: "1TB NVMe SSD"
},
price: 35000000,
in_stock: true
}).execute();
// 異なる種類のドキュメント — まったく異なるスキーマでもエラーにならない
products.add({
name: "ベーシックTシャツ",
category: "clothing",
attributes: {
sizes: ["S", "M", "L", "XL"],
colors: ["黒", "白", "グレー"],
material: "100% cotton"
},
price: 250000,
in_stock: true
}).execute();
// ドキュメントのクエリ
products.find("category = 'laptop' AND price > 30000000")
.fields("name", "price")
.order_by("price DESC")
.execute();
// ネストしたフィールドで検索
products.find("attributes.ram = '32GB DDR5'").execute();
実際のPythonバックエンドから使う場合:
import mysqlx
# X DevAPI接続
session = mysqlx.get_session({
'host': 'localhost',
'port': 33060,
'user': 'root',
'password': 'your_password'
})
db = session.get_schema('product_catalog')
products = db.get_collection('products')
# ドキュメントを追加
result = products.add({
"name": "MacBook Pro M3",
"category": "laptop",
"attributes": {
"chip": "Apple M3 Pro",
"ram": "18GB Unified Memory",
"storage": "512GB SSD"
},
"price": 55000000
}).execute()
print(f"Added ID: {result.get_generated_ids()}")
# フィルタ+ソート付きクエリ
docs = products.find("in_stock = true AND category = :cat") \
.bind('cat', 'laptop') \
.fields("name", "price", "attributes") \
.order_by("price DESC") \
.limit(10) \
.execute()
for doc in docs.fetch_all():
print(f"{doc['name']} — {doc['price']:,} VND")
session.close()
スロークエリを防ぐためのインデックス作成
usersテーブルが1000万行を超えたときにスロークエリが発生し、手探りでインデックスを最適化した経験から学んだことです。Document Storeも同様に、最初から適切なインデックスを設定しなければ同じ問題が起きます:
// categoryフィールドのインデックス(WHERE句でよく使用)
products.createIndex('category_idx', {
fields: [{
field: '$.category',
type: 'TEXT(50)'
}]
});
// 数値フィールドpriceのインデックス
products.createIndex('price_idx', {
fields: [{
field: '$.price',
type: 'DECIMAL(15,2)'
}]
});
// 作成したインデックスの確認
session.sql('SHOW INDEX FROM product_catalog.products').execute();
ベストなアプローチ:同じデータベース内でリレーショナルとドキュメントを組み合わせる
各アプローチを試した結果、最も効果的なのはどちらか一方を選ぶことではなく、同じMySQLインスタンス内で両方を使うことだとわかりました。
データの特性に応じて使い分けます:
- 通常のSQLテーブル:
orders、users、payments— 固定スキーマで、JOINが必要で、完全なトランザクション整合性が必要なもの。 - ドキュメントコレクション:
products、product_reviews、user_preferences— 種類によってスキーマが変わり、複雑なJOINが不要なもの。
同じMySQL Shellセッション内で、SQLとDocument APIを自由に組み合わせて使えます:
// orders用SQL(リレーショナル — JOINが必要)
session.sql(`
SELECT o.id, o.total, u.email
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'pending'
`).execute();
// product catalog用Document API(柔軟なスキーマ)
var catalog = session.getSchema('product_catalog');
catalog.getCollection('products')
.find("in_stock = true")
.limit(20)
.execute();
実用上の注意点
- ポート33060:X Protocolは専用ポートを使用します。
SHOW VARIABLES LIKE 'mysqlx_port'で確認できます。アプリサーバーが別のマシンにある場合はファイアウォールの開放を忘れずに。 - Document ID:MySQLは28文字の文字列形式の
_idを自動生成します(MongoDBのObjectIdとは異なります)。ドキュメント追加時に独自のIDを指定することも可能です。 - トランザクション:X DevAPIは完全なトランザクションをサポートしています —
session.startTransaction()→session.commit()。複数コレクションにわたるACIDが必要な場合に、MongoDBに対する大きな強みです。 - バックアップ/監視:既存のMySQLと共用できます — mysqldump、xtrabackup、Prometheus MySQL exporterがそのまま使え、追加設定は不要です。
このアプローチに切り替えて最も便利だと感じた点は:インフラを追加でメンテナンスする必要がなく、新しいクエリ言語をゼロから学ぶ必要もなく、MySQLに慣れているチームはX DevAPIの構文を覚えるだけで済むことです。データが増えて最適化が必要になっても、慣れ親しんだツールで十分に対応できます——2つのシステムを並行してデバッグする必要はありません。

