学習したことをAIで整理
既存の Laravel + Docker の TODO アプリを使って、機能追加ではなく MySQL 学習を進めた。
目標は「スキルマップ項目を説明できる状態になること」。
前提
- 既存プロジェクトは Laravel + Docker
- TODO CRUD はすでに動作
todosテーブル(例)id, user_id, title, content, status, priority, due_date, created_at, updated_at
- 既存機能は壊さない
- 学習優先で最小変更
今回やった最小変更
- 学習データ投入用 Seeder を追加
database/seeders/MySqlLearningTodoSeeder.php
- 学習用インデックス追加 Migration を追加
database/migrations/2026_03_15_000001_add_learning_indexes_to_todos_table.php
- 検証 SQL を追加
database/sql/mysql_learning_queries.sql
- 手順ドキュメントを追加
docs/mysql-learning.md
- README に導線を追記
docker compose exec の理解(まずここ)
docker compose exec mysql mysql -usail -ppassword laravelmysqlコンテナ内で MySQL クライアントを直接起動
docker compose exec mysql shmysqlコンテナ内の シェルに入る(その後にmysql ...を打つ)
docker compose exec php shphpコンテナ内のシェルに入る
学習データを投入
目的: EXPLAIN の差分が見える件数を作る。
やっていること: todos を目安 20,000 件まで追加(既存データは消さない)。
docker compose exec php php artisan db:seed --class=Database\\Seeders\\MySqlLearningTodoSeeder
件数確認:
SELECT COUNT(1) FROM todos;
インデックス追加前の確認
目的: まず「基準値」を見る。
やっていること: 現在のインデックスと実行計画を確認。
SHOW INDEX FROM todos;
EXPLAIN SELECT * FROM todos WHERE status = 'pending';
EXPLAIN
SELECT * FROM todos
WHERE user_id = 1 AND status = 'pending'
ORDER BY due_date ASC
LIMIT 20;
ここで分かったこと
- id(PRIMARY)と user_id(外部キー由来)しかない状態だと、
status 条件はフルスキャンになりやすい - user_id は効くが、status と ORDER BY due_date まで最適化しきれない
学習用インデックスを追加
目的: 追加前後比較をできるようにする。
やっていること: migration で 3 本追加。
idx_todos_status (status)
idx_todos_user_status_due_date (user_id, status, due_date)
idx_todos_title (title)
実行:
docker compose exec php php artisan migrate
確認:
SHOW INDEX FROM todos;
追加後 EXPLAIN の読み方
目的: どこが改善したかを数字で確認する。
やっていること: key/type/rows/Extra を比較。
EXPLAIN SELECT * FROM todos WHERE status = 'pending';
EXPLAIN
SELECT * FROM todos
WHERE user_id = 1 AND status = 'pending'
ORDER BY due_date ASC
LIMIT 20;
見るポイント:
key: 実際に使われたインデックスtype: ALL より ref/range の方が改善傾向rows: 読む見積行数(小さいほど良い)Extra: Using filesort / Using temporary の有無LIKE検索の違いを体感
EXPLAIN SELECT * FROM todos WHERE title LIKE '%urgent%';
EXPLAIN SELECT * FROM todos WHERE title LIKE 'urgent%';
何が違うか
'%urgent%'は先頭ワイルドカードなので、B-Tree index が効きにくい'urgent%'は前方一致なので、idx_todos_title が効く可能性が高い
インデックス設計の決め方(実務で使う型)
- 遅いクエリを 1 本決める
- WHERE / JOIN / ORDER BY を分解する
- 複合 index は「絞り込み列 → 並び替え列」の順で検討
- EXPLAIN で key/type/rows/Extra を比較して採用
- 増やしすぎない(更新コストが上がる)
補足(カーディナリティ)
- Cardinality が大きいほど、1値で強く絞れる傾向
- 低カーディナリティ列(例: status)単体 index は効きにくいことがある
- ただし複合 index の一部として有効なケースはある
正規化(1NF / 2NF / 3NF)を口頭で説明する
1NF
- 1セル1値(配列やCSVを入れない)
2NF - 複合主キーのとき、非キー列はキー全体に依存させる
片方だけで決まる列は分離(部分従属の排除)
3NF - 「主キー以外の項目が、別の項目で決まる状態」を分離
例: 部署ID -> 部署名 なら部署名は部署テーブルへ
この TODO への当てはめ
- users と todos を分け、todos.user_id で参照している
- todos に user_name などを重複保持しない
- 更新不整合を防げる(3NF的説明が可能)
SQLインジェクションとプレースホルダ
SQLインジェクションとは
入力値を使って、想定外の SQL を実行させる攻撃。
本来「データ」のはずが「SQL命令」として解釈されると発生する。
例の悪意入力:
' OR 1=1 --
なぜプレースホルダが安全か
- SQL文と入力値を分離して扱うから
- 入力は命令ではなく「値」としてバインドされる
安全な例:
where('email', $email)
DB::select('... where id = ?', [$id])
ウィンドウ関数とビュー(体感用)
ウィンドウ関数
目的: 行を減らさず、行ごとに順位などを付与する。
例:
SELECT
id, user_id, title, due_date,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY due_date ASC, id ASC
) AS rn
FROM todos
WHERE due_date IS NOT NULL;
ビュー
目的: 複雑な SELECT を名前付きで再利用する。
作成:
CREATE OR REPLACE VIEW v_user_todo_summary AS
SELECT user_id, status, COUNT(*) AS cnt
FROM todos
GROUP BY user_id, status;
利用:
SELECT * FROM v_user_todo_summary ORDER BY user_id, status;
削除
DROP VIEW IF EXISTS v_user_todo_summary;
最後に(今回の到達)
- 実行計画の基礎読み取り(key/type/rows/Extra)は説明可能
- 単一・複合インデックスの効果比較を説明可能
- LIKE '%xxx%' と LIKE 'xxx%' の差を説明可能
- 1NF/2NF/3NF を TODO 例で口頭説明できる状態に近づいた
- プレースホルダが安全な理由(SQL文と値の分離)を説明可能

コメント