Laravel TODO プロジェクトで MySQL 学習を進めた記録(実行計画・インデックス・正規化・SQL安全性)

学習したことを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 laravel
    • mysql コンテナ内で MySQL クライアントを直接起動
  • docker compose exec mysql sh
    • mysql コンテナ内の シェルに入る(その後に mysql ... を打つ)
  • docker compose exec php sh
    • php コンテナ内のシェルに入る

学習データを投入

目的: 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. 遅いクエリを 1 本決める
  2. WHERE / JOIN / ORDER BY を分解する
  3. 複合 index は「絞り込み列 → 並び替え列」の順で検討
  4. EXPLAIN で key/type/rows/Extra を比較して採用
  5. 増やしすぎない(更新コストが上がる)

補足(カーディナリティ)

  • 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文と値の分離)を説明可能

コメント

タイトルとURLをコピーしました