N+1問題とは - ORM で起きる原因と Eager Loading・JOIN・バッチ取得での対策

N+1問題とは - ORM で起きる原因と Eager Loading・JOIN・バッチ取得での対策

作成日:
読了:約32分
更新日:

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

一覧画面を開くと妙に遅い。けれどスロークエリログを見ても、遅いクエリは1本も見当たらない。こういうときに疑うべきなのがN+1問題です。1本1本のクエリは速いのに、その本数が一覧の件数に比例して増えていくため、合計すると遅くなります。

この記事では、N+1問題が起きる仕組みを SQL ログで確認したうえで、なぜ遅いのか、JOIN と IN 句という2つの解き方の違い、主要な ORM ごとの対策 API、見つけ方、そして対策のやりすぎで起きる問題までを整理します。ORM の API 名は Laravel・Rails・Django・Prisma の各公式ドキュメントと、graphql/dataloader の README で確認したものだけを載せています。

N+1問題とは

N+1問題は、一覧を取得するクエリが1回、そのあと一覧の各行について関連データを取るクエリが N 回発行され、合計で 1 + N 回のクエリが走ってしまう状態のことです。

Laravel の公式ドキュメントにある例がわかりやすいので、そのまま借ります。Book モデルが author というリレーションを持っているとします。

N+1 が起きるコード(Laravel)
use App\Models\Book;
 
$books = Book::all();
 
foreach ($books as $book) {
    echo $book->author->name;
}

このコードを実行したときに発行される SQL は、次のような形になります。

発行される SQL(N+1)
select * from books;
 
select * from authors where id = 1;
select * from authors where id = 2;
select * from authors where id = 3;
-- ... 本の冊数だけ続く
select * from authors where id = 25;

公式ドキュメントでも「本が25冊あれば26回のクエリが実行される(本の取得1回 + 著者の取得25回)」と説明されています。Rails Guides にも同じ構図の例があり、Book.limit(10) を回して book.author.last_name を読むと合計11回のクエリになると書かれています。名前は「N+1」ですが、順番で言えば「1 + N」です。

なぜ起きるのか - 遅延読み込み(Lazy Loading)

N+1問題の直接の原因は、ORM の遅延読み込み(Lazy Loading)です。多くの ORM では、$book->author や book.author のようにリレーションをプロパティとして読んだ瞬間に、まだ読み込まれていなければその場で SQL を発行します。Laravel の公式ドキュメントも、プロパティとしてアクセスしたリレーションは初めてアクセスされるまで実際には読み込まれない、と説明しています。

これ自体は便利な仕組みです。必要になるまでデータを取らないので、使わない関連データの分だけ無駄が減ります。問題は、ループの中でアクセスすると、行ごとに1回ずつ SQL が飛ぶことです。

厄介なのは、コード上はただのプロパティアクセスにしか見えない点です。特に次のような場所で気づかれにくくなります。

  • ビューテンプレートの中のループ(Blade、ERB、Django テンプレートなど)
  • API のレスポンスを組み立てるシリアライザやリソースクラス
  • 一覧の各行で呼ばれるモデルのアクセサやヘルパーメソッド
  • GraphQL のリゾルバ(後述)

コントローラでは一覧を1回取っているだけに見えても、その先のテンプレートやシリアライザで関連データを読んでいれば N+1 は起きます。

なぜ遅いのか - 往復回数が積み上がる

1本1本のクエリは主キーでの検索なので、インデックスが効いていれば非常に速く終わります。それでも遅くなるのは、クエリ1本ごとにアプリケーションとデータベースの間で往復(ラウンドトリップ)が発生するからです。往復のたびに、ネットワーク越しの送受信、SQL の解析と実行計画の作成、結果のシリアライズ、ORM 側でのオブジェクト生成といった固定的なコストがかかります。

数字を仮定して考えてみます。以下は説明のための仮定です。

  • 1往復あたりのコストを 1ms と仮定すると、一覧が100件なら N+1 では 101回で約101ms、対策後の2回なら約2ms
  • 一覧が1,000件なら N+1 では約1,001ms、対策後は2回のまま(IN 句に渡す ID は増えますが往復は増えません)
  • アプリケーションとデータベースが別ホストにあり、1往復が 1ms より大きければ、差はその分だけ広がります

ここで重要なのは、N+1 のコストは件数に比例して増えるということです。開発環境ではデータが10件しかなく、データベースも同じマシンにあるため、ほとんど遅さを感じません。本番でデータが増え、データベースが別ホストに置かれて初めて表面化する、というのが典型的なパターンです。

もう1つの特徴は、冒頭でも書いたようにスロークエリログに引っかかりにくいことです。スロークエリログは1本ごとの実行時間がしきい値を超えたものを記録する仕組みなので、1本は速いが本数が多いという N+1 は記録されません。1本のクエリが遅い場合の調べ方は 実行計画(EXPLAIN)の読み方 で扱いましたが、N+1 はそれとは別の観点、つまり1リクエストあたり何本のクエリが出ているかで見つける必要があります。

解き方は2つ - JOIN と IN 句による別クエリ

N+1 を解消する方法は、SQL のレベルで見ると大きく2つに分かれます。ORM の Eager Loading(事前読み込み)は、内部でこのどちらか、あるいは両方を使っています。

方法1: JOIN で1回にまとめる

JOIN で親と関連を1回で取る
SELECT books.*, authors.*
FROM books
LEFT OUTER JOIN authors ON authors.id = books.author_id;

クエリは1回で済みます。ただし、1対多や多対多のリレーションを JOIN すると、親の行が子の数だけ重複して返ってきます。著者1人に本が50冊あれば、著者の列は50回繰り返されます。さらに1つの親に対して複数の「多」側のリレーション(例えば投稿に対するコメントとタグ)を同時に JOIN すると、結果の行数は掛け算で増えていきます。

Django の公式ドキュメントも、select_related が JOIN を使うことを説明したうえで、「多」側のリレーションを結合したときに結果セットがずっと大きくなるのを避けるため、select_related は外部キーと1対1の関係に限られている、と書いています。

方法2: IN 句で関連をまとめて別クエリにする

親を取ったあと、関連を IN 句でまとめて取る
SELECT * FROM books;
 
SELECT * FROM authors WHERE id IN (1, 2, 3, 4, 5);

まず親を取り、その結果に含まれる外部キーを集めて、関連テーブルを IN 句で1回だけ引きます。クエリは2回になりますが、件数がいくら増えても2回のままです。行の重複も起きません。Laravel の with、Rails の preload、Django の prefetch_related はこちらの方式で、ORM が2回目の結果を親の各オブジェクトに振り分けてくれます。

ここで忘れがちなのが、2回目のクエリの検索列にインデックスがあるかです。authors.id は主キーなので問題ありませんが、1対多の子側を取る SELECT * FROM comments WHERE post_id IN (...) のようなクエリでは、post_id にインデックスがないとフルスキャンになります。インデックスが効く条件は データベースインデックス入門 にまとめています。

どちらを選ぶか

観点JOIN(1クエリ)IN 句による別クエリ
クエリ回数1回リレーションごとに1回ずつ追加
行の重複1対多・多対多では親が子の数だけ重複起きない
転送データ量重複分だけ増える必要な分だけ
関連テーブルの列で絞り込み・並び替えできる2回目のクエリ側でしかできない
向いている関係多対1、1対11対多、多対多

大まかには、多対1や1対1(本から著者を引くような向き)は JOIN、1対多や多対多(著者から本の一覧を引くような向き)は IN 句、と覚えておくと判断しやすくなります。関連テーブルの列で親を絞り込みたい場合は JOIN が必要です。

ORM 別の対策

ここからは主要な ORM ごとに、Eager Loading の書き方と、N+1 を検出・禁止する仕組みを見ていきます。

Laravel Eloquent

基本は with です。冒頭の例は次のように書き換えると、クエリが2回になります。

with による Eager Loading
$books = Book::with('author')->get();
 
foreach ($books as $book) {
    echo $book->author->name;
}
発行される SQL(公式ドキュメントより)
select * from books
 
select * from authors where id in (1, 2, 3, 4, 5, ...)

公式ドキュメントで確認できる主な書き方は次のとおりです。

Eloquent の Eager Loading のバリエーション
// 複数のリレーション
$books = Book::with(['author', 'publisher'])->get();
 
// ネストしたリレーション(ドット記法)
$books = Book::with('author.contacts')->get();
 
// 取得する列を絞る(id と外部キーは必ず含める)
$books = Book::with('author:id,name,book_id')->get();
 
// 取得済みのコレクションに後から読み込む(Lazy Eager Loading)
$users->loadMissing(['comments', 'posts']);
 
// 件数だけ欲しいときはモデルを読み込まずに数える
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
    echo $post->comments_count;
}

列を絞る書き方について、公式ドキュメントは「id 列と関連する外部キー列を必ず含めること」と注意しています。外部キーがないと、ORM が2回目のクエリの結果を親に振り分けられないためです。

N+1 を見逃さないための仕組みとして、遅延読み込みの禁止があります。AppServiceProvider の boot メソッドで Model::preventLazyLoading を呼ぶと、Eager Loading していないリレーションへのアクセスで例外が投げられます。公式ドキュメントの例では、本番以外でだけ有効にしています。

本番以外で遅延読み込みを禁止する
use Illuminate\Database\Eloquent\Model;
 
public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}

例外ではなくログに残したい場合は、Model::handleLazyLoadingViolationUsing で違反時の処理を差し替えられます。

違反をログに記録する
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
    $class = $model::class;
 
    info("Attempted to lazy load [{$relation}] on model [{$class}].");
});

また、Laravel 13.x の公式ドキュメントにはAutomatic Eager Loadingという機能が載っています。Model::automaticallyEagerLoadRelationships() を boot で呼ぶとアプリケーション全体で、コレクションに対して withRelationshipAutoloading() を呼ぶとそのコレクションだけで、リレーションの読み込みを自動化します。ドキュメント上はbetaと記載されているため、使う場合は対象のバージョンのドキュメントで現状を確認してください。導入されたバージョンは本記事では確認していません。

Rails(Active Record)

Rails には Eager Loading のメソッドが3つあり、Rails Guides では次のように説明されています。

  • preload: リレーションごとに別クエリを発行する(IN 句方式)。関連テーブルに条件を付けることはできない
  • eager_load: LEFT OUTER JOIN を使って1クエリで取る(JOIN 方式)。関連テーブルに条件を付けられる
  • includes: 状況に応じて preload と eager_load を使い分ける。Guides は基本的に includes を推奨している
includes による Eager Loading
books = Book.includes(:author).limit(10)
 
books.each do |book|
  puts book.author.last_name
end
includes(preload と同じ動き)で発行される SQL
SELECT books.* FROM books
  LIMIT 10
 
SELECT authors.* FROM authors
  WHERE authors.id IN (1,2,3,4,5,6,7,8,9,10)
eager_load で発行される SQL
SELECT "books"."id" AS t0_r0, "books"."title" AS t0_r1, ... FROM "books"
  LEFT OUTER JOIN "authors" ON "authors"."id" = "books"."author_id"
  LIMIT 10

includes は、関連テーブルの列に対する条件をハッシュで渡すと JOIN 方式に切り替わります。SQL の文字列で条件を書く場合は references で関連テーブルを明示する必要があります。

includes が JOIN に切り替わるケース
# ハッシュで条件を渡すと LEFT OUTER JOIN になる
Author.includes(:books).where(books: { out_of_print: true })
 
# 文字列で条件を書く場合は references が必要
Author.includes(:books).where("books.out_of_print = true").references(:books)

N+1 を禁止する仕組みは strict_loading です。User.strict_loading.first のように使うと、そのレコードから Eager Loading していないリレーションを読んだ時点で ActiveRecord::StrictLoadingViolationError が発生します。レコード単位の strict_loading! もあります。アプリケーション全体で有効にするには config.active_record.strict_loading_by_default = true を、例外ではなくログに出すには config.active_record.action_on_strict_loading_violation に :log を設定します。

Django

Django の Eager Loading は select_related と prefetch_related の2つで、JOIN 方式と IN 句方式がはっきり分かれています。

  • select_related: SQL の JOIN で関連オブジェクトを同じクエリで取る。外部キーと1対1の関係(単一の値になる関係)に限られる
  • prefetch_related: 関連ごとに1回の追加クエリで取り、Python 側で結合する。多対多や逆方向の外部キーにも使える
select_related と prefetch_related
# JOIN で author と、その先の hometown まで1クエリで取る
b = Book.objects.select_related("author__hometown").get(id=4)
p = b.author     # データベースにアクセスしない
c = p.hometown   # データベースにアクセスしない
 
# 1対1/多対1 は select_related、その先の多対多は prefetch_related
Restaurant.objects.select_related("best_pizza").prefetch_related("best_pizza__toppings")

最後の例について公式ドキュメントは、best_pizza が select_related で取得済みであることを prefetch_related が検知して再取得しないため、クエリは2回で済むと説明しています。

Django 6.1 ではfetch modeという仕組みが追加されました。読み込まれていないフィールドにアクセスしたときの振る舞いを、QuerySet.fetch_mode() で切り替えられます。

  • FETCH_ONE: 既定。アクセスしたインスタンスの分だけ取得する(従来の動き)
  • FETCH_PEERS: 同じ QuerySet から来た全インスタンスの分をまとめて取得する。必要になった時点で prefetch_related() が走るようなもので、リリースノートは「N+1 問題の多くを2回のクエリに減らせる」と説明している
  • FETCH_RAISE: FieldFetchBlocked 例外を投げる。意図しないクエリを防ぎたい箇所で使う
FETCH_PEERS(Django 6.1)
from django.db import models
 
books = Book.objects.fetch_mode(models.FETCH_PEERS)
for book in books:
    print(book.author.name)  # 本の取得と著者の取得の2クエリで済む

公式ドキュメントによると、fetch mode が効くのは ForeignKey、OneToOneField とその逆方向、defer() / only() で遅延させたフィールド、汎用リレーションです。多対多や逆方向の外部キーのような関連マネージャ経由のクエリには効かないため、そちらは引き続き prefetch_related で対処します。

なお Django 6.1 では、引数なしの select_related()(null でない外部キーをすべて辿る)が非推奨になりました。公式ドキュメントも、必要以上に複雑なクエリと多くのデータを返しやすいとして、多くの場合は推奨しないと書いています。

Prisma

Prisma ORM 7 では、include(または select)でリレーションを一緒に取得します。逆に、ユーザーの一覧を取ったあとループの中で投稿を findMany するような書き方をすると N+1 になります。

include と relationLoadStrategy(Prisma ORM 7)
const usersWithPosts = await prisma.user.findMany({
  relationLoadStrategy: "join", // または "query"
  include: {
    posts: true,
  },
});

relationLoadStrategy は、関連の読み込み方を選ぶトップレベルのオプションです。

  • join: データベース側で結合し、1回の SQL でネストしたデータを返す。Prisma のブログによると、PostgreSQL では JSON 集約と LATERAL JOIN を使う
  • query: テーブルごとにクエリを送り、アプリケーション側で結合する

このオプションは schema.prisma の generator ブロックで previewFeatures = ["relationJoins"] を指定したときに使え、対象は PostgreSQL、CockroachDB、MySQL です。フラグを有効にすると既定は join になり、フラグがなければ常にアプリケーション側で結合する方式になります。Prisma ORM 7 のドキュメントでもプレビュー機能として扱われており、本記事の執筆時点で GA(一般提供)になったことを示す公式の記載は確認できませんでした。

補足: Prisma の現行ドキュメントは Prisma ORM 8 を前提に書かれており、クエリの書き方が .include("author") のようなメソッドチェーンに変わっています。リレーションは親と同じ1本の SQL の中で相関サブクエリを使って読む、と説明されています。Prisma ORM 8 の提供状況(GA かどうか)は本記事では未確認のため、移行を検討する場合は公式の移行ガイドを確認してください。

GraphQL と DataLoader

GraphQL は構造上 N+1 が起きやすい仕組みです。リゾルバはフィールドごとに独立して呼ばれるため、posts の一覧を返したあと、各 post の author フィールドのリゾルバが投稿の件数だけ呼ばれ、それぞれがデータベースを引いてしまいます。

この問題に対する定番の解決策が graphql/dataloader です。README によると、DataLoader は1回の実行フレーム(イベントループの1 tick)の中で発生した個々の load 呼び出しをまとめ、要求されたすべてのキーを渡してバッチ関数を1回だけ呼びます。

DataLoader の基本形
const DataLoader = require("dataloader");
 
// バッチ関数: キーの配列を受け取り、値の配列を返す Promise を返す
async function batchGetUsers(ids) {
  const rows = await db.query("SELECT * FROM users WHERE id = ANY($1)", [ids]);
  const byId = new Map(rows.map((row) => [row.id, row]));
  // キーと同じ長さ・同じ順番で返す(見つからないキーは null などで埋める)
  return ids.map((id) => byId.get(id) ?? null);
}
 
// リクエストごとに新しいインスタンスを作る
function createLoaders() {
  return { user: new DataLoader(batchGetUsers) };
}
 
// リゾルバ側では1件ずつ load するだけでよい
const resolvers = {
  Post: {
    author: (post, _args, context) => context.loaders.user.load(post.authorId),
  },
};

バッチ関数には README で決められた約束があります。

  • 返す値の配列は、受け取ったキーの配列と同じ長さであること
  • 値の配列の各要素は、キーの配列の同じ位置に対応していること

SQL の IN 句(PostgreSQL なら = ANY(...))の結果は、キーの順番どおりには返ってきません。上の例のように、いったん ID をキーにした Map に入れてからキーの順に並べ直すのが定石です。

もう1つ大事なのがキャッシュの範囲です。DataLoader は同じキーの load を記憶しますが、README は「DataLoader のキャッシュは Redis や Memcache などの共有キャッシュを置き換えるものではない」とし、リクエストごとに新しい DataLoader を作ることを推奨しています。複数のユーザーで1つのキャッシュを共有すると、別のユーザーのデータが見えてしまう危険があるためです。アプリケーション全体で使うキャッシュの設計は キャッシュ戦略の基本 を参照してください。

DataLoader の考え方は GraphQL 以外にも使えます。Django の FETCH_PEERS や Laravel の Automatic Eager Loading も、「1件ずつ要求されたものを、まとめて取りに行く」という点では同じ発想です。

見つけ方

N+1 は1本のクエリが遅いわけではないので、リクエスト単位のクエリ本数と、値だけが違う同じ形の SQL の繰り返しを探します。

開発環境のツール

対象ツール確認できること
LaravelLaravel Debugbar発行された全クエリとバインド値、実行時間(QueryCollector)
RailsbulletN+1、不要な Eager Loading、counter cache が必要な箇所の通知
Djangodjango-debug-toolbarSQL パネルで各クエリの実行時間と EXPLAIN へのリンク

bullet は、N+1 を検出したときに通知と一緒にスタックトレースを出してくれるので、どのコードが原因かをすぐに追えます。Bullet.raise = true にすると検出時にエラーを投げるため、README でもテストを失敗させる用途が紹介されています。

Laravel Debugbar は、APP_DEBUG が true で、環境が production でも testing でもないときに有効になります。README は本番環境にはインストールしないことを強く推奨しています。

テストで本数を固定する

一度直した N+1 が再発しないようにするには、テストでクエリ本数を検査するのが確実です。

  • Django: TransactionTestCase.assertNumQueries で、処理中に発行されたクエリ本数を検証できる
  • Rails: strict_loading を有効にしておけば、遅延読み込みが起きた時点で例外になる
  • Laravel: テスト環境でも Model::preventLazyLoading が有効になる条件にしておけば、違反時に例外になる

前の節で書いたように、件数が少ないテストデータでは N+1 があっても速く終わります。実行時間ではなく、クエリ本数や遅延読み込みの有無で検査するのがポイントです。テストデータが1件だけだと 1 + 1 = 2回で、Eager Loading した場合と本数が変わらないこともあるため、一覧のテストでは2件以上のデータを用意しておきます。

本番環境では APM とトレース

本番では、APM(アプリケーション性能監視)や分散トレースのスパンを見ると、1つのリクエストの中に同じ形の SQL が何十本も並んでいる様子がそのまま見えます。リクエストあたりのクエリ本数をメトリクスとして記録しておけば、件数の増加に伴って本数が増えていくエンドポイントも見つけられます。ログ・メトリクス・トレースの使い分けは オブザーバビリティ入門 で整理しています。

Eager Loading しすぎの問題

N+1 を恐れて、何でもかんでも Eager Loading すればよいわけではありません。逆方向の問題もあります。

  • 使わない関連を読む: 一覧画面では著者名しか表示しないのに、著者・出版社・タグ・コメントまで毎回読み込むと、クエリ本数もデータ量も無駄に増えます。bullet が「不要な Eager Loading」を検出対象にしているのはこのためです
  • JOIN による行の爆発: 1対多のリレーションを複数 JOIN すると、結果の行数が掛け算で増えます。親100件、子が平均20件ずつのリレーションを2つ JOIN すれば、単純計算で 100 × 20 × 20 = 40,000行になります(仮の数字です)。この場合は IN 句方式に切り替えます
  • 全列の取得: 関連テーブルの大きなテキスト列や JSON 列まで読む必要がなければ、Laravel の author:id,name,book_id のように列を絞ります
  • 件数のためにモデルを読む: コメント数を表示するだけなのに、コメントを全件 Eager Loading して数えるのは無駄です。Laravel の withCount や、SQL の COUNT と GROUP BY を使って、データベース側で集計します

対策の基本は、その画面や API が実際に使う関連と列だけを読むことです。「とりあえず全部 with しておく」は、N+1 を別の問題に置き換えているだけになりがちです。

ページネーションとの組み合わせ

一覧画面にはたいていページネーションが付きます。Eager Loading との組み合わせで気をつけたい点が2つあります。

1つ目は、Eager Loading はページ単位で効くことです。1ページ20件なら、関連を取る IN 句に渡る ID も20件程度です。全件を取ってからアプリケーション側でページを切ると、IN 句が巨大になり、読み込むデータ量も膨らみます。必ずデータベース側で LIMIT を掛けた親に対して Eager Loading します。

2つ目は、1対多を JOIN した SQL に LIMIT を掛けると、親の件数ではなく結合後の行数で切られることです。

1対多の JOIN に LIMIT を付けると親の件数がずれる
-- 投稿を10件取りたいつもりが、「投稿 x コメント」の行が10行返る
SELECT posts.*, comments.*
FROM posts
LEFT OUTER JOIN comments ON comments.post_id = posts.id
ORDER BY posts.id DESC
LIMIT 10;
 
-- 親だけを先に LIMIT で取り、子は IN 句で取る2段階にする
SELECT * FROM posts ORDER BY id DESC LIMIT 10;
SELECT * FROM comments WHERE post_id IN (110, 109, 108, 107, 106, 105, 104, 103, 102, 101);

1件目の投稿にコメントが10件あれば、それだけで LIMIT 10 を使い切ってしまいます。生の SQL を書く場合は、親を先にページングしてから子を IN 句で取る2段階が安全です。ORM の JOIN 方式(Rails の eager_load など)と LIMIT を組み合わせたときの SQL がどうなるかは ORM の実装によるため、実際に発行される SQL をログで確認してください。

ページネーションそのものの方式(OFFSET 方式とカーソル方式)の違いは ページネーション入門 で扱っています。OFFSET が深くなるほど遅くなる問題は N+1 とは別の原因なので、両方を切り分けて考えます。

対策の選び方

最後に、N+1 に出会ったときの手順をまとめます。

  1. クエリ本数を数える。Debugbar・bullet・django-debug-toolbar・APM などで、1リクエストあたりの本数と、同じ形の SQL の繰り返しを確認する
  2. どの関連のアクセスが原因かを特定する。テンプレート、シリアライザ、リゾルバ、アクセサの中のループを疑う
  3. 関係の向きで方式を選ぶ。多対1・1対1は JOIN(select_related、eager_load)、1対多・多対多は IN 句(with、preload、prefetch_related)
  4. 読む列と関連を絞る。件数だけなら集計、表示しない関連は読まない
  5. IN 句の対象列にインデックスがあるか確認する
  6. 再発を防ぐ。preventLazyLoading・strict_loading・FETCH_RAISE・assertNumQueries を開発環境とテストで有効にする

N+1 は、ORM が「必要になったら取りに行く」ように作られていることの裏返しです。仕組みを知っていれば、SQL ログを見て「同じ形のクエリが件数分並んでいる」と気づいた時点で原因の見当がつきます。ORM のコードを書くときも、その裏で何本の SQL が出ているかを意識しておくと、本番でデータが増えてから慌てることが減るはずです。

参考資料

データベースインデックス入門 - B-treeの仕組みと、効くクエリ・効かないクエリ

データベースインデックス入門 - B-treeの仕組みと、効くクエリ・効かないクエリ

約10分

データベースのインデックスを実務目線で整理します。フルスキャンとの違い、B-tree インデックスがなぜ速いのか、等価・範囲・前方一致・ORDER BY・JOIN で効く理由、複合インデックスの左端プレフィックス、カバリングインデックス(index-only scan)、列に関数を使うと効かない・前方ワイルドカード LIKE が効かないといった落とし穴、書き込みコストやストレージのトレードオフ、InnoDB のクラスタ化インデックスと PostgreSQL の違い、EXPLAIN の読み方まで、PostgreSQL・MySQL 公式と Use The Index, Luke を一次ソースにまとめます。

PostgreSQL の TOAST 入門 - 大きな値はどこに、どう保存されるのか

PostgreSQL の TOAST 入門 - 大きな値はどこに、どう保存されるのか

約20分

PostgreSQL が 2kB を超える行の値を圧縮したり別テーブルへ逃がしたりする TOAST の仕組みを、PostgreSQL 18.6 で実際に動かした結果とともに解説します。発動する条件、約2,000バイトずつのチャンク、PLAIN / EXTENDED / EXTERNAL / MAIN の4つの保存方式、pglz と lz4、テーブルの容量の見方、そして SELECT * や length() が TOAST を読みに行くコスト、UPDATE で TOAST が膨らむ理由と、EXPLAIN の SERIALIZE で隠れた読み込みを見る方法まで。

トランザクションとACID・分離レベル入門 - dirty read / phantom と PostgreSQL・MySQLの違い

トランザクションとACID・分離レベル入門 - dirty read / phantom と PostgreSQL・MySQLの違い

約13分

データベースのトランザクションを実務目線で整理します。ACID(原子性・一貫性・分離性・永続性)の意味、BEGIN/COMMIT/ROLLBACKの基本、4つの分離レベル(READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLE)と、各レベルで起き得る異常(dirty read・non-repeatable read・phantom read)の対応関係を表で確認します。さらにPostgreSQLのデフォルトはREAD COMMITTED、MySQL InnoDBのデフォルトはREPEATABLE READという製品差や、PostgreSQLではREAD UNCOMMITTEDがREAD COMMITTED扱いになる点まで、PostgreSQL・MySQL公式を一次ソースにまとめます。

楽天市場のおすすめPR / 楽天アフィリエイト