Skip to content
Published on

データベースパフォーマンスチューニング完全ガイド:クエリ最適化、インデックス戦略、コネクションプール管理

Authors

TL;DR

  • EXPLAIN ANALYZE: クエリ実行計画の実際のコストと行数を分析する核心ツール
  • インデックス戦略: B-Tree(基本)、GIN(全文検索)、BRIN(時系列)、部分/式インデックス活用
  • スロークエリ: pg_stat_statements、slow_query_logでボトルネッククエリを特定
  • N+1問題: ORMのEager Loading、BatchSize、JOINで解決
  • コネクションプール: HikariCP(Java)、PgBouncer(PostgreSQL)で接続管理
  • パーティショニング: Range/List/Hashで大規模テーブルを分割
  • キャッシング: Redis/Memcachedで読み取り負荷を分散

目次

  1. なぜDBがボトルネックなのか
  2. EXPLAIN ANALYZE完全解説
  3. インデックス戦略
  4. スロークエリ分析
  5. N+1クエリ問題
  6. コネクションプーリング
  7. クエリ最適化パターン
  8. パーティショニング
  9. 読み取りレプリカとキャッシング
  10. PostgreSQL専用最適化
  11. MySQL専用最適化
  12. 実践クイズ
  13. 参考資料

1. なぜDBがボトルネックなのか

1.1 一般的なパフォーマンスボトルネック

Webアプリケーションのパフォーマンスボトルネックの80%以上がデータベースで発生します。

リクエストフローにおけるボトルネック:
Client -> CDN -> Load Balancer -> App Server -> [Database]
                                                  ^
                                           大半の遅延がここで発生
ボトルネックタイプ原因影響
遅いクエリインデックス不在、非効率なJOINレスポンスタイム増加
ロック競合同時更新の衝突スループット低下
コネクション枯渇プールサイズ不足リクエスト待機/失敗
過度なI/OフルテーブルスキャンCPU/ディスク過負荷
ネットワークラウンドトリップN+1クエリ不要なRTT増加

1.2 パフォーマンスチューニングの優先順位

パフォーマンス改善効果が大きい順にアプローチすべきです。

パフォーマンス改善効果ピラミッド:

        /\
       /  \        クエリ最適化(最大効果)
      /____\
     /      \      インデックス戦略
    /________\
   /          \    スキーマ設計
  /____________\
 /              \  ハードウェア/設定チューニング
/________________\ キャッシング/読み取りレプリカ

2. EXPLAIN ANALYZE完全解説

2.1 基本的な使い方

-- PostgreSQL
EXPLAIN ANALYZE
SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.created_at > '2025-01-01'
  AND o.total > 100;

2.2 実行計画の解釈

QUERY PLAN
---------------------------------------------------------------------------
Nested Loop  (cost=0.85..892.45 rows=23 width=48)
             (actual time=0.045..2.341 rows=19 loops=1)
  ->  Index Scan using idx_orders_created_at on orders o
        (cost=0.42..445.23 rows=23 width=20)
        (actual time=0.025..1.123 rows=19 loops=1)
        Filter: (total > 100)
        Rows Removed by Filter: 5
  ->  Index Scan using users_pkey on users u
        (cost=0.42..8.44 rows=1 width=36)
        (actual time=0.008..0.008 rows=1 loops=19)
Planning Time: 0.234 ms
Execution Time: 2.456 ms

解釈の重要ポイント:

項目意味注意点
cost=0.85..892.45開始~総推定コスト単位は任意のコスト単位
rows=23推定行数actual rowsと大きく異なる場合は統計更新が必要
actual time実際の所要時間(ms)loopsを掛ける必要がある
loops=19繰り返し実行回数Nested Loopで重要
Rows Removed by Filterフィルタで除去された行多い場合はインデックス改善が必要

2.3 スキャンタイプの理解

-- 1. Seq Scan(順次スキャン)- テーブル全体を読み取り
-- 小さいテーブルや大部分の行を読む必要がある場合に適切
EXPLAIN ANALYZE SELECT * FROM small_table;

-- 2. Index Scan - インデックスで行の位置を見つけてテーブルにアクセス
-- 選択性が高い(少ない行を返す)クエリに適切
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'user@example.com';

-- 3. Index Only Scan - インデックスだけで結果を返す(テーブルアクセス不要)
-- Covering Index使用時
EXPLAIN ANALYZE SELECT id, email FROM users WHERE email = 'user@example.com';

-- 4. Bitmap Index Scan - インデックスからビットマップを作成してテーブルにアクセス
-- 中程度の選択性のクエリに適切
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending' AND total > 100;
スキャンタイプのパフォーマンス順序(一般的):

Index Only Scan  >  Index Scan  >  Bitmap Scan  >  Seq Scan
(最速)                                            (最遅)

2.4 EXPLAINオプション

-- PostgreSQL EXPLAINオプション
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT * FROM orders WHERE user_id = 42;

-- BUFFERS: I/O情報を含む
-- FORMAT JSON: JSON形式で出力
-- VERBOSE: 追加情報を含む
-- COSTS: コスト推定値を含む(デフォルトON)
-- TIMING: 時間情報を含む(デフォルトON)
-- MySQL EXPLAINオプション
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 42;

-- MySQL 8.0+でツリー形式をサポート
EXPLAIN FORMAT=TREE
SELECT * FROM orders WHERE user_id = 42;

3. インデックス戦略

3.1 B-Treeインデックス(基本)

B-Treeは最も一般的なインデックスタイプで、等価検索と範囲検索に適しています。

-- 単一カラムインデックス
CREATE INDEX idx_users_email ON users(email);

-- 複合インデックス(カラム順序が重要!)
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);

-- 複合インデックス活用ルール(Leftmost Prefix Rule)
-- idx_orders_user_dateは以下のクエリに活用:
-- WHERE user_id = 1                          (O)
-- WHERE user_id = 1 AND created_at > '2025'  (O)
-- WHERE created_at > '2025'                  (X) 先頭カラムなし

3.2 インデックスタイプ比較

タイプ用途PostgreSQLMySQL
B-Tree等価、範囲、ソートデフォルトデフォルト
Hash等価のみサポートサポート
GIN全文検索、配列、JSONBサポート非サポート
GiST地理/幾何学、範囲サポート非サポート
BRIN時系列、順次データサポート非サポート
Full-Text全文検索サポートサポート

3.3 GINインデックス(全文検索/JSONB)

-- 全文検索GINインデックス
CREATE INDEX idx_articles_search
ON articles USING GIN(to_tsvector('english', title || ' ' || body));

-- JSONB GINインデックス
CREATE INDEX idx_events_data ON events USING GIN(metadata);

-- JSONB特定パスインデックス
CREATE INDEX idx_events_type ON events USING GIN((metadata -> 'type'));

-- 使用例
SELECT * FROM articles
WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('postgresql & tuning');

SELECT * FROM events
WHERE metadata @> '{"type": "click", "page": "/home"}';

3.4 BRINインデックス(時系列データ)

-- BRINインデックス - 時系列データに非常に効率的
-- B-Treeより100倍以上小さいサイズ
CREATE INDEX idx_logs_created ON logs USING BRIN(created_at);

-- 物理的な並び順と相関が高い場合に効果的
-- 確認方法:
SELECT correlation
FROM pg_stats
WHERE tablename = 'logs' AND attname = 'created_at';
-- correlationが1に近ければBRINが適切

3.5 部分インデックスと式インデックス

-- 部分インデックス(Partial Index)- 特定条件の行のみインデックス化
CREATE INDEX idx_orders_pending
ON orders(created_at)
WHERE status = 'pending';
-- 全体行の5%のみインデックス化 -> サイズ95%節約

-- 式インデックス(Expression Index)
CREATE INDEX idx_users_lower_email
ON users(LOWER(email));
-- WHERE LOWER(email) = 'user@example.com' クエリに活用

-- カバリングインデックス(Covering Index)
CREATE INDEX idx_orders_covering
ON orders(user_id, created_at) INCLUDE (total, status);
-- Index Only Scanが可能 -> テーブルアクセス不要

3.6 インデックス設計チェックリスト

インデックス設計チェックリスト:

必須確認:
  [x] WHERE句で頻繁に使用されるカラムにインデックス
  [x] JOIN条件カラムにインデックス
  [x] ORDER BYカラムがインデックスに含まれる
  [x] 複合インデックスのカラム順序を最適化
  [x] 選択性(Selectivity)が高いカラムを優先

注意事項:
  [x] 書き込みパフォーマンス vs 読み取りパフォーマンスのトレードオフ
  [x] 使用されていないインデックスを削除
  [x] インデックスサイズを監視
  [x] インデックスブロートを管理(PostgreSQL)

4. スロークエリ分析

4.1 PostgreSQL - pg_stat_statements

-- pg_stat_statementsの有効化(postgresql.conf)
-- shared_preload_libraries = 'pg_stat_statements'

-- 拡張のインストール
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- 遅いクエリTop 10(総時間基準)
SELECT
  substring(query, 1, 80) AS short_query,
  calls,
  round(total_exec_time::numeric, 2) AS total_ms,
  round(mean_exec_time::numeric, 2) AS mean_ms,
  round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct,
  rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

-- 平均実行時間が長いクエリ
SELECT
  substring(query, 1, 100) AS short_query,
  calls,
  round(mean_exec_time::numeric, 2) AS mean_ms,
  rows / NULLIF(calls, 0) AS avg_rows
FROM pg_stat_statements
WHERE calls > 100
ORDER BY mean_exec_time DESC
LIMIT 10;

4.2 MySQL - Slow Query Log

# my.cnf - Slow Query Log設定
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow-query.log
long_query_time = 1.0             # 1秒以上のクエリを記録
log_queries_not_using_indexes = 1 # インデックス未使用クエリを記録
min_examined_row_limit = 100      # 最低100行以上検査したクエリ
# mysqldumpslowでスロークエリ分析
mysqldumpslow -s t -t 10 /var/log/mysql/slow-query.log

# pt-query-digest(Percona Toolkit)
pt-query-digest /var/log/mysql/slow-query.log \
  --limit 10 \
  --order-by query_time:sum

4.3 自動化されたクエリモニタリング

-- PostgreSQL: リアルタイムの長時間クエリ確認
SELECT
  pid,
  now() - pg_stat_activity.query_start AS duration,
  query,
  state
FROM pg_stat_activity
WHERE (now() - pg_stat_activity.query_start) > interval '5 seconds'
  AND state != 'idle'
ORDER BY duration DESC;

-- ロック待ちクエリの確認
SELECT
  blocked.pid AS blocked_pid,
  blocked.query AS blocked_query,
  blocking.pid AS blocking_pid,
  blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid
JOIN pg_locks kl ON kl.locktype = bl.locktype
  AND kl.database IS NOT DISTINCT FROM bl.database
  AND kl.relation IS NOT DISTINCT FROM bl.relation
JOIN pg_stat_activity blocking ON kl.pid = blocking.pid
WHERE NOT bl.granted AND kl.granted;

5. N+1クエリ問題

5.1 N+1問題とは?

# N+1問題の例(Python/SQLAlchemy)

# BAD: N+1クエリ発生
users = session.query(User).all()  # 1回のクエリ
for user in users:
    print(user.orders)  # N回の追加クエリ(ユーザーごとに1回)

# 実際に実行されるSQL:
# SELECT * FROM users;                    -- 1回
# SELECT * FROM orders WHERE user_id = 1;  -- +1
# SELECT * FROM orders WHERE user_id = 2;  -- +1
# SELECT * FROM orders WHERE user_id = 3;  -- +1
# ...(N人のユーザー)

5.2 解決方法

# 解決1: Eager Loading(JOIN)
users = session.query(User).options(
    joinedload(User.orders)
).all()
# SQL: SELECT * FROM users LEFT JOIN orders ON ...  -- 1回のクエリ

# 解決2: Subquery Loading
users = session.query(User).options(
    subqueryload(User.orders)
).all()
# SQL: SELECT * FROM users;                         -- 1回
# SQL: SELECT * FROM orders WHERE user_id IN (...);  -- 1回

# 解決3: selectinload(SQLAlchemy推奨)
users = session.query(User).options(
    selectinload(User.orders)
).all()
// Node.js(Prisma)- N+1解決
// BAD: N+1
const users = await prisma.user.findMany();
for (const user of users) {
  const orders = await prisma.order.findMany({
    where: { userId: user.id }
  });
}

// GOOD: includeで解決
const users = await prisma.user.findMany({
  include: { orders: true }
});

// GOOD: 必要なフィールドのみ選択
const users = await prisma.user.findMany({
  select: {
    id: true,
    name: true,
    orders: {
      select: { id: true, total: true },
      where: { total: { gt: 100 } }
    }
  }
});
// Java(JPA/Hibernate)
// BAD: LAZYローディングでN+1発生
@Entity
public class User {
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Order> orders;
}

// GOOD 1: JPQL JOIN FETCH
@Query("SELECT u FROM User u JOIN FETCH u.orders")
List<User> findAllWithOrders();

// GOOD 2: EntityGraph
@EntityGraph(attributePaths = {"orders"})
@Query("SELECT u FROM User u")
List<User> findAllWithOrders();

// GOOD 3: BatchSize(Hibernate)
@Entity
public class User {
    @OneToMany(mappedBy = "user")
    @BatchSize(size = 100)
    private List<Order> orders;
}

5.3 N+1の検出

# Spring Boot - Hibernate N+1検出
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true
        session.events.log.LOG_QUERIES_SLOWER_THAN_MS: 25

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.stat: DEBUG
# Django - django-debug-toolbarまたはnplusone
# settings.py
INSTALLED_APPS = [
    'nplusone.ext.django',
]

NPLUSONE_RAISE = True  # 開発環境でN+1検出時にエラーを発生

6. コネクションプーリング

6.1 なぜコネクションプールが必要なのか?

DBコネクション作成はコストが高い操作です:TCP接続 + 認証 + セッション初期化に数十msかかります。

コネクションプールなし:
Request -> [新規接続作成 30ms] -> [クエリ 5ms] -> [接続切断] = 35ms

コネクションプール使用:
Request -> [プールから既存接続取得 0.1ms] -> [クエリ 5ms] -> [プールに返却] = 5.1ms

6.2 HikariCP設定(Java)

# application.yml - HikariCP最適設定
spring:
  datasource:
    hikari:
      # プールサイズ公式: connections = (core_count * 2) + spinning_disks
      # 4コアSSDサーバー: (4 * 2) + 0 = 8
      maximum-pool-size: 10
      minimum-idle: 5

      # コネクションタイムアウト(プールからの最大待機時間)
      connection-timeout: 30000   # 30秒

      # アイドルコネクション削除時間
      idle-timeout: 600000        # 10分

      # コネクション最大存続時間
      max-lifetime: 1800000       # 30分

      # コネクション検証クエリ
      connection-test-query: "SELECT 1"

      # コネクションリーク検出
      leak-detection-threshold: 60000  # 60秒

      # プール名(モニタリング用)
      pool-name: "MyApp-HikariPool"

6.3 PgBouncer設定(PostgreSQL)

# pgbouncer.ini
[databases]
myapp = host=localhost port=5432 dbname=myapp

[pgbouncer]
# プーリングモード
# session: セッション維持(デフォルト)
# transaction: トランザクション単位(推奨)
# statement: ステートメント単位(制限的)
pool_mode = transaction

# プールサイズ
default_pool_size = 20
min_pool_size = 5
reserve_pool_size = 5

# 接続制限
max_client_conn = 200
max_db_connections = 50

# タイムアウト
server_idle_timeout = 600
client_idle_timeout = 300
query_timeout = 30
client_login_timeout = 60

# 認証
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

# ログ
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
stats_period = 60

# 管理インターフェース
listen_addr = 0.0.0.0
listen_port = 6432
admin_users = pgbouncer

6.4 コネクションプールモニタリング

-- PgBouncerステータス確認
SHOW pools;
SHOW stats;
SHOW servers;
SHOW clients;

-- PostgreSQL接続数確認
SELECT count(*) AS total_connections,
       count(*) FILTER (WHERE state = 'active') AS active,
       count(*) FILTER (WHERE state = 'idle') AS idle,
       count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_txn
FROM pg_stat_activity
WHERE backend_type = 'client backend';

-- 最大接続数の確認
SHOW max_connections;

7. クエリ最適化パターン

7.1 アンチパターン10選

-- アンチパターン1: SELECT *
-- BAD
SELECT * FROM users WHERE id = 1;
-- GOOD
SELECT id, name, email FROM users WHERE id = 1;

-- アンチパターン2: 関数使用によるインデックス無効化
-- BAD(インデックス使用不可)
SELECT * FROM users WHERE YEAR(created_at) = 2025;
-- GOOD(インデックス使用可能)
SELECT * FROM users
WHERE created_at BETWEEN '2025-01-01' AND '2025-12-31';

-- アンチパターン3: LIKEの先頭ワイルドカード
-- BAD(インデックス使用不可)
SELECT * FROM products WHERE name LIKE '%phone%';
-- GOOD(全文検索インデックス活用)
SELECT * FROM products
WHERE to_tsvector('english', name) @@ to_tsquery('phone');

-- アンチパターン4: ORの代わりにUNION ALL
-- BAD(非効率なインデックス使用)
SELECT * FROM orders WHERE user_id = 1 OR status = 'pending';
-- GOOD
SELECT * FROM orders WHERE user_id = 1
UNION ALL
SELECT * FROM orders WHERE status = 'pending' AND user_id != 1;

-- アンチパターン5: 大きなIN句
-- BAD
SELECT * FROM products WHERE id IN (1, 2, 3, ..., 10000);
-- GOOD(一時テーブルまたはANY使用)
SELECT * FROM products WHERE id = ANY(ARRAY[1, 2, 3, ...]);
-- または一時テーブルJOIN
-- アンチパターン6: 不必要なDISTINCT
-- BAD
SELECT DISTINCT u.name FROM users u JOIN orders o ON u.id = o.user_id;
-- GOOD(EXISTS使用)
SELECT u.name FROM users u WHERE EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

-- アンチパターン7: COUNT(*) vs EXISTS
-- BAD(全行カウント)
SELECT CASE WHEN COUNT(*) > 0 THEN true ELSE false END
FROM orders WHERE user_id = 1;
-- GOOD(最初の行のみ確認)
SELECT EXISTS(SELECT 1 FROM orders WHERE user_id = 1);

-- アンチパターン8: 暗黙的な型変換
-- BAD(文字列カラムに数値比較 -> インデックス無効)
SELECT * FROM users WHERE phone = 01012345678;
-- GOOD
SELECT * FROM users WHERE phone = '01012345678';

-- アンチパターン9: OFFSETページネーション
-- BAD(大きなOFFSETは遅い)
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 100000;
-- GOOD(キーセットページネーション)
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 20;

-- アンチパターン10: 過度なサブクエリ
-- BAD
SELECT * FROM users WHERE id IN (
  SELECT user_id FROM orders WHERE total > (
    SELECT AVG(total) FROM orders
  )
);
-- GOOD(CTEまたはJOIN)
WITH avg_total AS (SELECT AVG(total) AS avg_val FROM orders)
SELECT DISTINCT u.*
FROM users u
JOIN orders o ON u.id = o.user_id
CROSS JOIN avg_total
WHERE o.total > avg_total.avg_val;

7.2 JOIN最適化

-- JOIN順序の最適化(小さいテーブルを先に)
-- PostgreSQLは自動最適化するが、ヒントが必要な場合:

-- テーブルサイズ確認
SELECT relname, reltuples::bigint AS row_count
FROM pg_class
WHERE relname IN ('users', 'orders', 'products')
ORDER BY reltuples DESC;

-- JOINでインデックスがあるカラムを使用
-- GOOD
SELECT u.name, o.total
FROM orders o
JOIN users u ON u.id = o.user_id  -- users.idはPK(インデックス)
WHERE o.status = 'completed';

-- Lateral Joinの活用(上位N件パターン)
SELECT u.name, recent_orders.*
FROM users u
CROSS JOIN LATERAL (
  SELECT id, total, created_at
  FROM orders
  WHERE user_id = u.id
  ORDER BY created_at DESC
  LIMIT 3
) recent_orders;

8. パーティショニング

8.1 パーティショニングが必要なタイミング

テーブルサイズが数千万行を超えるか、特定範囲のデータのみを頻繁にクエリする場合にパーティショニングを検討します。

8.2 PostgreSQLパーティショニング

-- Rangeパーティショニング(時系列データに最適)
CREATE TABLE orders (
  id bigserial,
  user_id bigint NOT NULL,
  total decimal(10,2),
  status varchar(20),
  created_at timestamp NOT NULL
) PARTITION BY RANGE (created_at);

-- 月別パーティション作成
CREATE TABLE orders_2025_01
  PARTITION OF orders
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE orders_2025_02
  PARTITION OF orders
  FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

-- 自動パーティション作成(pg_partman活用)
-- CREATE EXTENSION pg_partman;
SELECT partman.create_parent(
  'public.orders',
  'created_at',
  'native',
  'monthly'
);
-- Listパーティショニング(カテゴリ別分割)
CREATE TABLE products (
  id bigserial,
  name varchar(255),
  category varchar(50),
  price decimal(10,2)
) PARTITION BY LIST (category);

CREATE TABLE products_electronics
  PARTITION OF products
  FOR VALUES IN ('electronics', 'computers', 'phones');

CREATE TABLE products_clothing
  PARTITION OF products
  FOR VALUES IN ('clothing', 'shoes', 'accessories');
-- Hashパーティショニング(均等分配)
CREATE TABLE sessions (
  id uuid,
  user_id bigint,
  data jsonb,
  expires_at timestamp
) PARTITION BY HASH (user_id);

CREATE TABLE sessions_p0
  PARTITION OF sessions
  FOR VALUES WITH (MODULUS 4, REMAINDER 0);

CREATE TABLE sessions_p1
  PARTITION OF sessions
  FOR VALUES WITH (MODULUS 4, REMAINDER 1);

CREATE TABLE sessions_p2
  PARTITION OF sessions
  FOR VALUES WITH (MODULUS 4, REMAINDER 2);

CREATE TABLE sessions_p3
  PARTITION OF sessions
  FOR VALUES WITH (MODULUS 4, REMAINDER 3);

8.3 MySQLパーティショニング

-- MySQL Rangeパーティショニング
CREATE TABLE orders (
  id BIGINT AUTO_INCREMENT,
  user_id BIGINT NOT NULL,
  total DECIMAL(10,2),
  created_at DATETIME NOT NULL,
  PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (YEAR(created_at)) (
  PARTITION p2023 VALUES LESS THAN (2024),
  PARTITION p2024 VALUES LESS THAN (2025),
  PARTITION p2025 VALUES LESS THAN (2026),
  PARTITION p_future VALUES LESS THAN MAXVALUE
);

9. 読み取りレプリカとキャッシング

9.1 読み取りレプリカ戦略

書き込み/読み取り分離アーキテクチャ:

       書き込みリクエスト      読み取りリクエスト
          |                       |
     [Primary]     ->      [Replica 1]
          |                 [Replica 2]
          |                 [Replica 3]
     非同期レプリケーション
# Spring Boot - 読み取り/書き込み分離
spring:
  datasource:
    primary:
      url: jdbc:postgresql://primary:5432/myapp
      username: app_write
    replica:
      url: jdbc:postgresql://replica:5432/myapp
      username: app_read
# Django - 読み取り/書き込みルーター
# settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'HOST': 'primary.db.example.com',
        'NAME': 'myapp',
    },
    'replica': {
        'ENGINE': 'django.db.backends.postgresql',
        'HOST': 'replica.db.example.com',
        'NAME': 'myapp',
    }
}

DATABASE_ROUTERS = ['myapp.routers.ReadReplicaRouter']

# routers.py
class ReadReplicaRouter:
    def db_for_read(self, model, **hints):
        return 'replica'

    def db_for_write(self, model, **hints):
        return 'default'

9.2 クエリキャッシュ戦略

# Redisキャッシングパターン
import redis
import json
from functools import wraps

r = redis.Redis(host='localhost', port=6379, db=0)

# Cache-Aside(Lazy Loading)パターン
def cache_aside(key_prefix, ttl=3600):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            cache_key = f"{key_prefix}:{args}:{kwargs}"

            # 1. キャッシュから検索
            cached = r.get(cache_key)
            if cached:
                return json.loads(cached)

            # 2. DBから検索
            result = func(*args, **kwargs)

            # 3. キャッシュに保存
            r.setex(cache_key, ttl, json.dumps(result))

            return result
        return wrapper
    return decorator

@cache_aside("user", ttl=1800)
def get_user_profile(user_id):
    return db.query("SELECT * FROM users WHERE id = %s", [user_id])
# Write-Throughパターン
def update_user(user_id, data):
    # 1. DB更新
    db.execute("UPDATE users SET name=%s WHERE id=%s", [data['name'], user_id])

    # 2. キャッシュ更新(または削除)
    cache_key = f"user:{user_id}"
    r.delete(cache_key)  # Cache Invalidation
    # または
    r.setex(cache_key, 1800, json.dumps(data))  # Cache Update
キャッシング戦略比較:

Cache-Aside:
  メリット: 最も一般的、柔軟
  デメリット: 初回リクエストはキャッシュミス

Write-Through:
  メリット: キャッシュが常に最新
  デメリット: 書き込み遅延の増加

Write-Behind(Write-Back):
  メリット: 最高の書き込みパフォーマンス
  デメリット: データ損失リスク

Read-Through:
  メリット: キャッシュロジックが分離
  デメリット: 実装の複雑さ

10. PostgreSQL専用最適化

10.1 VACUUMとANALYZE

PostgreSQLのMVCCモデルは、更新/削除時に以前の行を即座に除去しません。VACUUMがこのデッドタプル(dead tuples)を整理します。

-- 手動VACUUM
VACUUM (VERBOSE) orders;

-- VACUUM FULL(テーブル再書き込み - ロック発生!)
VACUUM FULL orders;

-- 統計更新
ANALYZE orders;

-- VACUUM + ANALYZE同時実行
VACUUM ANALYZE orders;
# postgresql.conf - autovacuum設定
autovacuum = on
autovacuum_max_workers = 3
autovacuum_naptime = 60

# テーブルの20%変更時にautovacuum実行
autovacuum_vacuum_scale_factor = 0.2
autovacuum_vacuum_threshold = 50

# テーブルの10%変更時にautoanalyze実行
autovacuum_analyze_scale_factor = 0.1
autovacuum_analyze_threshold = 50

# autovacuum速度調整(I/O負荷)
autovacuum_vacuum_cost_delay = 2ms
autovacuum_vacuum_cost_limit = 200

10.2 HOT Updates

HOT(Heap-Only Tuple)Updatesは、インデックスの更新なしにテーブルのみを更新する最適化です。

-- HOT Update条件:
-- 1. 更新されたカラムにインデックスがない
-- 2. 新しい行が同じページに保存できる

-- HOT比率の確認
SELECT
  relname,
  n_tup_upd,
  n_tup_hot_upd,
  round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY n_tup_upd DESC;

-- HOT比率を高めるには:
-- 1. 頻繁に更新されるカラムのインデックスを最小化
-- 2. fillfactorを下げて同じページにスペースを確保
ALTER TABLE orders SET (fillfactor = 80);

10.3 pg_statビューの活用

-- テーブル別I/O統計
SELECT
  relname,
  seq_scan,
  idx_scan,
  n_tup_ins,
  n_tup_upd,
  n_tup_del,
  n_live_tup,
  n_dead_tup
FROM pg_stat_user_tables
ORDER BY seq_scan DESC;

-- インデックス使用率
SELECT
  indexrelname,
  idx_scan,
  idx_tup_read,
  idx_tup_fetch,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
ORDER BY idx_scan;

-- 未使用インデックス(削除候補)
SELECT
  indexrelname,
  idx_scan,
  pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexrelname NOT LIKE '%_pkey'
ORDER BY pg_relation_size(indexrelid) DESC;

-- キャッシュヒット率
SELECT
  sum(heap_blks_read) AS heap_read,
  sum(heap_blks_hit) AS heap_hit,
  round(100.0 * sum(heap_blks_hit) / NULLIF(sum(heap_blks_hit) + sum(heap_blks_read), 0), 2) AS hit_pct
FROM pg_statio_user_tables;
-- 99%以上が理想的

11. MySQL専用最適化

11.1 InnoDB Buffer Pool

# my.cnf - InnoDB最適化
[mysqld]
# Buffer Poolサイズ(全メモリの70~80%)
innodb_buffer_pool_size = 8G

# Buffer Poolインスタンス(8G以上で分割)
innodb_buffer_pool_instances = 8

# ログファイルサイズ
innodb_log_file_size = 1G
innodb_log_buffer_size = 64M

# I/Oスレッド
innodb_read_io_threads = 4
innodb_write_io_threads = 4

# 同時実行性
innodb_thread_concurrency = 0  # 自動

# フラッシュ方法
innodb_flush_method = O_DIRECT
innodb_flush_log_at_trx_commit = 1  # 安全(2: パフォーマンス優先)

# 一時テーブル
tmp_table_size = 256M
max_heap_table_size = 256M
-- Buffer Poolステータス確認
SHOW ENGINE INNODB STATUS\G

-- Buffer Poolヒット率
SELECT
  (1 - (
    (SELECT VARIABLE_VALUE FROM performance_schema.global_status
     WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads') /
    (SELECT VARIABLE_VALUE FROM performance_schema.global_status
     WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests')
  )) * 100 AS buffer_pool_hit_ratio;
-- 99%以上が理想的

11.2 MySQL 8.0クエリキャッシュの廃止

MySQL 8.0ではQuery Cacheが廃止されました。代わりに以下を活用します。

MySQL 8.0 Query Cache代替:

1. アプリケーションレベルキャッシング(Redis/Memcached)
2. ProxySQL クエリキャッシュ
3. MySQL Routerキャッシング
4. InnoDB Buffer Pool最適化
-- MySQL 8.0 Performance Schema活用
-- スロークエリ分析
SELECT
  DIGEST_TEXT,
  COUNT_STAR AS exec_count,
  ROUND(SUM_TIMER_WAIT/1000000000, 2) AS total_ms,
  ROUND(AVG_TIMER_WAIT/1000000000, 2) AS avg_ms,
  SUM_ROWS_EXAMINED,
  SUM_ROWS_SENT
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;

11.3 MySQLインデックスヒント

-- インデックスヒント(オプティマイザが間違った選択をする場合)
SELECT * FROM orders USE INDEX (idx_orders_user_date)
WHERE user_id = 1 AND created_at > '2025-01-01';

SELECT * FROM orders FORCE INDEX (idx_orders_status)
WHERE status = 'pending';

-- MySQL 8.0オプティマイザヒント
SELECT /*+ NO_INDEX(orders idx_orders_status) */
  * FROM orders WHERE user_id = 1;

SELECT /*+ JOIN_ORDER(users, orders) */
  u.name, o.total
FROM users u JOIN orders o ON u.id = o.user_id;

12. 実践クイズ

Q1: EXPLAIN ANALYZEでactual rowsとestimated rowsが大きく異なる場合、どうすべきですか?

回答:

統計情報が古くなっている可能性が高いです。以下の対策を取ります:

  1. ANALYZEを実行: ANALYZE table_name;で統計を更新します。
  2. 統計サンプルサイズの増加: ALTER TABLE SET (n_distinct = ...)またはdefault_statistics_targetの値を上げます。
  3. インデックスの確認: 適切なインデックスがあるか確認します。
  4. autovacuum設定の確認: autovacuum_analyze_scale_factorが適切か確認します。

estimated rowsが実際よりも非常に少ない場合、プランナーがNested Loopを選択してパフォーマンスが低下する可能性があり、非常に多い場合は不要なSeq Scanを選択する可能性があります。

Q2: 複合インデックスでカラム順序はなぜ重要ですか?

回答:

複合インデックスはLeftmost Prefix Ruleに従います。

例えばINDEX(a, b, c)インデックスがある場合:

  • WHERE a = 1 - 使用可能
  • WHERE a = 1 AND b = 2 - 使用可能
  • WHERE a = 1 AND b = 2 AND c = 3 - 使用可能
  • WHERE b = 2 - 使用不可(先頭カラムaなし)
  • WHERE a = 1 AND c = 3 - aのみインデックス使用、cはフィルタ

カラム順序の決定基準:

  1. 等価条件カラムを先に
  2. 範囲条件カラムを後に
  3. 選択性(Selectivity)が高いカラムを先に
Q3: N+1クエリ問題を検出する方法は?

回答:

  1. ログ分析: SQLクエリログで同じパターンのクエリが繰り返されているか確認
  2. ORMツール活用: Djangoのnplusone、Railsのbullet gem、Springのhibernate.generate_statistics
  3. APMツール: Datadog、New Relicなどでクエリパターンを分析
  4. pg_stat_statements: 同じクエリのcalls数が異常に高いか確認
  5. 開発環境: テストにクエリ数カウンターを追加して期待クエリ数を検証

解決方法:Eager Loading(JOIN)、Batch Loading(IN句)、DataLoaderパターンを適用

Q4: コネクションプールサイズはどのように決定しますか?

回答:

HikariCPの公式:connections = (core_count * 2) + effective_spindle_count

  • core_count: CPUコア数
  • effective_spindle_count: SSDは0、HDDはディスク数

例:4コアSSDサーバー = (4 * 2) + 0 = 8個

追加考慮事項:

  • ほとんどのアプリケーションでは10~20個で十分
  • プールサイズを無制限に増やすとむしろパフォーマンスが低下(コンテキストスイッチング、メモリ)
  • PostgreSQLのmax_connectionsと接続されたアプリケーション数を考慮
  • PgBouncerのような外部プーラーを使用すると、より多くのクライアントをサポート可能
Q5: OFFSETベースのページネーションが遅い理由と代替策は?

回答:

遅い理由: OFFSET 100000 LIMIT 20は100,020行を読み取り、最初の100,000行を破棄します。OFFSETが大きいほど、より多くの行を読み取って破棄する必要があります。

代替策:キーセットページネーション(Cursor-based Pagination)

-- 従来(遅い)
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 100000;

-- キーセット(速い)
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 20;

キーセットページネーションの利点:

  • OFFSETに関係なく一定のパフォーマンス
  • インデックスを効率的に使用
  • 同時挿入/削除でも一貫した結果

欠点:

  • 特定のページに直接移動できない
  • ソート基準カラムにインデックスが必要

13. 参考資料

  1. PostgreSQL Official Documentation - Performance Tips
  2. MySQL Performance Tuning Guide
  3. Use The Index, Luke
  4. HikariCP Wiki - About Pool Sizing
  5. PgBouncer Documentation
  6. Postgres EXPLAIN Visualizer (Dalibo)
  7. pg_stat_statements Documentation
  8. Percona Toolkit Documentation
  9. pgMustard - EXPLAIN ANALYZE Insights
  10. MySQL Performance Blog (Percona)
  11. PostgreSQL Wiki - Performance Optimization
  12. Citus Data Blog - PostgreSQL Tips
  13. Modern SQL Blog