クエリ最適化

クエリ最適化

💡 たとえ話

図書館で本を探す場面を想像してください:

📖 コアコンセプト

1. EXPLAIN実行計画

EXPLAIN はクエリ最適化のコアツールです。データベースがSQL文をどのように実行するかを教えてくれます。

SQL
EXPLAIN SELECT * FROM users WHERE username = 'alice';

主要フィールドの説明:

フィールド 意味 注目ポイント
type アクセスタイプ ALL(フルスキャン)→indexrangerefeq_refconst、右にいくほど良い
key 実際に使用されたインデックス NULL はインデックス未使用を意味する
rows 推定スキャン行数 小さいほど良い
Extra 追加情報 Using filesort(ソート必要)、Using temporary(一時テーブル必要)は要注意
possible_keys 使用可能なインデックス インデックスが選択されているかの分析に役立つ
SQL
-- 実行計画の確認
EXPLAIN SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id, u.name;

-- MySQL 8.0以降では実際の実行統計を確認可能
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 100;

type アクセスタイプの説明:

TEXT
最悪から最善へ:
ALL         → フルテーブルスキャン(最適化必須)
index       → フルインデックススキャン(ALLより少し良い)
range       → インデックス範囲スキャン(WHERE id > 100)
ref         → 非一意インデックス検索(WHERE username = 'alice')
eq_ref      → 一意インデックス検索(主キーでのJOIN)
const       → 定数検索(WHERE id = 1、最速)
system      → システムテーブル(稀に出現)

2. インデックス最適化戦略

SQL
-- 1. WHERE、JOIN、ORDER BYでよく使用されるフィールドにインデックスを作成
CREATE INDEX idx_user_email ON users(email);
CREATE INDEX idx_order_user_created ON orders(user_id, created_at);

-- 2. カバリングインデックス:クエリフィールドがすべてインデックス内にあり、テーブルルックアップが不要
-- 頻繁に実行する場合:SELECT id, user_id, created_at FROM orders WHERE user_id = ?
CREATE INDEX idx_order_covering ON orders(user_id, created_at, id);

-- 3. プレフィックスインデックス:長い文字列フィールドの先頭N文字のみインデックス
CREATE INDEX idx_user_name_prefix ON users(username(10));

-- 4. 複合インデックスは最左プレフィックスルールに従う
CREATE INDEX idx_abc ON table_name(a, b, c);
-- マッチ可能: WHERE a=1 | WHERE a=1 AND b=2 | WHERE a=1 AND b=2 AND c=3
-- マッチ不可: WHERE b=2 | WHERE c=3 | WHERE b=2 AND c=3

-- 5. テーブルのインデックス使用状況を確認
SHOW INDEX FROM orders;

-- 6. インデックスのカーディナリティを確認(カーディナリティが高いほど識別力が高い)
SELECT
    INDEX_NAME,
    COLUMN_NAME,
    CARDINALITY
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_NAME = 'orders';

3. フルテーブルスキャンの回避

SQL
-- ✅ フルテーブルスキャンを引き起こすパターン

-- 1. インデックス列に関数を使用
SELECT * FROM orders WHERE YEAR(created_at) = 2024;
-- ✅ 範囲クエリに書き換え
SELECT * FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';

-- 2. インデックス列で算術演算
SELECT * FROM orders WHERE id + 1 = 100;
-- ✅ 次のように書き換え
SELECT * FROM orders WHERE id = 99;

-- 3. 暗黙の型変換(phoneはVARCHAR、INTを渡す)
SELECT * FROM users WHERE phone = 13800138000;
-- ✅ 次のように書き換え
SELECT * FROM users WHERE phone = '13800138000';

-- 4. 先頭ワイルドカードのLIKE
SELECT * FROM users WHERE name LIKE '%alice%';
-- ✅ あいまい検索が必要な場合は全文インデックスを検討
ALTER TABLE users ADD FULLTEXT INDEX ft_name(name);
SELECT * FROM users WHERE MATCH(name) AGAINST('alice' IN BOOLEAN MODE);

-- 5. OR条件はインデックスを無効化する可能性あり
SELECT * FROM users WHERE status = 1 OR age > 25;
-- ✅ UNIONを使用
SELECT * FROM users WHERE status = 1
UNION
SELECT * FROM users WHERE age > 25;

-- 6. NOT IN / NOT EXISTSはフルテーブルスキャンを引き起こす可能性あり
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);
-- ✅ LEFT JOIN + IS NULLを使用
SELECT u.* FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.id IS NULL;

4. クエリの書き換えテクニック

SQL
-- 1. INの代わりにEXISTSを使用(大量データセットでより効率的)
-- 遅い
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);
-- 速い
SELECT * FROM users u WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 1000
);

-- 2. UNIONの代わりにUNION ALLを使用(重複除去が不要な場合)
-- UNIONは重複除去とソートを行うが、UNION ALLは行わない
SELECT name FROM users_2023 UNION ALL SELECT name FROM users_2024;

-- 3. SELECT *を避け、必要な列のみ選択
-- 遅い
SELECT * FROM orders WHERE user_id = 1;
-- 速い
SELECT id, order_no, total_amount, status FROM orders WHERE user_id = 1;

-- 4. ページネーション最適化(深層ページネーション問題)
-- 遅い(OFFSET 100000は100100行をスキャンする必要がある)
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 100000;
-- 速い(カーソルページネーション、前ページの最後のidを記憶)
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;

-- 5. ループ操作の代わりにバッチ操作
-- 遅い(ループ挿入)
INSERT INTO logs (msg) VALUES ('a');
INSERT INTO logs (msg) VALUES ('b');
INSERT INTO logs (msg) VALUES ('c');
-- 速い(バッチ挿入)
INSERT INTO logs (msg) VALUES ('a'), ('b'), ('c');

-- 6. WHERE句のインデックス列に <> や != の使用を避ける
SELECT * FROM users WHERE status != 0;
-- ✅ ステータス値が少数の場合、次のように書き換え
SELECT * FROM users WHERE status IN (1, 2, 3);

5. スロークエリログ

SQL
-- スロークエリログの設定を確認
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';

-- スロークエリログを有効化
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;  -- 1秒を超えるクエリをログに記録
SET GLOBAL log_queries_not_using_indexes = 'ON';  -- インデックス未使用のクエリをログに記録

-- スロークエリログの分析
-- mysqldumpslowツールを使用
-- mysqldumpslow -s t -t 10 /var/log/mysql/slow.log

-- pt-query-digest(Percona Toolkit)を使用
-- pt-query-digest /var/log/mysql/slow.log

6. チューニングチェックリスト

ステップ アクション ツール
1 スロークエリログを有効化し、遅いSQLを発見 slow_query_log
2 EXPLAINで実行計画を分析 EXPLAIN
3 インデックスがヒットしているか確認 type フィールド
4 filesort/temporaryがないか確認 Extra フィールド
5 SQLを書き換えるかインデックスを追加 DDL / SQL書き換え
6 最適化結果を検証 実行時間の比較

7. よくあるパフォーマンスの落とし穴

落とし穴 説明 解決策
SELECT * すべての列を選択し、カバリングインデックスを利用できない 必要な列のみ選択
大きなOFFSET 深層ページネーションで大量データをスキャン カーソルページネーション
N+1クエリ ループ内で関連データを1つずつクエリ バッチクエリまたはJOIN
インデックスなしJOIN 大きなテーブルをインデックスなしでJOIN JOIN列にインデックスを追加
大きなトランザクション 長時間ロックを保持 トランザクション範囲を縮小
誤ったデータ型 数値にVARCHARを使用 適切な型を選択

💡 基本構文

SQL
-- EXPLAIN基本使用法
EXPLAIN SELECT ...;
EXPLAIN ANALYZE SELECT ...;  -- MySQL 8.0+

-- クエリ実行時間の確認
SET profiling = 1;
SELECT ...;
SHOW PROFILES;
SHOW PROFILE FOR QUERY 1;

-- インデックスの確認
SHOW INDEX FROM table_name;

-- 特定のインデックスを強制使用
SELECT * FROM orders FORCE INDEX (idx_user_id) WHERE user_id = 100;

-- インデックスを無視(比較テスト用)
SELECT * FROM orders IGNORE INDEX (idx_user_id) WHERE user_id = 100;
💡 ヒント:最適化前に測定し、最適化後に測定しましょう。感覚で最適化せず、データに語らせましょう。EXPLAIN はあなたの最高の友です。

データベース方言の比較

データベースによって構文に大きな違いがあります。一般的な操作の比較を示します:

LIMITページネーション

SQL
-- MySQL / PostgreSQL / SQLite
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 20;

-- SQL Server
SELECT * FROM orders ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
-- SQL Server 2012以前
SELECT TOP 10 * FROM orders WHERE id NOT IN (SELECT TOP 20 id FROM orders ORDER BY id);

-- Oracle 12c+
SELECT * FROM orders ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
-- Oracle 12c以前
SELECT * FROM (SELECT o.*, ROWNUM rn FROM orders o WHERE ROWNUM <= 30) WHERE rn > 20;

オートインクリメント主キー

SQL
-- MySQL
CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50));
-- またはデフォルト値を使用
INSERT INTO users (name) VALUES ('Alice');  -- idが自動生成

-- PostgreSQL
CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(50));
-- PostgreSQL 10+
CREATE TABLE users (id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name VARCHAR(50));

-- SQLite
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);
-- SQLiteはROWIDもサポート
INSERT INTO users (name) VALUES ('Alice');

-- SQL Server
CREATE TABLE users (id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(50));

-- Oracle
CREATE TABLE users (id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name VARCHAR2(50));

文字列関数

SQL
-- 文字列連結
SELECT CONCAT('Hello', ' ', 'World');           -- MySQL, PostgreSQL
SELECT 'Hello' || ' ' || 'World';                -- PostgreSQL, SQLite, Oracle
SELECT name + ' ' + email FROM users;            -- SQL Server

-- 部分文字列
SELECT SUBSTRING('Hello World', 1, 5);           -- MySQL, SQL Server
SELECT SUBSTR('Hello World', 1, 5);              -- PostgreSQL, SQLite, Oracle

-- 文字列長
SELECT LENGTH('Hello');                           -- MySQL, PostgreSQL, SQLite
SELECT LEN('Hello');                              -- SQL Server

-- 大文字小文字変換
SELECT UPPER('hello'), LOWER('HELLO');           -- すべてのデータベース
SELECT UCASE('hello'), LCASE('HELLO');           -- MySQLは追加でサポート

-- 空白トリム
SELECT TRIM('  Hello  ');                         -- すべてのデータベース
SELECT LTRIM('  Hello'), RTRIM('Hello  ');       -- MySQL, SQL Server, PostgreSQL

-- 置換
SELECT REPLACE('Hello World', 'World', 'SQL');   -- すべてのデータベース

日付関数

SQL
-- 現在時刻
SELECT NOW();                                     -- MySQL, PostgreSQL
SELECT CURRENT_TIMESTAMP;                         -- すべてのデータベース
SELECT GETDATE();                                 -- SQL Server
SELECT datetime('now');                           -- SQLite

-- 日付算術
SELECT DATE_ADD('2024-01-01', INTERVAL 30 DAY);  -- MySQL
SELECT '2024-01-01'::DATE + INTERVAL '30 days';  -- PostgreSQL
SELECT DATEADD(DAY, 30, '2024-01-01');            -- SQL Server
SELECT date('2024-01-01', '+30 days');             -- SQLite

-- 年/月/日の抽出
SELECT YEAR(created_at), MONTH(created_at), DAY(created_at) FROM orders;  -- MySQL, SQL Server
SELECT EXTRACT(YEAR FROM created_at) FROM orders;                          -- PostgreSQL, MySQL 8.0+
SELECT strftime('%Y', created_at) FROM orders;                             -- SQLite

-- 日付フォーマット
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') FROM orders;                    -- MySQL
SELECT TO_CHAR(created_at, 'YYYY-MM-DD') FROM orders;                      -- PostgreSQL, Oracle
SELECT FORMAT(created_at, 'yyyy-MM-dd') FROM orders;                       -- SQL Server
SELECT strftime('%Y-%m-%d', created_at) FROM orders;                       -- SQLite

条件式

SQL
-- IF式
SELECT IF(score >= 60, 'Pass', 'Fail') FROM exams;                        -- MySQL
SELECT IIF(score >= 60, 'Pass', 'Fail') FROM exams;                       -- SQL Server
SELECT CASE WHEN score >= 60 THEN 'Pass' ELSE 'Fail' END FROM exams;     -- すべてのデータベース

-- COALESCE(最初の非NULL値を返す)
SELECT COALESCE(nickname, username, 'Anonymous') FROM users;              -- すべてのデータベース

-- NULLIF(等しい場合NULLを返す)
SELECT NULLIF(a, b);                                                        -- すべてのデータベース

論理型

SQL
-- MySQL: 組み込みのBOOLEAN型なし、TINYINT(1)を使用
CREATE TABLE users (is_active TINYINT(1) DEFAULT 1);
SELECT * FROM users WHERE is_active = TRUE;  -- TRUEは1と等しい

-- PostgreSQL: 組み込みのBOOLEAN型
CREATE TABLE users (is_active BOOLEAN DEFAULT TRUE);
SELECT * FROM users WHERE is_active = TRUE;

-- SQLite: 組み込みのBOOLEAN型なし、INTEGERを使用
CREATE TABLE users (is_active INTEGER DEFAULT 1);
SELECT * FROM users WHERE is_active = 1;

-- SQL Server: 組み込みのBIT型
CREATE TABLE users (is_active BIT DEFAULT 1);
SELECT * FROM users WHERE is_active = 1;

UPSERT(存在すれば更新、しなければ挿入)

SQL
-- MySQL
INSERT INTO stats (article_id, view_count) VALUES (1, 1)
ON DUPLICATE KEY UPDATE view_count = view_count + 1;

-- PostgreSQL
INSERT INTO stats (article_id, view_count) VALUES (1, 1)
ON CONFLICT (article_id) DO UPDATE SET view_count = stats.view_count + 1;

-- SQLite
INSERT INTO stats (article_id, view_count) VALUES (1, 1)
ON CONFLICT(article_id) DO UPDATE SET view_count = view_count + 1;

-- SQL Server
MERGE INTO stats AS target
USING (SELECT 1 AS article_id, 1 AS view_count) AS source
ON target.article_id = source.article_id
WHEN MATCHED THEN UPDATE SET view_count = target.view_count + 1
WHEN NOT MATCHED THEN INSERT (article_id, view_count) VALUES (source.article_id, source.view_count);

ウィンドウ関数のサポート

SQL
-- すべての主要データベースがサポート(MySQL 8.0+、PostgreSQL、SQL Server、SQLite 3.25+)
SELECT
    name,
    score,
    RANK() OVER (ORDER BY score DESC) AS ranking,
    ROW_NUMBER() OVER (PARTITION BY class_id ORDER BY score DESC) AS class_rank
FROM students;

-- MySQL 5.7以前はウィンドウ関数非対応。変数でシミュレーション
-- GROUP_CONCAT / STRING_AGG
SELECT category_id, GROUP_CONCAT(name SEPARATOR ',') FROM products GROUP BY category_id;  -- MySQL
SELECT category_id, STRING_AGG(name, ',') FROM products GROUP BY category_id;             -- PostgreSQL

例:スロークエリの特定と最適化(難易度⭐⭐)

元のスロークエリ:

SQL
-- 過去30日間のユーザーごとの注文合計金額をクエリ(usersテーブル10万行、ordersテーブル100万行と仮定)
EXPLAIN
SELECT u.name, u.email, SUM(o.total_amount) AS total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY u.id, u.name, u.email
ORDER BY total_spent DESC
LIMIT 20;
▶ 試してみよう

EXPLAIN分析の結果:

最適化手順:

SQL
-- 1. ordersテーブルに複合インデックスを追加
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);

-- 2. クエリを最適化:先にフィルタリングしてからJOIN
SELECT u.name, u.email, sub.total_spent
FROM users u
INNER JOIN (
    SELECT user_id, SUM(total_amount) AS total_spent
    FROM orders
    WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
    GROUP BY user_id
    ORDER BY total_spent DESC
    LIMIT 20
) sub ON u.id = sub.user_id
ORDER BY sub.total_spent DESC;

-- 3. 再度EXPLAINで最適化効果を確認
EXPLAIN SELECT u.name, u.email, sub.total_spent ...
-- type: ref、rows: 顕著に減少

例:深層ページネーションの最適化(難易度⭐⭐)

問題のあるクエリ:

SQL
-- ページ10000をクエリ(1ページあたり20件)、OFFSET 200000
SELECT id, title, created_at
FROM articles
WHERE status = 1
ORDER BY created_at DESC
LIMIT 20 OFFSET 200000;
-- インデックスがあっても200020行をスキャンする必要があり、非常に遅い
▶ 試してみよう

解決策1:カーソルページネーション(推奨)

SQL
-- 前ページの最後のcreated_atとidを記憶
-- 前ページの最後の項目:created_at='2024-03-15 10:30:00', id=50001
SELECT id, title, created_at
FROM articles
WHERE status = 1
  AND (created_at < '2024-03-15 10:30:00'
       OR (created_at = '2024-03-15 10:30:00' AND id < 50001))
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 20行のみスキャン、超高速

解決策2:遅延JOIN

SQL
-- まず主キーをクエリし、その後JOINで全データを取得
SELECT a.id, a.title, a.created_at
FROM articles a
INNER JOIN (
    SELECT id FROM articles
    WHERE status = 1
    ORDER BY created_at DESC
    LIMIT 20 OFFSET 200000
) b ON a.id = b.id;
-- サブクエリはカバリングインデックスを使用し、メインクエリは主キーでデータを取得

解決策3:ビジネス層の最適化

SQL
-- 可能であれば、最大ページネーション深度を制限
-- 最初の1000件のみ閲覧可能とし、ユーザーには検索で絞り込むよう促す
SELECT id, title, created_at
FROM articles
WHERE status = 1
  AND category_id = 5  -- フィルタ条件を追加
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;

🔧 シナリオ1:ECサイト商品リストクエリの最適化

SQL
-- 元のクエリ:複数条件フィルタリング + ソート + ページネーション
SELECT p.id, p.name, p.price, p.sales_count, c.name AS category_name
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.status = 1
  AND p.category_id IN (10, 11, 12, 13)
  AND p.price BETWEEN 50 AND 500
ORDER BY p.sales_count DESC
LIMIT 20;

-- 最適化:
-- 1. 主要フィルタ条件をカバーする複合インデックスを作成
CREATE INDEX idx_product_filter ON products(status, category_id, price, sales_count);

-- 2. EXPLAINでfilesortが表示される場合、インデックス順序を調整
CREATE INDEX idx_product_sort ON products(status, category_id, sales_count DESC, price);

-- 3. categoriesテーブルが小さい場合、カテゴリ名をproductsテーブルに非正規化
ALTER TABLE products ADD COLUMN category_name VARCHAR(50);
-- INSERT/UPDATE時に同期的に更新

🔧 シナリオ2:統計レポートクエリの最適化

SQL
-- 元のクエリ:月次注文統計(大規模データセット、毎回遅い)
SELECT
    DATE_FORMAT(created_at, '%Y-%m') AS month,
    COUNT(*) AS order_count,
    SUM(total_amount) AS revenue
FROM orders
WHERE created_at >= '2023-01-01'
GROUP BY month
ORDER BY month;

-- 最適化オプション1:事前計算サマリーテーブル
CREATE TABLE monthly_order_stats (
    month_key VARCHAR(7) PRIMARY KEY COMMENT 'フォーマット: 2024-01',
    order_count INT NOT NULL DEFAULT 0,
    revenue DECIMAL(15,2) NOT NULL DEFAULT 0,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 定期ジョブで毎日更新(増分計算)
INSERT INTO monthly_order_stats (month_key, order_count, revenue)
SELECT
    DATE_FORMAT(created_at, '%Y-%m'),
    COUNT(*),
    SUM(total_amount)
FROM orders
WHERE created_at >= CURDATE() - INTERVAL 1 DAY
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ON DUPLICATE KEY UPDATE
    order_count = VALUES(order_count),
    revenue = VALUES(revenue),
    updated_at = NOW();

-- サマリーテーブルをクエリ(ミリ秒レスポンス)
SELECT * FROM monthly_order_stats ORDER BY month_key;

❓ よくある質問

質問:インデックスは多いほど良いですか? 回答: いいえ。インデックスはストレージスペースを消費し、INSERT/UPDATE/DELETE操作を遅くします(書き込み操作のたびにインデックスを更新する必要がある)。本当に高速化が必要なクエリにのみインデックスを作成し、未使用のインデックスは定期的にクリーンアップしてください。

質問:複合インデックスのフィールド順序はどう選べば良いですか? 回答: カーディナリティの高いフィールドを最初に配置します(例:user_idはstatusよりカーディナリティが高い)。等価クエリフィールドを範囲クエリフィールドの前に配置し、実際のクエリ条件の組み合わせに基づいて決定します。

質問:クエリが既に速い場合、さらに最適化すべきですか? 回答: レスポンス時間が許容範囲内であれば、過度な最適化は不要です。ただし、データの増加とともにパフォーマンスが低下する可能性があることに留意し、負荷テストやインデックス戦略で事前に計画しましょう。

質問:EXPLAINが表示する行数は正確ですか? 回答: 完全ではなく、統計に基づいた推定値です。実際の実行ではより多くの行、またはより少ない行をスキャンする場合があります。ANALYZE TABLE を実行して統計を更新すると、推定精度が向上します。

📖 まとめ

このレッスンでは、SQLクエリ最適化の方法を体系的に学びました:

📝 演習

  1. 次のSQLにEXPLAIN分析を実行し、最適化してください:
    SQL
    SELECT * FROM orders WHERE YEAR(created_at) = 2024 AND user_id IN (SELECT id FROM users WHERE status = 1);
    
  2. 記事リストページ(カテゴリフィルタリング、時系列ソート、ページネーション対応)に適したインデックス戦略を設計してください。
  3. LIMIT 20 OFFSET 100000 を使用するクエリをカーソルページネーションに書き換えてください。

次のレッスン →28-project.md

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%