[{"content":"","date":"2026年4月18日","externalUrl":null,"permalink":"/ja/tags/cassandra/","section":"Tags","summary":"","title":"Cassandra","type":"tags"},{"content":"","date":"2026年4月18日","externalUrl":null,"permalink":"/ja/tags/elasticsearch/","section":"Tags","summary":"","title":"Elasticsearch","type":"tags"},{"content":"","date":"2026年4月18日","externalUrl":null,"permalink":"/ja/tags/i/o/","section":"Tags","summary":"","title":"I/O","type":"tags"},{"content":"","date":"2026年4月18日","externalUrl":null,"permalink":"/ja/tags/lsm-tree/","section":"Tags","summary":"","title":"LSM Tree","type":"tags"},{"content":"","date":"2026年4月18日","externalUrl":null,"permalink":"/ja/tags/mysql/","section":"Tags","summary":"","title":"MySQL","type":"tags"},{"content":"","date":"2026年4月18日","externalUrl":null,"permalink":"/ja/","section":"NineKoo9's story","summary":"","title":"NineKoo9's story","type":"page"},{"content":"","date":"2026年4月18日","externalUrl":null,"permalink":"/ja/tags/nosql/","section":"Tags","summary":"","title":"NoSQL","type":"tags"},{"content":"","date":"2026年4月18日","externalUrl":null,"permalink":"/ja/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"2026年4月18日","externalUrl":null,"permalink":"/ja/tags/rocksdb/","section":"Tags","summary":"","title":"RocksDB","type":"tags"},{"content":"","date":"2026年4月18日","externalUrl":null,"permalink":"/ja/tags/storage/","section":"Tags","summary":"","title":"Storage","type":"tags"},{"content":"","date":"2026年4月18日","externalUrl":null,"permalink":"/ja/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"このドキュメントは、疑問を持っていた点をAIエージェントと対話しながら整理したメモです。NoSQLとElasticsearchが特定のワークロードで有利になる理由を、LSMツリー、転置インデックス、HDD・SSD I/Oの観点から整理します。\nなぜNoSQLとElasticsearchは特定のワークロードで有利なのか？ LSMツリーとI/Oまで # 1. NoSQLはなぜRDBMSより速いのか？ # 「NoSQLはRDBMSより速い」という言葉は条件付きでのみ正しい。\n製品ごとにデータモデル、ストレージエンジン、一貫性モデル、分散構造が異なるからだ。\nそのため以下の項目は「一部のNoSQLが特定のワークロードで有利になりうる理由」として理解する方が正確だ。\n1-1. ジョインがない（最大の理由の1つ） # RDBMSは正規化されたテーブルを実行時にジョインしてデータを組み合わせる。\nSELECT u.name, o.total, p.name FROM Users u JOIN Orders o ON ... JOIN Products p ON ... Document DBでは関連データを1つのドキュメントに埋め込むモデルを選択できる。\n{ \u0026#34;user\u0026#34;: \u0026#34;建弘\u0026#34;, \u0026#34;orders\u0026#34;: [{ \u0026#34;total\u0026#34;: 5000, \u0026#34;product\u0026#34;: \u0026#34;本\u0026#34; }] } Mongodbのドキュメントでは、埋め込みデータモデルが関連データを1回のデータベース操作で取得できるようにすることがあり、読み取りパフォーマンスの優位性がある可能性があると説明している。\nただしこれはMongoDBのすべてのモデルが自動的に速いという意味ではなく、埋め込みを選択したモデリング方式が読み取りパスを単純にできるという意味だ。\n1-2. スキーマがないから書き込みが速い # この表現はそのままにしておくと断定的すぎる。\nMongoDBのドキュメントは基本的に柔軟なスキーマモデルを使用し、同じコレクションのドキュメントが基本的に同じフィールドとデータ型を持つ必要はないと説明している。同時にschema validationで制約を与えることもできると説明している。\nつまりより正確な表現は以下に近い。\n一部のドキュメントDBはデフォルトスキーマがより柔軟だ。 しかし「スキーマがないから無条件に書き込みが速い」とは一般化できない。 実際の書き込みコストは検証ルール、インデックス数、レプリケーション、durability設定などの要素にも大きく左右される。 1-3. 水平スケール（シャーディング）が設計に組み込まれている # これも製品別の違いを見る必要がある。\nApache Cassandraのドキュメントは、Cassandraがconsistent hashingベースのdataset partitioning、multi-master replication、incremental scale-outを使用すると説明している。このようなシステムは分散とパーティショニングがアーキテクチャの中心にあるため、ノードの追加がより自然だ。\nただしこれを**「ノードを増やせばスループットが常に線形増加する」**と理解するのは過剰だ。\n実際のパフォーマンスはパーティションキーの分布、レプリカ数、ネットワーク、クエリパターンによって変わる。\n1-4. ACIDを諦めた代償 # この文章も範囲が広すぎる。\n一部の分散NoSQLシステムはリレーショナルDBと異なる一貫性/トランザクションモデルを採用する。例えばCassandraはtunable consistencyとlast-write-winsベースの競合解決を説明しており、cross-partition transactionをリレーショナルDBのように一般的なデフォルトモデルとして置いていない。\nしかしすべてのNoSQLがACIDを「諦める」と書くのは不正確だ。\nそのためこの項目は以下のように理解する方が正しい。\n一部のNoSQLは強い一貫性や汎用トランザクションよりも可用性、分散性、シンプルな書き込みパスの方に重きを置く。 その選択が特定のワークロードでのパフォーマンス優位性につながりうる。 1-5. ディスク保存方式（Column-family DB限定） # 元文でColumn DB（Cassandra、HBase）と1まとめにした部分は少し注意が必要だ。\nCassandraは分析用columnar DBというよりwide-column / column-familyの系統と見る方がより正確だ。\n区分 典型的な行中心保存 Column-family系の一部のアプローチ 保存/アクセスの観点 行全体中心 パーティション・カラムグループ中心 特化する状況 行単位の照会/更新 モデリングされたアクセスパターンに合ったキーベースの照会 つまりこの項目の核心は「すべてのNoSQLがカラム保存だから速い」ではなく、データモデルと保存構造がアクセスパターンによりよく合いうるという点だ。\n1-6. LSMツリー（書き込みパフォーマンスの核心） # → 2節で詳細説明\n要約比較表 # 理由 核心的な効果 埋め込みベースのモデリング可能 読み取りパスの単純化が可能 LSM系ストレージエンジン 小さな書き込みをappend/flushベースで処理しやすい 分散構造を内蔵した製品が存在 パーティショニングと増設がより自然 一貫性/トランザクションモデルの違い 一部のワークロードでオーバーヘッド削減可能 データモデルの最適化 アクセスパターンによりよく合いうる 注意: 複雑なジョイン、強い一貫性、汎用トランザクションが重要な場合は、RDBMSの方がより適切またはより速い場合がある。\n2. LSMツリー（Log-Structured Merge Tree） # 2-1. 核心アイデア # 小さなランダム更新をメモリにまとめてから、ソートされたファイルに書き出す構造\nBツリー系ストレージエンジンは論理的には既存のページを探して更新するモデルに近い。\nLSM系は小さな書き込みをメモリにまとめておいてから新しいsorted fileを作ってflush/mergeする方に近い。\nこのときオペレーティングシステムのpage cacheやストレージコントローラーが実際のI/Oを緩衝できるため、\n「Bツリー = 常に物理ランダム書き込み」と断定するのは過剰だ。\nただしストレージエンジンの論理的な書き込みパターンが異なるという点は明らかだ。\n2-2. 書き込みフロー ステップ別説明 # 書き込みリクエスト ↓ ① MemTable（メモリにソートされた構造で積み上げる） ↓ ② SSTable（ディスクに新しいsorted fileとしてflush） ↓ ③ Compaction（複数のSSTableをマージ・整理） ① MemTable（メモリフェーズ） # RocksDBのドキュメント基準で新しい書き込みはまずmemtableに入る。 readsもmemtableを最初に参照する。最新のデータがここにあるからだ。 memtableがいっぱいになるとそれ以上書き込まないimmutable memtableになる。 ② SSTable（ディスクフェーズ） # バックグラウンド処理がimmutable memtableをSSTファイルにflushする。 Cassandraのドキュメントもmemtableが限界に達するとディスクにflushされてimmutable SSTableになると説明している。 核心は既存のファイルをその場で修正するよりも新しいソートファイルを作って書き出す構造という点だ。 ③ Compaction（バックグラウンド整理） # 複数のSSTableが積み重なるとcompactionがこれをマージする。 RocksDBのドキュメントはcompactionがどのsorted runをマージするか、そして読み取り時にどのsorted runを参照しなければならないかに直接影響を与えると説明している。 つまりcompactionは単純な掃除ではなくLSMの読み取り/書き込みのトレードオフを管理する核心的な動作だ。 2-3. なぜ順次書き込みが有利なのか？ # ストレージデバイス 核心の違い 意味 HDD ヘッド移動と回転遅延が大きい 順次アクセスが特に有利 SSD ヘッドはないがerase/write単位とGCコストがある 順次パターンがwrite amplificationを減らしやすい HDDでは連続した区間を読み書きする方が物理的に有利だ。\nSSDでは理由が異なる。Samsungの資料はNAND SSDがerase-before-write特性を持ち、garbage collectionによるwrite amplificationが発生すると説明している。またhostのwriteパターンがsequentialのときSSD WAFが1に近くなり、randomパターンはWAFを高める可能性があると説明している。\n2-4. LSMツリーを使う代表的な例 # システム 備考 Cassandra 公式ドキュメントでストレージエンジンがLSMベースだと説明 RocksDB memtable → SST → compaction構造を公式Wikiで説明 元文にあったMongoDB、HBase、LevelDBまで一行で同じカテゴリにまとめて書く方式は説明が雑になるため、ここでは今回の検証で直接確認したシステムのみ記載する。\n2-5. LSMツリーの欠点 # 書き込みが速い代わりに読み取りとバックグラウンド整理のコストが伴う。\nCassandraのドキュメントは、LSMベースのwrite pathがwrite-orientedなワークロードに最適化されているが、read performanceとwrite amplificationのトレードオフがあると説明している。 CassandraはRead性能補完のためにBloom filterを使用すると説明している。 compaction自体も複数のSSTableを再度読んで新たに書き出す必要があるため、バックグラウンドI/Oが発生する。 2-6. 一言まとめ # 「その場で継続的に修正するよりも、メモリにまとめてから新しいsorted fileとして書き出して後でマージする」という戦略がLSMの核心だ。\n3. Elasticsearchはなぜ検索が速いのか？ # 3-1. 転置インデックス（Inverted Index）— 核心 # 一般的な行スキャン方式では\u0026quot;Java\u0026quot;を探すために文書を1つずつ確認しなければならない。\ndoc1: \u0026#34;Java Spring 開発者募集\u0026#34; doc2: \u0026#34;Python データエンジニア募集\u0026#34; doc3: \u0026#34;Javaバックエンド 3年以上\u0026#34; 転置インデックスは逆に用語 → 文書リストの構造を作る。\n\u0026#34;Java\u0026#34; → [doc1, doc3] \u0026#34;Spring\u0026#34; → [doc1] \u0026#34;Python\u0026#34; → [doc2] そのため検索時に全文書を最初から最後まで走査する代わりに、\n該当する用語のpostings listに直接アクセスできる。\nつまり核心は全数スキャンを避けるインデックス構造にある。\n3-2. シャード（Shard）並列検索 # インデックスを複数のシャードに分けると検索処理を分散できる。\n検索リクエスト ├──→ Shard 0 ├──→ Shard 1 └──→ Shard 2 ↓ 結果マージ → レスポンス ただし元文のように**「シャードが3つなら検索時間が理論上1/3」と言うのは過剰だ。\nElasticのドキュメントは各シャードが検索をsingle CPU threadで実行すると説明しつつ、同時にシャードが多すぎると検索パフォーマンスが低下することがある**とも説明している。\nつまりより正確な表現は以下のとおりだ。\nシャードは並列処理の単位になりうる。 しかしシャード数を増やせば無条件に速くなるわけではない。 実際のshard sizeと数はベンチマークで調整する必要がある。 3-3. セグメント（Segment）— LSMと似ている部分 # 各シャード内部はLuceneセグメントで構成される。\n操作 処理方式 追加 新しいセグメントとして反映 削除 即時物理削除ではなく、その後のmergeで整理 修正 内部的には削除 + 新しいドキュメント追加に近い処理 Elasticのドキュメントは、Elasticsearchがnear real-time searchを提供し、ドキュメントがインメモリのインデックシングバッファから新しいセグメントとして記録され、このセグメントがまずfilesystem cacheに載ってから検索可能になると説明している。このプロセスをrefreshと呼ぶ。\nまたElasticのドキュメントはセグメントがimmutableであり、小さなセグメントが後でより大きなセグメントにmergeされると説明している。\nしたがってElasticsearchは検索エンジンの観点から以下のように理解すればよい。\n検索可能なセグメントを素早く開く。 削除と整理はmergeがバックグラウンドで担当する。 おかげで検索パスとインデックシングパスを比較的分離して運営できる。 3-4. まとめ # Elasticsearchが速い理由は転置インデックス + シャード単位分散 + immutableセグメントとrefresh/mergeモデルが一緒に噛み合うからだ。\nさらにElasticは以下も公式に提供している。\ntextフィールド用analyzer デフォルト類似度BM25 Levenshtein edit distanceベースのfuzzyクエリ つまりElasticsearchは単純なインデックスライブラリではなく、検索機能のサーフェスそのものが広い分散検索エンジンだ。\n4. MySQL全文検索 vs Elasticsearch # 質問: MySQLも全文検索（Full-Text Search）に転置インデックスを使っているのに、なぜElasticsearchより遅かったり不便に感じることがあるのか？\n核心は転置インデックスの有無自体ではなく、どんな問題を主目的として設計しているかだ。\n4-1. シャーディングなし — 単一ノード # 元文のように「MySQLは基本的に1台のサーバー内で一人ですべて処理する」と単純化するのは雑すぎる。\nアプリケーションレベルのシャーディングや他の分散構成が可能だからだ。\nただしMySQLのFULLTEXT検索は本質的にトランザクションDB内の機能であり、\nElasticsearchはシャード分散を基本概念とした検索エンジンであるという違いは大きい。\nつまり大規模分散検索という問題では、Elasticsearch側がより直接的な解法を提供する。\n4-2. 転置インデックスの品質が異なる # ここで最も重要な訂正が必要だ。\nMySQL InnoDB full-text indexも公式ドキュメント上はinverted index designだ。\nまたMySQLはrelevance計算、boolean search、query expansion、CJK用のbuilt-in ngram parserも提供している。\nしたがって違いは「MySQLは転置インデックスがなくElasticsearchだけにある」ではない。\nより正確な違いはテキスト分析と検索機能サーフェスの幅にある。\n機能 MySQL Elasticsearch 転置インデックス サポート（FULLTEXT） サポート 関連度計算 サポート サポート CJK処理 ngram parserサポート analyzerの組み合わせで豊富に設定可能 タイポ許容 限定的 fuzzy query提供 アナライザー構成 比較的限定的 analyzer中心で非常に柔軟 4-3. 書き込み/読み取りの競合（構造の違い） # MySQL側はInnoDB full-text indexを維持するためにauxiliary index tablesとfull-text index cacheを使用し、最近挿入されたトークンをキャッシュにまとめてからbatch flushする。\nElasticsearch側はrefreshで新しいセグメントを開き、その後mergeで整理する。\nしたがって違いはこのように理解する方が良い。\nMySQL全文検索はトランザクションDB内部に統合された検索機能だ。 Elasticsearchは検索を中心にセグメント/refresh/mergeモデルを運営する。 つまり両方ともインデックスを維持するが、運用モデルが異なる。\n4-4. メモリ活用戦略が異なる # この項目も元文の表現を調整する必要がある。\nElasticsearchは公式ドキュメントでfilesystem cacheの上でセグメントを素早く開いて検索可能にするnear real-timeモデルを説明している。 MySQL InnoDBは公式ドキュメントでfull-text index cacheを設けて最近挿入したトークンを一時保存してbatch flushすると説明している。 つまり両方ともキャッシュを活用するが、何を、どんな目的で、どんな検索構造に合わせてキャッシュするかが異なる。\n4-5. 結論 # 転置インデックスというアイデア自体は両方に存在する。\n違いはトランザクションDBに含まれた全文検索機能なのか、\nそれとも分散検索を主目的として作られた検索エンジンなのかにある。\n状況 より自然な選択 リレーショナルデータ内での比較的シンプルな検索 MySQL全文検索 分散検索、analyzer調整、fuzzy search、検索専用運用 Elasticsearch 5. ランダムI/O vs 順次I/O — ハードウェアまで # 質問: 結局OS観点ではファイルを開くことなのに、なぜ速度差が生まれるのか？\n質問: BツリーとLSM Treeの違いがハードウェアレベルでどのように適用されるのか？\n5-1. I/Oソフトウェアスタック — レイヤー全体の流れ # file.write(data)を呼び出したときの流れを単純化すると以下のとおりだ。\nアプリケーション（Bツリー / LSMツリー） ↓ OSページキャッシュ ↓ ファイルシステム（ext4 / xfsなど） ↓ ブロック層 / デバイスドライバー ↓ HDD / SSD Linuxカーネルのドキュメントはnormal reads, writes and mmaps go through the page cacheと説明している。\nまたext4のドキュメントは構造の位置が絶対ディスクオフセットではなくblock number基準で保存されると説明している。\nしたがって核心は「ファイルを開く」という事実自体よりも、\nストレージエンジンがどんな論理的な書き込みパターンを作り、ファイルシステムとデバイスがそれをどんなブロック配置で実現するかにある。\n5-2. HDDハードウェア — ランダムvs順次の物理的な違い # HDDのパフォーマンスは大まかに以下のコストで説明できる。\n要素 説明 例 Seek time ヘッドが目的のトラックに移動 Seagateの例で平均\u0026lt;12ms Rotational latency 目的のセクターがヘッドの下に来るまで待機 Seagateの例で平均5.1ms Transfer time 実際にデータを読み書きする時間 ブロックサイズ・転送速度によって異なる 核心は小さなランダムI/Oでは実際の転送時間よりも位置を探しに行くコストの方がはるかに大きいという点だ。\nランダムI/O: seek + 回転待機が複数回繰り返される 順次I/O: 一度位置を確定してから連続転送しやすい そのためHDDでは順次パターンの優位性が特に顕著に現れる。\n5-3. Bツリー vs LSMツリー — ブロックアクセスパターン比較 # Bツリーの書き込み（既存ページ更新中心） # ① 関連ページを探索 ② メモリで更新 ③ 変更ページを再度書き込んで反映 論理的には既存の位置のページを探して更新するモデルに近い。 そのため小さな更新が多くなるほど、ストレージ層に散らばった書き込み負荷を作りやすい。 ただしこの文章を**「Bツリーは無条件に毎回物理ランダム書き込み」**と理解してはいけない。\npage cache、filesystem allocation、SSD FTLが中間で緩衝して再配置できるからだ。\nLSMツリーの書き込み（新しいファイルのappend/flush中心） # ① メモリのMemTableにまず反映 ② いっぱいになったらSSTableにflush ③ 後でcompactionによりマージ 論理的には新しいsorted fileを作って書き出す構造だ。 そのため小さな書き込みをより大きな単位のflush/merge作業にまとめやすい。 このパターンがHDDの順次I/OやSSDのwrite amplification管理によりよく合う場合が多い。 SSDでも順次が有利な理由 # 項目 内容 Erase-before-write NANDはoverwriteを直接行わず消去-書き込み特性を持つ Garbage collection 有効ページを移動しながらblockを空ける過程が必要 Write Amplification GCのためhostのwriteよりも多くの内部writeが発生しうる 順次パターンの優位性 Samsungの資料基準でsequentialなhost writeはSSD WAFを1に近づけられる つまりSSDではHDDのようにヘッド移動がボトルネックにはならないが、\nGCとWAFのため書き込みパターンが依然として重要だ。\n5-4. 一言まとめ # Bツリー系は論理的に既存ページを更新する構造に近く、\nLSM系は新しいsorted fileを作って書き出す構造に近い。\n実際の物理I/Oはカーネルとデバイスが緩衝するが、ストレージエンジンの書き込みパターンの違いはパフォーマンス特性として現れる。\n全体概念の連結マップ # NoSQLが特定のワークロードで有利になる理由 │ ├── 読み取り側面 │ ├── 埋め込みベースのモデリング（一部のDocument DB） │ ├── 転置インデックス（Elasticsearch、MySQL FTS） │ ├── シャード単位分散検索（Elasticsearch） │ └── immutableセグメント + refresh/merge（Elasticsearch） │ └── 書き込み側面 ├── LSMツリー → memtable + SSTable + compaction ├── 分散製品別consistency/transactionモデルの違い └── 柔軟なスキーマ/モデリングの選択肢（一部のDocument DB） ランダム vs 順次I/O │ ├── OSレイヤー: page cacheを経由 ├── ファイルシステム: block number基準で構造を解釈 ├── HDD: seek + rotational latencyが大きなコスト └── SSD: garbage collection + write amplificationが核心コスト Bツリー vs LSMツリー（I/O観点） │ ├── Bツリー: 既存ページ更新中心 └── LSMツリー: 新しいsorted file flush/merge中心 参考資料 # MongoDB Docs — Embedded Data Models: https://www.mongodb.com/docs/manual/data-modeling/embedding/ MongoDB Docs — Schema Validation: https://www.mongodb.com/docs/manual/core/schema-validation/ Apache Cassandra Docs — Dynamo: https://cassandra.apache.org/doc/latest/cassandra/architecture/dynamo.html Apache Cassandra Docs — Storage Engine: https://cassandra.apache.org/doc/stable/cassandra/architecture/storage-engine.html RocksDB Wiki — MemTable: https://github.com/facebook/rocksdb/wiki/Memtable RocksDB Wiki — Compaction: https://github.com/facebook/rocksdb/wiki/Compaction Elastic Docs — Near real-time search: https://www.elastic.co/docs/manage-data/data-store/near-real-time-search Elastic Docs — Merge settings: https://www.elastic.co/docs/reference/elasticsearch/index-settings/merge Elastic Docs — Size your shards: https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/size-shards Elastic Docs — analyzer: https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/analyzer Elastic Docs — similarity: https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/similarity Elastic Docs — Fuzzy query: https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-fuzzy-query MySQL 8.4 Reference Manual — Full-Text Search Functions: https://dev.mysql.com/doc/refman/8.4/en/fulltext-search.html MySQL 8.4 Reference Manual — Natural Language Full-Text Searches: https://dev.mysql.com/doc/refman/8.4/en/fulltext-natural-language.html MySQL 8.4 Reference Manual — Boolean Full-Text Searches: https://dev.mysql.com/doc/refman/8.4/en/fulltext-boolean.html MySQL 8.4 Reference Manual — ngram Full-Text Parser: https://dev.mysql.com/doc/refman/8.4/en/fulltext-search-ngram.html MySQL 8.4 Reference Manual — InnoDB Full-Text Indexes: https://dev.mysql.com/doc/refman/8.4/en/innodb-fulltext-index.html Linux Kernel Docs — Page Cache: https://docs.kernel.org/next/mm/page_cache.html Linux Kernel Docs — ext4 Blocks: https://docs.kernel.org/next/filesystems/ext4/blocks.html Samsung Semiconductor — A Brief History of Data Placement Technologies: https://semiconductor.samsung.com/news-events/tech-blog/a-brief-history-of-data-placement-technologies/ Seagate Archive HDD Product Manual: https://www.seagate.com/files/www-content/support-content/enterprise-servers-storage/nearline-storage/archive-hdd/_shared/masters/archive-sata-hdd-%20100743737-product-manual.pdf ","date":"2026年4月18日","externalUrl":null,"permalink":"/ja/posts/nosql-elasticsearch-deep-dive/","section":"Posts","summary":"NoSQLが常に速いわけではないという前提から出発し、LSMツリーの書き込みパス、Elasticsearchの転置インデックスとセグメント、MySQL全文検索との違い、HDD・SSD I/Oパターンまで元の構造を維持して整理します。","title":"なぜNoSQLとElasticsearchは特定のワークロードで有利なのか？ LSMツリーとI/Oまで","type":"posts"},{"content":"このドキュメントは疑問に思っていた点をAIエージェントとの対話を通じて整理したメモです。L4とL7ロードバランシングの違い、TLS終端とルーティング基準、LogstashとElasticsearchの手前での選択ポイントを整理します。\nL4 vs L7ロードバランシング完全まとめ # 1. L4 vs L7ロードバランシングの基本概念 # L4ロードバランシング（Transport Layer） # L4ロードバランシングはTCP/UDPのようなトランスポート層の基準で接続を分散する。代表的にIP、Port、プロトコル、接続単位の情報をもとに対象サーバーを選ぶ。\nメリット\nアプリケーションのリクエスト内容を直接解析しないため、処理経路がシンプルだ TCP/UDPベースの様々なトラフィックに適用しやすい シンプルな分散、静的IP、大量の接続処理といった要件に向いている デメリット\nURL、ヘッダー、クッキー、gRPCメソッドのようなアプリケーション情報ベースのルーティングはできない 細かいリクエスト単位のポリシーはL7より制限的だ 参考：TLS終端はL7で一般的だが、AWS NLBのようなL4系でもTLS listenerとしてサポートする製品がある。そのため「L4なら必ずTLS終端不可」と断定するのは誤りになる場合がある。\nL7ロードバランシング（Application Layer） # L7ロードバランシングはアプリケーション層のリクエストを解析して分散する。Webトラフィック基準ではURL、ヘッダー、クッキー、メソッド、クエリ文字列などを基準にルーティングできる。\nメリット\nURLパス、ホスト、ヘッダー、クエリ文字列ベースのルーティングが可能 クッキーベースのsticky sessionを実装可能 TLS終端とHTTP/gRPCレベルのポリシー適用がしやすい カナリアデプロイ、A/Bテスト、認証、リダイレクトのような高度な制御に有利だ デメリット\nリクエストを解析する必要があるため、L4よりも一般的に処理経路が複雑だ 製品が理解するアプリケーションプロトコルに依存する 機能が増えるほど設定と障害ポイントが増える 参考：多くのL7製品はHTTP/HTTPS中心だが、製品によってはHTTP/2、gRPC、WebSocketもサポートする。そのため「L7 = 通常のHTTPのみ」と見るのも過度な単純化だ。\n一目で比較 # 項目 L4 L7 基準 接続/トランスポート層の情報 リクエストの内容 代表的な判断要素 IP、Port、プロトコル、接続ハッシュ URL、Header、Cookie、Method、Query String 速度/複雑度 シンプルで軽い 相対的に複雑 TLS終端 製品依存 一般的に多くサポート パスベースのルーティング 不可 可能 代表的な例 AWS NLB、NGINX stream AWS ALB、NGINX HTTP/gRPC 2. いつL4を、いつL7を使うべきか？ # 核心原則 # L4は接続をどこに送るかを決めることに強く、L7はリクエストの内容を見てどのように分けるかを決めることに強い。\nL7も複数のサーバーにトラフィックを分散できる。違いはリクエストの内容を読む機能が実際に必要かどうかにある。\nシンプルな分散 → L4が自然な理由 # 状況：同じSpringサーバー3台にトラフィックを分散 # クライアント → [ロードバランサー] → Server A → Server B → Server C 3台のサーバーが全く同じ役割をしており、リクエストごとに別のポリシーが不要なら、URLやヘッダーを読む必要はない。\nL4：接続単位で分散 → 通常よりシンプルで軽い L7：HTTPリクエストを解析してから分散 → 機能は多いが必要ない場合も多い つまりこの場合は**「L7ができないから」ではなく「L4で十分だから」**L4がより自然な選択になる。\nMSA環境 → L7が必要な理由 # 状況：ショッピングモールのMSA # /api/users → User Service（ポート8081） /api/orders → Order Service（ポート8082） /api/products → Product Service（ポート8083） 1つの進入点からパスごとに異なるサービスへ送るためにはリクエストの内容を読む必要がある。\nクライアントがGET /api/ordersをリクエスト L4：接続情報だけ見るのでURLパス自体は判断基準にならない ❌ L7：/api/ordersパスを読んでOrder Serviceへルーティング ✅ この構造ではL7プロキシ、API Gateway、Ingress Controllerのようなリクエスト認識型（L7）の構成が必要だ。\nカナリアデプロイも区別して見る必要がある # /api/ordersのリクエストのうち → 特定のヘッダーがあればv2へ → それ以外はv1へ このようにヘッダー、クッキー、パス、メソッドを基準にトラフィックを分けるカナリア/A-Bルーティングにはl7が必要だ。\n一方でシンプルな重み付けベースの分散は一部のL4製品もサポートする。例えばAWS Network Load Balancerは複数のtarget groupにweightを設定できる。\n正確に言えば**「精密なリクエストベースのカナリア」はL7の領域であり、「シンプルな比率分割」は製品によってL4でも可能な場合がある。**\nまとめ # 状況 適切なLB 理由 同じサーバーN台にシンプルな分散 L4 リクエスト内容を読まなくてよい URLパスごとに異なるサービスへルーティング L7 リクエスト内容を読む必要がある ヘッダー/クッキー/パスベースのカナリア・A/B L7 リクエストの属性を基準に分岐 シンプルなTCP/UDPサービスの分散 L4 アプリケーション内容の解析が不要 TLS終端 + HTTPポリシー/認証/リダイレクト L7が一般的 リクエスト単位のポリシーによく合う 3. ELKスタックでなぜL4ロードバランシングを使うのか？ # ELKのデータフロー # アプリケーションサーバー群 ↓ （ログ転送） [Logstashクラスター - 3台] ← ここの手前にLBを置ける ↓ Elasticsearch ↓ Kibana Logstashの手前にロードバランサーを置く理由は、入力トラフィックを複数のLogstashインスタンスに分散するためだ。\n核心は「Logstashの入力プロトコル」だ # Logstash自体はHTTP専用の製品ではない。どのinput pluginを使うかによって、手前で扱うプロトコルが変わる。\nBeats input：LogstashがBeats frameworkの接続を受ける FilebeatのLogstash output：lumberjack protocolを使いTCPの上で動作する UDP input：UDPでイベントを受け取る HTTP input：HTTP(S)リクエストをイベントとして受け取る つまり**「Logstashの手前は必ずL4」でも「必ずL7」**でもない。\n実際に使う入力がBeats/TCP/UDP系であればL4が自然で、HTTP inputを使えばL7も可能だ。\nなぜL4がよく合うのか？ # ログ収集経路では以下の条件が多いからだ。\n入力がBeats、TCP、UDP、syslogのようにHTTPではない場合が多い 複数のLogstashインスタンスが同じ役割をするため、パスベースのルーティングが不要な場合が多い シンプルな分散が目的なら、リクエスト内容をわざわざ解析する必要が少ない Filebeat → LB → Logstash A → Logstash B → Logstash C この構造では**「どのLogstashがこのログを処理してもよいか？」が核心であり、ほとんどの場合そうだ。\nそのためHTTPの機能よりTCP/UDPレベルの分散**の方が重要な場合が多い。\nL7にしてはいけないのか？ # いけないわけではない。 Logstashにはhttp input pluginがあるため、HTTPでイベントを受ける構成であればL7も使える。\nただしこの場合も確認すべき質問はシンプルだ。\n入力は本当にHTTPか？ URL/ヘッダーベースのルーティングが必要か？ 複数のLogstashインスタンスが同じ役割なら、L7の機能が実質的に必要か？ 結論：Logstashの手前の選択は「ELKだから」ではなく「入力プロトコルとルーティングの要件が何か」で決めるのが正しい。\n4. Elasticsearchのシャード構成とロードバランシング # 重要な概念の区分 # シャードのルーティングと外部のロードバランサーは別のレイヤーの話だ。\n外部のロードバランサーがシャードを直接分散させるわけではない。\nElasticsearchクラスターは各ノードがクラスターの状態を共有し、適切なノードにリクエストを転送できる。\nESの内部シャードルーティングのメカニズム # クライアント ↓ HTTPリクエスト [Node 1] ← coordinatingの役割 ↓ ┌─────────────────┐ ↓ ↓ [Node 2] [Node 3] Shard 0, 1 Shard 2, 3 クライアントのリクエストを受けたノードはクラスターの状態をもとに、適切なシャードがあるノードにリクエストを転送し、レスポンスをまとめて返すことができる。\nElasticのドキュメントでもcoordinating-only nodeをスマートなロードバランサーのように動作すると説明している。\nつまり、外部LBが行うことは通常**「最初の進入ノードを選ぶこと」であり、「シャードを直接分けること」**ではない。\nではESの手前に置くロードバランサーは何をするのか？ # ノードレベルの分散だ。\nクライアント群 ↓ [外部LB] → Node 1 → Node 2 → Node 3 ↓ （以降のシャード単位の調整はES内部で処理） どのノードに最初に入っても、その後のシャード単位の分散とreduceはElasticsearchクラスターが処理する。\nESの手前でなぜL4がよく言及されるのか？ # 理由1：外部LBと内部transportポートは区別する必要がある # Elasticsearchは HTTPインターフェースとtransportインターフェースを別々に持つ。\n9200台 → HTTP client communication 9300台 → node-to-node transport TCP ここで重要な点は、9300台のtransportポートが存在するからといって、外部クライアント用LBが必ず9300も扱わなければならないわけではないということだ。\n通常クライアントの進入点はHTTP（9200台）であり、9300台はクラスター内部の通信用と見る。\nつまり**「9300があるから外部ESの手前は必ずL4」**と言うのは過剰だ。\n理由2：URLベースのルーティングが通常必要ない # GET /my-index/_search POST /my-index/_doc Elasticsearch APIはパスが異なっても、一般的な複数ノードクラスターの手前では特定のURLを特定のノードに送らなければならない場合が多くない。\nつまり外部LBの観点では通常：\nどのhealthyなノードに最初に送るかだけを決めればよく 以降のシャードの配置と内部転送はElasticsearchが処理する このためリクエスト内容を読むL7の機能が必ずしも必要でない場合が多い。\n理由3：シンプルなノード分散だけを望むならL4で十分だ # 外部で必要なものが：\n静的な進入点 複数のESノードへの基本的な分散 TCP/TLSレベルのシンプルな転送 程度であれば、L4が自然な選択になる。\nESでL7を使ってはいけないのか？ # 可能だ。 9200台のHTTP APIの手前にはL7を置けるし、実際にAWS ALBのようなHTTP/HTTPSベースのL7ロードバランサーで構成することもできる。\nただしこの場合も質問は同じだ。\npath/headerベースのポリシーが必要か？ 認証/リダイレクト/WAFのようなHTTP機能が必要か？ シンプルなノード分散以上の要件があるか？ なければL4で十分かもしれず、あればL7が意味を持つ。\n5. 最終まとめ # 選択基準の一文まとめ # L7の方がより多くのリクエスト情報を理解するが、その情報が必要でなければL4がより単純な選択になる。\n全ケースのまとめ # 状況 適切なLB 核心的な理由 同じサーバーN台のシンプルな分散 L4 リクエスト内容を読む必要がない MSA URLパスごとのルーティング L7 リクエストパス/ヘッダーベースの分岐が必要 ヘッダー・クッキー・パスベースのカナリア/A-B L7 リクエスト単位のポリシーが必要 シンプルなTCP/UDPサービスの分散 L4 接続レベルの分散で十分 Logstashの手前（Beats/TCP/UDP入力） L4が自然 入力プロトコルがHTTPでない場合がある Elasticsearchノードの手前 要件による シンプルなノード分散ならL4で十分な場合が多い 参考資料 # AWS - Application Load Balancer AWS Docs - Listeners for your Application Load Balancers AWS Docs - Condition types for listener rules AWS Docs - Action types for listener rules AWS Docs - Target groups for your Application Load Balancers AWS Docs - What is a Network Load Balancer? AWS Docs - Listeners for your Network Load Balancers AWS Docs - Health checks for Network Load Balancer target groups AWS Docs - Target groups for your Network Load Balancers Elastic Docs - Beats input plugin Elastic Docs - Udp input plugin Elastic Docs - Http input plugin Elastic Docs - Configure the Logstash output (Filebeat) Elastic Docs - Networking settings Elastic Docs - Node roles Elastic Docs - Search shard routing NGINX Docs - HTTP Load Balancing NGINX Docs - TCP and UDP Load Balancing ","date":"2026年4月13日","externalUrl":null,"permalink":"/ja/posts/l4-vs-l7-load-balancing/","section":"Posts","summary":"L4とL7の基準の違いだけでなく、TLS終端がなぜ絶対的な区分線でないのか、LogstashとElasticsearchの手前ではなぜ通常L4またはシンプルな分散が多く使われるのかを整理します。","title":"[Network] L4 vs L7ロードバランシングまとめ - ALB/NLB、Logstash、Elasticsearch","type":"posts"},{"content":"","date":"2026年4月13日","externalUrl":null,"permalink":"/ja/tags/aws/","section":"Tags","summary":"","title":"AWS","type":"tags"},{"content":"","date":"2026年4月13日","externalUrl":null,"permalink":"/ja/tags/l4/","section":"Tags","summary":"","title":"L4","type":"tags"},{"content":"","date":"2026年4月13日","externalUrl":null,"permalink":"/ja/tags/l7/","section":"Tags","summary":"","title":"L7","type":"tags"},{"content":"","date":"2026年4月13日","externalUrl":null,"permalink":"/ja/tags/load-balancer/","section":"Tags","summary":"","title":"Load Balancer","type":"tags"},{"content":"","date":"2026年4月13日","externalUrl":null,"permalink":"/ja/tags/logstash/","section":"Tags","summary":"","title":"Logstash","type":"tags"},{"content":"","date":"2026年4月13日","externalUrl":null,"permalink":"/ja/tags/network/","section":"Tags","summary":"","title":"Network","type":"tags"},{"content":"","date":"2026年4月8日","externalUrl":null,"permalink":"/ja/tags/arp/","section":"Tags","summary":"","title":"ARP","type":"tags"},{"content":"このドキュメントは疑問に思っていた点をAIエージェントと対話しながらまとめたメモです。IPとMACの役割の違いからARP、TCP/UDP、NAT/PATまで実際のパケットの流れに合わせて整理します。\n目次 # 基礎 — IPアドレス vs MACアドレス ARP（Address Resolution Protocol） TCP — 接続指向プロトコル TCP 3-Way Handshake（接続の確立） TCP 4-Way Handshake（接続の終了） UDP NAT（Network Address Translation） PATポート番号変換メカニズム 1. 基礎 — IPアドレス vs MACアドレス # 例えで理解する # 概念 例え IPアドレス 家の道路名住所（論理アドレス、ネットワークによって変わる場合がある） MACアドレス 同じLANでフレームを転送する際に参照するインターフェースアドレス（通常はNICに割り当てられるが変更されることもある） IPアドレス：192.168.0.10のような形式。ネットワーク上で「どこへ送るか」経路を見つけるために使用 MACアドレス：AA:BB:CC:DD:EE:FFのような形式。同じネットワーク（LAN）内でイーサネットフレームをどのインターフェースへ送るか識別する際に使用 💡 核心：インターネット全体のルーティングはIP基準で、イーサネットのようなローカルリンクで次のホップまでフレームを送る際にMACアドレスが使われる。\n2. ARP（Address Resolution Protocol） # ARPはなぜ必要なのか？ # イーサネットLANで同じリンクの相手や次のホップにフレームを送るためには、その対象のMACアドレスが必要だ。 アプリケーションやOSは通常まずIPアドレスを知っているため、このIPをMACアドレスに解決する過程が必要だ。\n「IPはわかったけど、MACアドレスはどうやって知るの？」 → ARPが解決してくれる。\nARPの動作過程 # シナリオ1：PCが初めて外部ネットワークに出ようとするとき（Gateway MACアドレス探索） # [PC] ─────────── [Gateway/ルーター] ─────── [インターネット] Step 1. PCが外部との通信を試みる\nPCは外部ネットワークに出ようとする時点でGateway（ルーター）のMACアドレスが必要になる場合がある しかしGatewayのMACアドレスを知らない！ Step 2. ARP Request（要求） — Broadcast\nPCがネットワーク全体に呼びかける： 「私は192.168.0.1（Gateway IP）を持つ装置を探しています！ MACアドレスをお持ちの方は答えてください！」 これをBroadcastという → 同じネットワークのすべての装置に送信 Broadcastアドレス：FF:FF:FF:FF:FF:FF Step 3. ARP Reply（応答） — 通常Unicast\nGatewayが応答： 「私だよ！私のMACアドレスはAA:BB:CC:DD:EE:FFだよ！」 一般的な応答はUnicast → 要求したホストに1:1で送信 Step 4. ARPキャッシュへの保存\nPCは受け取ったMACアドレスをARPキャッシュテーブルに保存 次回はまた聞かなくてよい（一定時間後に期限切れ） # WindowsでARPキャッシュを確認するコマンド arp -a シナリオ2：ネイバー（韓国の検索エンジン）にアクセスするとき # [PC] → [Gateway/ルーター] → [インターネット] → [ネイバーサーバー] 核心的な疑問：「ネイバーサーバーのMACアドレスに直接送るべきか？」\n❌ 違う！ 理由：\nネイバーサーバーは別のネットワークにある MACアドレスは同じリンク/LAN内でのみ直接意味がある 別のネットワークに出るには**Gateway（ルーター）**を経由しなければならない したがってPCが実際にすること：\n目的地IP → ネイバーサーバーIP（203.xxx.xxx.xxx） 目的地MAC → GatewayのMACアドレス ← 核心ポイント！ 📌 別のサブネットへ出るパケットは、リモートサーバーのMACではなく、まずデフォルトゲートウェイのMACに転送する。\nGatewayは受け取ったパケットを見て：\n目的地IPを確認 どこにルーティングするか決定 次の経路に転送 同じサブネット内の別のホストに送る場合は、このとき相手ホストのMACアドレスをARPで探す。\nシナリオ3：DHCP + ARPの組み合わせ # PCが起動時にIPを自動的に受け取る過程を単純化して見ると：\n1. PC起動 2. DHCPDISCOVER → Broadcast 3. DHCPOFFER ← DHCPサーバーの応答 4. DHCPREQUEST → 使用するアドレスのリクエスト 5. DHCPACK ← IPアドレスと設定の確定 6. その後、同じリンクでGatewayのMACアドレスが必要な場合はARPを実行 💡 DHCPはデフォルトゲートウェイのIPアドレスをオプションとして提供できるが、ゲートウェイのMACアドレスまでは提供しない。 だからローカルリンクで実際にフレームを送る前には、やはりARPが必要だ。\n毎回ARPをするのは無駄なのでARPキャッシングで保存して再使用する。\n3. TCP — 接続指向プロトコル # TCP vs UDP の比較 # 特性 TCP UDP 接続 接続後に通信（Connection） 接続なしで直接送信 信頼性 高い（再送、順序保証） 低い 速度 相対的に遅い 速い 使用場所 Web、メール、ファイル転送 ストリーミング、ゲーム、DNS ヘッダーサイズ 通常20〜60バイト 8バイト TCPの核心概念：シーケンス番号（Sequence Number） # TCPはデータを**断片（Segment）**に分けて送る。\nシーケンス番号 1, サイズ：1460バイト → ACK番号 1461 シーケンス番号 1461, サイズ：1460バイト → ACK番号 2921 このように動作する理由：\nパケットが途中で消失したり順序が変わる場合がある シーケンス番号で**「何番目のバイトまで正しく受け取ったか」**を追跡できる 欠けた番号があれば → 再送要求 TCP接続自体は通常送信元IP/ポート + 目的地IP/ポートの組み合わせで区別され、Sequence番号はその接続内でバイトストリームの位置を追跡する。\n4. TCP 3-Way Handshake（接続の確立） # なぜ3回もやり取りするのか？ # 両側が互いに確認しなければならない：\n「私は送れる？」 → 「うん、あなたが送るの受け取ったよ」 「私も送れる？」 → 「うん、あなたが送るの受け取ったよ」 これを効率的にまとめると3回になる。\n詳細な過程 # Client Server | | | ① SYN（seq=100） | | ─────────────────────────────────\u0026gt; | | | | ② SYN+ACK（seq=300, ack=101） | | \u0026lt;───────────────────────────────── | | | | ③ ACK（seq=101, ack=301） | | ─────────────────────────────────\u0026gt; | | | | [接続の確立完了！] | ① Client → Server：SYN # SYN：Synchronize（同期化）フラグ Clientが言う：「私はseq=100から始めるよ。接続しよう！」 ② Server → Client：SYN + ACK # SYN：「私はseq=300から始めるよ！」 ACK：「あなたの100番受け取ったよ。次は101番をちょうだい」（ack=100+1=101） 2つを一度に送る → 効率的！ ③ Client → Server：ACK # 「あなたの300番受け取ったよ。次は301番をちょうだい」（ack=300+1=301） この時点から**接続完了！**データ転送が可能 3-Way Handshakeで交換するもの # TCP 3-way handshakeの核心は初期シーケンス番号の同期化で、SYN区間で必要な場合はMSSのようなTCPオプションも一緒に渡される。\n1. Sequence Numberの交換\n互いの初期シーケンス番号を共有 これで今後やり取りするバイトを追跡 2. MSS（Maximum Segment Size）\n「私はこの接続でこのサイズまでのTCPデータの断片を受け取れる」 MSSはよく「ネゴシエーション」のように説明されるが、正確には各方向で自分が受信できるサイズを知らせる値 実際の転送サイズは相手が広告したMSSとパスのMTUなどを合わせて考慮して決定される 5. TCP 4-Way Handshake（接続の終了） # なぜ4回も必要なのか？ # 3-Wayと違って、終了は一方向ずつ別々に閉じられるからだ。\n理由：TCPは双方向通信のため、各方向を独立的に終了できる。\n以下の例はClientが先に終了を開始するActive Closeの状況だ。実際にはどちらの側でも先に終了を開始できる。\n詳細な過程 # Client（Active Close） Server（Passive Close） | | | ① FIN（seq=1000） | | ─────────────────────────────────\u0026gt; | | | | ② ACK（ack=1001） | | \u0026lt;───────────────────────────────── | | | | （FIN_WAIT_2状態で待機） | ← サーバーが残りのデータを | | 送信し終える時間 | ③ FIN（seq=2000） | | \u0026lt;───────────────────────────────── | | | | ④ ACK（ack=2001） | | ─────────────────────────────────\u0026gt; | | | |（TIME_WAIT待機後に完全終了） | 完全終了 ① Client → Server：FIN # 「私はこれ以上送るデータがない。接続を切ろう」 しかしまだ受け取ることは可能な状態（Half-Close） ② Server → Client：ACK # 「わかった、あなたのFIN受け取ったよ」 Serverはまだ送るデータが残っている可能性がある この間にServerが残りのデータを送信 ③ Server → Client：FIN # 「私も今すべて送り終えた。接続を切ろう」 ④ Client → Server：ACK # 「わかった、完全に終了するよ」 Clientはすぐに終了せずTIME_WAIT状態でしばらく待機 理由：最後のACKの再送が必要な場合があり、遅延した重複セグメントがネットワークに残っている可能性があるため FIN + ACKがなぜよく別々に見えるのか？ # 説明の便宜上FIN → ACK → FIN → ACKの4段階をよく描くが、常に4つの個別パケットに分かれるわけではない。\n3-WayでSYN+ACKを一度にまとめたのと違って、終了区間は以下のように動作する場合がある。\nServerはClientのFINを受け取るとまずACKで受信の事実を知らせなければならない しかしServerの側にまだ送るデータが残っていれば、自分のFINは後で送る 逆に送るデータがこれ以上なければ、ACKとFINを同じセグメントに乗せて送ることもできる つまり、代表的な状態遷移は4-way closeとして説明するが、実際のパケット数は状況によってまとまる場合がある。\n6. UDP # UDPヘッダーがシンプルな理由 # TCPヘッダー：送信元ポート、目的地ポート、シーケンス番号、ACK番号、 フラグ、ウィンドウサイズ、チェックサム、緊急ポインター...（通常20〜60バイト） UDPヘッダー：送信元ポート、目的地ポート、長さ、チェックサム（8バイト） UDPの特徴 # 内蔵の再送/順序保証/フロー制御がない：TCPのように相手がどれだけ受け取れるか確認しながら転送速度を調整する機能がプロトコルの基本に含まれていない 必要な制御はアプリケーションレベルで実装しなければならない マルチメディアによく使われる理由：映像/音声ストリーミングはわずかな損失よりリアルタイム性の方が重要な場合が多い 7. NAT（Network Address Translation） # NATが必要な理由 — IPv4アドレス不足の問題 # IPv4アドレスの総数： 約43億個 世界のインターネット機器：数十億台以上 IPv4アドレスが限られているため生まれた代表的な解決策の一つ： 「公開IP1つで複数の機器がインターネットを使おう」 → NAT\n💡 ここで言うNAT/PATは主に家庭用IPv4環境基準の説明だ。\n公開IP vs プライベートIP # 区分 公開IP（Public IP） プライベートIP（Private IP） 付与主体 ISP（KT、SKTなど） 通常は内部DHCPサーバー/ルーター インターネット通信 グローバルインターネットで直接ルーティング可能 グローバルインターネットで直接ルーティングされない 一意性 世界中で唯一 同じプライベートネットワーク内でのみ唯一 例 203.245.10.5 192.168.0.x、10.x.x.x、172.16.x.x 〜 172.31.x.x [インターネット] | | ← 公開IP：203.245.10.5（ISPが自宅に付与） | [ルーター/Gateway] ← 公開IP + プライベートIP の両方を持つ！ | |── 192.168.0.2（自分のノートPC） |── 192.168.0.3（自分のスマホ） |── 192.168.0.4（自分のTV） NATの動作原理 # 📤 出るとき（内部 → 外部） # [ノートPC 192.168.0.2 : ポート5000] ↓ [ルーター] 送信元IP/ポートを入れ替え！ ↓ 送信元：192.168.0.2:5000 → 203.245.10.5:12345（公開IP） 目的地：ネイバーサーバー ルーターがNATテーブルに記録：\nプライベートIP:ポート 公開IP:ポート 192.168.0.2:5000 ↔ 203.245.10.5:12345 192.168.0.3:6000 ↔ 203.245.10.5:12346 192.168.0.4:7000 ↔ 203.245.10.5:12347 📥 入るとき（外部 → 内部） # [ネイバーサーバー] → 203.245.10.5:12345 に応答を送信 ↓ [ルーター] NATテーブルを検索！ 「12345ポートは192.168.0.2:5000だった」 ↓ 目的地を192.168.0.2:5000に復元して転送 💡 ポート番号でどの機器のリクエストかを区別するのが核心！ これをPAT（Port Address Translation）またはNAPTとも呼ぶ\nNATの種類 # 種類 説明 使用例 Static NAT プライベートIP 1個 ↔ 公開IP 1個の固定マッピング 社内サーバーの外部公開 Dynamic NAT 公開IPプールから動的に割り当て 企業環境 PAT（最も一般的） 公開IP 1個 + ポートで複数の機器を区別 家庭用ルーター NATのメリット・デメリット # ✅ メリット\nIPアドレスの節約 — 公開IP 1個で複数の機器がインターネットを使用できる アドレスの秘匿 — 内部のプライベートIP体系が外部に直接露出しない ❌ デメリット\n外部から内部への直接アクセスが難しい 従来のoutbound-initiated NATでは、NATテーブルや静的マッピングがなければ外部から内部ホストに先にアクセスするのが難しい これがゲームをするとき**「ポートフォワーディング」**が必要な代表的な理由 追跡が難しい — 同じ公開IPから複数のユーザーが出てくるのでログ分析が複雑 📌 NATはアドレス変換機能だ。実務ではステートフルフィルタリングと一緒に動作する場合が多いが、NAT自体がファイアウォールと同じ概念ではない。\nポートフォワーディング（NATの応用） # 「外部から自分のサーバーにアクセスしたい」\n外部ユーザー → 203.245.10.5:8080 にアクセス ↓ ルーター設定：「8080ポートに来たものは192.168.0.2:8080に転送して」 ↓ 自分のノートPCサーバーに到達 ✅ ルーターの管理ページでポートフォワーディングルールを直接作成すればよい。\n8. PATポート番号変換メカニズム # 核心的な問題状況 # ノートPC 192.168.0.2:51234 → ネイバー:443 スマホ 192.168.0.3:51234 → ネイバー:443 ← ポートまで同じ！ TV 192.168.0.4:51234 → ネイバー:443 ルーターの視点からこれを公開IP一つで送り出すと誰のもの？ → 応答が返ってきたときに誰に渡せばいいかわからない\nクライアントポート番号の特性 # TCP/UDPでクライアントが使用するポートは通常Ephemeral Port（一時ポート）：\nWell-Known Port: 0 〜 1023 （HTTP:80、HTTPS:443などサーバー用） Registered Port: 1024 〜 49151 （登録されたアプリケーション用） Ephemeral Port: 49152 〜 65535 ← クライアントが動的に使用 クライアントはサーバーに接続するとき、この範囲からOSが動的にポートを一つ選んで使う。\nポート変換方式 — 3つのケース # ✅ ケース1：衝突がない場合（そのまま通過） # ノートPC 192.168.0.2:51234 → ルーター 203.245.10.5:51234 → ネイバー スマホ 192.168.0.3:62891 → ルーター 203.245.10.5:62891 → ネイバー 送信元ポートが違えばそのまま同じ外部ポートにマッピングされる場合がある。 NATテーブルに記録しておくだけでOK。\nNATテーブル： プライベート側 公開側 192.168.0.2:51234 ↔ 203.245.10.5:51234 192.168.0.3:62891 ↔ 203.245.10.5:62891 🔥 ケース2：ポートの衝突が発生（核心！） # ノートPC 192.168.0.2:51234 → すでにNATテーブルにある！ スマホ 192.168.0.3:51234 → 同じ外部ポートを使おうとしているがすでに使用中！ このときルーターがすること：\n1. NATテーブルで該当の外部ポートの使用状況を確認 2. 衝突を検知 3. 使用可能な別の外部ポートを選択 4. 例：51235が空いている → スマホのパケットのポートを51235に置き換え 5. 変換して送り出す NATテーブル： プライベート側 公開側 192.168.0.2:51234 ↔ 203.245.10.5:51234 （元のまま維持） 192.168.0.3:51234 ↔ 203.245.10.5:51235 ← ポート番号が変更された どの外部ポートを選ぶかはNATの実装によって異なる。\nケース3：NATテーブルは思ったより多くの情報を参照する # 実際のNATの実装はポートだけを見るのではなく、セッション情報をより精密に追跡する。\nRFC 4787はUDP NATの動作を説明する際にセッションを以下のようなendpoint tupleの観点から見ている。\n（送信元IP : 送信元ポート : 目的地IP : 目的地ポート） 例えば：\n192.168.0.2:51234 → ネイバー:443 192.168.0.2:51234 → Google:443 同じ内部ポートを使っていても目的地が違えば別のセッションとして管理される場合がある。 ただし外部ポートの再使用方式はendpoint-independent / address-dependent / address-and-port-dependentのようにNATの実装によって異なる場合がある。\n全体の流れの例 # ① ノートPC（192.168.0.2:51234） → ネイバー:443 リクエスト ルーター：テーブル確認 → 空いている → そのまま51234を使用 NATテーブル登録：0.2:51234 ↔ 公開:51234 → ネイバー:443 ② スマホ（192.168.0.3:51234） → ネイバー:443 リクエスト ルーター：テーブル確認 → 公開:51234→ネイバー:443 はすでに使用中 → 別の外部ポートを選択 → 例：51235に変換 NATテーブル登録：0.3:51234 ↔ 公開:51235 → ネイバー:443 ③ ネイバー → 公開:51234 に応答 ルーター：51234 → ノートPC（192.168.0.2:51234）に転送 ✅ ④ ネイバー → 公開:51235 に応答 ルーター：51235 → スマホ（192.168.0.3:51234）に転送 ✅ 限界 — ポートの空間は有限だ # 使用可能なポート番号の範囲：0 〜 65535 公開IP 1個だけで維持できる同時セッション数にはポート空間の限界がある。 実際に使用可能な数は予約ポート、実装方式、プロトコル別の分離などによって変わる場合がある。\n大型企業/ISP環境ではこれも足りなくなって：\n公開IPを複数プールで運用 または**Carrier-Grade NAT（CGNAT）**のようにNATを大規模に運用 全体像の最終整理 # PC起動 └─→ DHCPでIPを取得 └─→ ARPでGatewayのMACアドレスを取得 └─→ NATでプライベートIP → 公開IPに変換 └─→ TCP 3-Way Handshakeでサーバーと接続 └─→ データのやり取り（Seq/ACKで信頼性を保証） └─→ TCP 4-Way Handshakeで接続終了 世界中のインターネット | 公開IP（203.245.10.5） | [ルーター] ← NAT/PATを実行 / | \\ スマホ PC TV 192. 192. 192. 168. 168. 168. 0.3 0.2 0.4 （プライベートIPたち） 核心まとめカード # 概念 一行まとめ ARP IPアドレスからリンク層アドレスを見つけ出すプロトコル MACアドレス 同じLAN内でフレームを転送する際に参照するインターフェースアドレス TCP 3-Way SYN → SYN+ACK → ACK で接続を確立、初期Seq番号の同期化とSYNオプションの交換 TCP 4-Way 代表的にFIN → ACK → FIN → ACK で接続終了、実際のパケットは状況によってまとまる場合がある NAT プライベートIP ↔ 公開IPの変換でIPv4プライベートネットワークが外部と通信できるようにする方式 PAT ポート番号も一緒に変換して複数のプライベートホストを公開IP 1個にマッピング ポートの衝突 ルーターが別の外部ポートを選択してNATテーブルに記録 UDP 接続なしで最小限のヘッダーで転送し、信頼性制御はアプリケーションが担当 参考資料 # RFC 826 - An Ethernet Address Resolution Protocol RFC 2131 - Dynamic Host Configuration Protocol RFC 2132 - DHCP Options and BOOTP Vendor Extensions RFC 768 - User Datagram Protocol RFC 9293 - Transmission Control Protocol (TCP) RFC 1918 - Address Allocation for Private Internets RFC 3022 - Traditional IP Network Address Translator (Traditional NAT) RFC 4787 - Network Address Translation (NAT) Behavioral Requirements for Unicast UDP RFC 6335 - Service Name and Port Number Registry Procedures ","date":"2026年4月8日","externalUrl":null,"permalink":"/ja/posts/network-fundamentals-arp-tcp-udp-nat/","section":"Posts","summary":"IPとMACの役割の違い、ARP動作、TCP接続の確立と終了、UDPの特性、NAT/PATのポート変換概念を公式ドキュメント基準で保守的に整理します。","title":"IP、MAC、ARP、TCP/UDP、NAT/PATは実際の通信でどのようにつながるか？","type":"posts"},{"content":"","date":"2026年4月8日","externalUrl":null,"permalink":"/ja/tags/nat/","section":"Tags","summary":"","title":"NAT","type":"tags"},{"content":"","date":"2026年4月8日","externalUrl":null,"permalink":"/ja/tags/pat/","section":"Tags","summary":"","title":"PAT","type":"tags"},{"content":"","date":"2026年4月8日","externalUrl":null,"permalink":"/ja/tags/tcp/","section":"Tags","summary":"","title":"TCP","type":"tags"},{"content":"","date":"2026年4月8日","externalUrl":null,"permalink":"/ja/tags/udp/","section":"Tags","summary":"","title":"UDP","type":"tags"},{"content":"このドキュメントは、疑問を持っていた点をAIエージェントと対話しながら整理したメモです。InnoDBで通常のSELECTとlocking readの違い、MVCCと2PLの関係、next-key lockとundo/redoの役割を整理します。\n目次 # MVCC 2PL MVCCと2PLの関係 Isolation Levelの深掘り比較 Non-Repeatable Read vs Phantom Read Locking ReadとNext-Key Lock Steal/No-Force方針とRedo/Undo Logの関係 核心まとめ 1. MVCC # 概念 # MVCCはデータの複数のバージョンを活用して、読み取りと書き込みが互いを不必要にブロックしないようにする方式だ。\nInnoDBの通常のSELECTはREAD COMMITTEDとREPEATABLE READでデフォルトでconsistent nonlocking readとして処理される。つまり通常は現在の最新値をそのまま読むのではなく、特定の時点のスナップショットを基準に見えるバージョンを読む。\nInnoDBの実装 - 隠しカラムとundo log # MySQL公式ドキュメントを基準にInnoDBは各行に内部フィールドを維持する。\n隠しカラム 役割 DB_TRX_ID この行を最後にINSERTまたはUPDATEしたトランザクションID DB_ROLL_PTR rollback segmentのundo log recordを指すポインタ DB_ROW_ID 内部row id 現在のrow DB_TRX_ID = 110 DB_ROLL_PTR ──▶ undo log record 以前のバージョン情報 核心は「行を丸ごと複数コピーしておく」というよりも:\n現在のrowには最新バージョンがあり 必要ならDB_ROLL_PTRをたどってundo情報を使ってより古いバージョンを再構成する と理解する方が正確だ。\n公式ドキュメントは削除も内部的にはdelete markが付くupdateのように扱うと説明している。\nRead View # Read Viewは「何が見えて何が見えないか」を決定するスナップショットメタデータだ。\n厳密には活性トランザクションのリストとvisibility boundaryを基準に判断するため、よく言われる「自分のTRX_IDより小さいものはすべて見える」程度に単純化するのは不十分だ。ただし概念的には以下のように理解してよい。\nスナップショット時点より以前にコミットされたバージョンは見える その時点にまだ活性中であるか、以降にコミットされたバージョンは見えない 現在のrowが見えない場合、InnoDBは必要に応じてundoを使って古いバージョンを再構成する 例外的に、現在のトランザクションがearlier statementsで直接作った変更は現在のトランザクションから見える Isolation Levelごとのスナップショット生成タイミング # Isolation Level 通常のSELECT基準の動作 READ UNCOMMITTED nonlocking readだが一貫したスナップショット読み取りではない READ COMMITTED 各consistent readごとにfresh snapshotを生成 REPEATABLE READ 最初のconsistent readが作ったsnapshotを再利用 SERIALIZABLE autocommit=0の場合、plain SELECTが事実上FOR SHAREとして処理される MVCCが与える効果 # READ COMMITTEDとREPEATABLE READの通常のSELECTでは以下の性質が核心だ。\nコミットされていないか後でコミットされた変更は見えない consistent readは読み取り中にロックを取らないため読み取り-書き込みの競合を大幅に減らす REPEATABLE READでは同じトランザクション内のrepeated plain SELECTが互いに一貫した結果を見る 2. 2PL # 概念 # 2PL（Two-Phase Locking）はロック取得区間とロック解放区間を分離する古典的な同時実行制御モデルだ。\nGrowing Phase Shrinking Phase （ロック取得のみ可能） （ロック解放のみ可能） ────────────────────────────────────────── Lock A Lock B Lock C ──▶ Unlock A Unlock B Unlock C Growing Phase: 必要なロックを取得するだけで解放しない Shrinking Phase: ロックを解放し始めたら新しいロックを取得しない InnoDBではどう見るべきか # MySQL公式ドキュメントはInnoDBのトランザクションモデルがmulti-versioning databaseの利点とtraditional two-phase lockingを組み合わせようとすると説明している。\nそのためInnoDB全体を即「Strict 2PLデータベース」と呼ぶのは過剰だが、locking readと書き込み区間のロック動作だけ取り出してみると、strict 2PLに近い性格がある。\n例えばFOR UPDATE、FOR SHARE、UPDATE、DELETEはロックを取ってトランザクション終了時点まで維持する。\nBEGIN; SELECT * FROM orders WHERE id = 1 FOR UPDATE; UPDATE orders SET status = \u0026#39;DONE\u0026#39; WHERE id = 1; COMMIT; 公式ドキュメントはFOR SHAREとFOR UPDATEで取ったロックがCOMMITまたはROLLBACK時に解放されると明示している。\nInnoDBの主なロック種類 # ロック種類 説明 Shared Lock（S） 読み取り保護用ロック Exclusive Lock（X） 修正/削除用ロック Intention Lock（IS / IX） テーブルレベルの意図表示 Gap Lock インデックスレコード間のgapに対するロック Next-Key Lock index-record lock + gap lock Insert Intention Lock INSERT直前のgapに取られる意図ロック ロックの実際の範囲は分離レベルと検索条件、使用したインデックスによって異なる。\nDeadlock # ロックベースの同時実行制御の代表的な副作用はデッドロックだ。\nT1: Lock A → Lock B 試みる T2: Lock B → Lock A 試みる InnoDBはdeadlock detectionによってこのような循環待機を検出し、1つのトランザクションをロールバックして問題を解決する。\n3. MVCCと2PLの関係 # InnoDBはどちらか一方だけを使うエンジンではなく、読み取りパスとロックパスを分離して一緒に使用する。\n処理 使用方式 通常のSELECT（READ COMMITTED、REPEATABLE READ） MVCCベースのconsistent nonlocking read SELECT ... FOR UPDATE / SELECT ... FOR SHARE locking read、最新状態 + ロック UPDATE / DELETE 最新状態 + ロック INSERT insert intention lock + record lock したがってInnoDBを最も無理なく説明する方式は以下だ。\n通常の読み取りはMVCC locking readとDMLはロックベース ロックベースの区間はstrict 2PLに近い性格 公式ドキュメントがREPEATABLE READの中でnonlocking SELECTとlocking statementを混在させることを推奨しない理由もここにある。1つのトランザクション内にsnapshot worldとcurrent worldが共存するからだ。\n4. Isolation Levelの深掘り比較 # Read Committed - SELECTを実行したその瞬間を基準に # T1: BEGIN T1: (t=1) SELECT ... -\u0026gt; snapshot A 生成 T2: UPDATE ... ; COMMIT (t=2) T1: (t=3) SELECT ... -\u0026gt; snapshot B 生成 -\u0026gt; T2の変更が反映される場合がある ポイントは単純だ。\n各consistent readごとに新しいsnapshotを作る 同じトランザクション内でもrepeated readの結果が変わりうる locking read / UPDATE / DELETEは通常gapではなくindex recordのみロックする gap lockingは主にforeign key checkとduplicate key checkでのみ使用される つまりREAD COMMITTEDでは、rangeベースのlocking readでphantom rowの問題が残りうる。\nRepeatable Read - 最初のconsistent readを基準に # T1: BEGIN T1: (t=1) SELECT ... -\u0026gt; snapshot A 生成 T2: UPDATE ... ; COMMIT (t=2) T1: (t=3) SELECT ... -\u0026gt; snapshot A 再利用 -\u0026gt; plain SELECTの結果は一貫している ポイントは以下のとおりだ。\n最初のconsistent readが作ったsnapshotを再利用する 同じトランザクション内のplain SELECTは互いに一貫した結果を見る range条件のlocking read、UPDATE、DELETEはgap lockまたはnext-key lockを使う場合がある ただしここでも注意すべき点がある。\nplain SELECTはread viewベースのsnapshotを見る locking readとDMLはlatest stateを基準にロックを使用する そのため同じトランザクション内でnonlocking SELECTとlocking statementを混在させると解釈が難しくなる。\nSerializable - 「MVCC完全放棄」と断定するのは過剰 # 元文のようにSERIALIZABLEを「MVCCを完全に捨てる」と書くのは誇張だ。\nMySQL 8.4公式ドキュメントはSERIALIZABLEを**REPEATABLE READと似ているが**、autocommitがオフのときにplain SELECTを暗黙的にSELECT ... FOR SHAREに変えると説明している。\nつまり:\n手動トランザクションではplain SELECTもlocking readのように動作する しかしautocommitがオンであればplain SELECTは自分自身が1つのread-only transactionなので、consistent nonlocking readとして直列化できる したがってより正確な理解はこうだ。\nSERIALIZABLE = 「常にMVCC完全放棄」 ではなく 「RRよりも厳格で、特にautocommit offのplain SELECTはlocking readのように扱う」 Isolation Level全体比較 # Level plain SELECT locking read / DML 特徴 READ UNCOMMITTED nonlocking read、ただし一貫していない 概ねRCと似たlocking dirty readが可能 READ COMMITTED 各consistent readごとにfresh snapshot gap lockingをほぼ無効化 repeated readが不安定 REPEATABLE READ 最初のconsistent read snapshotを再利用 range scan時にgap/next-key lockを使用可能 InnoDBのデフォルト値 SERIALIZABLE autocommit offならplain SELECTが事実上FOR SHARE 最も保守的なlocking 同時実行性が低下する可能性 5. Non-Repeatable Read vs Phantom Read # 共通前提 - plain SELECTとlocking readを区別しなければならない # この2つの異常現象は教科書的には似て見えるが、InnoDBでは通常のconsistent readかlocking readかによって説明が異なる。\n5-1. Non-Repeatable Read - updateされた既存の行 # T1: BEGIN T1: SELECT * FROM users WHERE id = 1 -\u0026gt; \u0026#39;Alice\u0026#39; T2: UPDATE users SET name=\u0026#39;Bob\u0026#39; WHERE id=1; T2: COMMIT T1: SELECT * FROM users WHERE id = 1 -\u0026gt; ? 現在のrowがすでに変更されていても、REPEATABLE READのplain SELECTはsnapshotの基準に合う古いバージョンを探す必要がある場合がある。\n[現在のRow] id=1, name=\u0026#39;Bob\u0026#39; DB_TRX_ID = 110 DB_ROLL_PTR ──────────▶ [Undo Log] id=1, name=\u0026#39;Alice\u0026#39; READ COMMITTEDでは2番目のSELECTが新しいsnapshotを取るのでBobが見える REPEATABLE READでは最初のsnapshotを再利用するので、update undoを使って古いバージョンを再構成できる つまり既存の行のupdateは、update undoチェーンが重要なケースだ。\n5-2. Phantom Read - 範囲内に新しい行が現れる場合 # T1: BEGIN T1: SELECT * FROM users WHERE age \u0026gt; 20 T2: INSERT INTO users VALUES (3, \u0026#39;Charlie\u0026#39;, 28); T2: COMMIT T1: SELECT * FROM users WHERE age \u0026gt; 20 -\u0026gt; ? ここで注意すべき点は以下の2つだ。\nREPEATABLE READの**plain SELECT**は同じsnapshotを再利用するため、repeated readの結果セットが安定している。 MySQL公式ドキュメントがphantom problemを説明するときに使う代表的な例は**SELECT ... FOR UPDATEのようなlocking read**だ。 つまり「phantomはinsertされた行にundo logがないから発生する」と説明するのは不正確だ。\nより正確に言えば:\n新しくinsertされた行は自分のsnapshot以降に生まれたrowなら見えない insert undoはrollback用途であり、commit後に捨てられる consistent readで過去バージョンの再構成に核心的に使われるのはupdate undoだ locking readのphantom防止はnext-key lockが担当する 5-3. 2つの違いの核心比較 # 区分 Non-Repeatable Read Phantom Read 観察対象 同じrowの値が変わる 同じpredicateの結果セットが変わる 代表的な原因 既存の行のUPDATE 範囲内に新しい行のINSERTまたは既存の行の移動 REPEATABLE READのplain SELECT snapshot + update undoで安定 同じsnapshotを再利用するため結果セットが安定 locking readでの防御 最新状態に対してrow lockを使用 range scanにnext-key/gap lockを使用 核心はこうだ。\nplain SELECTの反復可能性はsnapshotが担当する locking readのphantom防止はnext-key lockが担当する 6. Locking ReadとNext-Key Lock # Locking Readは最新状態とロックを一緒に使う # SELECT ... FOR UPDATEとSELECT ... FOR SHAREはplain SELECTとは異なるlocking readだ。\n最新状態を基準に読む 検索過程で出会ったindex recordsにロックをかける トランザクション終了まではロックを維持する 公式ドキュメントはold versionはロックできず、old versionはundo logを適用してメモリ上で再構成すると説明している。\nまたlocking readは明示的なトランザクション内で使用しなければならない。ドキュメントもSTART TRANSACTIONまたはautocommit=0の状態を前提に説明している。\nNext-Key Lockが必要な理由 # MySQL公式ドキュメントのphantomの例は以下のとおりだ。\nSELECT * FROM child WHERE id \u0026gt; 100 FOR UPDATE; インデックスに90、102のみあるとしよう。\nT1: SELECT * FROM child WHERE id \u0026gt; 100 FOR UPDATE; T2: INSERT INTO child VALUES (101); -- gapがロックされていないとphantomが可能 このとき単純なrow lockだけでは(90, 102)の間に101が新しく入ってくることを防げない。そこでInnoDBはnext-key lockingを使用する。\nNext-Key Lockの意味 # next-key lockは:\nindex-record lock そのレコードの前のgap lock の組み合わせだ。\n(90, 102] \u0026lt;- 102 record + その前のgap (102, +∞) \u0026lt;- 最後のrecordの後ろのgap 公式ドキュメントはデフォルトのREPEATABLE READでInnoDBがsearchとindex scanにnext-key lockを使ってphantom rowを防ぐと説明している。\n逆にREAD COMMITTEDではgap lockingがほぼ無効化されるため、range条件のlocking readではphantom rowの問題が再び発生する可能性がある。\n7. Steal/No-Force方針とRedo/Undo Logの関係 # 背景 - Buffer Pool、Dirty Page、Checkpoint # InnoDBはtableとindexデータをメモリのBuffer Poolにキャッシュする。修正されたページはすぐにデータファイルに書き込まれない場合があり、このようなページをdirty pageと呼ぶ。\n[Disk] ──読み取り──▶ [Buffer Pool] └─ dirty page └─ background flush / checkpoint 公式ドキュメントは:\ndirty pageはバックグラウンドで後でflushされる場合があり page cleanerがこれを担当し checkpointはfuzzy checkpointingで動作してバッファプール全体を一度にflushする必要がないと説明している つまりcommitとデータファイルflushのタイミングは常に1:1で対応しているわけではない。\nRedo Log # MySQL 8.4公式ドキュメントでredo logは:\ndisk-based data structure crash recoveryのときデータファイルに最後まで反映できなかった変更を補正するための構造 として説明される。\nつまり予期しない終了前にデータファイルの更新が完了しなかった変更は、再起動時にredoをreplayして復元する。\nUndo Log # undo log recordはclustered index recordの最新変更を元に戻すための情報を持つ。\n公式ドキュメントは以下を明確に区分している。\ninsert undo: rollbackにのみ必要でcommit後に捨てられる update undo: rollbackだけでなくconsistent readにも使われ、どのsnapshotも必要としなくなった時のみ捨てられる したがってundo logの役割は2つある。\nrollbackのための復元情報 consistent readのための過去バージョン再構成情報 Steal/No-Forceという表現は解釈だ # ここで元文のSteal + No-Forceの説明は教科書的な解釈としてはかなり有用だが、MySQL公式ドキュメントがInnoDBをその用語で直接規定しているわけではない。\nただし公式ドキュメントが示している事実をまとめると:\ndirty pageはバックグラウンドで後でflushされる場合があり redoはcrash recoveryに必要であり undoはrollbackとconsistent readに必要だ という点から、理論的にはsteal/no-forceに近い運営モデルとして理解できると言う程度は可能だ。\nつまりこの部分は以下のように表現する方が安全だ。\n「InnoDBは公式ドキュメント上Steal + No-Forceと規定される」 よりも 「公式ドキュメントが説明するflush/recoveryの動作を教科書の用語で読むと、steal/no-forceに近く理解できる」 8. 核心まとめ # RC/RRのplain SELECT -\u0026gt; MVCCベースのconsistent nonlocking read READ COMMITTED -\u0026gt; consistent readごとにfresh snapshot REPEATABLE READ -\u0026gt; 最初のconsistent read snapshotを再利用 FOR UPDATE / FOR SHARE / UPDATE / DELETE -\u0026gt; 最新状態 + ロック -\u0026gt; lockingパートはstrict 2PLに近い phantom防止 -\u0026gt; plain SELECTの反復可能性はsnapshotが担当 -\u0026gt; locking readのphantom防止はnext-key lockが担当 SERIALIZABLE -\u0026gt; RRよりも厳格 -\u0026gt; autocommit offのplain SELECTは事実上FOR SHAREのように動作 redo / undo -\u0026gt; redoはcrash recovery replay -\u0026gt; undoはrollback + old-version reconstruction 一言でまとめるとこうだ。\nInnoDBは読み取りをすべてロックで処理するエンジンでもなく、すべてMVCCのみを使うエンジンでもない。plain SELECTはsnapshotを読み、locking readと書き込み処理は最新状態を基準にロックを使用する。\n参考資料 # MySQL 8.4 Reference Manual, InnoDB Transaction Model\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-model.html MySQL 8.4 Reference Manual, InnoDB Multi-Versioning\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-multi-versioning.html MySQL 8.4 Reference Manual, Consistent Nonlocking Reads\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-consistent-read.html MySQL 8.4 Reference Manual, Transaction Isolation Levels\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html MySQL 8.4 Reference Manual, Locking Reads\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html MySQL 8.4 Reference Manual, InnoDB Locking\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html MySQL 8.4 Reference Manual, Phantom Rows\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-next-key-locking.html MySQL 8.4 Reference Manual, Undo Logs\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-undo-logs.html MySQL 8.4 Reference Manual, Redo Log\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-redo-log.html MySQL 8.4 Reference Manual, Configuring Buffer Pool Flushing\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-buffer-pool-flushing.html MySQL 8.4 Reference Manual, InnoDB Checkpoints\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-checkpoints.html ","date":"2026年4月2日","externalUrl":null,"permalink":"/ja/posts/mysql-mvcc-2pl/","section":"Posts","summary":"InnoDBで通常のSELECTとlocking readがどのように異なる動作をするか、REPEATABLE READとREAD COMMITTEDの違い、phantomとnext-key lock、undo/redoの役割を一度に整理します。","title":"[MySQL] 同時実行制御まとめ — MVCC、2PL、Locking Read、Next-Key Lock","type":"posts"},{"content":"","date":"2026年4月2日","externalUrl":null,"permalink":"/ja/tags/2pl/","section":"Tags","summary":"","title":"2PL","type":"tags"},{"content":"","date":"2026年4月2日","externalUrl":null,"permalink":"/ja/tags/database/","section":"Tags","summary":"","title":"Database","type":"tags"},{"content":"","date":"2026年4月2日","externalUrl":null,"permalink":"/ja/tags/innodb/","section":"Tags","summary":"","title":"InnoDB","type":"tags"},{"content":"","date":"2026年4月2日","externalUrl":null,"permalink":"/ja/tags/isolation-level/","section":"Tags","summary":"","title":"Isolation Level","type":"tags"},{"content":"","date":"2026年4月2日","externalUrl":null,"permalink":"/ja/tags/mvcc/","section":"Tags","summary":"","title":"MVCC","type":"tags"},{"content":"","date":"2026年3月31日","externalUrl":null,"permalink":"/ja/tags/devops/","section":"Tags","summary":"","title":"DevOps","type":"tags"},{"content":"","date":"2026年3月31日","externalUrl":null,"permalink":"/ja/tags/docker/","section":"Tags","summary":"","title":"Docker","type":"tags"},{"content":"","date":"2026年3月31日","externalUrl":null,"permalink":"/ja/tags/docker-compose/","section":"Tags","summary":"","title":"Docker Compose","type":"tags"},{"content":"","date":"2026年3月31日","externalUrl":null,"permalink":"/ja/tags/gcp/","section":"Tags","summary":"","title":"GCP","type":"tags"},{"content":"チームプロジェクトでGCP VMにDocker Composeでサービスをデプロイする中で経験したトラブルと解決過程を、Claudeと共にまとめた記事です。 公式ドキュメントでは見落としやすい、初心者がはまりやすいポイント（Debian/Ubuntuの混同、ECDSA証明書の互換性問題、ディスク容量不足など）を中心に記録しています。\n1. 目標 # GCP仮想サーバー(VM)1台にDB・バックエンド・フロントエンド・NginxをDocker Composeでまとめて起動し、 外部からHTTPSでアクセスできるようにします。iOSモバイルアプリ開発者も同じバックエンドを使用します。\n2. 最終構成図 # ブラウザ / iOSアプリ (https://ainiinu.kr) │ ▼ ┌──────────────────────────────────────┐ │ GCP VM (Debian 12 bookworm) │ │ │ │ Docker Compose │ │ ┌────────────────────────────────┐ │ │ │ nginx (:80/:443 外部公開) │ │ │ │ ├─ / → frontend │ │ │ │ ├─ /api/v1/* → backend │ │ │ │ └─ /ws/* → backend │ │ │ ├────────────────────────────────┤ │ │ │ frontend (:3000 内部のみ) │ │ │ ├────────────────────────────────┤ │ │ │ backend (:8080 内部のみ) │ │ │ ├────────────────────────────────┤ │ │ │ postgres (:5432 内部のみ) │ │ │ └────────────────────────────────┘ │ │ │ │ ホストにインストール: │ │ - certbot (SSL証明書の発行/更新) │ │ - /etc/letsencrypt → nginxにマウント│ └──────────────────────────────────────┘ 外部にはNginxの80/443ポートのみを公開 その他のサービスはDockerの内部ネットワークでのみ通信 NginxがリバースプロキシとSSLターミネーションを担当 SSL証明書はホストでcertbotを使って直接管理（Docker certbotコンテナは使用しない） 3. 事前準備 # 3.1 GCP Compute Engineインスタンスの作成 # 項目 内容 GCP VM Compute Engineインスタンスを作成 OS Debian 12 (bookworm) — GCPデフォルトイメージ ディスク 最小20GB以上を推奨（デフォルトの10GBはDockerビルド時に容量不足） 固定IP 外部IPを「静的外部IP」に昇格（VM再起動してもIP維持） SSHアクセス GCPコンソールの「SSH」ボタンをクリック（ブラウザからすぐターミナルが開く） ディスクサイズを必ず20GB以上に設定しましょう！\nGCPのデフォルトディスクは10GBですが、Dockerイメージのビルド時に以下の容量が必要です:\nDockerエンジン + イメージレイヤーキャッシュ: ~2GB バックエンドビルド（Gradle + JDK + 依存関係）: ~3GB フロントエンドビルド（Node.js + node_modules）: ~2GB PostgreSQLデータ: ~500MB OS + システム: ~2GB 10GBではビルド中に no space left on device エラーが発生します。 インスタンス作成時に20〜30GBに設定すると安心です。\n既に作成したインスタンスのディスクを拡張する方法は10.5節を参照。\n3.2 ドメインのDNS設定 # ドメイン購入後、GCP VMの外部IPにDNS Aレコードを紐付けます。 DNSプロバイダーはどこでも構いません（Route 53、Cloudflare、お名前.comなど）。\nainiinu.kr → 34.47.72.28 (GCP VM外部IP) 確認方法（VMで）:\nsudo apt install -y dnsutils # nslookupがなければインストール nslookup ainiinu.kr # ドメインがどのIPに解決されるか確認 curl -s ifconfig.me # VMの外部IPを確認 nslookupはDNS検索ツールです。\n簡単に言えば「このドメイン名が今どのIPアドレスを指しているか？」をDNSサーバーに問い合わせるコマンドです。\nこのコマンドが重要な理由は、ドメイン設定がまだVMに反映されていないのに、サーバー設定の問題だと勘違いしやすいからです。\nたとえばNginxの設定・ファイアウォール・certbotのコマンドがすべて正常でも、DNS AレコードがまだIPを指していなければ、外部アクセスやSSL発行は失敗します。\nnslookup ainiinu.krを実行したとき、下部のAddressの値が自分のGCP VMの外部IPと一致している必要があります。\nServer: 169.254.169.254 Address: 169.254.169.254#53 Non-authoritative answer: Name: ainiinu.kr Address: 34.47.72.28 上のServer / Addressは検索に使ったDNSサーバーの情報 下のName / Addressが実際にドメインが解決された結果 つまりこの段階では、下の結果のIPだけ確認すれば大丈夫です。\nこの値がVMの外部IPと異なる場合は、DNSレコードが間違っているか、まだ伝播（propagation）が完了していない状態です。\n3.3 GCPファイアウォールの設定 # これを忘れると外部からアクセスできず、SSL証明書の発行も失敗します！\nGCPコンソール → VPCネットワーク → ファイアウォール → ファイアウォールルールを作成:\n項目 値 名前 allow-http-https タイプ 上り（Ingress） ターゲット すべてのインスタンス 送信元IPアドレス範囲 0.0.0.0/0 プロトコル/ポート TCP: 80, 443 アクション 許可 優先度 1000 デフォルトで存在するdefault-allow-internalはGCP内部通信用なので外部アクセスには関係ありません。 default-allow-ssh（TCP 22）はSSHアクセス用なので触らないでください。\n3.4 プライベートOrganizationリポジトリのクローン # OrganizationのプライベートリポジトリはふつうのGit cloneでは失敗します。GitHub Personal Access Token (PAT) が必要です。\nトークン生成手順:\nGitHub → 右上プロフィール → Settings Developer settings → Personal access tokens → Tokens (classic) Generate new token (classic) をクリック Select scopes で repo にチェック — これは「このトークンがプライベートリポジトリにアクセスできる権限」を意味します 生成後、ghp_xxxx...形式のトークンをコピー（この画面を離れると再確認できません！） repoスコープとは？ GitHubトークンは実行できる操作の範囲（scope）を指定します。 repoをチェックするとプライベートリポジトリへの読み書き権限が付与されます。 cloneだけするならこれ一つにチェックすれば十分です。\nOrganization権限の付与（重要！）:\nPersonalトークンを作成しても、Organizationのリポジトリにはすぐにアクセスできません。 トークン作成後、追加の承認が必要です:\nGitHub → Settings → Developer settings → Personal access tokens 作成したトークンをクリック 下部の Organization access セクションで該当orgの横の \u0026ldquo;Grant\u0026rdquo; または \u0026ldquo;Authorize\u0026rdquo; をクリック Grantボタンが表示されない場合は、Organization設定でPATアクセスを許可する必要があります:\nGitHub → Organizationページ → Settings → Third-party access → Personal access tokens → ポリシーを \u0026ldquo;Allow\u0026rdquo; に変更 cloneコマンド:\ngit clone https://ghp_xxxx@github.com/\u0026lt;org\u0026gt;/\u0026lt;repo\u0026gt;.git 4. Docker \u0026amp; Docker Composeのインストール # 4.1 GCP VMのOS確認 # GCP Compute EngineのデフォルトイメージはubuntuではなくDebianです！ これを知らないとDockerのインストールではまります。\nlsb_release -cs # → bookworm (Debian 12) lsb_releaseとは？ Linux Standard Base release。OSディストリビューション情報を確認するコマンドです。 -cはコードネーム（codename）、-sは短い出力（short）。bookwormが出てくればDebian 12です。\n4.2 Dockerインストール時の注意点 # よくあるミスとエラー:\n試みた方法 結果 原因 sudo apt install docker.io Dockerはインストールされるがdocker composeコマンドがない docker.ioはDebian/Ubuntuのデフォルトパッケージで、composeプラグインが含まれない sudo apt install docker-compose-plugin Unable to locate package Docker公式リポジトリが登録されていない状態 Ubuntu用Dockerリポジトリを追加後にインストール Package 'docker-ce' has no installation candidate GCP VMがUbuntuではなくDebianなのにUbuntuのリポジトリを追加してしまった Docker Desktopはインストール不可 — GCP VMはGUIのないサーバーなので、Docker Desktop（Mac/Windows デスクトップ用）は使用できません。\ndocker.ioとdocker-ceの違い:\ndocker.io — Debian/Ubuntuの公式パッケージリポジトリから提供されるDocker。バージョンが古くcomposeプラグインが含まれない。 docker-ce — Docker公式リポジトリから提供されるCommunity Edition。最新版 + composeプラグイン付き。 結論: 常にdocker-ceを使いましょう。 4.3 正しいインストール方法（Debian 12 bookworm） # # 1. 必須ツールのインストール sudo apt update sudo apt install -y ca-certificates curl gnupg # 2. Docker公式GPGキーの追加 # GPGキー = パッケージが本当にDockerが作ったものか検証する署名キー sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg # 3. Debian用Dockerリポジトリを追加（Ubuntuではないことに注意！） # この行がaptに「DockerのパッケージはこのURLからダウンロードしろ」と伝える echo \u0026#34;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable\u0026#34; | sudo tee /etc/apt/sources.list.d/docker.list \u0026gt; /dev/null # 4. Docker + Composeプラグインのインストール sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # 5. 現在のユーザーにdocker実行権限を付与 # これをしないと毎回 sudo docker ... で実行しなければならない sudo usermod -aG docker $USER # 6. SSH再接続（権限の適用 — 再接続しないとグループ変更が反映されない） exit # GCPコンソールから再度SSH接続 # 7. インストール確認 docker --version # Dockerバージョンを表示 docker compose version # Docker Composeバージョンを表示 各パッケージの役割:\ndocker-ce — Dockerエンジン（コンテナ実行の核心） docker-ce-cli — dockerコマンド（CLIツール） containerd.io — コンテナランタイム（Docker内部で実際にコンテナを管理） docker-buildx-plugin — docker buildxコマンド（マルチプラットフォームイメージビルドに対応） docker-compose-plugin — docker composeコマンド（複数コンテナを一括管理） 4.4 AWS EC2基準（参考） # Ubuntu基準:\n# Docker公式リポジトリを追加後インストール（docker.ioではなくdocker-ceを使用） sudo apt update sudo apt install -y ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg echo \u0026#34;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable\u0026#34; | sudo tee /etc/apt/sources.list.d/docker.list \u0026gt; /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin sudo usermod -aG docker $USER # SSH再接続後に適用 Amazon Linux 2023基準:\nsudo yum update -y sudo yum install -y docker sudo systemctl enable --now docker sudo usermod -aG docker $USER # Composeプラグインのインストール sudo mkdir -p /usr/local/lib/docker/cli-plugins sudo curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose # SSH再接続後に適用 注意: docker-compose（ハイフン、v1）は2023年7月にEOLとなりました。 現在はdocker compose（スペース、v2プラグイン）を使う必要があります。 Ubuntuでもdocker.io + docker-composeの代わりに、Docker公式リポジトリのdocker-ce + docker-compose-pluginをインストールしましょう。\nDockerインストール以降の全工程（git clone、docker compose up、Nginx、SSLなど）はGCP/AWS共通です。\n5. プロジェクトで作成したファイル # 5.1 フロントエンドDockerfile # aini-inu-frontend/Dockerfile:\nFROM node:22-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_PUBLIC_ENABLE_MSW=false ENV NEXT_PUBLIC_API_PROXY_TARGET=http://backend:8080 ENV NEXT_PUBLIC_WS_URL=ws://backend:8080 RUN npm run build FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV HOSTNAME=0.0.0.0 ENV PORT=3000 RUN addgroup --system --gid 1001 nodejs \u0026amp;\u0026amp; \\ adduser --system --uid 1001 nextjs COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public USER nextjs EXPOSE 3000 CMD [\u0026#34;node\u0026#34;, \u0026#34;server.js\u0026#34;] マルチステージビルドとは？ 一つのDockerfile内で複数のステージ（FROM）を経るビルド方式です。 ビルドに必要なツール（node_modules全体、ビルドツールなど）はbuilderステージだけで使い、 最終イメージ（runner）には実行に必要な最小限のものだけコピーします。 結果: イメージサイズが数GB → 数百MBに削減されます。\nnpm ciとnpm installの違い:\nnpm install — package.jsonを基準に依存関係を解決し、package-lock.jsonを修正することがある npm ci — package-lock.jsonを正確に従います。より速く再現性が高い。CI/Docker環境に適しています。 standaloneビルドの注意点: Next.jsのoutput: 'standalone'はサーバーコードのみを含みます。 .next/static（JS/CSSバンドル）とpublic（静的ファイル）は自動で含まれないため、必ず別途COPYする必要があります。 これを忘れるとページは表示されるがCSSがなかったり画像が表示されなかったりします。\n5.2 バックエンドDockerfile # aini-inu-backend/Dockerfile:\nFROM gradle:8.14.3-jdk21 AS builder WORKDIR /workspace COPY . . RUN ./gradlew bootJar --no-daemon FROM eclipse-temurin:21-jre WORKDIR /app COPY --from=builder /workspace/build/libs/*.jar /app/app.jar EXPOSE 8080 ENTRYPOINT [\u0026#34;java\u0026#34;, \u0026#34;-Duser.timezone=Asia/Seoul\u0026#34;, \u0026#34;-jar\u0026#34;, \u0026#34;/app/app.jar\u0026#34;] バックエンドもマルチステージビルドを使用しています。\nbuilderステージ — Gradle + JDK 21全体を含むイメージ（gradle:8.14.3-jdk21）でbootJarを実行します。 bootJarはSpring Bootアプリを一つの実行可能な.jarファイルにパッケージングするGradleタスクです。 --no-daemonはGradleデーモンプロセスを起動しないオプションです。Dockerビルドのような一回限りの実行ではデーモンが不要なのでメモリを節約できます。\nrunnerステージ — eclipse-temurin:21-jreはJava実行環境（JRE）のみを含む軽量イメージです。 builderで作った.jarファイル一つだけコピーしてくるので、Gradle・ソースコード・依存関係キャッシュなどのビルドツールは最終イメージに含まれません。 結果: ビルドイメージ（~2.5GB）→ 実行イメージ（~300MB）に軽量化されます。\n-Duser.timezone=Asia/Seoul — JVMのデフォルトタイムゾーンを日本時間に合わせる場合はAsia/Tokyoに変更してください。 これを設定しないとコンテナ内がUTC（+0時間）で動作し、DB保存時刻・ログ時刻がずれます。\n5.3 フロントエンド .dockerignore # aini-inu-frontend/.dockerignore:\nnode_modules .next .env* .git .gitignore .claude *.md npm-debug.log* .DS_Store .dockerignoreとは？ Dockerビルド時のコンテキスト（ビルドに送るファイル群）から除外するパターンを指定します。 .gitignoreと同じ文法です。node_modulesを除外するとビルドコンテキストの送信が数秒 → 数ミリ秒に短縮されます。\n5.4 Nginx設定ファイル # nginx/nginx.conf:\n# Rate Limiting: IPあたりの毎秒リクエスト数を制限 limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=ws:10m rate=5r/s; # --- HTTP → HTTPSリダイレクト --- server { listen 80; server_name ${DOMAIN}; # Let\u0026#39;s Encrypt証明書発行/更新用 location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } } # --- HTTPSメインサーバー --- server { listen 443 ssl; server_name ${DOMAIN}; # SSL証明書（ホストの/etc/letsencryptをマウント） ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem; # SSLセキュリティ設定 ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; # 共通プロキシヘッダー proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Swagger UI → backend（APIドキュメント） location /swagger-ui/ { proxy_pass http://backend:8080; } location /v3/api-docs { proxy_pass http://backend:8080; } # REST API → backend location /api/v1/ { limit_req zone=api burst=20 nodelay; proxy_pass http://backend:8080; client_max_body_size 10m; # 画像アップロード用 } # WebSocket → backend location /ws/ { limit_req zone=ws burst=10 nodelay; proxy_pass http://backend:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; proxy_read_timeout 3600s; # WebSocket接続の維持 proxy_send_timeout 3600s; } # その他すべて → frontend location / { proxy_pass http://frontend:3000; } } Nginx設定の解説:\nlimit_req_zone — 悪意のあるリクエスト攻撃への防御。IPごとに毎秒許可するリクエスト数を制限します。\n$binary_remote_addr — クライアントIPアドレス（バイナリ形式、メモリ効率が良い） zone=api:10m — \u0026ldquo;api\u0026quot;という名前の10MB共有メモリ領域（約16万IPを追跡可能） rate=10r/s — 毎秒10リクエストを許可 burst=20 — 瞬間的に20個まで許可（超過時は429 Too Many Requests） nodelay — burst範囲内のリクエストは遅延なしで即時処理 proxy_pass — リクエストを他のサーバーに転送（リバースプロキシの核心）。 http://backend:8080のbackendはDocker Composeのサービス名 = Docker内部DNSで自動解決されます。\nWebSocketプロキシの必須ヘッダー:\nproxy_http_version 1.1 — WebSocketはHTTP/1.1が必須 Upgrade: websocket — 「この接続をWebSocketにアップグレードして」 Connection: upgrade — 「接続を維持しながらプロトコルを変更して」 この3行がないとWebSocket接続がすぐに切れます。 proxy_read_timeout 3600s — Nginxはデフォルト60秒間応答がないと接続を切ります。 WebSocketは長時間維持される接続なので1時間（3600秒）に延長しています。\nclient_max_body_size 10m — リクエストbodyの最大サイズ。デフォルトは1MBなので 画像アップロード時に413 Request Entity Too Largeエラーが出ます。10MBに設定しています。\n${DOMAIN}環境変数の置換: nginx.confを/etc/nginx/templates/default.conf.templateとしてマウントすると NginxのDockerイメージが起動時に${DOMAIN}のような環境変数を自動置換してくれます。 おかげで設定ファイルにドメインをハードコードしなくて済みます。\n5.5 ルートdocker-compose.yml # services: postgres: image: pgvector/pgvector:pg16 container_name: aini-inu-postgres restart: unless-stopped environment: TZ: Asia/Seoul POSTGRES_DB: ${POSTGRES_DB:-ainiinu} POSTGRES_USER: ${POSTGRES_USER:-ainiinu} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ainiinu} volumes: - aini_inu_postgres_data:/var/lib/postgresql/data - ./aini-inu-backend/docker/postgres/init:/docker-entrypoint-initdb.d:ro healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;pg_isready -U ${POSTGRES_USER:-ainiinu} -d ${POSTGRES_DB:-ainiinu}\u0026#34;] interval: 5s timeout: 5s retries: 30 backend: build: context: ./aini-inu-backend dockerfile: Dockerfile container_name: aini-inu-backend restart: unless-stopped depends_on: postgres: condition: service_healthy env_file: - .env.docker environment: TZ: Asia/Seoul SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-ainiinu} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-ainiinu} SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-ainiinu} COMMUNITY_STORAGE_LOCAL_BASE_DIR: /app/var/uploads COMMUNITY_STORAGE_PUBLIC_BASE_URL: https://${DOMAIN} volumes: - aini_inu_backend_uploads:/app/var/uploads frontend: build: context: ./aini-inu-frontend dockerfile: Dockerfile container_name: aini-inu-frontend restart: unless-stopped depends_on: - backend nginx: image: nginx:alpine container_name: aini-inu-nginx restart: unless-stopped depends_on: - frontend - backend ports: - \u0026#34;80:80\u0026#34; - \u0026#34;443:443\u0026#34; volumes: - ./nginx/nginx.conf:/etc/nginx/templates/default.conf.template:ro - /etc/letsencrypt:/etc/letsencrypt:ro - certbot_webroot:/var/www/certbot:ro environment: DOMAIN: ${DOMAIN} volumes: aini_inu_postgres_data: aini_inu_backend_uploads: certbot_webroot: docker-compose.ymlの用語解説:\nimage: pgvector/pgvector:pg16 — Docker Hubからイメージをダウンロードして使用。 build: context: ./aini-inu-backend — 該当ディレクトリのDockerfileで直接ビルド。\nrestart: unless-stopped — コンテナが死んだら自動再起動。ただしdocker compose stopで手動停止した場合は再起動しません。\ndepends_on + condition: service_healthy — postgresがhealthcheckを通過してからbackendが起動します。 これがないとDBがまだ準備できていないのにbackendが先に起動して接続失敗します。\nenv_file: .env.docker — このファイルの環境変数をコンテナ内に注入します。 注意: docker compose自体が読む.envとは別物です。.envはcomposeファイル内の${変数}置換用、env_fileはコンテナ内部の環境変数注入用です。\n${POSTGRES_DB:-ainiinu} — .envファイルにPOSTGRES_DBがあればその値、なければデフォルト値のainiinuを使用。\nvolumes（サービスレベル）:\naini_inu_postgres_data:/var/lib/postgresql/data — DBデータをDockerボリュームに永続保存。 コンテナを削除してもデータは維持されます。 ./nginx/nginx.conf:/etc/nginx/templates/default.conf.template:ro — ホストのファイルをコンテナ内に読み取り専用（:ro）でマウント。 /etc/letsencrypt:/etc/letsencrypt:ro — ホストのSSL証明書ディレクトリをnginxにマウント。 ports: \u0026quot;80:80\u0026quot; — ホストの80ポート → コンテナの80ポートにマッピング。外部からアクセス可能になります。 portsがないサービス（postgres、backend、frontend）はDocker内部からのみアクセス可能です。\nvolumes（最下部、トップレベル）: ここで宣言されたボリュームはDockerが管理する永続ストレージです。 docker compose downしても維持されます。docker compose down -vで削除されます（注意！）。\n5.6 環境変数ファイル # .env.docker.example（コミット対象 — テンプレート）:\n# ドメイン DOMAIN=your-domain.com # DB（Docker内部通信用） POSTGRES_DB=ainiinu POSTGRES_USER=ainiinu POSTGRES_PASSWORD=change-me-in-production # JWT JWT_SECRET=your-super-secret-key-must-be-at-least-32-characters-long-for-hs256-algorithm # Gemini AI GEMINI_API_KEY= GEMINI_EMBEDDING_MODEL=gemini-embedding-001 # Lost Pet AI（任意） LOSTPET_AI_VECTOR_TOP_K=50 LOSTPET_SEARCH_SESSION_TTL_HOURS=24 LOSTPET_SEARCH_TOP_N=20 # PgVector（任意） SPRING_AI_PGVECTOR_INITIALIZE_SCHEMA=false SPRING_AI_PGVECTOR_TABLE_NAME=lostpet_vector_store SPRING_AI_PGVECTOR_SCHEMA_NAME=public SPRING_AI_PGVECTOR_DIMENSIONS=768 SPRING_AI_PGVECTOR_DISTANCE_TYPE=COSINE_DISTANCE SPRING_AI_PGVECTOR_INDEX_TYPE=HNSW # Community Storage（任意） COMMUNITY_STORAGE_PRESIGNED_EXPIRES_SECONDS=300 重要: .env.dockerと.envは.gitignoreに追加して、絶対にコミットしないようにしましょう。\n.envと.env.dockerとenv_fileの関係:\nファイル 誰が読む 用途 .env docker compose CLI composeファイル内の${変数}置換（例: ${DOMAIN}、${POSTGRES_DB}） .env.docker backendコンテナ env_file:ディレクティブでコンテナ内部に環境変数を注入（JWT_SECRET、GEMINI_API_KEYなど） このプロジェクトでは両者の内容が同じなのでcp .env.docker .envでコピーします。 .envを作らないとWARN: The \u0026quot;DOMAIN\u0026quot; variable is not setという警告が発生します。\n6. VMでデプロイする（実践の順序） # 6.1 リポジトリのクローン # git clone https://ghp_xxxx@github.com/\u0026lt;org\u0026gt;/aini-inu-monorepo.git cd aini-inu-monorepo 6.2 環境変数の設定 # cp .env.docker.example .env.docker nano .env.docker # 実際の値で編集（DOMAIN、JWT_SECRET、GEMINI_API_KEYなど） cp .env.docker .env # docker composeが.envをデフォルトで読むのでコピーが必要 nanoエディタのショートカットは12.2節を参照。\n6.3 SSL証明書の発行（初回のみ） # 必ずdocker compose upの前に実行してください！（80ポートが空いている必要があります）\ncertbotはLet\u0026rsquo;s Encrypt証明書を発行・更新するACMEクライアントツールです。\n簡単に言えば、HTTPSに必要なSSL証明書を自動で作成し、期限前に更新まで処理してくれるプログラムです。\nここではcertbotがLet\u0026rsquo;s Encryptサーバーと通信しながら 「このドメインを本当にあなたが管理していますか？」を検証した後、/etc/letsencrypt/以下に証明書と秘密鍵を保存します。\nその後nginxがこのファイルを読み取ってhttps://ドメインへのアクセスを処理します。\nこの記事でDocker certbotコンテナの代わりにホストに直接インストールする理由はシンプルです。\n設定がより直感的でデバッグしやすい /etc/letsencryptパスをそのままnginxにマウントして使いやすい standaloneモードで実行するときの80ポート占有問題を把握しやすい # 1. certbotのインストール sudo apt install -y certbot # 2. 全コンテナの停止（80ポートを確保） docker compose down # 3. 証明書の発行（必ず --key-type rsa を含めること！） # certbot 2.0.0からデフォルトのキータイプがECDSA(secp256r1)に変更されました。 # 特定のOpenSSLバージョン + cipher組み合わせで互換性問題が発生する可能性があるため、 # 互換性問題が出た場合はRSAに切り替えると解決します。（10.7節参照） sudo certbot certonly --standalone -d ainiinu.kr --agree-tos --key-type rsa -m your@email.com certbotのオプション説明:\ncertonly — 証明書の発行のみ（Webサーバーの設定は自動変更しない） --standalone — certbotが一時的なWebサーバーを直接起動してLet\u0026rsquo;s Encryptの検証を実行 -d ainiinu.kr — 証明書を発行するドメイン --agree-tos — Let\u0026rsquo;s Encryptの利用規約に自動同意 -m — 証明書の有効期限通知を受け取るメールアドレス 成功すると:\nSuccessfully received certificate. Certificate is saved at: /etc/letsencrypt/live/ainiinu.kr/fullchain.pem Key is saved at: /etc/letsencrypt/live/ainiinu.kr/privkey.pem This certificate expires on 2026-06-07. SSL証明書の発行ができない場合のチェックリスト:\n症状 原因 解決 certbotコマンド自体がCreatingで止まる Docker certbotコンテナ方式の問題 ホストに直接sudo apt install certbot後、standaloneモードを使用 certbotが無限待機 80ポートをnginxが占有している docker compose downで全コンテナ停止後に再試行 Let\u0026rsquo;s Encrypt検証失敗 GCPファイアウォールで80ポートが未許可 VPCファイアウォールにTCP 80、443インバウンドルールを追加 DNS検証失敗 ドメインがVMのIPを指していない nslookup ainiinu.krの結果がVMの外部IPと一致するか確認 6.4 全サービスの起動 # docker compose up -d --build オプション説明:\n-d（detached）— バックグラウンドで実行。これがないとターミナルにログが出続け、ターミナルを閉じるとサービスも終了します。 --build — イメージを再ビルド。コード変更後は必要。変更がなければキャッシュを使うので速いです。 初回のビルドは時間がかかります（バックエンドのGradleビルド + フロントエンドのnpm build）。\n6.5 状態の確認 # # サービスの状態確認 — 4つすべてUpなら成功 docker compose ps # 問題があればログを確認 docker compose logs -f --tail=50 # 特定サービスのログだけ見る docker compose logs -f backend docker compose logs -f nginx docker compose logs -f frontend ログオプションの説明:\n-f（follow）— リアルタイムで新しいログが出続けます。tail -fと同じ概念。 --tail=50 — 最近50行から表示。これがないと最初から全部出力されてスクロール爆発。 ログを見て抜けるにはCtrl+C 長いログが画面に収まらないとき: スクロールではなくless形式のページャーが出ることがあります → qを押して抜けます 6.6 アクセスの確認 # https://ainiinu.kr → フロントエンド（Webページ） https://ainiinu.kr/api/v1/ → バックエンドAPI https://ainiinu.kr/swagger-ui/index.html → Swagger UI（APIドキュメント） wss://ainiinu.kr/ws/ → WebSocket ポート番号なしでドメインだけでアクセス可能（nginxが80/443の標準ポートを使用）。\nSwagger UIが表示されない場合: Nginxのルーティングルールを追加する必要があります。詳細は10.6節を参照。\nなぜポート番号が不要なのか？ HTTPのデフォルトポートは80、HTTPSのデフォルトポートは443です。 ブラウザはhttps://ainiinu.krをhttps://ainiinu.kr:443に自動解釈します。 nginxが443ポートを占有しているのでポート指定なしで直接アクセス可能です。\n6.7 証明書の自動更新 # certbotをsudo apt installでインストールするとsystemdタイマーで自動更新が設定されます。\n注意: standaloneモードで発行したため、更新時にも80ポートが必要です。 nginxが80を占有していると自動更新が失敗します。\n解決方法1 — renewal hookの設定（推奨）: certbotが更新前後に自動でnginxを停止/起動するようにhookを設定します。\n# 更新設定ファイルにhookを追加 sudo nano /etc/letsencrypt/renewal/ainiinu.kr.conf # [renewalparams]セクションの下に以下の2行を追加: pre_hook = docker compose -f /home/\u0026lt;user\u0026gt;/aini-inu-monorepo/docker-compose.yml stop nginx post_hook = docker compose -f /home/\u0026lt;user\u0026gt;/aini-inu-monorepo/docker-compose.yml start nginx 解決方法2 — webroot方式に切り替える: nginxを止めずに更新できます。nginx.confに既に/.well-known/acme-challenge/パスが設定されているので:\nsudo certbot certonly --webroot -w /var/lib/docker/volumes/\u0026lt;project\u0026gt;_certbot_webroot/_data -d ainiinu.kr --force-renewal --key-type rsa 手動更新が必要な場合:\n# nginx停止 → 更新 → nginx再起動 docker compose stop nginx sudo certbot renew docker compose start nginx Let\u0026rsquo;s Encrypt証明書は90日有効。certbotは期限30日前から自動更新を試みます。\n7. ソースコード確認結果 # 7.1 CORS — 問題なし # WebConfig.java: setAllowedOriginPatterns(\u0026quot;*\u0026quot;) + allowCredentials(true) SecurityConfig.java: Securityフィルターレベルでも同じCORS設定を適用 WebSocketConfig.java: WebSocketもsetAllowedOriginPatterns(\u0026quot;*\u0026quot;) Docker/GCPデプロイでドメインが変わってもコード修正不要 7.2 WebSocket（チャット）— 問題なし # STOMPエンドポイント: /ws/chat-rooms/{roomId} 認証: STOMP CONNECT時にAuthorization: Bearer \u0026lt;JWT\u0026gt;ヘッダーで処理 メッセージブローカー: インメモリ（/topic、/queue） イベント発行: /topic/chat-rooms/{roomId}/events フロントエンド: NEXT_PUBLIC_WS_URL環境変数でWebSocket URLを決定 プロトコル自動検知: HTTPS → wss://、HTTP → ws:// 失敗時のHTTPポーリングフォールバックが実装済み Nginxで/ws/*をプロキシする際にupgradeヘッダーだけ設定すれば動作します 7.3 画像アップロード/表示 — 問題なし（注意事項あり） # 動作方式:\nフロントエンド → POST /api/v1/images/presigned-url（presigned URL要求） フロントエンド → PUT /api/v1/images/presigned-upload/{token}（ファイルアップロード） DBに相対パスを保存: /api/v1/images/local?key=community/post/... 画像取得: GET /api/v1/images/local?key=...（認証不要、@Public） Docker環境:\napplication.propertiesのデフォルト値: ../common-docs/storage（ローカル開発用相対パス） Docker composeでCOMMUNITY_STORAGE_LOCAL_BASE_DIR=/app/var/uploadsにオーバーライド Dockerボリュームで永続化 — 正しく処理されています 注意: 画像URLが相対パス\nバックエンドが/api/v1/images/local?key=...形式の相対パスを返す Web: Next.js rewrites → Nginxを通じて正常動作 iOS: ベースURL（https://ainiinu.kr）を先頭に付けて組み合わせる必要あり 8. iOSモバイルアプリ開発者連携ガイド # アクセス方式 # iOSアプリ → https://ainiinu.kr/api/v1/... → Nginx → backend iOSアプリ → wss://ainiinu.kr/ws/... → Nginx → backend Nginxが/api/v1/*と/ws/*をバックエンドにルーティングするので、 iOSアプリはWebフロントエンドと同じドメインの同じパスでAPIを呼び出せます。\n考慮事項 # 項目 内容 HTTPS必須 iOS ATS仕様でHTTPはブロック（HTTPSデプロイなので問題なし） CORS不要 ネイティブアプリはCORS制約なし APIドキュメント（Swagger） https://ainiinu.kr/swagger-ui/index.html — ブラウザで直接確認可能 APIドキュメント（JSON） https://ainiinu.kr/v3/api-docs — OpenAPI JSONスペックをダウンロード APIドキュメント（ファイル） common-docs/openapi/openapi.v1.json — リポジトリ内のスナップショット 認証 JWTトークン発行/更新フローを同様に実装 画像URL バックエンドレスポンスの相対パスにベースURLを組み合わせる必要あり WebSocket wss://ainiinu.kr/ws/chat-rooms/{roomId} + STOMP + JWTヘッダー プッシュ通知 必要になったらバックエンドにAPNs連携を追加 9. デバッグガイド # 9.1 502 Bad Gateway # ブラウザで502 Bad Gatewayが出る = Nginxがバックエンド/フロントエンドに接続できていない状態です。\nデバッグの順序:\n# 1. 全サービスの状態確認 docker compose ps # → STATUSが\u0026#34;Up\u0026#34;ではないサービスがあればそれが原因 # 2. 死んでいるサービスのログを確認 docker compose logs --tail=100 backend # またはfrontend # 3. リアルタイムログを見ながらリクエストを再試行 docker compose logs -f backend # → 別のブラウザタブで会員登録などのリクエストを試みる # → ログにエラーが出ているか確認 # 4. バックエンドログに何も出ていなければnginxの問題 docker compose logs --tail=50 nginx 実際に経験した事例:\nバックエンドがApplication run failedで起動失敗 → nginxがbackend:8080に接続できない → 502を返す。 原因: 削除されたSQLシードファイル（10_core_sample_seed.sql）をapplication.propertiesでまだ参照していた。 解決: spring.sql.init.data-locationsから該当ファイルへの参照を削除。\n9.2 コンテナが再起動し続けるとき # # 再起動回数の確認 docker compose ps # → RESTARTSカラムが増え続けていたら起動失敗 + 自動再起動を繰り返している # 最近のログからエラーを探す docker compose logs --tail=200 backend | grep -i \u0026#34;error\\|exception\\|failed\u0026#34; grep -iとは？ 大文字小文字を区別せず（-i = ignore case）パターン検索します。 |（パイプ）= 前のコマンドの出力を後のコマンドの入力に渡します。 \\| = grepでのOR条件（\u0026ldquo;error\u0026quot;または\u0026quot;exception\u0026quot;または\u0026quot;failed\u0026rdquo;）。\n9.3 特定サービスだけ再ビルド/再起動 # # バックエンドだけ再ビルド + 再起動（他のサービスは触らない） docker compose up -d --build backend # nginx設定変更後にnginxだけ再起動 docker compose restart nginx # フロントエンドだけ再ビルド docker compose up -d --build frontend 9.4 コンテナ内に入って直接確認 # # backendコンテナ内でbashを実行 docker compose exec backend sh # コンテナ内で環境変数を確認 env | grep SPRING env | grep JWT # コンテナ内から出る exit docker compose execとdocker compose runの違い:\nexec — 既に実行中のコンテナ内でコマンドを実行 run — 新しいコンテナを作成してコマンドを実行（使い捨て） 9.5 DBへの直接アクセス # # postgresコンテナでpsqlを実行 docker compose exec postgres psql -U ainiinu -d ainiinu # SQL実行例 SELECT count(*) FROM member; \\dt -- テーブル一覧 \\d member -- memberテーブルの構造 \\q -- psqlの終了 9.6 ネットワーク/ポート関連 # # 80/443ポートを誰が占有しているか確認 sudo ss -tlnp | grep \u0026#39;:80\\|:443\u0026#39; # Docker内部ネットワークの確認（コンテナ間通信の問題時） docker network ls docker network inspect aini-inu-monorepo_default ss -tlnpの説明:\nss — socket statistics（ネットワークソケット情報、netstatの現代的な代替） -t — TCPのみ -l — LISTEN状態のみ（接続待ちポート） -n — 数字で表示（名前解決しない、速い） -p — どのプロセスが占有しているか表示 9.7 ディスク/リソース関連 # # ディスク使用量の確認 df -h # Dockerが使うディスク容量 docker system df # 未使用のイメージ/コンテナ/ボリュームの整理（空き容量確保） docker system prune -f # 停止したコンテナ、未使用イメージを削除 docker image prune -a -f # 全未使用イメージを削除（注意: ビルドキャッシュも削除される） VMのディスクが一杯になったら？ Dockerイメージとビルドキャッシュがすぐに溜まります。 docker system dfで確認し、docker system pruneで整理します。 ビルドするたびに古いイメージが残るので定期的に整理が必要です。\n9.8 DBスキーマの直接変更（DDLマイグレーション） # エンティティからフィールドを削除したがDBテーブルにはカラムが残っている場合、Docker再起動なしで実行中のPostgreSQLに直接SQLを実行できます。\nなぜ必要なのか？ Hibernateのddl-auto=updateはカラムの追加のみ行い、カラムの削除はしません。 そのためJavaエンティティからフィールドを削除しても、DBには該当カラムがNOT NULL制約とともに残り、 INSERTのときnull value in column \u0026quot;xxx\u0026quot; violates not-null constraintエラーが発生します。\n方法1: 一行コマンドでSQLを実行（推奨）\n# docker execで実行中のpostgresコンテナにSQLを直接実行 docker exec -i aini-inu-postgres psql -U ainiinu -d ainiinu -c \u0026#34;ALTER TABLE pet DROP COLUMN IF EXISTS is_certified;\u0026#34; コマンド解説:\ndocker exec -i — 実行中のコンテナ内でコマンドを実行（-iはstdinを維持） aini-inu-postgres — コンテナ名（docker-compose.ymlのcontainer_name） psql -U ainiinu -d ainiinu — ainiinuユーザーでainiinuデータベースに接続 -c \u0026quot;SQL文\u0026quot; — SQLを一行実行後に終了 方法2: DDLファイルをまとめて実行\nプロジェクトのdb/ddl/ディレクトリにマイグレーションSQLファイルがある場合:\n# ローカルのSQLファイルをコンテナのpsqlにパイプ docker exec -i aini-inu-postgres psql -U ainiinu -d ainiinu \u0026lt; aini-inu-backend/src/main/resources/db/ddl/13_pet_certification_removal.sql \u0026lt;（リダイレクション）の説明: ファイルの内容をコマンドの入力（stdin）として渡します。 つまりSQLファイル内のすべてのSQL文が順番にpsqlで実行されます。\n方法3: psqlの対話セッションで実行\n# 1. postgresコンテナのpsqlに接続 docker exec -it aini-inu-postgres psql -U ainiinu -d ainiinu # 2. SQLを直接入力 ALTER TABLE pet DROP COLUMN IF EXISTS is_certified; ALTER TABLE pet DROP COLUMN IF EXISTS certification_number; # 3. 変更の確認 \\d pet -- petテーブルの構造確認（削除されたカラムがなければ正常） # 4. 終了 \\q -iと-itの違い:\n-i（interactive）— stdinを開いておく。パイプ（\u0026lt;）で入力を渡すときに使用。 -it（interactive + tty）— ターミナルセッションを開いて直接タイピングするときに使用。 ファイル実行は-i、対話接続は-itを使います。 実行前の確認 / 実行後の検証:\n# 実行前: 該当カラムが存在するか確認 docker exec -i aini-inu-postgres psql -U ainiinu -d ainiinu -c \u0026#34;\\d pet\u0026#34; | grep is_certified # 実行後: カラムが削除されたか確認（出力がなければ成功） docker exec -i aini-inu-postgres psql -U ainiinu -d ainiinu -c \u0026#34;\\d pet\u0026#34; | grep is_certified IF EXISTSを必ず付けましょう: DROP COLUMN IF EXISTSはカラムがなくてもエラーになりません。 既に適用したDDLを誤って再実行しても安全です。\n10. トラブルシューティングまとめ # 10.1 Dockerインストール関連 # Q: sudo apt install docker.ioしたがdocker composeコマンドがない？\ndocker.ioはDebian/Ubuntuのデフォルトパッケージで、Docker Composeプラグインが含まれていません。 Docker公式リポジトリからdocker-ce + docker-compose-pluginをインストールする必要があります。（4章参照）\nQ: Unable to locate package docker-compose-plugin?\nDocker公式リポジトリがシステムに登録されていない状態です。4.3節のGPGキー + リポジトリ追加の手順を先に実行してください。\nQ: Package 'docker-ce' has no installation candidate?\nUbuntu用のリポジトリを追加したが実際のOSがDebianの場合です。lsb_release -csでOSを確認し、結果がbookwormであればDebian 12なので、リポジトリURLをhttps://download.docker.com/linux/debianに変更してください。\nQ: Docker Desktopをインストールしてはいけないのか？\nGCP VMはGUIのないサーバーなのでDocker Desktopはインストールできません。Docker DesktopはMac/Windowsのデスクトップ環境専用です。\n10.2 プライベートリポジトリのクローン関連 # Q: Organizationのプライベートリポジトリをcloneすると権限エラーになる？\nGitHub PAT（Personal Access Token）を生成し、Organization accessで該当orgの**\u0026ldquo;Grant\u0026rdquo;**ボタンをクリックする必要があります。Personalトークンを作成しても自動的にorgのリポジトリにアクセスできるわけではありません。\n10.3 環境変数関連 # Q: WARN: The \u0026quot;DOMAIN\u0026quot; variable is not set. Defaulting to a blank string.?\ndocker composeはデフォルトでプロジェクトルートの.envファイルを読みます。.env.dockerだけ作って.envを作らないとこの警告が発生します。\ncp .env.docker .env 10.4 SSL証明書関連 # Q: docker compose run --rm certbot ...がCreatingで止まる？\nDocker certbotコンテナ方式は様々な理由で止まることがあります。ホストに直接certbotをインストールするのが最も確実です:\ndocker compose down # 80ポートを解放 sudo apt install -y certbot # ホストに直接インストール sudo certbot certonly --standalone -d ainiinu.kr --agree-tos -m your@email.com Q: certbot standaloneも止まる？\nGCPファイアウォールでTCP 80ポートが開いているか確認してください。Let\u0026rsquo;s EncryptサーバーがHTTPでの検証リクエストを送るので、ファイアウォールでブロックされると無限待機になります。\n10.5 ディスク容量不足（no space left on device） # 症状: Dockerビルド中にエラーが発生:\ntarget backend: failed to solve: ResourceExhausted: failed to copy files: copy file range failed: no space left on device 原因: GCPのデフォルトディスクが10GBですが、Gradleビルド（JDK + 依存関係）とNext.jsビルド（node_modules）が同時に進むとディスクが一杯になります。\n即時解決 — Dockerキャッシュの整理:\n# 未使用のイメージ、ビルドキャッシュ、停止したコンテナをすべて削除 docker system prune -a -f # 整理された容量 + 残り容量を確認 df -h docker system prune -a -fの説明:\nprune — 未使用リソースを整理 -a（all）— 現在実行中でない全イメージも削除（タグされたイメージを含む） -f（force）— 確認なしで即削除 注意: 次回docker compose up --buildのときは最初からビルドし直すので時間がかかります 根本的な解決 — ディスクサイズを拡張する:\nGCPコンソール → Compute Engine → ディスクメニュー VMに接続されたディスクをクリック → 上部の編集ボタン サイズを20GB以上に変更 → 保存 VMでパーティションを拡張（VM再起動なしで可能）: # 1. パーティションの拡張（growpartがなければインストール） sudo apt install -y cloud-guest-utils sudo growpart /dev/sda 1 # 2. ファイルシステムの拡張（ext4基準） sudo resize2fs /dev/sda1 # 拡張の確認 df -h # → /dev/sda1 のSizeが増えているか確認 growpartとは？ ディスクのパーティションテーブルを拡張するコマンドです。 GCPでディスクサイズを増やしてもパーティションサイズは自動で変わらないので、 growpartでパーティションを先に拡張してからresize2fsでファイルシステムを拡張します。\nresize2fsとは？ ext4ファイルシステムのサイズをパーティションに合わせて拡張するコマンドです。 growpartなしでresize2fsだけ実行するとパーティションサイズがそのままなので効果がない場合があります。 データ損失なしにオンライン（サービス停止なしに）拡張可能です。\nディスク容量の監視:\n# 全体のディスク使用量 df -h # Dockerが使う容量の詳細 docker system df # どのディレクトリが容量を多く使っているか確認 sudo du -sh /* 2\u0026gt;/dev/null | sort -rh | head -10 df -hの読み方:\nFilesystem Size Used Avail Use% Mounted on /dev/sda1 20G 3.8G 15G 21% / Size — 全体のディスクサイズ Used — 使用中の容量 Avail — 残り容量 Use% — 使用率（80%を超えたら注意、90%を超えたら危険） Mounted on — /がルートパーティション（最重要） ビルドごとの予想ディスク使用量:\n項目 容量 OS + システムパッケージ ~2GB Dockerエンジン ~500MB バックエンドビルドイメージ（Gradle + JDK） ~2.5GB フロントエンドビルドイメージ（Node.js + npm） ~1.5GB 最終実行イメージ（backend + frontend + nginx + postgres） ~1.5GB PostgreSQLデータ ~500MB（データ量に応じて増加） アップロード画像（ユーザーデータ） 可変 合計（余裕を含む） 最小15GB、推奨20GB 10.6 Swagger UIが表示されずフロントエンドページが出る # 症状: https://ainiinu.kr/swagger-ui/index.htmlにアクセスするとSwaggerではなくフロントエンドの空白ページが出る。\n原因: Nginxのlocation /がマッチしない全パスをフロントエンドに送ります。/swagger-ui/もフロントエンドに行ってしまいます。\n解決: nginx.confにSwaggerパスをバックエンドにルーティングするルールを追加:\nlocation /swagger-ui/ { proxy_pass http://backend:8080; } location /v3/api-docs { proxy_pass http://backend:8080; } 教訓: バックエンドが直接サービングするパスが新たに追加されたら、nginx.confにも該当パスのルーティングルールを必ず追加してください。 Nginxは最も具体的な（longest prefix）locationからマッチするので、/swagger-ui/は/より優先してマッチします。\n10.7 ERR_SSL_PROTOCOL_ERROR（ECDSA証明書の互換性問題） # 症状: ブラウザでhttps://ainiinu.krにアクセスするとERR_SSL_PROTOCOL_ERRORエラーが表示される。 Nginxログに以下のエラーが繰り返される:\nSSL_do_handshake() failed (SSL: error:0A000119:SSL routines:ssl_do_handshake:no suitable signature algorithm) iOSアプリからのAPI呼び出しは正常なのに、Webブラウザからだけ SSL接続が失敗する状況。\n原因:\ncertbot 2.0.0（2023年1月リリース）から証明書を発行するときデフォルトのキータイプがECDSA secp256r1（P-256）に変更されました。\nRSAとECDSAとは？\nSSL証明書は内部に「キー（key）」を持っており、このキーを作る暗号化方式が2つあります:\n方式 説明 特徴 RSA 古くからある標準方式。ほぼすべての環境でサポート。 互換性最高、キーサイズが大きい（2048〜4096 bit） ECDSA 新しい楕円曲線方式。より短いキーで同等のセキュリティレベル。 パフォーマンス良好、一部の環境で互換性問題 例えるなら: RSAはすべての鍵穴に合う万能鍵、ECDSAは最新のスマートロック — より効率的だが古い扉には合わないことがある。\nECDSA証明書が問題を引き起こす理由:\nnginx:alpineイメージのOpenSSLバージョンとECDSA P-256キー + SHA384署名の組み合わせで一部のクライアントと署名アルゴリズムのネゴシエーションに失敗 エラーメッセージのno suitable signature algorithm = 「サーバーとクライアントが合意できる署名方式がない」という意味 iOSのネイティブHTTPクライアントはこの組み合わせを処理できるが、一部のブラウザでハンドシェイク失敗 SSLハンドシェイクとは？\nブラウザがHTTPSサーバーに接続するときに最初に行う「握手」の過程:\nブラウザ: 「私はこんな暗号化方式をサポートしています」（サポートリストを送信） サーバー: 「その中からこれにしましょう」（方式を選択） 両者が合意したら → 暗号化された接続が確立 合意失敗 → ERR_SSL_PROTOCOL_ERROR 診断方法:\n# 1. 現在の証明書のキータイプを確認 sudo certbot certificates # → Key Type: ECDSA ← これが問題の原因 # 2. 証明書の詳細情報を確認 sudo openssl x509 -in /etc/letsencrypt/live/ainiinu.kr/fullchain.pem -text -noout | head -20 # → Signature Algorithm: ecdsa-with-SHA384 ← ECDSA署名を確認 # → Public Key Algorithm: id-ecPublicKey ← ECDSAキーを確認 # 3. SSL接続テスト（VMから直接） openssl s_client -connect ainiinu.kr:443 -servername ainiinu.kr 2\u0026gt;\u0026amp;1 | head -30 # → verify return:1 であれば証明書自体は有効だが、ブラウザの互換性問題 解決 — RSAキータイプで証明書を再発行:\n# 1. nginxを停止（80ポートを解放 — certbot standaloneが80ポートを必要とする） docker compose stop nginx # 2. RSAキータイプで証明書を強制再発行 sudo certbot certonly --standalone -d ainiinu.kr --agree-tos --force-renewal --key-type rsa # 3. nginxコンテナを完全に再作成（新しい証明書を反映） docker compose up -d --force-recreate nginx 各オプションの説明:\n--force-renewal — 既存の証明書が有効でも強制的に新規発行 --key-type rsa — これがポイント！ ECDSAの代わりにRSAキーで証明書を生成 --force-recreate — コンテナを削除してから新規作成（単純なrestartより確実に証明書を再読み込みする） 再発行後の確認:\n# キータイプがRSAに変更されたか確認 sudo certbot certificates # → Key Type: RSA ← こうなれば正常 # nginxログにSSLエラーがないか確認 docker compose logs --tail=10 nginx ブラウザキャッシュの問題: 証明書を交換してもブラウザが以前のSSLセッションをキャッシュしていることがあります。\nシークレット/プライベートモードで先にアクセステスト またはブラウザキャッシュを削除（Cmd+Shift+DeleteまたはCtrl+Shift+Delete）後に再試行 教訓: certbot 2.0.0からデフォルトのキータイプがECDSAに変わりました。ECDSA自体に問題があるわけではありませんが、特定のサーバー/クライアントの組み合わせで互換性問題が発生することがあるので、互換性の問題が出た場合は--key-type rsaに切り替えると解決します。 今後の証明書更新時もRSAを維持するには:\n# /etc/letsencrypt/renewal/ainiinu.kr.conf ファイルに以下があるか確認 key_type = rsa --key-type rsaで発行するとcertbotが自動的に更新設定にもRSAを記録するので、 以降の自動更新でもRSAで更新されます。\n10.8 バックエンド起動失敗 # Q: No data scripts found at location 'classpath:db/seed/xxx.sql'?\napplication.propertiesのspring.sql.init.data-locationsで参照しているSQLファイルが実際に存在しない場合です。 ファイルが削除/移動されていたら設定からも該当の参照を削除してください。\nQ: バックエンドのログにApplication run failedが見える？\nSpring Bootの起動に失敗しています。ログのCaused by:またはErrorを探すと根本原因が出ます:\ndocker compose logs backend | grep -i \u0026#34;caused by\\|error creating bean\\|application run failed\u0026#34; 11. HTTPS方式の比較（参考） # 方法 メリット デメリット Nginx + Let\u0026rsquo;s Encrypt（採用） composeで一緒に管理、自動更新可能 Nginx設定が必要 Caddy 設定2〜3行、証明書の自動発行/更新 Nginxより馴染みが薄いかも GCP Load Balancer インフラレベルで処理、コンテナ変更不要 GCP費用追加、設定が複雑 Cloudflare Proxy DNS変更だけで完了、無料SSL 外部サービスへの依存 NginxをDocker Composeに含める方式を採用した理由:\nVMにDocker + certbot以外にインストールするものがない docker compose up -d一度で全サービス起動 サーバー移転時はcomposeファイルだけ持っていけば完了 コンテナ間の通信がDockerの内部ネットワークで整理されている 12. Linux/ターミナル必須コマンド # GCP VMにSSHで接続するとターミナル（CLI）しか使えません。 よく使うコマンドをまとめます:\n12.1 ファイル/ディレクトリ # ls # 現在のディレクトリのファイル一覧 ls -la # 隠しファイル含む詳細情報（権限、サイズ、日付） pwd # 現在の位置（Print Working Directory） cd /path/to/dir # ディレクトリ移動 cd .. # 上位ディレクトリ cd ~ # ホームディレクトリ cat filename # ファイルの内容をすべて出力 less filename # ファイルの内容をページ単位で見る（qで終了） head -n 20 file # 最初の20行だけ見る tail -n 20 file # 最後の20行だけ見る tail -f file # ファイルに追加される内容をリアルタイムで見る（ログ監視） 12.2 ファイル編集 # nano filename # nanoエディタ（初心者向け、下部にショートカット表示） vi filename # viエディタ（慣れると速い） nanoのショートカット: Ctrl+O → Enter = 保存、Ctrl+X = 終了、Ctrl+K = 行削除、Ctrl+W = 検索\nvi最小サバイバルガイド:\ni → 入力モード（文字入力可能） Esc → コマンドモード（入力終了） :wq + Enter → 保存して終了 :q! + Enter → 保存せず終了 /検索語 + Enter → 検索、nで次の結果 12.3 プロセス/システム # ps aux # 実行中のすべてのプロセス ps aux | grep docker # docker関連プロセスだけフィルター kill \u0026lt;PID\u0026gt; # プロセスの終了 sudo systemctl status docker # Dockerサービスの状態確認 12.4 ネットワーク # curl https://ainiinu.kr # URLにHTTPリクエストを送る curl -I https://ainiinu.kr # レスポンスヘッダーだけ見る curl -s ifconfig.me # 自分の外部IPを確認 nslookup ainiinu.kr # DNSの確認 sudo ss -tlnp # 開いているポートの確認 12.5 ターミナル制御 # Ctrl+C # 実行中のコマンドを強制停止（ログ表示、サーバー実行などを止めるとき） Ctrl+D # 入力終了 / ターミナルセッション終了 Ctrl+L # 画面を消去（clearと同じ） q # less、git logなどのページャーから抜ける ↑/↓ # 以前に入力したコマンドの履歴を遡る Tab # ファイル名/コマンドの自動補完 qで抜ける場面:\ndocker compose logs（ページャーモードのとき） git log（コミット履歴が長いとき） lessコマンドでファイルを見るとき manコマンドでマニュアルを見るとき 画面に:または(END)が表示されたらqを押して抜けられます。 Ctrl+Cが効かなくて画面が止まって見える場合は大体qで抜けられます。\n12.6 便利な組み合わせ # # パイプ（|）— 前のコマンドの出力を後のコマンドの入力に渡す docker compose logs backend | grep ERROR # ログからERRORだけフィルター ps aux | grep nginx # nginxプロセスを探す # \u0026amp;\u0026amp; — 前のコマンドが成功した場合のみ後のコマンドを実行 git pull \u0026amp;\u0026amp; docker compose up -d --build # pull成功時のみビルド開始 # ; — 前のコマンドの結果に関わらず後のコマンドも実行 docker compose down; docker compose up -d # 常に両方実行 # \u0026gt; — 出力をファイルに保存（上書き） docker compose logs backend \u0026gt; backend.log # ログをファイルに保存 # \u0026gt;\u0026gt; — 出力をファイルに追記 echo \u0026#34;メモ\u0026#34; \u0026gt;\u0026gt; notes.txt 13. 運用コマンドチートシート # コピペ用の素早い参照。各コマンドの詳細説明は括弧内の節を参照。\n# ── 日常運用（6.4節）── docker compose up -d --build # 全サービス起動（ビルド含む） docker compose down # 全サービス停止 docker compose ps # サービス状態確認 git pull \u0026amp;\u0026amp; docker compose up -d --build # コード更新後に再デプロイ # ── ログ確認（6.5節）── docker compose logs -f --tail=50 # 全ログ（最近50行からリアルタイム） docker compose logs -f backend # 特定サービスのログ docker compose logs backend | grep -i error # エラーだけフィルター # ── サービス管理（9.3節）── docker compose restart nginx # 特定サービスだけ再起動 docker compose up -d --build backend # 特定サービスだけ再ビルド # ── SSL証明書（6.7節）── sudo certbot certificates # 証明書の有効期限確認 docker compose stop nginx \u0026amp;\u0026amp; sudo certbot renew \u0026amp;\u0026amp; docker compose start nginx # 手動更新 # ── データ管理 ── docker compose down \u0026amp;\u0026amp; docker compose up -d --build # DB維持、コンテナ再作成 docker compose down -v # すべてを初期化（DBデータ含む削除 — 注意！） # ── ディスク整理（9.7節）── docker system df # Dockerのディスク使用量確認 docker system prune -f # 未使用リソースの整理 docker image prune -a -f # 全未使用イメージ削除（ビルドキャッシュ含む） 14. まとめ # 現在のソースコードでコード修正は最小限（next.config.tsにoutput: 'standalone'の一行）でDockerデプロイが可能 プロジェクトに追加したファイル: フロントエンドDockerfile、.dockerignore、nginx.conf、ルートdocker-compose.yml、.env.docker.example GCP VM（Debian 12）にDocker公式リポジトリからインストール、SSLはホストのcertbotで発行 docker compose up -d --buildで全サービスを起動 https://ainiinu.krでWebとiOS両方から同じドメインでアクセス可能 ","date":"2026年3月31日","externalUrl":null,"permalink":"/ja/posts/gcp-deployment-guide/","section":"Posts","summary":"GCP VMにDocker ComposeでバックエンドとフロントエンドとDBとNginxをデプロイし、HTTPSを適用する実践ガイド","title":"GCP Docker デプロイ \u0026 運用ガイド","type":"posts"},{"content":"","date":"2026年3月31日","externalUrl":null,"permalink":"/ja/tags/nginx/","section":"Tags","summary":"","title":"Nginx","type":"tags"},{"content":"このドキュメントは、疑問を持っていた点をAIエージェントと対話しながら整理したメモです。InnoDB のインデックスとページ構造、ロックとMVCC、セッションとコネクションプールまでMySQL の核心概念を一度にまとめます。\n1. ランダムアクセス（Random Access） # 1-1. 意味 # ランダムアクセスとは、格納されたデータの位置に関係なく、任意の位置のデータに直接アクセスできる方式を意味します。\n例えば、配列で a[100] に直接アクセスしたり、DBでインデックスを使って特定のキー値を素早く見つけたりすることがランダムアクセスの代表的な例です。\n1-2. データベースにおいてなぜ重要か # DBMSは大量のデータを格納・検索します。このとき、すべてのデータを最初から最後まで順次読み取るのは非効率です。 そのためDBMSはインデックスを使って**「どのあたりに目的の値があるか」**を素早く見つけ、その位置に直接移動します。\nつまり、インデックスはランダムアクセスを効率的に可能にするための核心ツールです。\n2. InnoDBで主キー（PK）が物理的な格納位置を決めるということの意味 # 2-1. クラスタード インデックス（Clustered Index） # InnoDBではテーブルデータが主キー順に格納されます。 つまり、主キー自体が単なる識別子であるだけでなく、クラスタードインデックスのリーフページでの配置基準になります。\nこれはよく以下のように表現されます。\nInnoDB テーブル = 主キー基準のクラスタードインデックス リーフページ = 実際の行（row）データを格納 2-2. どういう意味か # 例えば、PKが以下のようだとします。\nid = 1, 2, 3, 4, 5 InnoDBはこのデータをおおむね id 順に並べた状態でページに格納します。 ここで言う「物理的な格納位置」とはファイル内のオフセットが永久に固定されるという意味よりも、B-treeのリーフページがPK順で構成されるという意味に近いです。\nつまり：\nPKが小さいほど前のページ PKが大きいほど後ろのページ に配置される傾向があります。\n2-3. なぜ重要か # 主キー基準でデータが並べられているため：\nPK照会が非常に高速 PK範囲照会が非常に有利 セカンダリインデックスがPKを参照するため、PKのサイズがインデックス全体のサイズに影響を与える 3. 「頻繁に変わる値をPKにすると性能が悪くなる」の意味 # 3-1. 理由 # InnoDBはデータをPK順に格納するため、PK値が変わるとその行の格納位置が変わる可能性があります。\nつまり、UPDATE pk = ... は内部的に実質：\n既存レコードを削除 新しいPK位置に再挿入 に近いコストを引き起こす可能性があります。\n3-2. 副作用 # ページ再配置コストの発生 ページ分割（page split）の可能性が増加 セカンダリインデックスも一緒に整備が必要 ディスクI/Oの増加 ロック競合の可能性が増加 3-3. 実務の観点 # そのためPKは通常以下の特性を持つ値が良いです。\n短く 固定的で 絶対に変わらず 可能であれば単調増加（auto increment等） 4. 主キーが実際に変わる場合の例 # 通常PKはあまり変わらないのが正しいです。 しかし間違ったモデリングをするとPKの変更が必要になることがあります。\n例 # 4-1. 自然キーをPKにした場合 # 例：\nメールアドレス 住民番号 社員番号 ユーザー名（username） これらの値は「変わらないだろう」と思いますが、実際のサービスでは変わることがあります。\n例：\nメール変更 ユーザー名変更ポリシーの許可 外部システム統合によるID規則の変更 4-2. システム統合 # AシステムとBシステムを統合する際にPK体系が衝突するとPKの再整備が必要になる場合があります。\n4-3. 業務ルールの変更 # 以前は商品コードをPKとして使っていたが、商品コードのルール自体が変わる場合です。\n5. B+木の探索計算量とディスクアクセス # 5-1. 探索計算量 # B+木は高さを h とすると探索コストがおおよそ O(h) です。\n5-2. なぜディスクアクセス回数と結びつくか # B+木の各ノードは通常**1ページ（page）**単位で格納されます。 したがってルート → 内部ノード → リーフノードと下っていくとき、ノードを1つ読むたびにページを1つ読むことになります。\nつまり：\n木の高さ = 最悪の場合に読む必要のあるページ数 ページがバッファプールにない場合 = ディスクI/Oが発生 5-3. 一度にリーフまで行けない理由 # 1ページの中には木全体は入っていません。 1ページには一部のキーと子ページのアドレスだけが入っています。\nそのため検索過程は：\nルートページを読む その中でどの子ページに行くかを決定 子ページを読む 再び次の子を決定 リーフページに到達 という形になります。\n6. MySQLで「アクセス」は誰が行うか # MySQLでディスクアクセスはユーザーが直接行うのではなく、ストレージエンジンとOSが協力して実行します。\nフローを単純化すると以下のようになります。\nクライアントがSQLを送信 MySQLサーバーがパース・最適化 ストレージエンジン（InnoDB等）が必要なページをリクエスト バッファプールになければOSを通じてディスクから読み込む ページをメモリに読み込んで使用 つまり、実際のSQLはMySQLが処理しますが、物理的な読み書きリクエストは結局OSとディスクサブシステムが担当します。\n7. B+木のページの中には何が入っているか # 7-1. 内部ノード # 内部ノードには概ね以下があります。\n複数のキー値 各キー範囲に対応する子ページのポインタ 例えば：\nkey \u0026lt; 10 の場合はpage A 10 \u0026lt;= key \u0026lt; 20 の場合はpage B key \u0026gt;= 20 の場合はpage C 7-2. リーフノード # リーフノードには実際の検索対象があります。\nInnoDBのクラスタードインデックスリーフ：実際の row データ セカンダリインデックスリーフ：インデックスキー + PK値 7-3. ページ間の移動 # 現在のページでキー値を比較した後、 その結果に合った**次のページ番号（page id）**を見つけてそのページを読みます。\nつまり、「別のページに行く」とは結局ページ番号を使ってそのページをメモリ/ディスクから読み取ることです。\n8. バッファ管理ポリシー：STEAL / NO-STEAL、FORCE / NO-FORCE # トランザクション中に修正されたページ（ダーティページ）をいつディスクに書き込むかを基準に分けた代表的なポリシーです。\nただしこれらの用語はデータベースの教科書でよく使われる分類であり、MySQL公式ドキュメントがInnoDBをこの4つの用語で直接規定しているわけではありません。 InnoDBのバッファプールflush、redo、チェックポイントの動作を理解するための概念的なフレームとして見るのが安全です。\n8-1. STEAL # コミット前でもダーティページをディスクに書き込める\n意味 # バッファ空間が不足すると、まだコミットされていないトランザクションが修正したページもディスクに書き出せます。\n長所 # バッファ管理が柔軟 メモリ使用効率が良い 短所 # コミット前のデータがディスクに記録される可能性があるため、障害時にUNDOが必要 8-2. NO-STEAL # コミット前のダーティページをディスクに書き込まない\n長所 # 障害時のUNDO負担が軽減 短所 # バッファへの圧迫が大きい メモリ管理が難しく性能上不利になり得る 8-3. FORCE # コミット時に該当トランザクションの修正内容をディスクページまで強制的に記録\n長所 # 障害後のREDO必要性が低減 短所 # コミットのたびにディスク書き込み負担が大きい 性能低下の可能性が大きい 8-4. NO-FORCE # コミット時にログのみ保証し、データページは後で書いても良い\n長所 # コミットが速い 現代的なWAL系DBMSで一般的な方向 短所 # 障害時にREDOが必要 9. REDOとUNDO # 9-1. REDO # すでにコミットされた変更を再び適用して復旧すること\n例：\nコミットは完了したがデータページへの反映前に障害が発生 ログを見て変更内容を再適用 つまり、**耐久性（Durability）**を確保するための復旧\n9-2. UNDO # コミットされていない変更を元に戻すこと\n例：\nトランザクションが途中で失敗 すでに一部の変更が反映されていれば元の状態に復元する必要がある つまり、**原子性（Atomicity）**を確保するための復旧\n9-3. 違いの整理 # REDO：「やるべきだったことをもう一度行う」 UNDO：「やってはいけないことを取り消す」 10. InnoDBのUNDOログとREDOログの性格 # 10-1. UNDOログ # InnoDBのUNDOは論理的な性格が強いです。\n特に：\nUPDATE前の値を復元 DELETEの取り消し INSERTの取り消し（挿入された行を削除） などの目的で使用されます。\nMVCCでも過去のバージョンを再構成する際に使用されます。 ただしINSERTのUNDOはMVCC用の過去バージョン提供には直接使用されず、主にロールバック時に「挿入された行を削除するための情報」として使われます。\n10-2. REDOログ # REDOは一般的に低レベルの変更復旧情報の性格が強いです。\nつまり、SQLを再実行するというよりも、データファイルにまだ反映されていない変更を復旧時点で再適用できるように記録します。\n11. 「INSERT前にはデータがないのにUNDOログがなぜ必要か？」 # 非常に重要なポイントです。\n11-1. 核心 # INSERT前には「以前の値」がないため、UPDATEのようにbefore imageを保存するわけではありません。 しかしロールバックするためには**「この行を削除すべきだ」**という情報は必要です。\nつまり、INSERTのUNDOは通常：\n以前の値を復元する用途というよりは 「このINSERTを取り消すためにどのレコードを削除すべきか」を示す情報 です。\n11-2. MVCCとの関係 # INSERTされた新しい行はもともと過去のバージョンがないため、 他のトランザクションの一貫した読み取りではまったく見えなければそれで十分です。\nそのためINSERTのUNDOはMVCCで過去バージョンを作る役割よりも、 主にロールバック/復旧用の意味が大きいです。\n12. 物理的ログ vs 論理的ログ # 12-1. 物理的ログ # 「ページのどこがどのように変わったか」を記録\n長所 # 再適用が明確 REDOに適している 短所 # ログ量が増える可能性がある ページ構造に依存 同時実行性・柔軟性の面で不利になり得る 12-2. 論理的ログ # 「どの操作を行ったか」を記録\n例：\nAの残高から100差し引く Bの残高に100増やす 長所 # より抽象的 一部の状況では同時実行性と柔軟性の面で有利 短所 # 再実行時にコンテキストが必要になる場合がある 13. REPEATABLE READ、READ COMMITTED、MVCCの理解 # 13-1. REPEATABLE READの核心 # InnoDBの REPEATABLE READ では、**通常の SELECT（consistent read）は概ねトランザクション内の最初のconsistent readが作った読み取りスナップショット（read view）**を基準に照会します。\nそのため同じトランザクション内では、後から他のトランザクションがコミットしたデータでも、最初のスナップショットになければ見えない場合があります。\nつまり：\nトランザクション2が開始した後 トランザクション1がコミットしても トランザクション2の通常のSELECTは依然として以前のスナップショット基準で読む これが 「トランザクション2は自身のトランザクションID以前の値しか読まないと思っていたが、なぜトランザクション1が記録したデータは読めないのか？」 に対する核心的な理由です。\n正確な理解 # 重要なのは単純に「トランザクションIDがより小さいか」ではなく、 トランザクション2が生成したRead Viewでそのトランザクションが可視かどうかです。\nまた SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE のようなロックを使うパスは常に同じルールでsnapshotを読むのではなく最新状態とロックルールを合わせて使用するため、通常の SELECT と区別して理解する必要があります。\n13-2. READ COMMITTEDの核心 # 各SELECT文が実行されるたびに新しいRead Viewを作ります。\nそのため一般的に：\n一度照会したときに見えなかった値が 後で他のトランザクションがコミットした後に再照会すると 見える場合があります。 ただし実際の実験では以下に注意する必要があります。\n最初のSELECTと2番目のSELECTが本当にそれぞれ独立したconsistent readだったか ロック読み取り（SELECT ... FOR UPDATE）だったか アプリケーション/フレームワークがトランザクション境界をどのように設定したか つまり、理論上READ COMMITTEDは「文単位の最新コミット反映」ですが、 実験状況のクエリの種類とトランザクションの流れによって観察結果の解釈を慎重に行う必要があります。\n14. ロック（lock）の種類とレコードロック # 14-1. レコードロック（Record Lock） # インデックスレコードを1つロックします。\nつまり「行全体」を抽象的にロックするのではなく、 実際にはインデックスエントリ基準でロックがかかると理解する必要があります。\n14-2. いつ使用されるか # UPDATE DELETE SELECT ... FOR UPDATE SELECT ... FOR SHARE（LOCK IN SHARE MODE と同等の構文） などで使用されます。\n15. INSERT時のロックはどのように動作するか # ユーザーが特に多く気になる部分です。\n15-1. 「存在しないデータにどのように排他ロックをかけるか？」 # 正確には存在しない行自体をロックするのではなく、 その行が入るインデックス区間/位置と挿入されるレコードに対して適切なロックメカニズムを使用します。\n関連概念：\ninsert intention lock gap lock next-key lock record lock 15-2. Insert Intention Lock # 挿入しようとするインデックス区間に対して 「私はここにinsertしようとしている」という意図を示すロックです。\n複数のトランザクションが同じgapにinsertしようとしても、 実際の同じ位置/同じキーの衝突でなければある程度並行可能性を持ちます。\n15-3. Gap Lock # 存在するインデックスレコード間の「間隔」をロックします。\n主な目的：\nファントム読み取りの防止 範囲内での新しいレコード挿入の防止 15-4. Next-Key Lock # Record Lock + Gap Lock\nつまり：\nあるインデックスレコードと その前のgap を一緒にロックします。\n16. InnoDBはPKインデックスにのみロックをかけるか？ # いいえ。 InnoDBはインデックス基準でロックを管理し、これはPK（クラスタードインデックス）だけでなく**セカンダリインデックス（secondary index）**にも適用されます。\nつまり：\nどのインデックスを通じて探索するか どのような条件で照会/修正するか によってロックがかかるインデックスが変わる可能性があります。\nただしInnoDBでは実際のレコードはPKインデックスのリーフにあるため、 セカンダリインデックスを通じてアクセスしてもPK側の確認が伴う場合があります。\n17. Uniqueインデックス vs 一般セカンダリインデックス # 17-1. よくある説明 # 「ユニークインデックスは1件だけ読めばよいが、ユニークでないインデックスはもう1件余分に読む必要があるため遅い」\nこの説明は完全に間違いとは言えないですが、過度に単純化された説明です。\n17-2. 正確には # ユニークインデックスは条件が一致すると最大1件のみ存在することが保証されているため、 オプティマイザは探索結果を素早く確定できます。\n一方、一般インデックスは同じキーが複数ある場合があり\n追加レコードの存在可能性の確認 複数エントリの走査 などが必要になる場合があります。\nしかし性能差は通常非常に微細であり、 実際のボトルネックは通常以下の方が大きいです。\nカバリングインデックスの有無 ランダムI/Oの有無 ページキャッシュのヒット率 返却カラム数 読まなければならない実際のrow数 18. 特定のメールの存在確認クエリの比較 # 対象テーブル：\nCREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE ); 比較対象：\nSELECT * FROM users WHERE email = :user_email; SELECT email FROM users WHERE email = :user_email; SELECT count(*) FROM users WHERE email = :user_email; 18-1. SELECT * # 最も非効率になる可能性が大きいです。\n理由 # email インデックスで位置は見つけても * のため全rowを読む必要がある つまりインデックスだけでは終わらない 通常カバリングインデックスではない 実行計画の観点：\nインデックス探索後 実際のclustered index rowへのアクセスが必要 18-2. SELECT email # このクエリは email インデックスだけで結果を返せます。\n長所 # カバリングインデックスが可能 テーブルrowの全体を読まなくて済む場合がある ネットワーク返却データも少ない 18-3. SELECT count(*) # 存在確認だけしたい場合は動作自体は正しいです。 ただしこの例は email が UNIQUE なので差が小さい場合がありますが、存在確認だけ必要なら通常 EXISTS や SELECT 1 ... LIMIT 1 のように**「あるかどうか」を直接表現する形**をより多く推奨します。\n18-4. 存在確認だけ必要な場合に最も推奨される形 # SELECT EXISTS( SELECT 1 FROM users WHERE email = :user_email ) AS email_exists; 理由 # 存在確認が目的であることが明確 最初の行を見つけたらそれ以上見る必要がない 読む人にも意図が明確 19. SELECT * が非効率な理由をオプティマイザと実行計画の観点で見ると # 19-1. カバリングインデックス不可 # SELECT * はすべてのカラムが必要なため email ユニークインデックスだけではクエリを終えられません。\nつまり：\nemail インデックスで条件一致エントリを見つける そのエントリが指す実際のrowを再度読む 19-2. 結果送信量の増加 # 存在確認だけ必要なのに：\nid username email すべてを返すと不要なI/Oとネットワークコストが生じます。\n19-3. 実務観点の結論 # **「必要なカラムだけ照会する」**という原則が非常に重要です。\n20. Unique制約とインデックス # 20-1. UNIQUEをかけるとインデックスができるか？ # 一般的にそうです。 MySQLはUNIQUE制約のためにユニークインデックスを作成します。\n20-2. そのカラムでWHERE検索すると速くなるか？ # はい、通常速くなります。\n例：\nSELECT * FROM users WHERE email = \u0026#39;a@b.com\u0026#39;; ここで email にUNIQUEインデックスがあれば、 MySQLはインデックスを使って素早く探索できます。\n20-3. UNIQUEでもNULLは？ # MySQLでは UNIQUE インデックスでも NULL 可能なカラムであれば複数の NULL 値を許容します。 つまり、「UNIQUE だから NULL も1つしかダメ」と理解すると誤りです。\n21. JOINの構文2種類 # SELECT * FROM employees e JOIN salaries s ON e.emp_no = s.emp_no; SELECT * FROM employees e, salaries s WHERE e.emp_no = s.emp_no; 違い # 機能的には同じinner joinの結果を出します。\n推奨方法 # 最初のように JOIN ... ON を使うのが良いです。\n理由 # 可読性が良い JOINの条件が明確 複雑なクエリでのミスを減らす 22. Undoログと Undoページの違い # Undoログ # トランザクションの変更を元に戻すための論理的な情報\nUndoページ # そのUndoログが実際に格納される物理的なページ単位\nつまり：\nUndoログ = 内容 Undoページ = その内容を格納する空間 23. トランザクションはDB ACIDを保証するだけで、Javaコードの原子性を保証しない # この文章は核心的に正しいです。\n意味 # DBトランザクションはデータベース内部の変更に対してACIDを保証しますが、 アプリケーションコード全体が自動的に原子化されるわけではありません。\nSpringの @Transactional も基本的に現在の実行スレッドにバインドされたトランザクション境界を扱うものであり、メソッド内で新たに作ったスレッドにまで自動的に伝播するモデルではありません。\n例：\nDB照会 Java条件分岐 なければ生成 保存 この全体がアプリケーションレベルの競合条件（race condition）にさらされる可能性があります。\nsynchronized で防ぐことがなぜ限界があるか # アプリケーションインスタンスが複数ある場合、JVM内部のlockは無力 DBレベルの競合状態はDBの制約/ロックで解決する必要がある 代表的な解決策： UNIQUE制約 適切なトランザクションとロック upsertパターン 24. メディア復旧（Media Recovery） # メディア復旧とはディスク障害、ファイル破損、記録媒体の障害などによって失われたデータを バックアップとログを利用して復元する作業です。\nつまり：\n単純なトランザクションのロールバック/クラッシュ復旧より大きな障害範囲 バックアップ + アーカイブログ + redoの適用などの手順が重要 25. PKサイズが大きくなるほどインデックスサイズが大きくなる理由 # InnoDBではセカンダリインデックスのリーフエントリには単に「セカンダリインデックス値」だけでなく 該当rowを見つけるためのPK値も一緒に格納されます。\n例：\nPK = INT (4 bytes) セカンダリインデックスの各エントリがPK 4 bytesを含む ところがPKが：\n長い文字列 複合キー UUID文字列形式 であれば、すべてのセカンダリインデックスエントリがその分大きくなります。\n影響 # インデックスサイズの増加 ページあたりに格納できるエントリ数の減少 B+木の高さが増加する可能性 キャッシュ効率の低下 したがってPKは短いほど有利です。\n26. MySQLスレッドとOSカーネルスレッドの関係 # MySQLサーバーは内部的に：\nクライアント接続ごとに紐づくリクエスト処理用スレッド バックグラウンド作業用スレッド を使用します。\n基本的なconnection thread modelではクライアント接続ごとに専用のスレッドが付き、これらのスレッドはOSがスケジューリングする実行単位の上で動作します。\n同期がなぜ必要か # 複数のスレッドが同時に：\nバッファプール ロックテーブル インデックス構造 ログバッファ のような共有リソースにアクセスすると衝突が発生する可能性があります。\nそのため：\nmutex semaphore latch rw-lock のような同期ツールが使用されます。\nつまりOSはスレッド実行と基本的な同期プリミティブ機能を提供し、 MySQLはこれを使ってDB内部の共有リソースを保護します。\n27. B-treeインデックスの「固有番号」と実際のキー値 # この部分は表現上の混乱がありました。 実務/教科書で一般的に重要なのは以下です。\n実際のキー値：インデックスを構成するカラム値 行の識別情報： InnoDBセカンダリインデックスであればPK値 MyISAMであればデータファイルのoffsetのような位置情報 つまり、通常の学習で重要な区分は 「インデックスキー」vs「そのキーが指す実際のレコードの識別子」です。\n28. MyISAM vs InnoDB # 28-1. MyISAMの特徴 # トランザクション非サポート テーブルロックを使用 read-mostly ワークロードに主に使用 データとインデックスを別々に格納 28-2. MyISAMのテーブルロック # MyISAMはrow lockではなくtable lock基準です。\n長所 # 実装がシンプル オーバーヘッドが少ない 短所 # 同時書き込みに弱い 書き込み作業が多いとボトルネックになる 28-3. 高速な読み取り演算 # トランザクションとMVCCがなく、テーブルロック基準であるため、歴史的には読み取り比率が非常に高い環境で使われてきました。\n28-4. InnoDBは遅いか？ # 単純な読み取り構造だけ見るとMyISAMが有利に見えるかもしれませんが、 実際の現代サービス環境ではInnoDBの利点の方がはるかに大きいです。\n特に：\nPK照会はInnoDBのクラスタードインデックスが非常に高速 トランザクションサポート row-level locking crash recovery MVCC そのため一般的なサービスではほぼInnoDBが標準的な選択です。\n29. SpringでMyISAMを使うとトランザクションが使えないか？ # DBエンジンレベルでは事実上そうです。\nSpringの @Transactional はフレームワークレベルでトランザクション境界を設定してくれますが、 実際のDBがトランザクションをサポートしていなければACIDの保証を受けられません。\nつまり：\nSpringがアノテーションを付けても MyISAMの変更はCOMMIT/ROLLBACKベースの本物のトランザクションで保護されない したがってロールバックを呼び出してもMyISAMの変更は元に戻らない場合がある したがってトランザクションが必要なアプリケーションであればInnoDBが必要です。\n30. wait_timeout の global と session の違い # global # 新たに作成されるセッションのデフォルト値\nsession # 現在接続された特定のセッションにのみ適用される値\nつまり：\nSET GLOBAL wait_timeout = ... → 新しい接続から影響 SET SESSION wait_timeout = ... → 現在の接続にのみ影響 MySQLは接続スレッドが開始されるときにsession wait_timeout の値をglobal wait_timeout または interactive_timeout から初期化します。\n31. MySQLのセッション（Session）とは何か # セッションは**スキーマ単位ではなく、クライアントとサーバー間の1つの接続（connection）**を意味します。\nつまり：\nWebサーバーのDB接続1つ = DBセッション1つ このセッションは以下を持ちます。\nセッション変数 トランザクション状態 現在のdefault schema temporary table等 32. Springサーバーに100人がリクエストを送るとセッションは1つか？ # いいえ。 通常はDBコネクションプール（HikariCP等）を使用します。\nつまり：\nリクエスト100個が来ても DBセッションはコネクションプールのサイズ分だけ運用できる 各リクエストは必要に応じてプールから接続を1つ借りて使用 したがって「SpringサーバーとDBの間に単1つのセッションしかない」というのは誤りです。\n33. インメモリDBMS vs 大きなページキャッシュを持つディスクベースのDBMS # ユーザーが提起した質問は非常に本質的です。\n「ページ全体をメモリにキャッシュするなら、ディスクベースのDBMSもインメモリDBMSと同じではないか？」\n一見似ているように見えますが、構造的に異なります。\n33-1. 単純なページキャッシュとインメモリDBの違い # ディスクベースのDBMSは基本的に：\nディスクページ構造 バッファマネージャー ページのflush WALベースのrecovery ページフォーマットの維持 を前提として設計されています。\n一方インメモリDBMSはもともと：\nポインターベースのデータ構造 キャッシュフレンドリーなレイアウト ディスクページ形式の最小化 シリアライズ/デシリアライズの最小化 などを目標として設計されています。\n33-2. シリアライズのオーバーヘッドの観点 # ディスクベースのDBMSはデータをディスクに永続化するために ページフォーマット、ログフォーマット、flushルールなどを管理する必要があります。\nつまりメモリに載っていても：\nディスクに合った構造の維持 ページのdirty管理 チェックポイント シリアライズ形式の考慮 のようなオーバーヘッドがあります。\nインメモリDBMSはこのような従来のディスクページ中心の制約がはるかに少ないです。\n33-3. データレイアウト維持のオーバーヘッド # ディスクベースのDBMSは通常ページ単位の整合性を維持する必要があります。\n例：\nスロットディレクトリ ページヘッダー 空きスペース管理 page split page compaction このようなレイアウト管理コストが大きいです。\n一方インメモリDBMSはディスクページレイアウトにあまり縛られず CPU cache フレンドリーな構造を選ぶ自由がより大きいです。\nつまり：\n「すべてメモリに載せておく」という事実だけは同じ しかし内部のデータ構造と実行エンジンの最適化が異なります。 34. 選択度（Selectivity）/ 基数性（Cardinality） # インデックスキー値の重複が多くなるほど：\n**基数性（cardinality）**は低くなり **選択度（selectivity）**も低くなります なぜ重要か # 選択度が低いと特定の値で検索しても多くのrowが出る可能性が大きいため、 オプティマイザはインデックス使用の利点を低く評価する場合があります。\n例：\n性別カラム（M、F）のインデックスは選択度が非常に低い メールカラムはほぼ一意 → 選択度が非常に高い 35. データベースはどのようにデータを読み込むか # 全体的なフローを整理すると以下のようになります。\n35-1. パース # SQL文法の解析\n35-2. 最適化 # オプティマイザが実行計画を選択\n35-3. 実行 # インデックス探索 or フルスキャン 必要なページをバッファプールで検索 なければディスクから読み込む 35-4. 結果生成 # フィルタ、JOIN、ソート、集計を実行\n35-5. 返却 # クライアントに結果を送信\n核心は結局：\nDBMSは「ページ」単位でデータを読み込み、バッファプール/インデックス/オプティマイザを利用してディスクI/Oを最小化する。\n36. 最後に改めて整理する核心概念 # 36-1. InnoDBで重要なこと # PK = クラスタードインデックス セカンダリインデックスのリーフにはPKが入る トランザクション/MVCC/UNDO/REDOサポート ロックはインデックス基準で動作 REPEATABLE READでのスナップショット読み取りが重要 36-2. クエリ最適化観点の核心 # SELECT * を避ける 必要なカラムだけ照会 存在確認は EXISTS を検討 カバリングインデックスの有無が重要 PKは短く安定しているほど良い 36-3. アプリケーション観点の核心 # DBトランザクションがJavaコード全体の原子性を保証するわけではない マルチインスタンス環境ではJVM synchronized だけでは不十分 データの整合性はDBの制約条件とトランザクション設計で確保する必要がある 37. 学習ポイントのまとめチェックリスト # 以下の項目を自分で説明できれば、今回の対話の核心をよく理解したことになります。\nランダムアクセスとは何かを説明できる。 InnoDBでPKがなぜ物理的な格納順序に影響を与えるかを説明できる。 PKが大きくなるとなぜセカンダリインデックスも大きくなるかを説明できる。 B+木の高さとディスクI/O回数の関係を説明できる。 STEAL / NO-STEAL、FORCE / NO-FORCE を教科書の概念として区別できる。 REDOとUNDOの違いを説明できる。 INSERTのUNDOがなぜ必要かを説明できる。 REPEATABLE READでなぜ他のトランザクションのコミットが見えない場合があるかを説明できる。 レコードロック、ギャップロック、ネクストキーロックを区別できる。 SELECT *、SELECT email、COUNT(*)、EXISTS の中で存在確認に何が適切かを説明できる。 MyISAMとInnoDBの違いを説明できる。 SpringのリクエストとDBセッション（コネクションプール）の関係を説明できる。 インメモリDBとディスクベースDB+大きなキャッシュの違いを説明できる。 38. まとめ # 今回の対話は単純な文法よりも重要な、DBMSが内部的にどのように動作するかを理解する方向で進められました。 バックエンド開発者にとって特に重要な観点は以下の3つです。\n性能：インデックス、ページ、ランダムI/O、カバリングインデックス 整合性：トランザクション、ロック、MVCC、UNIQUE制約 設計：PK選択、クエリ形式、アプリケーション-DBの役割分離 この3つを継続的に結びつけて学習するとMySQLをはるかに深く理解できます。\n参考資料 # MySQL 8.4 Reference Manual, Clustered and Secondary Indexes https://dev.mysql.com/doc/refman/8.4/en/innodb-index-types.html MySQL 8.4 Reference Manual, The Physical Structure of an InnoDB Index https://dev.mysql.com/doc/refman/8.4/en/innodb-physical-structure.html MySQL 8.4 Reference Manual, File Space Management https://dev.mysql.com/doc/refman/8.4/en/innodb-file-space.html MySQL 8.4 Reference Manual, Buffer Pool https://dev.mysql.com/doc/refman/8.4/en/innodb-buffer-pool.html MySQL 8.4 Reference Manual, Transaction Isolation Levels https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html MySQL 8.4 Reference Manual, Consistent Nonlocking Reads https://dev.mysql.com/doc/refman/8.4/en/innodb-consistent-read.html MySQL 8.4 Reference Manual, Locking Reads https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html MySQL 8.4 Reference Manual, Locks Set by Different SQL Statements in InnoDB https://dev.mysql.com/doc/refman/8.4/en/innodb-locks-set.html MySQL 8.4 Reference Manual, Undo Logs https://dev.mysql.com/doc/refman/8.4/en/innodb-undo-logs.html MySQL 8.4 Reference Manual, Redo Log https://dev.mysql.com/doc/refman/8.4/en/innodb-redo-log.html MySQL 8.4 Reference Manual, Server System Variables (wait_timeout) https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html MySQL 8.4 Reference Manual, The MyISAM Storage Engine https://dev.mysql.com/doc/refman/8.4/en/myisam-storage-engine.html MySQL 8.4 Reference Manual, Connection Interfaces https://dev.mysql.com/doc/refman/8.4/en/connection-interfaces.html MySQL 8.4 Reference Manual, CREATE INDEX Statement https://dev.mysql.com/doc/refman/8.4/en/create-index.html Spring Framework Javadoc, @Transactional https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/annotation/Transactional.html ","date":"2026年3月27日","externalUrl":null,"permalink":"/ja/posts/mysql-database-session-summary/","section":"Posts","summary":"クラスタードインデックス、セカンダリインデックス、undo/redo、REPEATABLE READ、READ COMMITTED、MyISAM との違い、wait_timeout、DBセッションとコネクションプールまで一度に網羅するMySQL整理ノートです。","title":"[MySQL] データベース学習まとめ — セッション、インデックス、ロック、MVCC","type":"posts"},{"content":"","date":"2026年3月27日","externalUrl":null,"permalink":"/ja/tags/lock/","section":"Tags","summary":"","title":"Lock","type":"tags"},{"content":"","date":"2026年3月27日","externalUrl":null,"permalink":"/ja/tags/session/","section":"Tags","summary":"","title":"Session","type":"tags"},{"content":"","date":"2026年3月27日","externalUrl":null,"permalink":"/ja/tags/spring/","section":"Tags","summary":"","title":"Spring","type":"tags"},{"content":"","date":"2026年3月27日","externalUrl":null,"permalink":"/ja/tags/transaction/","section":"Tags","summary":"","title":"Transaction","type":"tags"},{"content":"このドキュメントは疑問に思っていた点をAIエージェントと対話しながらまとめたメモです。Nested Loop Joinを中心にドライビングテーブルとドリブンテーブルがなぜ重要で、実行計画でどのように読むかを整理します。\n目次 # 核心概念の整理 JOINの内部動作原理 なぜドライビングテーブルの選択が重要なのか？ 良いドライビング/ドリブンテーブルの選択基準 インデックスとの関係 JOINアルゴリズム別ドライビング/ドリブンの役割の違い オプティマイザーがドライビングテーブルを決定する方法 強制的にドライビングテーブルを指定する方法 実務チェックリスト 一行まとめ 1. 核心概念の整理 # JOINを実行する際、オプティマイザーは2つのテーブルのうちどちらを先に読むかを決定する。\n区分 別名 説明 ドライビングテーブル Driving Table / Outer Table 先に読まれるテーブル。JOINの基準点となる ドリブンテーブル Driven Table / Inner Table ドライビングテーブルの各行ごとに探索されるテーブル この選択は単純な順序の問題ではなく、クエリ全体の性能を大きく左右する核心要素だ。\nただしこの用語は特にJOIN順序や**Nested Loop Join（NLJ）**を説明する際に最も直感的だ。 Hash JoinやMerge Joinではouter/inner、build/probeといった表現がより正確な場合もある。\n2. JOINの内部動作原理 # 2-1. Nested Loop Join（NLJ）— ドライビング/ドリブン概念を理解するのに最適なモデル # ドライビング/ドリブンの概念はNLJで最も明確に現れる。\n// ドライビング = A、ドリブン = B と仮定 for each row in A (ドライビング): // Aを一度巡回 for each matching row in B (ドリブン): // BをAのrow数だけ繰り返し探索 output(A.row + B.row) この構造を見ると核心の非対称性が明確になる。\nドライビングテーブルは一度読まれる ドリブンテーブルはドライビングの結果row数だけ繰り返し探索される したがってNLJではドリブンテーブルの探索コストが全体のJOIN性能を支配しやすい。\n2-2. 探索回数から見るコスト # ドライビング結果 = N件 ドリブン探索コスト = 1回当たり C 全体コスト = N × C Nを減らすには → ドライビングテーブルにフィルター効果の大きいWHERE条件 Cを減らすには → ドリブンテーブルのJOIN列にインデックス 3. なぜドライビングテーブルの選択が重要なのか？ # 例示シナリオ # SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.status = \u0026#39;PENDING\u0026#39;; テーブルの状況（仮定）：\nテーブル 全件数 条件フィルター後の件数 orders 1,000,000 500件（status = 'PENDING'） customers 100,000 条件なし ケース1：ordersがドライビング（良い場合）\n1. ordersでstatus = \u0026#39;PENDING\u0026#39;フィルター → 500件抽出 2. 500件のcustomer_idでcustomersテーブルのインデックス探索 → 500回 総探索回数 = 500回 ケース2：customersがドライビング（悪い場合）\n1. customers全体読み込み → 100,000件 2. 100,000件それぞれでordersテーブルを探索 → 100,000回 総探索回数 = 100,000回 同じロジックのクエリでもジョイン順序によってコストが大きく変わる。\n4. 良いドライビング/ドリブンテーブルの選択基準 # 4-1. ドライビングテーブルとして適しているもの # ✅ WHERE条件適用後の結果件数が少ないテーブル ✅ フィルター効果の大きい条件を持つテーブル ✅ JOIN前にrow数を多く減らせるテーブル フィルター効果の計算例 # ordersテーブル：1,000,000件 status = \u0026#39;PENDING\u0026#39;条件後の結果：500件 条件一致率 = 500 / 1,000,000 = 0.0005（0.05%） → 残るrow比率が非常に低いのでフィルター効果が大きい 文献によってselectivityという用語を「残る比率」と定義することもあり、実務では逆に「フィルターがよく効く」という意味で緩く使われることもある。 実践では用語よりも条件適用後に何件残るかを見る方が安全だ。\n4-2. ドリブンテーブルとして適しているもの # ✅ JOIN列（ON句の列）にインデックスが張られているテーブル ✅ 単件または少数件のlookupの役割をするテーブル ✅ テーブルが大きくてもJOIN列インデックスで素早く見つけられるテーブル 特にNLJではドリブンテーブルのJOIN列インデックスが事実上性能の核心だ。\n黄金則：ドライビングは「小さく」、ドリブンは「インデックスあり」\n5. インデックスとの関係 — 最も重要な部分 # 5-1. 理想的なインデックス構成例 # -- テーブル構造 CREATE TABLE orders ( id BIGINT PRIMARY KEY, customer_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL ); CREATE TABLE customers ( id BIGINT PRIMARY KEY, name VARCHAR(100), email VARCHAR(200) ); -- インデックス追加 CREATE INDEX idx_orders_status ON orders(status); -- WHERE条件用 CREATE INDEX idx_orders_customer_id ON orders(customer_id); -- JOIN/FK列用 5-2. 理想的な実行フロー # SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.status = \u0026#39;PENDING\u0026#39;; Step 1. ordersでidx_orders_statusを使用 → status = \u0026#39;PENDING\u0026#39; に該当するrowだけ素早く抽出（ドライビング） Step 2. 抽出されたN件のcustomer_idそれぞれで customersのPKインデックスを探索（ドリブン） → 毎回繰り返しのたびにindex seek 結果：全体コスト ≈ N × lookup_cost 5-3. ドリブンテーブルにインデックスがない場合 # -- 最悪に近いシナリオ SELECT * FROM small_ref_table s JOIN huge_table h ON h.non_indexed_col = s.id; → small_ref_tableの結果がN件なら → huge_tableをN回Full Table Scan → 全体コスト = N × M （M = huge_table全件数） → N=1,000、M=5,000,000ならrow比較数が爆増 結論：NLJでドリブンテーブルのJOIN列にインデックスがなければ、全体のJOINコストが急激に悪化する可能性がある。\n5-4. FK列インデックスの注意事項 # FK列インデックスはDBMSによって自動生成の有無が異なる。\nMySQL InnoDB：参照する側（referencing table）に必要なインデックスがなければ自動的に生成される場合がある PostgreSQL：FK宣言だけでreferencing columnsのインデックスを自動生成しない SQL Server：FK宣言だけでcorresponding indexを自動生成しない したがって実務では以下の原則が安全だ。\nFKがあるからインデックスもあるはずだ → 危険な仮定 実際のインデックス定義 + EXPLAINの結果を確認 → 安全なアプローチ -- 必要な場合は明示的に作成 CREATE INDEX idx_orders_customer_id ON orders(customer_id); 6. JOINアルゴリズム別ドライビング/ドリブンの役割の違い # 6-1. Nested Loop Join（NLJ） # ドライビング/ドリブンの概念が最も直接的に適用されるアルゴリズムだ。\n特徴： - ドライビングテーブルを一度巡回 - ドリブンテーブルをドライビングのrow数だけ繰り返し探索 最適条件： - ドライビング：少量の結果、フィルター効果が大きい - ドリブン：JOIN列にインデックスが非常に重要 適した状況：OLTP、比較的小さい結果セット、インデックスが整備された環境 -- NLJの動作疑似コード for each row r1 in driving_table: lookup driven_table where join_col = r1.join_col if found: emit(r1, matched_row) 6-2. Hash Join（MySQL 8.4、PostgreSQL、SQL Serverなど） # インデックスが弱い状況でも大容量JOINを処理できるアルゴリズムだ。\n動作方式： Phase 1（Build）：通常はより小さい入力をハッシュテーブルに積み込む Phase 2（Probe）：もう一方の入力をスキャンしてハッシュテーブルとマッチング 特徴： - ドリブンのインデックスがなくても動作可能 - メモリ使用量が大きい - メモリを超えるとディスクspillが発生する可能性がある 適した状況：大容量データ、equi-join中心、インデックスが弱い環境 PostgreSQLのドキュメントはHash Joinで一方の入力がHashノードでハッシュテーブルになり、もう一方の入力がこれをprobeすると説明している。 MySQL 8.4のドキュメントもEXPLAIN FORMAT=TREEにHashとInner hash joinノードが表示されると説明している。\n-- MySQL 8.4でHash Joinの動作を確認 EXPLAIN FORMAT=TREE SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id; -- 出力に \u0026#34;Hash\u0026#34; または \u0026#34;Inner hash join\u0026#34; が見えればHash Joinを使用中 -- MySQL：hash joinのメモリに関連する代表的な設定 SET join_buffer_size = 256 * 1024 * 1024; -- 256MB MySQL 8.4のドキュメント基準でhash joinのメモリ使用量はjoin_buffer_sizeと関連があり、不足するとディスクファイルを使用する場合がある。\n6-3. Sort Merge Join（PostgreSQL、Oracle、SQL Server） # 両方の入力がJOIN列基準でソートされているとき有利なアルゴリズムだ。\n動作方式： 1. 両方の入力をJOIN列基準でソート 2. ソートされた2つのリストを順次スキャンしてマージ 特徴： - 両方の入力がソートされていなければならない - ソートコストがかかる場合がある - 範囲条件やソートされた入力の活用に有利な場合がある - ドライビング/ドリブンの概念がNLJより重要でない PostgreSQLの公式ドキュメントもmerge joinは入力データがjoin keyを基準にソートされていなければならないと説明している。\n6-4. アルゴリズム選択のまとめ # アルゴリズム インデックスの必要性 メモリ使用 適した環境 Nested Loop Join ドリブンのインデックスが非常に重要 低い OLTP、少量結果 Hash Join インデックス依存度が低い 高い 大量データ、equi-join Sort Merge Join ソートされた入力が重要 中間 大量データ、ソート活用 7. オプティマイザーがドライビングテーブルを決定する方法 # オプティマイザーは統計情報（テーブル件数、インデックス分布、列分布など）を基にコストを推定してジョイン順序を決定する。\n7-1. 実行計画（EXPLAIN）の読み方 # -- MySQL EXPLAIN SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.status = \u0026#39;PENDING\u0026#39;; +----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | +----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+ | 1 | SIMPLE | o | ref | idx_orders_status | idx_orders_status | 22 | const | 500 | | 1 | SIMPLE | c | eq_ref | PRIMARY | PRIMARY | 8 | mydb.o.customer_id| 1 | +----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+ MySQLの公式ドキュメントはEXPLAINがテーブルがどの順序でジョインされるかを示すと説明している。 したがってtabular EXPLAINでは通常、上から下の順序がジョイン順序を反映する。\nただし以下の例外は覚えておく必要がある。\nconstまたはsystemテーブルは最適化段階で先に読まれて計画の一番前に現れる場合がある Hash Joinのような場合はFORMAT=TREEで見る方が実際のbuild/probe構造をよく示す 7-2. type列 — MySQLで見る主要なアクセス方式 # type値 意味 system テーブルに行が1件だけの場合 const PK/Unique = 定数値、1件アクセス eq_ref PK/Uniqueを利用したジョインlookup ref 一般的なインデックスlookup range インデックス範囲探索 index インデックス全体スキャン ALL テーブル全体スキャン 概して上から下に行くほどコストが大きくなる傾向がある。\nドリブンテーブルのtypeがALLであれば、インデックス追加やジョイン順序の再検討をまず疑う方がよい。\n7-3. 追加で確認すべき列 # 列 意味 rows オプティマイザーが予測した探索件数 filtered WHERE条件で絞り込まれる割合の推定値 Extra Using index、Using filesort、Using join buffer (hash join)などの補足情報 7-4. 統計情報の更新 # オプティマイザーの判断がおかしいときは統計が古くなっている可能性も確認する必要がある。\n-- MySQL ANALYZE TABLE orders; ANALYZE TABLE customers; -- PostgreSQL ANALYZE orders; ANALYZE customers; -- Oracle EXEC DBMS_STATS.GATHER_TABLE_STATS(\u0026#39;schema_name\u0026#39;, \u0026#39;orders\u0026#39;); 8. 強制的にドライビングテーブルを指定する方法 # オプティマイザーが誤った判断を下すときは、ヒントやplanner設定で影響を与えることができる。 ただしヒントはあくまで例外的な手段であり、まずインデックスと統計を正した方が安全だ。\n8-1. MySQL # -- STRAIGHT_JOIN：FROM句の順序を固定 SELECT STRAIGHT_JOIN * FROM orders o JOIN customers c ON o.customer_id = c.id; -- JOIN_ORDER：より細かいジョイン順序の指定 SELECT /*+ JOIN_ORDER(o, c) */ * FROM orders o JOIN customers c ON o.customer_id = c.id; MySQL 8.4の公式ドキュメント基準で：\nSTRAIGHT_JOINはFROM句の順序に従わせる JOIN_FIXED_ORDERはSTRAIGHT_JOINと同じ効果だ JOIN_ORDERは特定のテーブルのジョイン順序を指定する Hash Joinの制御にも注意が必要だ。\n-- MySQL 8.4ではHASH_JOIN/NO_HASH_JOINの代わりにBNL/NO_BNLを使用 SELECT /*+ NO_BNL(c) */ * FROM orders o JOIN customers c ON o.customer_id = c.id; MySQL 8.4のドキュメントはHASH_JOIN / NO_HASH_JOINヒントが効果がなく、hash joinの制御にはBNL / NO_BNLを使うよう説明している。\n8-2. PostgreSQL # -- ジョインの再整列を強く制限 SET join_collapse_limit = 1; -- 特定のアルゴリズムを非選好 SET enable_hashjoin = off; SET enable_mergejoin = off; PostgreSQLの公式ドキュメント基準で：\njoin_collapse_limitはplannerがJOIN順序をどの程度再配置するかに影響を与える enable_hashjoin、enable_mergejoin、enable_nestloopは該当のジョイン方式を無効化というよりも非選好にする 特にnested loopは他の代替がなければ完全に排除されない 8-3. Oracle # -- LEADINGヒント：ジョイン順序の起点を指定 SELECT /*+ LEADING(o) */ * FROM orders o JOIN customers c ON o.customer_id = c.id; -- USE_NL：指定したテーブルをinner tableとしてnested loopを使用 SELECT /*+ LEADING(o) USE_NL(c) */ * FROM orders o JOIN customers c ON o.customer_id = c.id; Oracleの公式ドキュメントは：\nLEADINGをジョイン順序を決めるmultitable hintとして USE_NLを指定したテーブルをinner tableとしてnested loops joinするよう指示するhintとして説明している 8-4. SQL Server # SELECT * FROM orders o INNER LOOP JOIN customers c ON o.customer_id = c.id OPTION (FORCE ORDER); SQL Serverの公式ドキュメントは：\nLOOP、HASH、MERGEをjoin hintとして FORCE ORDERをquery hintとして提供する 一つのjoin hintを与えるとクエリ全体のジョイン順序にも影響が出る場合があると説明している ⚠️ ヒント使用時の注意事項 データ分布、統計、インデックスが変わるとヒントがかえって性能を悪化させる可能性がある。 可能な限りインデックス設計と統計情報の更新で先に解決しよう。\n9. 実務チェックリスト # 9-1. クエリ作成時 # ✅ WHERE条件で結果が多く減るテーブルをドライビング候補として見る ✅ ドリブンテーブルのJOIN列（ON句）にインデックスがあるか確認する ✅ FK列は「自動インデックスがあるはず」と仮定せず実際のインデックスを確認する ✅ 3つ以上のJOIN時はEXPLAINで実行順序を確認する ✅ SELECT * を避け、必要な列だけを選択してカバリングインデックスの可能性を高める 9-2. 性能問題が疑われる際の診断手順 # Step 1. EXPLAINを実行 → JOIN順序とアクセスタイプを確認 → ドリブン側がALLかどうかまず確認する Step 2. rows / filteredを確認 → ドライビング候補の結果件数が異常に大きくないか点検 Step 3. 統計情報の更新 → ANALYZEまたはDBMS_STATS後に実行計画を再確認 Step 4. インデックスの追加 → ドリブンJOIN列のインデックス → 必要な場合はドライビングフィルター列のインデックス Step 5. それでも遅い場合 → DBMS別ヒントまたはplanner設定で例外的に補正 9-3. よくあるミスと正しい解決策 # ミス1：ドリブンテーブルのJOIN列にインデックスがない # -- 悪い例 SELECT * FROM small_table s JOIN huge_table h ON h.non_indexed_col = s.id; -- 解決 CREATE INDEX idx_huge_non_indexed ON huge_table(non_indexed_col); ミス2：大型テーブルがドライビングとして選択される # -- 悪い例 SELECT * FROM customers c JOIN orders o ON o.customer_id = c.id WHERE o.status = \u0026#39;PENDING\u0026#39;; -- 確認後にジョイン順序を補正 SELECT STRAIGHT_JOIN * FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.status = \u0026#39;PENDING\u0026#39;; ただしこのような場合もヒントの前にインデックスと統計から確認することが優先だ。\nミス3：FKだからインデックスが自動的にあると仮定 # ALTER TABLE orders ADD CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers(id); この宣言だけですべてのDBMSが自動インデックスを作るわけではない。\nPostgreSQL、SQL Serverはreferencing columnsのインデックスを自動生成しない MySQL InnoDBは必要なインデックスを自動生成できるが、実際にどのインデックスがあるかは確認する必要がある -- 実際のスキーマを確認後、必要な場合は明示的に作成 CREATE INDEX idx_orders_customer_id ON orders(customer_id); ミス4：3つ以上のテーブルJOIN時に順序を放置 # SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id JOIN products p ON o.product_id = p.id JOIN categories cat ON p.category_id = cat.id WHERE o.created_at BETWEEN \u0026#39;2024-01-01\u0026#39; AND \u0026#39;2024-03-31\u0026#39;; このようなクエリは必ず実行計画を見て：\nどのテーブルが先に読まれるか 中間段階でrow数がどれだけ減るか どのポイントでFull ScanやHash/Mergeが付くか を確認する必要がある。\n9-4. インデックス設計ガイド # -- ドライビング候補用：WHERE条件列 CREATE INDEX idx_orders_status_created ON orders(status, created_at); -- ドリブン候補用：JOIN列 CREATE INDEX idx_orders_customer_id ON orders(customer_id); -- カバリングインデックス例 CREATE INDEX idx_orders_covering ON orders(customer_id, status, created_at); MySQLの場合EXPLAINのExtraにUsing indexが見えれば、カバリングインデックスの活用状況を判断するのに役立つ。\n10. 一行まとめ # 特にNLJでは、ドライビングテーブルは「フィルター後の結果が少ないもの」に、ドリブンテーブルは「JOIN列にインデックスがあるもの」に選ばれることでJOINが速くなる。\n小さいドライビング × インデックスありのドリブン = 速いJOIN 大きいドライビング × インデックスなしのドリブン = 遅いJOIN 参考 — DB別クイックリファレンス # 目的 MySQL PostgreSQL Oracle 実行計画の確認 EXPLAIN、EXPLAIN ANALYZE、FORMAT=TREE EXPLAIN、EXPLAIN ANALYZE EXPLAIN PLAN FOR 統計の更新 ANALYZE TABLE t ANALYZE t DBMS_STATS.GATHER_TABLE_STATS ジョイン順序に影響を与える STRAIGHT_JOIN、JOIN_ORDER、JOIN_FIXED_ORDER join_collapse_limit LEADING Hash Joinの制御 BNL、NO_BNL enable_hashjoin USE_HASH 参考資料 # MySQL 8.4 Reference Manual, SELECT Statement (STRAIGHT_JOIN) https://dev.mysql.com/doc/refman/8.4/en/select.html MySQL 8.4 Reference Manual, EXPLAIN Statement https://dev.mysql.com/doc/refman/8.4/en/explain.html MySQL 8.4 Reference Manual, Hash Join Optimization https://dev.mysql.com/doc/refman/8.4/en/hash-joins.html MySQL 8.4 Reference Manual, Optimizer Hints https://dev.mysql.com/doc/refman/8.4/en/optimizer-hints.html MySQL 8.4 Reference Manual, FOREIGN KEY Constraints https://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html PostgreSQL 18 Documentation, Using EXPLAIN https://www.postgresql.org/docs/18/using-explain.html PostgreSQL 18 Documentation, Query Planning https://www.postgresql.org/docs/18/runtime-config-query.html PostgreSQL 18 Documentation, Constraints https://www.postgresql.org/docs/18/ddl-constraints.html PostgreSQL 18 Documentation, CREATE TABLE https://www.postgresql.org/docs/18/sql-createtable.html Oracle Database, Influencing the Optimizer https://docs.oracle.com/en/database/oracle/oracle-database/21/tgsql/influencing-the-optimizer.html Oracle Database, DBMS_STATS https://docs.oracle.com/en/database/oracle/oracle-database/19/arpls/DBMS_STATS.html SQL Server, Join hints (Transact-SQL) https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-join?view=sql-server-ver16 SQL Server, Query hints (Transact-SQL) https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-query?view=azuresqldb-current SQL Server, Primary and foreign key constraints https://learn.microsoft.com/en-us/sql/relational-databases/tables/primary-and-foreign-key-constraints?view=sql-server-ver16 ","date":"2026年3月21日","externalUrl":null,"permalink":"/ja/posts/rdbms-join-driving-driven-table/","section":"Posts","summary":"ドライビングテーブルとドリブンテーブルの概念が特にNested Loop Joinでなぜ重要なのか、EXPLAINでどう読むかとDBMS別の制御方法をまとめます。","title":"[RDBMS] JOINのドライビングテーブルとドリブンテーブル整理","type":"posts"},{"content":"","date":"2026年3月21日","externalUrl":null,"permalink":"/ja/tags/join/","section":"Tags","summary":"","title":"JOIN","type":"tags"},{"content":"","date":"2026年3月21日","externalUrl":null,"permalink":"/ja/tags/postgresql/","section":"Tags","summary":"","title":"PostgreSQL","type":"tags"},{"content":"","date":"2026年3月21日","externalUrl":null,"permalink":"/ja/tags/query-optimization/","section":"Tags","summary":"","title":"Query Optimization","type":"tags"},{"content":"","date":"2026年3月21日","externalUrl":null,"permalink":"/ja/tags/rdbms/","section":"Tags","summary":"","title":"RDBMS","type":"tags"},{"content":"","date":"2026年3月21日","externalUrl":null,"permalink":"/ja/tags/sql/","section":"Tags","summary":"","title":"SQL","type":"tags"},{"content":"","date":"2026年3月15日","externalUrl":null,"permalink":"/ja/tags/configuration/","section":"Tags","summary":"","title":"Configuration","type":"tags"},{"content":"","date":"2026年3月15日","externalUrl":null,"permalink":"/ja/tags/entitymanager/","section":"Tags","summary":"","title":"EntityManager","type":"tags"},{"content":"","date":"2026年3月15日","externalUrl":null,"permalink":"/ja/tags/jpa/","section":"Tags","summary":"","title":"JPA","type":"tags"},{"content":"","date":"2026年3月15日","externalUrl":null,"permalink":"/ja/tags/jvm/","section":"Tags","summary":"","title":"JVM","type":"tags"},{"content":"","date":"2026年3月15日","externalUrl":null,"permalink":"/ja/tags/singleton/","section":"Tags","summary":"","title":"Singleton","type":"tags"},{"content":"","date":"2026年3月15日","externalUrl":null,"permalink":"/ja/tags/thread-safety/","section":"Tags","summary":"","title":"Thread Safety","type":"tags"},{"content":"このドキュメントは、疑問を持っていた点をAIエージェントと対話しながら整理したメモです。Springシングルトン Beanがステートレスでなければならない理由と、ローカル変数、Entity、@Configurationの動作をまとめます。\nなぜSpringのシングルトンBeanはステートレスでなければならないのか？ スレッド、Entity、@Configurationまで # 💡 Springのシングルトン Bean、JVMスレッド構造、DTO/Entityのライフサイクルに関する疑問と整理\n1. シングルトンBeanはなぜステートレス（Stateless）でなければならないのか？ # 疑問 # シングルトンはステートレスで使用しなければならない。マルチスレッドで動作すると値が変更される余地があるからだ。もし値を設定しておいて、特定のクライアントが値を変更できるフィールドがあってはならない。\n回答 # Springコンテナはデフォルトでは Beanをシングルトンとして管理する。正確には、通常Springコンテナ内でBean定義（bean definition）ごとに1つのインスタンスを共有するという意味であり、一般的なSpring MVCサーバーアプリケーションでは複数のリクエストスレッドがこのインスタンスを共有して使用する。\n@Service public class OrderService { // ❌ 絶対にこうしてはいけない - インスタンス変数 = 共有状態 private int orderCount = 0; public void createOrder() { orderCount++; // スレッドAとBが同時にアクセスすると？ -\u0026gt; レースコンディション } } スレッドAがorderCountを読んで1を加えようとしている間にスレッドBも同じ値を読んでしまうと、2回呼び出したのに1しか増えないという問題が発生する。 そのためシングルトン Beanには状態を変更できるインスタンスフィールドがあってはならない。 どうしても必要な場合はThreadLocalのような、より狭いスコープの保存方法を検討できる。ただしスレッドプール環境では後始末（remove）が必要だ。 @Service public class UserContextHolder { // ✅ 各スレッドで独立した保存領域 private static final ThreadLocal\u0026lt;Long\u0026gt; currentUserId = new ThreadLocal\u0026lt;\u0026gt;(); public void set(Long userId) { currentUserId.set(userId); // スレッドAがsetしてもスレッドBには影響しない } public Long get() { return currentUserId.get(); } public void clear() { currentUserId.remove(); } } 核心まとめ # シングルトン Bean = 通常コンテナ内にインスタンスが1つ -\u0026gt; 複数のスレッドが共有 インスタンス変数に状態を保存するとレースコンディションが発生 解決策: ステートレスに設計するか、やむを得ない場合はより狭いscopeやThreadLocalなどを検討 2. 一般的なドメインオブジェクトはなぜこのルールに当てはまらないのか？ # 疑問 # 私たちが一般的に定義したドメインはなぜ適用されないのか…？ シングルトンで管理するものではないから、そうなのか。\n回答 # おおむね正しい。ドメインオブジェクト（Entity、VOなど）はSpringコンテナが管理する基本シングルトン Beanではない。 通常はアプリケーションコードがnewで生成するか、JPAがPersistence Context内で管理する。\n// ドメインオブジェクト - Springが管理しない（シングルトンではない） Order order = new Order(1L, \u0026#34;チキン\u0026#34;, 20000); // Spring Bean - シングルトンで管理される @Service public class OrderService { ... } シングルトンで管理されるのはSpring IoC コンテナによってDI（依存性注入）されるものだ。@Component、@Service、@Repository、@Controllerなどのアノテーションが付いたクラスがこれに該当する。\nただし「ドメインオブジェクトは必ずリクエストごとに生成されて、そのスレッドでのみ使われる」と一般化するのは過剰だ。実際のライフサイクルはどこで生成したか、どこに参照を保持しているかによって異なる。それでも一般的なWebリクエスト処理コードでは、Springシングルトン Beanのように共有されるオブジェクトとして扱わない場合がほとんどだ。\n3. StringBufferを使わない理由 - メソッドローカル変数のスレッド安全性 # 疑問 # StringBufferのように同期化が適用されたデータ構造を実際には使わないということは…、通常はメソッド内で1つのスレッドが処理するローカル変数として使用されるからなのか？\n回答 # 正確だ。StringBufferはsynchronizedがかかっているためマルチスレッドでは安全だが、Spring環境での文字列操作はほぼ必ずメソッド内ローカル変数として行われる。\n@Service public class ReportService { public String generateReport(List\u0026lt;Item\u0026gt; items) { // ✅ このsbはこのスレッドの実行フローでのみ使用される // 他のスレッドがアクセスする方法がない StringBuilder sb = new StringBuilder(); for (Item item : items) { sb.append(item.getName()).append(\u0026#34;\\n\u0026#34;); } return sb.toString(); } } ローカル変数なのでそもそも共有されない。 より正確にはStringBuilderオブジェクトはヒープに生成されても、その参照が他のスレッドに共有されない。 同期化する理由自体がないので、不要なsynchronizedオーバーヘッドのあるStringBufferよりもStringBuilderを使う。 4. シングルトンオブジェクトなのにメソッド呼び出しはなぜ安全なのか - JVMスタックの秘密 # 疑問 # シングルトンオブジェクトだとしても1つだけ生成されるだけで、実行時には各JVMで各スレッドに割り当てられたスタックで演算が行われるから、そういうことなのか？\n回答 # 正確だ。核心はJVMのメモリ構造だ。ただしもう少し正確に言うと、オブジェクト自体はヒープにあり、メソッドのローカル変数とパラメーターの参照が各スレッドのスタックフレームに置かれる。\n┌──────────────────────────────────────────────┐ │ Heap │ │ │ │ ┌────────────────┐ │ │ │ OrderService │ ← シングルトン、1つだけ │ │ │ （インスタンス） │ │ │ └────────────────┘ │ │ │ │ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │DTO-1 │ │DTO-2 │ │DTO-3 │ ← リクエストごとに生成 │ │ └──────┘ └──────┘ └──────┘ │ └──────────────────────────────────────────────┘ ┌───────────────┐ ┌───────────────┐ │ Thread A │ │ Thread B │ │ Stack │ │ Stack │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ │createOrder│ │ │ │createOrder│ │ │ │price=1000 │ │ │ │price=2000 │ │ │ │dto (ref) │ │ │ │dto (ref) │ │ │ └───────────┘ │ │ └───────────┘ │ └───────────────┘ └───────────────┘ OrderServiceはヒープ（Heap） に1つだけ存在する。 しかしcreateOrder()メソッドが呼び出されると、その中のローカル変数とパラメーターは各スレッドのスタックフレームに独立して生成される。 シングルトンオブジェクトだとしても、メソッド内ローカル変数はスレッドごとに隔離されているので、まったく問題ない。 @Service public class OrderService { public OrderResponse createOrder(OrderRequest request) { // priceはスレッドAのスタックフレームでのみ使用される -\u0026gt; 安全 int price = request.getPrice(); // dtoオブジェクトはヒープに生成されるが、現在のスレッドの参照でのみ扱えば安全 OrderDto dto = new OrderDto(price); return new OrderResponse(dto); } } 5. WebリクエストスレッドはJVMスレッドと同じか？ # 疑問 # このWebリクエスト用のスレッドとJVMのこのスレッドは同じものなのか…？ 同じものなのか？ それぞれコールスタックを持つ。\n回答 # 従来の同期式Spring MVC/Servlet処理を基準にすると正しい。 Tomcatのスレッドプールにあるスレッドはjava.lang.Threadのインスタンスであり、それぞれJVMが管理する独立したコールスタックを持つ。\nHTTPリクエスト到着 → Tomcatスレッドプールからjava.lang.Threadを1つ割り当て → このThreadがFilter → DispatcherServlet → Controller → Service → Repositoryを呼び出す → すべてこのThreadのコールスタック上で実行 → レスポンス返却後、Threadはプールに返却 Webサーバー（Tomcat）のスレッドとJVMのスレッドは別の存在ではなく、TomcatがJVMのスレッドをプールで管理してリクエストに割り当てる構造だ。\nただしこれは同期式リクエスト処理の説明として理解するのが正しい。\nServlet async（Callable、DeferredResult、WebAsyncTask）を使うと処理スレッドが変わりうる。 @AsyncやReactiveフローでは、1つのリクエストが複数のスレッドにまたがることがある。 そのためThreadLocalベースの状態は非同期境界を越えるときに追加の考慮が必要だ。 6. Entityは状態を変更するのになぜ問題ないのか？ # 疑問 # Entityはどのように処理されるのか？ 値を変更することもあるのに…、なぜ…？ インスタンス変数として使われるんじゃないか？ ああ…、単純にシングルトンじゃないからなのか。\n回答 # 正しい。Entityはシングルトンではないため状態を変更しても問題ない。\n@Entity public class Order { @Id @GeneratedValue private Long id; private String status; // インスタンス変数だが、シングルトンではないのでOK public void cancel() { this.status = \u0026#34;CANCELLED\u0026#34;; // このインスタンスの状態変更 } } リクエストAで取得したOrderインスタンスとリクエストBで取得したOrderインスタンスは互いに異なるオブジェクトであるのが一般的だ。 同じDB行（row）を取得しても、一般的なtransaction-scoped persistence contextでは、各トランザクションが別々のPersistence Contextを持つため、それぞれ異なるEntityインスタンスを扱うことになる。 同時に同じ行を修正する同時実行の問題は、Entityのインスタンス共有の問題ではなく、DBレベルのロック/トランザクション分離の問題だ。 つまりEntityの状態変更自体は自然なことだ。注意すべきは**EntityManagerとPersistence ContextのThread-safetyを前提に共有してはならない**ということだ。\n7. なぜController、Service、RepositoryはシングルトンでDTOとEntityはそうでないのか？ # 疑問 # なぜRepositoryやController、Serviceなどはシングルトンで管理して、DTOのようなものやドメインオブジェクトは…？ この基準は何なのか…？\n回答 # 実務的によく使う基準は**「このオブジェクトが振る舞いを提供するのか、データを持つのか」だ。この区別は学習用としてかなり役に立つ。ただしより厳密なSpringの基準はBeanとして登録されているか、またどのscopeを持つか**だ。\nシングルトンで管理するもの: 振る舞い（ロジック）中心のオブジェクト # @Controller // シングルトン - リクエストをルーティングする「振る舞い」 @Service // シングルトン - ビジネスロジックを実行する「振る舞い」 @Repository // シングルトン - データアクセスを実行する「振る舞い」 @Component // シングルトン - その他インフラロジック これらの共通点は通常状態なしに振る舞いのみを提供することだ。リクエストが100個来てもOrderServiceのcreateOrder()ロジック自体は同じだ。だから1つだけ作っておいて共有しても問題ない。\nシングルトンで管理してはいけないもの: データ（状態）中心のオブジェクト # // DTO - リクエストごとに異なるデータを持つ必要がある OrderRequest request1 = new OrderRequest(\u0026#34;チキン\u0026#34;, 20000); // スレッドAのリクエスト OrderRequest request2 = new OrderRequest(\u0026#34;ピザ\u0026#34;, 30000); // スレッドBのリクエスト // Entity - それぞれ異なる行（row）を表現 Order order1 = new Order(1L, \u0026#34;チキン\u0026#34;, 20000); Order order2 = new Order(2L, \u0026#34;ピザ\u0026#34;, 30000); もしOrderRequestをシングルトンにしたら？\n// ❌ 絶対にこうしてはいけない @Component // シングルトンで登録されると... public class OrderRequest { private String itemName; private int price; // スレッドA: setItemName(\u0026#34;チキン\u0026#34;) // スレッドB: setItemName(\u0026#34;ピザ\u0026#34;) // スレッドA: getItemName() -\u0026gt; \u0026#34;ピザ\u0026#34; ??? -\u0026gt; チキンを注文したのにピザが出てくる } 比較まとめ表 # 区分 シングルトン Bean 毎回生成するオブジェクト 本質 振る舞いを提供 データを持つ 状態 ステートレス（stateless） 状態が存在する（stateful） 例 Controller、Service、Repository DTO、Entity、VO ライフサイクル アプリ起動〜終了 リクエスト処理中または必要な時点にのみ存在 生成コスト 一度だけ作って節約 軽量で、リクエストごとに異なる必要があるため毎回生成 8. 毎回生成するとメモリの無駄遣いではないか？ # 疑問 # シングルトンで管理されないものは継続的に生成される余地があるということだが…、DTOやドメインオブジェクトは…？ このくらいは許容しようということなのか…？ この基準は何なのか…？\n回答 # DTOやEntityはほとんどの場合、寿命が非常に短い。JVM GCはこのような短命（short-lived）オブジェクトを処理することに最適化されているため、リクエストごとにDTOをいくつか作るのはパフォーマンスにほぼ影響しない。\npublic OrderResponse createOrder(OrderRequest request) { Order order = Order.create(request); // 生成 orderRepository.save(order); // 保存 return OrderResponse.from(order); // レスポンスDTO生成 } // メソッド終了後、参照されなくなるとGC対象 これらのオブジェクトは通常短時間で使われ、JVMのYoung Generation GCによって素早く回収される。 一方Service、Repositoryのような Beanは内部に他のBeanへの参照、コネクションプール、設定値などを持っているため初期化コストがはるかに大きい。 こういったものをリクエストごとに作ったら本当に無駄だ。 不変オブジェクトにするとさらに良い # // ✅ 不変DTO - recordを使用（Java 16+） public record OrderRequest(String itemName, int price) {} // ✅ 不変DTO - クラスを使用 public class OrderResponse { private final Long orderId; private final String status; public OrderResponse(Long orderId, String status) { this.orderId = orderId; this.status = status; } // getterのみ提供、setterなし } 不変オブジェクトは生成後に状態が変更されないため、たとえ共有されても安全だ。DTOをできる限り不変にするのが良い習慣だ。\n9. @ConfigurationとCGLIBプロキシ - シングルトン保証の秘密 # 疑問 # AppConfigはCGLIBを使ったプロキシオブジェクトなのか？ @Beanだけを使うとSpring Beanとして登録はされるが、シングルトンは保証されない。\n回答 # @Configuration public class AppConfig { @Bean public MemberRepository memberRepository() { return new MemoryMemberRepository(); } @Bean public MemberService memberService() { return new MemberServiceImpl(memberRepository()); // memberRepository()を呼び出し } @Bean public OrderService orderService() { return new OrderServiceImpl(memberRepository()); // memberRepository()をまた呼び出し } } Javaコードだけ見るとmemberRepository()が2回呼ばれるためインスタンスが2つ生成されそうだが、Springはデフォルトで@Configuration(proxyBeanMethods = true)クラスをCGLIBでプロキシする。\nCGLIBプロキシの動作原理 # memberRepository()呼び出し時: if (SpringコンテナにmemberRepository Beanがすでにあれば) return 既存のBean; ← シングルトン保証 else 新たに生成 → コンテナに登録 → return; memberService()とorderService()が受け取るmemberRepositoryは同じインスタンスだ。 SpringはAppConfigクラスをそのまま使わず、CGLIBライブラリでAppConfigを継承したプロキシクラスを動的に生成する。 このプロキシが@Beanメソッドをインターセプトして、すでにコンテナに登録された Beanがあれば既存のインスタンスを返す。 @Configurationなしで@Beanだけ使うと？ # // ⚠️ @Configurationなしで@Beanだけを使用 @Component // @Configurationではない public class AppConfig { @Bean public MemberRepository memberRepository() { return new MemoryMemberRepository(); } @Bean public MemberService memberService() { // CGLIBプロキシがないので純粋なJavaメソッド呼び出し // -\u0026gt; ここでmemberRepository()を直接呼ぶと新しいオブジェクトが生成される可能性がある return new MemberServiceImpl(memberRepository()); } } この場合も各@Beanが登録されるBean自体のデフォルトscopeは依然としてsingletonだ。 ただしCGLIBプロキシが適用されないため、同じ設定クラス内でmemberRepository()を直接呼び出すとコンテナを迂回する通常のメソッド呼び出しになる。 つまり厳密に言えば「シングルトンデフォルト自体がなくなる」というよりも、@Beanメソッド間の直接呼び出しに対するコンテナの保証が壊れるということだ。 最終まとめ: 一目でわかる核心要約 # Springコンテナ（Heap） ┌─────────────────────────────┐ シングルトン Bean │ Controller ─── Service ─── Repository │ （振る舞い中心、 │ （ステートレス、1つだけ存在、全スレッド共有）│ @Component系） │ │ └─────────────────────────────┘ ↑ 共有使用 ↑ ┌───────┴───────────┴───────┐ │ │ ┌───────────────┐ ┌───────────────┐ │ Thread A │ │ Thread B │ │ （リクエスト#1）│ │ （リクエスト#2）│ │ │ │ │ │ ローカル変数: │ │ ローカル変数: │ │ - DTO-A │ │ - DTO-B │ │ - Entity-A │ │ - Entity-B │ │ （このスレッドのみ│ │ （このスレッドのみ│ │ 使用、GC対象） │ │ 使用、GC対象） │ └───────────────┘ └───────────────┘ データオブジェクト データオブジェクト （状態中心、 （状態中心、 毎回new生成） 毎回new生成） 核心をひとことで # Springがシングルトンで管理する基準は通常「共有しても良いロジック Beanかどうか」に近く、スレッド安全性の核心は結局、共有可変状態を持たないことだ。\n疑問 → 結論の流れまとめ # シングルトンはなぜステートレスなのか？ -\u0026gt; すべてのスレッドが共有するため、状態を持つとレースコンディションが発生する ドメインオブジェクトはなぜ当てはまらないのか？ -\u0026gt; Springの基本シングルトン Beanではないから StringBufferを使わない理由は？ -\u0026gt; メソッド内ローカル変数として使用されるためそもそも共有されない シングルトンなのにメソッドが安全なのはなぜ？ -\u0026gt; メソッド実行は各スレッドのスタック上で独立して行われる WebスレッドはJVMスレッドと同じか？ -\u0026gt; 同期式MVCでは概ね同じだ。ただしasync/reactiveまで一般化してはいけない Entityは状態変更しても問題ないのか？ -\u0026gt; シングルトンではないので問題なく、DBの同時実行性はトランザクションとロックで解決する シングルトンの基準は？ -\u0026gt; 実務的には振る舞いオブジェクト（Serviceなど）vsデータオブジェクト（DTO、Entity）の区別が有用で、厳密にはBean登録の有無とscopeが基準 毎回生成すると無駄なのか？ -\u0026gt; 短命オブジェクトはYoung Gen GCに最適化されている。不変にするとさらに良い @ConfigurationのCGLIBは？ -\u0026gt; @Beanメソッド間の直接呼び出しでもコンテナのセマンティクスを維持させる 参考資料 # Spring Framework Reference - Bean Scopes: https://docs.spring.io/spring-framework/reference/core/beans/factory-scopes.html Spring Framework Reference - Basic Concepts: @Bean and @Configuration: https://docs.spring.io/spring-framework/reference/core/beans/java/basic-concepts.html Spring Framework Reference - @Configuration Classes: https://docs.spring.io/spring-framework/reference/core/beans/java/configuration-annotation.html Spring Framework Reference - Asynchronous Requests: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html The Java Virtual Machine Specification, Chapter 2: https://docs.oracle.com/javase/specs/jvms/se24/html/jvms-2.html Java API - ThreadLocal: https://docs.oracle.com/en/java/javase/26/docs/api/java.base/java/lang/ThreadLocal.html Java API - StringBuffer: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/StringBuffer.html Java API - StringBuilder: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/StringBuilder.html Jakarta Persistence API - EntityManager: https://jakarta.ee/specifications/persistence/4.0/apidocs/jakarta.persistence/jakarta/persistence/entitymanager Jakarta Persistence API - PersistenceContextType: https://jakarta.ee/specifications/persistence/4.0/apidocs/jakarta.persistence/jakarta/persistence/persistencecontexttype ","date":"2026年3月15日","externalUrl":null,"permalink":"/ja/posts/spring-singleton-thread-safety/","section":"Posts","summary":"Springシングルトン Beanのステートレス設計、ローカル変数とスレッド安全性、EntityとPersistence Context、@ConfigurationのCGLIB動作を一度に整理します。","title":"なぜSpringのシングルトンBeanはステートレスでなければならないのか？ スレッド、Entity、@Configurationまで","type":"posts"},{"content":"このドキュメントは疑問に思っていた点をAIエージェントとの対話を通じて整理したメモです。お客さんとバリスタの例を通して、メッセージパッシング、役割と責任、メソッド名と結合度の関係を整理します。\nオブジェクト指向設計：メッセージパッシングと役割・責任 # お客さん（Customer）とバリスタ（Barista）の関係を通じて、オブジェクト指向の設計原則を理解してみる。\nQ1. バリスタがお客さんから注文を受けるという形でメッセージを表現することはできないか？ # ウーアハン技術ブログ オブジェクト指向とメッセージではcustomer.orderCoffee(barista)という形で、\nお客さんがバリスタにメッセージを送る方向で設計されている。\nしかし現実ではバリスタがお客さんの注文を受けるのも事実で、\nそれを逆にしてbarista.takeOrderFrom(customer)のように\nバリスタがお客さんから注文を受け取る方向で設計してはいけないのか？\nどちらの方向も同じ状況を表しているように思えるが、どんな違いがあるのか？\n本質を突いた質問だ。この質問は単にコードの文法を超えて、オブジェクトの役割、責任、協力をどのように分けるかと結びついている。\n結論から言うと、この例では「お客さんがバリスタにコーヒーを注文する」という方向の方が自然だ。\nbarista.takeOrderFrom(customer)のような形式が文法的に不可能なわけではないが、通常は結合度と責任の分離を不利にしやすい。\nなぜ「お客さん」がメッセージを送る主体なのか？ # 最も重要な問いは**「誰が要求し、誰がその要求を処理するのか」**だ。\n現実のカフェを思い浮かべてみよう。\n要求の始まり： コーヒーを必要としているのはお客さんだ。お客さんは「コーヒーを作ってほしい」という**要求（Request）**を送る。 要求の処理： バリスタは要求を受け取り、コーヒーを準備する役割を担う。 GNU Smalltalkのドキュメントは、オブジェクトを外から見ると状態と操作だけが見えるブラックボックスとして説明し、何かをさせるにはそのオブジェクトにメッセージを送る必要があると説明している。Javaのドキュメントもオブジェクトはメソッドを通じて外部と相互作用すると説明している。\nこの基準で見ると、このシナリオはコーヒーを必要とするお客さんがバリスタに要求を送る協力としてモデリングする方が読みやすい。\nお客さんオブジェクト： 注文する(order)というメッセージを送る責任を持つ。 バリスタオブジェクト： コーヒーを作る(makeCoffee)というメッセージを受け取って処理する責任を持つ。 コードで表現すると、お客さんオブジェクトのメソッド内部でbarista.makeCoffee(...)を呼び出す形になる。\npublic class Customer { public void orderCoffee(Barista barista, String coffeeName) { barista.makeCoffee(coffeeName); } } この構造が自然に感じられる理由は、要求を始める背景がお客さんの必要にあるからだ。\n「バリスタが注文を受ける」とすると何が問題か？ # barista.takeOrderFrom(customer)のようなコードを想像できる。このコードが常に間違いとは言えないが、通常は以下のような問題が生じやすい。\n1. 具体型への依存と高い結合度 # バリスタは本来、お客さんが誰であるかを知らなくてよい。ただ「コーヒーの注文」という要求を受けて処理すればよい。 しかしtakeOrderFrom(customer)という形になると、バリスタがお客さんという具体型を知らなければならない。 さらに、お客さんが何を望んでいるのか（customer.getWants()）を直接聞かなければならなくなる可能性もある。 こうなるとバリスタはお客さんという特定の型に依存することになる。 後でお客さんではなくキオスクやモバイルアプリから注文を受けるとなれば\n→ takeOrderFrom(kiosk)、takeOrderFrom(mobileApp)のように広がっていく可能性が大きくなる。\n→ 変更に弱い構造になりやすい。 2. 不要な責任の増加 # バリスタの核心的な責任は**「コーヒーを準備すること」**だ。 ところが注文情報を能動的に収集する責任まで担うと、バリスタの役割の境界が曖昧になる可能性がある。 このような構造はSRPの観点からも責任の分離を難しくする可能性がある。 3. 協力の流れが不自然だ # 出発の要求はお客さん側にあるのに、コードの中心はバリスタオブジェクトになる。 すると要求者と処理者の区別が曖昧になり、この例の協力構造を読みにくくする可能性がある。 2つの観点の比較 # 項目 👍 お客さんが注文する (Customer → Barista) 👎 バリスタが注文を受ける (Barista → Customer) メッセージの主体 お客さん（Client）：要求する側 バリスタが注文者の情報を読み取る必要がある 結合度 低い：バリスタはお客さんを直接知らなくても協力可能 高くなりやすい：バリスタがお客さんの型を知る必要がある 柔軟性・拡張性 高い：キオスクが注文しても要求の形式を保ちやすい 低くなりやすい：注文の主体が増えるほどバリスタのコードが揺れやすい 責任の分離 明確：お客さん（注文）、バリスタ（製造） 曖昧になりやすい：バリスタが注文と製造の責任を一緒に持つ可能性がある 自然さ 現実の要求方向と協力構造がよく合っている 要求者と処理者の役割が不明瞭になりやすい 整理すると、オブジェクト指向設計の核心は現実世界の役割と責任をコードに機械的にコピーすることではなく、\n誰が何を要求し、誰がそれを処理するかを自然に表現することだ。\nこの例では、サービスを要求するお客さんがメッセージを送り、\nサービスを提供するバリスタがそのメッセージを受け取って処理する構造の方が読みやすく、変更にも有利だ。\nQ2. なぜお客さんがバリスタに「コーヒーを製造しろ」というメッセージを送るのか？ # 先ほどお客さんがバリスタにメッセージを送る主体だと言った。\nしかしお客さんも同様に、外部の誰かがお客さんに\n「コーヒーを注文しろ」とメッセージを送ることで始まるのではなく、\nmainや他の実行主体がお客さんのメソッドを直接呼び出して始まる。\nそれなら、わざわざお客さんを経由せずにmainがバリスタに\nbarista.makeCoffee()を直接呼び出してはいけないのか？\nなぜお客さんオブジェクトがバリスタにmakeCoffee()を送る流れの方が自然で、\nまたそのメッセージ名がなぜ「製造せよ（makeCoffee）」なのか？\nこの質問は**「オブジェクトにどんなメッセージを送るか」**を考えるプロセスと直結している。\n結論から言うと、この例ではお客さんがバリスタにmakeCoffee()を要求する流れの方が自然だ。\nただしこれを「お客さんが命令する」という意味で理解すると不自然だ。\nオブジェクト指向でのメッセージ送信は通常**要求（Request）**として理解する方がより正確だ。\nなぜmainがbarista.makeCoffee()を直接呼び出してはいけないのか？ # 厳密に言えば、mainがbarista.makeCoffee()を直接呼び出すコード自体が禁止されているわけではない。\nただしこの例をオブジェクト協力の観点でモデリングするとき、mainが2つのオブジェクトの関係をすべて知って調整するより、お客さんオブジェクトが自分の責任の中でバリスタに要求を送る構造の方が自然だ。\nその理由は以下の通りだ。\nmainが直接調整すると、「お客さんはコーヒーが必要で、その要求はバリスタに行くべき」というドメインルールをmainが代わりに担うことになる。 するとCustomerが持つ**「注文する」**という責任が弱くなりうる。 協力が複雑になるほど、外部の調整者がすべてのオブジェクト関係を把握しなければならず、変更点が大きくなりやすい。 ここで重要なのは実行の最初の呼び出し者とドメインモデルで要求を始めるオブジェクトを区別することだ。\n実際のランタイムではmain、コントローラー、サービス、テストコードが最初の呼び出し者になりうる。 しかしこのシナリオのドメインモデルでは、お客さんがコーヒーを欲しがってバリスタに要求すると解釈する方が自然だ。 核心：メッセージの名前はメッセージを「受け取る」オブジェクトの責任を基準に決まる # お客さんがバリスタにメッセージを送るとき、そのメッセージの名前（makeCoffee）は\nお客さんが勝手に決めるものではない。\nそれはバリスタが外部に公開している操作の一つを呼び出すことに近い。\nOracle Javaのドキュメントは、オブジェクトのメソッドが外の世界と相互作用する主な手段であり、インターフェースはクラスと外の世界の間の契約であると説明している。\nつまり、メッセージ名は通常受け取るオブジェクトがどんな責任を外部に約束しているかを基準に決める方が自然だ。\nバリスタオブジェクトの立場から自分の核心的な責任を考えてみよう # バリスタの最も本質的な責任は**「コーヒーを準備すること」**だ。 したがってバリスタはその責任をよく表す名前で機能を外部に公開する方が自然だ。 makeCoffee() createCoffee() provideCoffee() 逆にtakeOrder()はバリスタが処理過程の一部として持つことはできても、\nこの例でバリスタの核心的な結果責任を最もよく表す名前とは言いにくい。\nお客さんは「注文受付」そのものを望んでいるのではなく、その結果である作られたコーヒーを望んでいる。\nだからお客さんはバリスタの核心機能であるmakeCoffee()を**要求（Request）**すると見るのが自然だ。\n観点の違い：「注文」と「製造」 # 観点 説明 コード表現 お客さんの観点 お客さんは「注文」という行動をする。目的はコーヒーを得ることだ。 customer.orderCoffee(barista, menuItem) バリスタの観点 バリスタは外部からの要求を受け取り「コーヒーを製造する」責任を果たす。 public Coffee makeCoffee(MenuItem item) つまり、お客さんのorderCoffee(...)メソッドの内部でバリスタのmakeCoffee(...)を呼び出す流れになる。\npublic class Customer { public void orderCoffee(Barista barista, String coffeeName) { System.out.println(\u0026#34;お客さん: \u0026#39;\u0026#34; + coffeeName + \u0026#34;\u0026#39; を一杯ください。\u0026#34;); barista.makeCoffee(coffeeName); } } public class Barista { public Coffee makeCoffee(String coffeeName) { System.out.println(\u0026#34;バリスタ: はい、\u0026#39;\u0026#34; + coffeeName + \u0026#34;\u0026#39; を作ります。\u0026#34;); return new Coffee(coffeeName); } } 「聞かずに命じろ（Tell, Don\u0026rsquo;t Ask）」の観点 # この概念は、オブジェクト指向でよく言及される**「Tell, Don\u0026rsquo;t Ask」**ガイドとも結びつく。\n❌ 状態を聞いて外部で判断する方式 # if (barista.isAvailable()) { barista.takeThisOrder(order); } 呼び出し側がバリスタの状態をより多く知らなければならない。 次の行動を呼び出し側で組み合わせることになり、判断の責任が外に漏れやすい。 もちろんisAvailable()のようなメソッドが常に間違いなわけではない。\nただし呼び出し側が受信オブジェクトの内部事情を多く知らないと次の行動を決められないなら、それだけ結合度は高くなりやすい。\n✅ 望む結果を直接要求する方式 # barista.makeCoffee(order); 呼び出し側は望む結果を要求する。 具体的な処理過程と内部判断はバリスタが責任を持つ。 この方式が常に正解とは言えないが、この例ではカプセル化と責任の分離によりよく合っている。\n最終まとめ # メッセージ（メソッド）の名前は通常「受け取るオブジェクト」の責任を最もよく表す方向で決まる。 この例でバリスタの核心的な責任は「製造」なので、公開インターフェースとしてmakeCoffee()を置く解釈が自然だ。 お客さんはバリスタの公開された機能であるmakeCoffee()を要求する。\nこれは命令というより、オブジェクト間の協力における要求として理解する方が正確だ。 barista.takeOrderFrom(customer)のような構造は具体型への依存と責任の混在を高めやすいため、通常はお客さんが要求を送る方向の方が読みやすく変更にも有利だ。 このようにメッセージ一つを決めるときでも各オブジェクトの役割と責任を一緒に考えることが、\n良いオブジェクト指向設計を作る上で重要だ。\n参考資料 # Woowa Brothers 技術ブログ — オブジェクト指向とメッセージ: https://techblog.woowahan.com/2502/ GNU Smalltalk User\u0026rsquo;s Guide — Overview: https://www.gnu.org/software/smalltalk/manual/html_node/Overview.html Oracle Java Tutorials — What Is an Object?: https://docs.oracle.com/javase/tutorial/java/concepts/object.html Oracle Java Tutorials — What Is an Interface?: https://docs.oracle.com/javase/tutorial/java/concepts/interface.html Oracle Java Tutorials — Defining an Interface: https://docs.oracle.com/javase/tutorial/java/IandI/interfaceDef.html ","date":"2026年3月9日","externalUrl":null,"permalink":"/ja/posts/oop-message-passing/","section":"Posts","summary":"お客さんとバリスタの例を通して、誰が要求を始めるのか、なぜメソッド名が受信オブジェクトの責任を基準に決まるのか、なぜ具体型への依存が結合度を高めやすいのかをまとめます。","title":"[OOP] オブジェクト指向設計：メッセージパッシングと役割・責任","type":"posts"},{"content":"","date":"2026年3月9日","externalUrl":null,"permalink":"/ja/tags/design/","section":"Tags","summary":"","title":"Design","type":"tags"},{"content":"","date":"2026年3月9日","externalUrl":null,"permalink":"/ja/tags/encapsulation/","section":"Tags","summary":"","title":"Encapsulation","type":"tags"},{"content":"","date":"2026年3月9日","externalUrl":null,"permalink":"/ja/tags/message-passing/","section":"Tags","summary":"","title":"Message Passing","type":"tags"},{"content":"","date":"2026年3月9日","externalUrl":null,"permalink":"/ja/tags/object-oriented-programming/","section":"Tags","summary":"","title":"Object-Oriented Programming","type":"tags"},{"content":"","date":"2026年3月9日","externalUrl":null,"permalink":"/ja/tags/oop/","section":"Tags","summary":"","title":"OOP","type":"tags"},{"content":"","date":"2026年3月3日","externalUrl":null,"permalink":"/ja/tags/memory-management/","section":"Tags","summary":"","title":"Memory Management","type":"tags"},{"content":"","date":"2026年3月3日","externalUrl":null,"permalink":"/ja/tags/mmu/","section":"Tags","summary":"","title":"MMU","type":"tags"},{"content":"","date":"2026年3月3日","externalUrl":null,"permalink":"/ja/tags/os/","section":"Tags","summary":"","title":"OS","type":"tags"},{"content":"","date":"2026年3月3日","externalUrl":null,"permalink":"/ja/tags/page-fault/","section":"Tags","summary":"","title":"Page Fault","type":"tags"},{"content":"","date":"2026年3月3日","externalUrl":null,"permalink":"/ja/tags/page-table/","section":"Tags","summary":"","title":"Page Table","type":"tags"},{"content":"","date":"2026年3月3日","externalUrl":null,"permalink":"/ja/tags/tlb/","section":"Tags","summary":"","title":"TLB","type":"tags"},{"content":"","date":"2026年3月3日","externalUrl":null,"permalink":"/ja/tags/virtual-memory/","section":"Tags","summary":"","title":"Virtual Memory","type":"tags"},{"content":"このドキュメントは疑問に思っていた点をAIエージェントとの対話を通じて整理したメモです。仮想アドレスが物理アドレスに変換されるフローを基準に、MMU、TLB、ページテーブル、page faultを整理します。\n仮想アドレスはどのように物理アドレスになるのか？ # TLB / MMU / OSメモリ管理者（MMS）\n目次 # MMUとTLBの役割と違い ページングシステムとページテーブルの問題 TLBの詳細説明 Context SwitchとTLB MMU vs TLBの比較まとめ OSメモリ管理者（MMS） 1. MMUとTLBの役割と違い # 全体像 — ハードウェア構造 # CPU ┌─────────────────────────────────────┐ │ ALU / Register / Control Unit │ │ │ │ ┌─────────────────────────────┐ │ │ │ MMU │ │ │ │ ┌───────────────────────┐ │ │ │ │ │ TLB (変換キャッシュ) │ │ │ │ │ └───────────────────────┘ │ │ │ │ ページテーブルウォークロジック │ │ │ └─────────────────────────────┘ │ │ │ │ ┌───────────────┐ │ │ │ L1/L2 Cache │ │ │ └───────────────┘ │ └──────────────┬──────────────────────┘ │ 物理アドレス ▼ [ RAM (DRAM) ] MMUはCPU側のハードウェアアドレス変換ユニットだ。\n説明の便宜上「CPUとメモリの間」と言うことがあるが、実際にはCPUに統合されたアドレス変換ハードウェアと理解する方が近い。\nTLBはその中で最近の変換結果をキャッシュする小さなハードウェアキャッシュだ。\n2. ページングシステムとページテーブルの問題 # ページサイズを小さくすると？ # 非連続なメモリ割り当てポリシーでページングシステムを使う場合、\nページサイズを小さくすると内部断片化は減るがページテーブルが巨大になる問題が発生する。\n計算例 # 以下は32bitアドレス空間、4KBページ、エントリ4byte、単一レベルページテーブルを単純に仮定した計算だ。\n32bitアドレス空間、4KBページ基準 → ページ数 = 2^32 / 2^12 = 2^20 = 約100万エントリ → エントリ1つあたり4byte → ページテーブル1つ = 4MB → プロセス100個 = 400MBがページテーブルだけで消費 現代のシステムはアドレス空間がより大きく、実際には使用していない区間も多い。\nそのため階層型（多段階）ページテーブルを使用してページテーブルのメモリ無駄を減らす。\nパフォーマンス問題 — page table walkのコスト # ページテーブル自体はメモリにある。\nしたがってTLB missが発生するとMMUはpage table walkを実行して必要なエントリを読み込む必要がある。\n単一レベルのテーブルのみを仮定すると「ページテーブル1回 + 実際のデータ1回」のように説明できる。 しかし現代のCPUは多段階ページテーブルを使うため、TLB miss時に追加のメモリ参照が必要になる場合がある。 実際のコストはCPU cache、page walk cacheの有無によって変わる。 つまり核心は**「メモリアクセスが常に正確に2倍」**ではなく、\n**「TLB missはコストが高く、だから変換キャッシュが重要だ」**という点だ。\n3. TLB (Translation Lookaside Buffer) # 役割 # 最近使用された仮想アドレス → 物理アドレス変換結果をキャッシュするハードウェアキャッシュ。\n動作フロー # CPUが仮想アドレスを生成 │ ▼ ┌───────────┐ │ TLB参照 │──── TLB Hit ──→ 物理アドレスを即時返却 └───────────┘ │ TLB Miss ▼ MMUがPage Table Walkを実行 （メモリにあるページテーブルを巡回） │ ▼ 物理アドレスを取得 → TLBに保存 → 返却 TLBの特徴 # 項目 内容 保存対象 最近使用した仮想アドレス → 物理アドレス変換結果 位置 MMUが活用する小さなハードウェア変換キャッシュ サイズ/構造 実装依存。一般的に非常に小さく高速 効果 page table walkを減らしてアドレス変換の遅延を下げる 実装によってはinstruction TLB / data TLBが分離されていたり、\n複数段階のTLBが存在する場合もある。\nTLBが効果的な理由 # 時間的局所性 + 空間的局所性のおかげだ。\n同じページや隣接するページへの繰り返しアクセスパターンが多いため、\n非常に小さな変換キャッシュだけでもpage table walkを大幅に減らすことができる。\n4. Context Switch時のTLB # プロセスやアドレス空間が変わると、既存のTLBエントリをそのまま使えない状況が生まれる。\nただしこのとき常に全TLBをflushしなければならないわけではない。\n2つの処理方式 # 方式 説明 デメリット TLB Flush アドレス空間切り替え時に関連エントリを削除 切り替え直後にTLB missが増える ASID (Address Space ID) TLBエントリにアドレス空間識別子をタグ付け 識別子数と管理ポリシーの制約がある つまり、アドレス空間の切り替え = 必ず全TLBの初期化と覚えるより、\nアーキテクチャがアドレス空間識別子をサポートするかどうかによってflush戦略が変わると理解する方が正確だ。\n5. MMU vs TLBの一行まとめ # MMU TLB 役割 アドレス変換全体を担当 よく使う変換結果のキャッシュ 位置 CPUのアドレス変換ハードウェア MMUが活用する変換キャッシュ 実装の性格 table walk、権限チェック、fault発生などを含む 実装依存の小さなオンチップキャッシュ なければ 仮想アドレスを物理アドレスに自動変換しにくい page table walkのコストが増えてパフォーマンス低下 結論： MMUはアドレス変換という機能全体を担うユニットであり、\nTLBはその中でパフォーマンスのボトルネックを解決するために存在するキャッシュコンポーネントだ。\nTLBはMMUの一部として理解すればよい。\n6. OSメモリ管理者（MMS） # 標準用語の整理 # 「MMS」はLinux/Windowsの公式ドキュメントで代表的な標準略語として広く使われるというよりも、\n文脈上OSのメモリ管理サブシステムを指す表現として理解する方が安全だ。\nLinuxは通常Memory Management（MM）またはVM subsystemの文脈で説明する。 WindowsはMemory Managerという名称を使う。 ハードウェア vs ソフトウェアのメモリ管理の区分 # [ プロセス ] → 仮想アドレスを生成 │ ▼ [ MMU / TLB ] ← ハードウェア アドレス変換担当 │ │ Page Fault発生時に例外 ▼ [ OSメモリ管理者 ] ← ソフトウェア、カーネル内部 ページテーブル管理、ページ置き換え、スワップ、fault処理担当 │ ▼ [ 物理メモリ（RAM）+ スワップ領域（ディスク）] OSメモリ管理者が行うこと # 1. ページテーブルの作成と管理 # プロセスごとのアドレス空間に合わせてページテーブルを作成・維持する。 一般的な汎用OSではOSまたはハイパーバイザーがテーブルを管理し、MMUはその結果を使って変換を実行する。 2. Page Faultの処理 # MMUが非常駐ページやアクセス権限違反などの条件でpage fault例外を発生させる場合がある。 OSはその原因を確認してページをロードするか、権限エラーなら例外を伝えるかプロセスを終了させる。 正常な場合はページテーブルを更新した後、命令を再実行させる。 3. スワップ（Swap）管理 # 物理メモリが不足すると、どのページをディスクに退避させるかを決める。 ページ置き換えアルゴリズムを使ってメモリの圧迫を緩和する。 この部分はパフォーマンスに大きな影響を与えるOSのポリシー領域だ。 4. メモリ割り当て（malloc、mmap）の背後 # ユーザープログラムがメモリを要求したとき、カーネルは仮想アドレス空間を確保してマッピングポリシーを決める。 実際の物理フレームの割り当ては最初のアクセス時点まで遅らせる場合がある。これがdemand pagingの文脈だ。 5. 共有メモリ / Copy-on-Write（CoW） # fork()のような操作では、最初からすべてのページをコピーせずに共有できる。 その後どちらかが書き込みを試みたときだけコピーして、メモリの無駄を減らす。 OS別メモリ管理者の名称 # OS メモリ管理者の名称 Linux MM subsystem / VM subsystem（mm/） Windows Memory Manager（Mm、MmXxx関数群） Linuxカーネルソースのmm/ディレクトリには\npage_alloc.c、swapfile.c、mmap.cのようなコードが入っている。\nMMU vs OSメモリ管理者の比較 # MMU OSメモリ管理者 種類 ハードウェア ソフトウェア（カーネル） 役割 アドレス変換の実行、権限チェック、faultシグナル 変換規則（ページテーブル）の作成、置き換え/スワップポリシーの決定、fault処理 時間特性 メモリアクセス経路の非常に高速なハードウェア処理 fault発生時のはるかに重いカーネル処理 比喩 通訳者 通訳者に辞書とルールを提供する人 核心まとめ：\nMMUは**メカニズム（Mechanism）を担当し、\nOSメモリ管理者はポリシー（Policy）**を担当する。\n参考資料 # Arm Learn the Architecture - Memory management: https://developer.arm.com/-/media/Arm%20Developer%20Community/PDF/Learn%20the%20Architecture/LearnTheArchitecture-MemoryManagement-101811_0100_00_en.pdf Linux Kernel Documentation - Page Tables: https://docs.kernel.org/6.9/mm/page_tables.html Linux Kernel Documentation - Cache and TLB Flushing Under Linux: https://www.kernel.org/doc/html/latest/core-api/cachetlb.html Intel - Machine Check Error Avoidance on Page Size Change: https://www.intel.com/content/www/us/en/developer/articles/technical/software-security-guidance/technical-documentation/machine-check-error-avoidance-on-page-size-change.html Microsoft Learn - Windows kernel-mode memory manager: https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/windows-kernel-mode-memory-manager ","date":"2026年3月3日","externalUrl":null,"permalink":"/ja/posts/memory-management-study/","section":"Posts","summary":"MMUとTLBの関係、階層型ページテーブルが必要な理由、context switchとTLBの無効化、page faultとOSメモリ管理者の役割を一度に整理します。","title":"仮想アドレスはどのように物理アドレスになるのか？ MMU、TLB、ページテーブル、page fault","type":"posts"},{"content":"このドキュメントは疑問に思っていた点をAIエージェントと対話しながらまとめたメモです。HTTPのステートレス性と接続の再利用、gRPCのHTTP/2使用理由、HTTP/2多重化とJava NIO多重化の違いをまとめています。\nHTTP / gRPC / 多重化の整理 # 1. HTTPのステートレス性とTCP接続管理の関係 # HTTPのステートレス性とは？ # RFC基準でHTTPの核心的な性質は**ステートレス性（statelessness）**です。\nつまり、各リクエストは前のリクエストとは独立して理解できるものでなければならず、サーバーが前のリクエストのコンテキストを必ず覚えている必要はありません。\nこれはTCPを毎回切断するという意味ではありません。\nHTTPのステートレス性はアプリケーションのセマンティクスであり、TCP接続の再利用有無は**接続管理（connection management）**の問題です。\nTCP接続のライフサイクル — HTTPバージョン別 # HTTP/1.0（初期） # クライアント サーバー |-- TCP 3-way handshake --\u0026gt;| |-- HTTP リクエスト -------\u0026gt;| |\u0026lt;-- HTTP レスポンス --------| |-- TCP 終了 (4-way) ------\u0026gt;| ← リクエストごとに接続/切断 リクエスト/レスポンスのペアごとにTCPを新たに確立して切断していました。非効率。\nHTTP/1.1 — Persistent Connectionがデフォルト # クライアント サーバー |-- TCP 3-way handshake --\u0026gt;| |-- リクエスト1 -----------\u0026gt;| |\u0026lt;-- レスポンス1 ------------| |-- リクエスト2 -----------\u0026gt;| ← 同じTCP接続を再利用 |\u0026lt;-- レスポンス2 ------------| |-- リクエスト3 -----------\u0026gt;| |\u0026lt;-- レスポンス3 ------------| |-- TCP 終了 -------------\u0026gt; | ← 一定時間後または明示的な終了 HTTP/1.1からはpersistent connectionがデフォルトです。\n別途Connection: closeを送らない限りTCP接続を維持します。\nHTTP/2、HTTP/3 # HTTP/2: 1つのTCP接続で多重化（複数のリクエストを並列に） HTTP/3: TCPの代わりにQUIC（UDP基盤）を使用し、接続のオーバーヘッドをさらに削減 バージョン別比較 # HTTP/1.0 HTTP/1.1 HTTP/2 デフォルト動作 リクエストごとにTCPを再生成 TCPを再利用（keep-aliveがデフォルト） 1つのTCPで複数リクエスト Keep-Alive オプション（ヘッダーの明示が必要） デフォルト値 別途のConnection: keep-aliveという概念ではなく、持続接続がデフォルト 核心ポイント # ステートレス性 = サーバーが各リクエストを独立して処理する（stateless） Keep-Alive = TCPの物理接続を再利用する（persistent connection） 両者は異なる次元の概念です。ステートレス性（HTTPのセマンティクス）とKeep-Alive（TCP接続管理）は互いに独立しています。 2. gRPCの通信プロトコル # レイヤー構造 # ┌─────────────────────────────┐ │ gRPC（アプリケーション）│ ← サービス/メソッド定義（proto） ├─────────────────────────────┤ │ HTTP/2 │ ← gRPCを運ぶアプリケーションプロトコル ├─────────────────────────────┤ │ TLS（任意、通常推奨） │ ← 暗号化 ├─────────────────────────────┤ │ TCP │ ← 接続 └─────────────────────────────┘ 標準gRPCはHTTP/2上にマッピングされるRPC（Remote Procedure Call）フレームワークです。\nTLSは実務では非常に一般的ですが、gRPC自体がTLSを強制しているわけではありません。\ngRPCがHTTP/2を使う理由 # ① 多重化\n1つのTCP接続で: Stream 1: リクエストA ─────────────► レスポンスA Stream 3: リクエストB ──────────────────────► レスポンスB Stream 5: リクエストC ───────────────────────────► レスポンスC 複数のRPC呼び出しが同時に1つの接続を共有します。\n参考として、クライアントライブラリのchannelは実装によって複数のHTTP/2接続を管理することがあります。\nただし個々のRPC呼び出しはHTTP/2のstreamとして配信されると理解しておくとよいです。\n② ストリーミングのサポート\nHTTP/2のストリームの概念のおかげで、gRPC固有の4種類の通信パターンが可能になります。\ngRPCの4種類の通信パターン # 1. Unary（通常のリクエスト/レスポンス） # クライアント ──リクエスト1件──► サーバー ◄─レスポンス1件── 形式的に最も馴染みのあるリクエスト/レスポンスパターンです。\n2. Server Streaming # クライアント ──リクエスト1件──► サーバー ◄─レスポンス1── ◄─レスポンス2── ◄─レスポンス3── ◄─END── 例: リアルタイム株価、ログのストリーミング\n3. Client Streaming # クライアント ──リクエスト1──► サーバー ──リクエスト2──► ──リクエスト3──► ◄─レスポンス1件── 例: ファイルアップロード、センサーデータのバッチ送信\n4. Bidirectional Streaming # クライアント ──リクエスト1──► サーバー ◄─レスポンス1── ──リクエスト2──► ◄─レスポンス2── （双方向自由に） 例: チャット、リアルタイムゲーム、自動運転のテレメトリー\nデータのシリアライズ: Protocol Buffers # 実務でRESTがJSONとよく使われるように、gRPCは基本的にProtobufを使用します。\nただしRESTがJSONだけに縛られないのと同様に、gRPCも他のデータ形式に拡張できます。\nservice TelemetryService { rpc SendData (stream TelemetryData) returns (Ack); } message TelemetryData { float speed = 1; float latitude = 2; float longitude = 3; } JSON比での一般的なメリット:\nサイズが小さくなる可能性が高い（バイナリ） パース/シリアライズのパフォーマンスが有利なケースが多い スキーマベースなので型安全性とコード生成に有利 REST vs gRPC 比較 # 注意: RESTはプロトコル名ではなくアーキテクチャスタイルです。以下の表は厳密な定義の比較というよりも、実務でよく見かける組み合わせを比較したものです。\nREST gRPC プロトコル HTTPセマンティクス上（HTTP/1.1、HTTP/2、HTTP/3など） 標準gRPCはHTTP/2 データ形式 JSONが一般的 Protobufがデフォルト 通信パターン リクエスト/レスポンス中心 4種類のパターン インターフェース定義 OpenAPIなど任意 .protoを使うのが一般的 ブラウザのサポート 広い オリジナルgRPCは制限的、通常grpc-webを使用 用途 公開API、汎用連携 サービス間通信、低レイテンシ/ストリーミング 3. HTTP/2 多重化の詳細 # HTTP/1.1の問題: HOL Blocking # HTTP/1.1では同じ接続上でレスポンスをリクエストの順番に合わせて返さなければならないため、前のリクエストが遅延すると後のリクエストも遅延しやすくなります。\n過去のpipeliningもこの問題を根本的に解決できませんでした。\nTCP接続1: リクエストA ──► [レスポンスAを待ち中...] ──► リクエストB ──► リクエストC リクエストAのレスポンスが遅れるとB、CがすべてブロッキングされるのがこれがHOL Blockingです。\nブラウザはこれを回避するために通常複数の並列TCP接続を開いて対処してきました。\nHTTP/2の核心概念 3つ # 1. Stream（ストリーム） # 1つのTCP接続の中に作られる独立した仮想チャネル。\n各ストリームは固有のStream IDを持ちます（奇数: クライアント開始、偶数: サーバー開始） 同時に複数のストリームが存在できます 2. Frame（フレーム） # HTTP/2の最小転送単位。すべてのデータはフレームに分割されて転送されます。\n┌──────────────────────────────────────┐ │ Length (3B) │ Type (1B) │ Flags (1B)│ ├──────────────────────────────────────┤ │ Stream ID (4B) │ ├──────────────────────────────────────┤ │ Payload ... │ └──────────────────────────────────────┘ 主なフレームタイプ:\nHEADERS: HTTPヘッダーを転送 DATA: 実際のbodyデータ SETTINGS: 接続設定 WINDOW_UPDATE: フロー制御 3. Message（メッセージ） # 1つのHTTPリクエストまたはレスポンス。複数のフレームの集まりとして表現されます。\nリクエスト1件 = HEADERSフレーム + DATAフレーム（群） + 必要に応じてtrailing HEADERS 多重化の動作原理 # 1つのTCP接続でフレームが混在して転送され、Stream IDによってどのリクエストのフレームかを区別します。\n時間 → ──[S1:HEADERS]──[S3:HEADERS]──[S1:DATA]──[S5:HEADERS]──[S3:DATA]──[S1:DATA]──► ↑ ↑ ↑ ↑ リクエストA開始 リクエストB開始 リクエストAのbody リクエストC開始 受信側はStream IDを見て各リクエストに再組み立てします。\nHOL Blockingの解決 # HTTP/1.1: リクエストA [==============遅いレスポンス==============] リクエストBは待機... HTTP/2: Stream1（リクエストA）: [==============遅いレスポンス==============] Stream3（リクエストB）: [==速いレスポンス==] ← リクエストAを待たない！ Stream5（リクエストC）: [====レスポンス====] ← 独立して処理 追加機能 # 優先度シグナル: 以前はストリームごとに重みと依存関係を送ることができましたが、このpriority signalingは最新のHTTP/2 RFCでdeprecatedになりました。\nフロー制御（Flow Control）: ストリーム単位だけでなく接続全体単位でも受信ウィンドウを調節します。WINDOW_UPDATEフレームで制御します。\n限界: TCPレベルのHOL Blockingは依然として存在 # TCPパケットロスが発生した場合: パケット1 (Stream1 DATA) ✓ パケット2 (Stream3 DATA) ✗ ← 損失！ パケット3 (Stream5 DATA) ✓ ← 再送信待ち... 全ストリームがブロッキング HTTP/2はHTTPレイヤーのHOL Blockingは解決 TCPパケットロス時に全ストリームが止まる問題は依然として存在 これを解決したのがHTTP/3 + QUIC（UDP基盤） 4. HTTP/2多重化 vs Java NIO I/O多重化 # 共通した哲学 # 「1つの主体が複数のチャネルを同時に処理する」\n名前は同じですが、異なるレイヤーの概念です。\n比較 # HTTP/2 多重化 Java NIO I/O 多重化 レイヤー アプリケーションレイヤー（HTTP/2がTCP上で動作） OS/ランタイムレイヤー 多重化の対象 1つのTCP接続内のストリーム群 複数のTCP接続（ソケット）群 核心的な問題 複数のリクエストを1つの接続で処理 複数の接続を1つのスレッドで処理 Java NIO I/O多重化 # 従来の方式（Blocking I/O） # スレッド1 ──────► ソケットA（接続を待ち中... blocked） スレッド2 ──────► ソケットB（データを待ち中... blocked） スレッド3 ──────► ソケットC（データを待ち中... blocked） 接続1本に対してスレッド1本。接続が1万あればスレッドも1万 → メモリ/コンテキストスイッチングが爆発\nNIOの方式 # ┌── ソケットA（イベントがあるときだけ通知） Selector（1個）────┼── ソケットB（イベントがあるときだけ通知） スレッド1個 └── ソケットC（イベントがあるときだけ通知） SelectorはSelectableChannelを扱うJavaの多重化器です。内部的にはプラットフォームごとのI/O多重化メカニズム（select、poll、epoll、kqueueなど）に依存して動作します。\nソケットを登録しておくと、イベントが発生したソケットだけを選んで通知してくれます。\nSelector selector = Selector.open(); socketA.register(selector, SelectionKey.OP_READ); socketB.register(selector, SelectionKey.OP_READ); socketC.register(selector, SelectionKey.OP_READ); while (true) { selector.select(); // イベントのあるソケットが出るまで待機 for (SelectionKey key : selector.selectedKeys()) { if (key.isReadable()) { SocketChannel ch = (SocketChannel) key.channel(); ch.read(buffer); } } } スレッド1つで何千ものソケットを処理できます。\n2つの概念の位置 — 具体的なシナリオ # クライアント3人がサーバーに接続してそれぞれ複数のリクエストを送る状況:\n① TCP接続（ソケット）が3つできる\nクライアント1 ──────── ソケットA ──┐ クライアント2 ──────── ソケットB ──┤── サーバー クライアント3 ──────── ソケットC ──┘ ② NIO多重化 = ソケット3つを監視する問題\nソケットAにデータが来たか？ ソケットBにデータが来たか？ ← これをスレッド1つで監視 ソケットCにデータが来たか？ NIO多重化の関心事 = 複数のソケット（接続）をいかに効率よく監視するか\n③ HTTP/2多重化 = 各ソケット内の問題\nソケットA（クライアント1とのTCP接続）内に: ├── Stream 1: GET /user ├── Stream 3: GET /posts └── Stream 5: GET /images HTTP/2多重化の関心事 = 1つのソケット内で複数のリクエストをいかに同時に処理するか\n④ 全体像（実際のフレームワークでは2つの概念が一緒に動作する）\nサーバー Selector（スレッド1つでソケット3つを監視） ← NIO多重化 │ ├── ソケットA内 → Stream1、Stream3、Stream5 ← HTTP/2多重化 ├── ソケットB内 → Stream1、Stream3 └── ソケットC内 → Stream1 一言まとめ # 対象 役割 NIO多重化 複数のソケット（接続） スレッド1つで複数のソケットを外から監視 HTTP/2多重化 複数のストリーム（リクエスト） 1つのソケット内を複数のレーンに分ける Netty、Spring WebFluxのようなフレームワークはこの2つの概念を合わせて活用しています。\n参考資料 # RFC 9110 — HTTP Semantics: https://www.rfc-editor.org/rfc/rfc9110 RFC 9112 — HTTP/1.1: https://www.rfc-editor.org/rfc/rfc9112.html RFC 9113 — HTTP/2: https://www.rfc-editor.org/rfc/rfc9113 RFC 9114 — HTTP/3: https://www.rfc-editor.org/rfc/rfc9114 gRPC Docs — Core concepts, architecture and lifecycle: https://grpc.io/docs/what-is-grpc/core-concepts/ gRPC Blog — gRPC on HTTP/2: https://grpc.io/blog/grpc-on-http2/ gRPC Docs — Basics tutorial for Web: https://grpc.io/docs/platforms/web/basics/ gRPC Blog — The state of gRPC in the browser: https://grpc.io/blog/state-of-grpc-web/ Oracle Java API — SelectableChannel: https://docs.oracle.com/javase/8/docs/api/java/nio/channels/SelectableChannel.html ","date":"2026年2月25日","externalUrl":null,"permalink":"/ja/posts/http-grpc-multiplexing/","section":"Posts","summary":"HTTPのステートレス性と接続の再利用がどう違うのか、gRPCがなぜHTTP/2を使うのか、HTTP/2の多重化とJava NIO I/O多重化は何が違うのかを一度に整理します。","title":"[Network] HTTP、gRPC、多重化の整理 — ステートレス性、HTTP/2、Java NIO","type":"posts"},{"content":"","date":"2026年2月25日","externalUrl":null,"permalink":"/ja/tags/grpc/","section":"Tags","summary":"","title":"GRPC","type":"tags"},{"content":"","date":"2026年2月25日","externalUrl":null,"permalink":"/ja/tags/http/","section":"Tags","summary":"","title":"HTTP","type":"tags"},{"content":"","date":"2026年2月25日","externalUrl":null,"permalink":"/ja/tags/http/2/","section":"Tags","summary":"","title":"HTTP/2","type":"tags"},{"content":"","date":"2026年2月25日","externalUrl":null,"permalink":"/ja/tags/http/3/","section":"Tags","summary":"","title":"HTTP/3","type":"tags"},{"content":"","date":"2026年2月25日","externalUrl":null,"permalink":"/ja/tags/java-nio/","section":"Tags","summary":"","title":"Java NIO","type":"tags"},{"content":"","date":"2026年2月25日","externalUrl":null,"permalink":"/ja/tags/multiplexing/","section":"Tags","summary":"","title":"Multiplexing","type":"tags"},{"content":"このドキュメントは疑問に思っていた点をAIエージェントとの対話を通じて整理したメモです。JavaがプラットフォームIndependenceを得る方法と、JVM、GC、JIT、JDK/JREの役割を一つの流れで整理します。\nJavaはなぜ一度コンパイルすれば複数のOSで実行できるのか？ # 1. JavaがJVMで得るメリット # \u0026ldquo;Write Once, Run Anywhere\u0026rdquo; # Javaのソースコードをコンパイルすると.classファイル（バイトコード）が生成される。\nこのバイトコードは特定のOS/CPUに依存しない中間言語だ。\nJavaソースコード(.java) ↓ javacコンパイル バイトコード(.class) ← OSに依存しない中間言語 ↓ JVM実行 各OSで動作 2. 「JVMもOSに合わせてインストールが必要じゃないの？」→ そうだ！ # ここで核心となる概念が出てくる。\n役割 誰が負担する？ OS依存性の処理 JVM（JVMがOS別に別々に存在する） OSを意識する主体 開発者 → JVMへ移転 つまり、開発者がOSを意識していた負担をJVMの開発会社（Oracleなど）が代わりに引き受ける構造だ。\n[Windows JVM] ┐ [Mac JVM] ├──── 同じ.classバイトコードを実行 [Linux JVM] ┘ シンプルな例のレベルでは.classファイル1つを持ち歩くと考えてもよい。\nただし実際のデプロイでは互換Javaバージョン、依存ライブラリ、ネイティブモジュールの有無も合わせる必要がある。\nつまり「OS独立性」は大きく得られるが、「どんな環境でも必ず動く」まで意味するわけではない。\n3. Python、Cは？ そのまま実行できないの？ # Cの場合 — プラットフォームごとに再コンパイルが必要 # 同じCコードでも： → Windowsでコンパイル → Windows実行ファイル(.exe) → Macでは実行不可 → Macでコンパイル → Mac実行ファイル → Windowsでは実行不可 Cはソースコードを各OS/CPU向けの機械語に直接変換するため、\nデプロイ時にプラットフォームごとに別々にビルドする必要がある。\nオープンソースプログラムで「Windowsバージョン / Macバージョン」を別々に提供する理由がこれだ。\nPythonの場合 — インタープリター/ランタイムが実行を担当 # Pythonソースコード(.py) ↓ Pythonの実装（CPythonなど） Pythonバイトコード(.pycなど、内部キャッシュ) ↓ インタープリター実行 各OSで動作 Pythonもプラットフォーム別のインタープリターをインストールする必要があるという点ではJavaと似ている。\nしかし構造が「ほぼ同じ」とは言いにくい。\nCPythonはソースを内部的にバイトコードにコンパイルしてから実行する。 このバイトコードはCPythonの実装とバージョンに依存する内部表現に近い。 一方JavaのJVMバイトコードはJVM仕様によって標準化された配布フォーマットだ。 そのためPythonは通常ソースコード（.py）やwheelのようなパッケージで配布し、\nJavaは.class/.jarのようなJVMバイトコードの成果物を配布することが多い。\n言語別の比較まとめ # 配布単位 OS依存性の処理 特徴 C プラットフォーム別実行ファイル 開発者が直接行う 最も高速、移植性が低い Java バイトコード（.class） JVMが処理 移植性が高い、JVMのインストールが必要 Python ソースコード（.py）、パッケージ インタープリター/ランタイムが処理 便利だが実装ごとに差異がある、インタープリターのインストールが必要 結局**「OS依存性を誰が処理するか」の違い**だ。\nJavaの革新は「開発者がプラットフォームを意識しなくてよいように、その責任をJVMに移したこと」だ。\n4. JVMの革新 # プラットフォーム独立性が最も有名だが、JVMがもたらした革新は複数ある。\n革新1. プラットフォーム独立性（最も有名） # \u0026ldquo;Write Once, Run Anywhere\u0026rdquo;\n上で説明したものと同じ。\n革新2. 自動メモリ管理（Garbage Collection） # C/C++開発者が最も苦労していた一つがメモリを直接管理しなければならないことだった。\n// Cでは開発者が直接解放する必要がある int* arr = malloc(100); // ... 使った後に free(arr); // これを忘れると？ → メモリリーク（Memory Leak） JVMは**GC（Garbage Collector）**が使われていないメモリを自動的に回収する。\n開発者がfree()のようなものを意識する必要がなくなる。\n生産性と安定性の面で非常に大きな革新だった。\n革新3. バイトコード検証と歴史的なサンドボックスモデル # バイトコードはJVMの上でのみ実行されるため、JVMはロード/リンクの過程でclassファイルを検証できる。\nclassファイルのロード ↓ 形式チェック + バイトコード検証 型安全性/基本的な整合性の確認 ↓ 実行 この検証プロセスは不正なバイトコード、不正な型の使用、スタックアンダーフロー/オーバーフローのような問題を減らす上で重要だった。\nつまり、Javaセキュリティの一つの柱は「JVMがどんなバイトコードでもそのまま実行するわけではない」という点だ。\n初期のJavaにはこれに加えてSecurity Managerベースのサンドボックスがあり、\nAppletのようなリモートコードを制限された権限で実行できた。\nただしこれは歴史的な文脈として理解するのが正しい。\nJava 9（2017）: Applet API deprecated JDK 11（2018）: ブラウザプラグイン、Applet Viewer、Java Web Start削除 Java 17（2021）: Applet API / Security Manager 削除予定（deprecated for removal） JDK 24（2025）: Security Manager 永続的に無効化 そのため今日のJavaを説明する際に**「サンドボックスが核心」と言うと、やや昔の説明に近くなる。\n現在も重要なのはバイトコード検証、型安全性、メモリ安全性**の方だ。\n革新4. JITコンパイル（Just-In-Time） # 初期のJVMはバイトコードを1行ずつ解釈していたため遅かったが、後にJITコンパイラーが導入された。\nバイトコード ↓ インタープリターで実行 + プロファイリング ホットコードを検出 ↓ JITが機械語にコンパイル その後より高速に実行 「頻繁に実行されるコードは実行中に機械語に最適化しよう」というアイデアだ。\n現代のJVMはここにtiered compilationのような技法も使う。\nおかげでJavaは多くのワークロードで高いパフォーマンスを出せるようになった。\nつまり移植性 + ランタイム最適化を両立させた。\n革新5. JVM自体がエコシステムになった # JVMという実行環境が標準化されたことで、Java以外の他の言語もJVM上で動くように作られ始めた。\nKotlin ┐ Scala ├──── すべてJVMの上で実行される（Javaのエコシステムをそのまま活用） Groovy ┘ 特にKotlin/JVMはJavaとの高レベルな相互運用性を提供するため、Javaライブラリをそのまま活用できる。\nJVMが一つの汎用実行プラットフォームになったわけだ。\nただしAndroidについては一点区別が必要だ。\nGoogleはKotlinをAndroid開発で公式サポートしている。 しかしAndroidアプリの最終ランタイムは**標準JVMではなくART（Android Runtime）**だ。 ビルドの過程でJava/Kotlinの.classバイトコードはDEXバイトコードに変換される。 つまり「KotlinがAndroidでJVMの上でそのまま実行される」という言い方は厳密には正確ではない。\nそれでもJavaとの相互運用性 + 既存エコシステムの再利用性が非常に大きいことは確かだ。\n革新の優先度まとめ # 順位 革新 一行サマリー ★ プラットフォーム独立性 開発者がOSを意識しなくてよくなった ★ GC（自動メモリ管理） メモリバグから解放 ★ JVMのエコシステム化 Kotlinなど他の言語もJavaの資産を活用 ☆ バイトコード検証/歴史的サンドボックス 型安全性と制限実行モデル ☆ JITコンパイル 移植性とランタイムパフォーマンスの最適化 プラットフォーム独立性が最も有名で象徴的な革新なのは確かだが、\n実際の業務でJavaを選ぶ理由はGC + 成熟したエコシステム + JITパフォーマンスが複合的に作用する。\n5. JVMのインストール方法 # brew install openjdk@21をすると何がインストールされるか？ # openjdk@21 └── JDK ├── JVM ← バイトコード実行エンジン ├── 標準クラスライブラリ/ランタイム構成要素 └── 開発ツール ← javac、javadoc、jdbなど Homebrewのopenjdk@21はJDK 21の配布版だ。\n昔の資料のようにJVM / JRE / JDKを別々のボックスで分けて説明するのは、概念の区別としては有効だ。\nしかし現在の基準では注意が必要だ。\nJDKはJVMを含む上位概念だ。 JREはJVM + 実行用ライブラリという伝統的な概念だ。 しかしJDK 11（2018）以降は別途JREイメージがなくなった。 そのためopenjdk@21をインストールする際は\n**「JDKの中にJVMと実行構成要素、開発ツールが一緒に入ってくる」**と理解する方が正確だ。\nbrewでインストールすること自体は正しい。\nJVMはOSにデフォルトでインストールされておらず、通常はJDK配布版をインストールして一緒に取得する。\n「OS別にインストールされる」という言葉の意味 # brewでインストールする際に内部的に自分のOS/アーキテクチャに合ったバイナリをダウンロードする。\nbrew install openjdk@21 → Mac（ARM）用OpenJDKバイナリをダウンロード → Linuxなら Linux用バイナリをダウンロード JVM自体がOS別に異なって作られており、ユーザーは自分のOSに合ったものをインストールする。\nこのプロセスをbrewが自動的に処理してくれる。\nただし、ここでも表現を正確にする必要がある。\nHomebrewはJavaでは公式にmacOSとLinuxで使うパッケージマネージャーだ。 そのためこの文脈でのbrew install openjdk@21はMac/Linux基準の説明だ。 Windowsは通常別のインストール経路を使う。 全体フローのまとめ # [Oracle / OpenJDKの開発チーム] → Mac用JVMのビルド → Windows用JVMのビルド ← JVM自体はOS別に存在する → Linux用JVMのビルド [開発者] → brew install openjdk@21 ← Mac/Linuxで自分の環境に合ったJDKをインストール → javac Hello.java ← バイトコードを生成 → java Hello ← インストールされたJVMが実行 **「JVMがOS依存性を処理してくれる」**という言葉は、\nJVMの開発チームがOS別にJVMを別々に作って配布し、その手間を開発者の代わりに引き受けていることを意味する。\n開発者はその実装をインストールした後、共通のバイトコードを乗せて実行するということだ。\nつまり以降は.class/.jarがOSごとに再コンパイルされる必要はないが、\n引き続き**Javaバージョンの互換性、ライブラリの依存関係、デプロイ方式（jar、jlink runtime、containerなど）**は別途意識する必要がある。\n参考資料 # The Java Virtual Machine Specification, Java SE 24: https://docs.oracle.com/javase/specs/jvms/se24/html/ Oracle Java Security Overview: https://docs.oracle.com/en/java/javase/24/security/java-security-overview1.html Java HotSpot Virtual Machine Performance Enhancements: https://docs.oracle.com/en/java/javase/24/vm/java-hotspot-virtual-machine-performance-enhancements.html JEP 411 — Deprecate the Security Manager for Removal: https://openjdk.org/jeps/411 JEP 486 — Permanently Disable the Security Manager: https://openjdk.org/jeps/486 Oracle JDK Migration Guide, Release 11: https://docs.oracle.com/en/java/javase/11/migrate/ Homebrew Formulae — openjdk@21: https://formulae.brew.sh/formula/openjdk%4021 Python Documentation — dis: https://docs.python.org/3/library/dis.html Python Documentation — The import system: https://docs.python.org/3/reference/import.html Kotlin Documentation — Calling Java from Kotlin: https://kotlinlang.org/docs/java-interop.html Android Developers — Kotlin and Android: https://developer.android.com/kotlin Android Developers — d8: https://developer.android.com/tools/d8 Android Open Source Project — Android runtime and Dalvik: https://source.android.com/docs/core/runtime ","date":"2026年2月19日","externalUrl":null,"permalink":"/ja/posts/jvm-complete-guide/","section":"Posts","summary":"Javaがなぜプラットフォーム独立性を得るのか、Python/Cとは何が違うのか、現代JVMにおけるGCとJIT、JDK/JREのインストールの概念を一度に整理します。","title":"[Java] Javaはなぜ一度コンパイルすれば複数のOSで実行できるのか？ JVM、GC、JIT、JDK/JRE","type":"posts"},{"content":"","date":"2026年2月19日","externalUrl":null,"permalink":"/ja/tags/bytecode/","section":"Tags","summary":"","title":"Bytecode","type":"tags"},{"content":"","date":"2026年2月19日","externalUrl":null,"permalink":"/ja/tags/gc/","section":"Tags","summary":"","title":"GC","type":"tags"},{"content":"","date":"2026年2月19日","externalUrl":null,"permalink":"/ja/tags/java/","section":"Tags","summary":"","title":"Java","type":"tags"},{"content":"","date":"2026年2月19日","externalUrl":null,"permalink":"/ja/tags/jdk/","section":"Tags","summary":"","title":"JDK","type":"tags"},{"content":"","date":"2026年2月19日","externalUrl":null,"permalink":"/ja/tags/jit/","section":"Tags","summary":"","title":"JIT","type":"tags"},{"content":"","date":"2026年2月19日","externalUrl":null,"permalink":"/ja/tags/jre/","section":"Tags","summary":"","title":"JRE","type":"tags"},{"content":"","date":"2026年2月14日","externalUrl":null,"permalink":"/ja/tags/concurrency/","section":"Tags","summary":"","title":"Concurrency","type":"tags"},{"content":"このドキュメントは疑問に思っていた点をAIエージェントと対話しながらまとめたメモです。マルチスレッド環境でSingletonの実装がなぜ壊れてしまうのか、Holderパターンとenumの方式、Spring singletonの概念を合わせて整理しています。\nSingleton、そのまま実装したらいけないの？ # 「nullをチェックするだけでいいんじゃないの？」 # Singletonを初めて学ぶと、大抵こう実装します。\npublic class Settings { private static Settings instance; private Settings() { } public static Settings getInstance() { if (instance == null) { instance = new Settings(); } return instance; } } 論理的には完璧に見えます。\nシングルスレッドであれば、instanceがなければ作り、あればそれを返すのでインスタンスは1つだけ生成されるように見えます。\nところがこのコードはマルチスレッド環境ではSingletonを保証できません。\nどこで問題が発生するのか？ # スレッドAとスレッドBが同時にgetInstance()を呼び出す状況を考えてみましょう。\nスレッドA → if (instance == null) をチェック → nullだ、true！ スレッドB → if (instance == null) をチェック → nullだ、true！ ← 同時に通過 スレッドA → instance = new Settings() を実行 スレッドB → instance = new Settings() を実行 ← 2つ目のインスタンスが生成！ 2つのスレッドが同時にnullチェックを通過すると、それぞれがインスタンスを作ってしまいます。\n共有フィールドinstanceに対する同期がないため、Singletonの保証が崩れる可能性があります。\n「じゃあsynchronizedをかければいいんじゃないの？」 # その通りです。同期化するとThread-safeになります。\npublic static synchronized Settings getInstance() { if (instance == null) { instance = new Settings(); } return instance; } しかしこの方法ではgetInstance()のすべての呼び出しが同じmonitorを通ることになります。\nインスタンスが既に生成された後でも毎回同期化の経路を通らなければならないため、呼び出しが多くて競合が発生すると不要なcontentionが生じる可能性があります。\n「じゃあ必要な瞬間だけロックをかけたらどうだろう？」 # そのアイデアがDouble-Checked Lockingです。\npublic static Settings getInstance() { if (instance == null) { // 1回目のチェック — ロックなし synchronized (Settings.class) { if (instance == null) { // 2回目のチェック — ロックあり instance = new Settings(); } } } return instance; } インスタンスが生成された後はロックをかけないので呼び出しの経路は軽くなります。\nしかしこのコードもvolatileなしには安全ではありません。\nJavaのメモリモデルは、同期化されていない共有の読み書きに対して予期せぬ実行を許容します。\nオブジェクトの生成は概念的に以下の順序で行われると考えられます。\n1. メモリ空間の確保 2. コンストラクターの実行 3. instance変数への参照の公開 volatileなしでDCLを使うと、instanceへの書き込みとその後の読み取りの間に必要なhappens-before関係がなく、\n他のスレッドが生成が完全に終わる前の参照を観測する実行が許容される可能性があります。\nこれを防ぐにはvolatileキーワードが必要です。\nprivate static volatile Settings instance; // DCLではvolatileが必要 結局コードがどんどん複雑になります。ミスも犯しやすくなります。\nより良い方法があります — Holderパターン # public class Settings { private Settings() { } private static class SettingsHolder { private static final Settings INSTANCE = new Settings(); } public static Settings getInstance() { return SettingsHolder.INSTANCE; } } synchronizedもなく、volatileもありません。\nそれなのにどうやってThread-safeを保証するのでしょうか？\n核心はJVMのクラス初期化メカニズムです # JVMはクラスやインターフェースを初めて能動的に使用するタイミングで初期化し、\nその初期化手続きを同期化します。これはJLS §12.4.1、§12.4.2が保証している動作です。\nHolderパターンはこのメカニズムをそのまま活用します。\nSettingsクラスはロードされても SettingsHolderはまだ初期化されない ↓ getInstance() が初めて呼ばれる ↓ SettingsHolder.INSTANCE に初めてアクセス ↓ SettingsHolder の初期化が始まる ↓ INSTANCE = new Settings() — 初期化手続きの中で一度だけ実行 ↓ 以降のgetInstance()呼び出し → そのまま返すだけ（追加のロックなし） ロックなしでも安全で、実際に必要なタイミングまで初期化を遅らせることができ、実装の複雑さも低いです。\n方式別比較 # 実装方式 遅延初期化 Thread-safe パフォーマンス 実装の複雑さ 基本nullチェック ✅ ❌ ✅ 低 synchronized ✅ ✅ ❌ 低 Double-Checked Locking ✅ ✅（volatile必須） ✅ 高 Holderパターン ✅ ✅ ✅ 低 その他知っておくといいこと # Enum Singleton # 《Effective Java》でよく紹介される方式です。\npublic enum Settings { INSTANCE; } // 使用 Settings.INSTANCE; 最も短く、enum定数はシリアライズ時に名前ベースで特別扱いされるため、別途readResolveのような考慮が不要になります。\nまた、通常のリフレクションによるインスタンス化も許容されません。\nただし「アプリケーション起動と同時に必ず生成される」と一般化すると正確ではありません。\nenum定数もそのenumタイプが初期化されるときに生成されるので、HolderパターンのようにgetInstance()呼び出し直前まで遅らせる形とは異なります。\nSpring環境では？ # Springを使うなら、自分でSingletonパターンを実装するよりも、コンテナが管理するbean scopeを活用するケースが多いです。\n@Component public class Settings { // Spring IoCコンテナがデフォルトのsingleton scopeで管理します。 } Spring Beanのデフォルトスコープはsingletonです。\nただしこれはGoFのSingletonのようにJVM全体でクラスあたり1つという意味ではなく、Spring IoCコンテナ内でbean definitionあたり1つという意味です。\nそのためSpringを使っても状態の共有問題がなくなるわけではありません。\n状態を持つフィールドがあると依然として並行性の問題が発生しうるので、通常はbeanをできるだけステートレスに設計します。\nまとめ # 最初に見たコードが間違いである理由はロジックが単純だからではなく、\n共有状態を複数のスレッドが同時に読み書きできるという点を考慮していなかったからです。\n自分でSingletonクラスを実装する必要があり、遅延初期化が必要な場合はHolderパターンをまず検討する価値があります。\nJVMが保証するクラス初期化手続きを活用するため、synchronizedやDCLよりも実装ミスが少なくなります。\n遅延初期化が特に必要なく、enumの制約が問題なければenum方式も良い選択肢です。\n「複雑な問題を複雑に解くのは実力ではない。\n既にある保証を巧みに活用することが本当の実力だ。」\n参考資料 # Java Language Specification, SE 21, §12.4.1 When Initialization Occurs: https://docs.oracle.com/javase/specs/jls/se21/html/jls-12.html#jls-12.4.1 Java Language Specification, SE 21, §12.4.2 Detailed Initialization Procedure: https://docs.oracle.com/javase/specs/jls/se21/html/jls-12.html#jls-12.4.2 Java Language Specification, SE 21, §17.1 Synchronization: https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html#jls-17.1 Java Language Specification, SE 21, §17.4.5 Happens-before Order: https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html#jls-17.4.5 Java Object Serialization Specification, §1.12 Serialization of Enum Constants: https://docs.oracle.com/en/java/javase/25/docs/specs/serialization/serial-arch.html#serialization-of-enum-constants Oracle Java Tutorials, Reflection API, Troubleshooting: IllegalArgumentException When Attempting to Instantiate an Enum Type: https://docs.oracle.com/javase/tutorial/reflect/special/enumTrouble.html Spring Framework Reference, Bean Scopes / The Singleton Scope: https://docs.spring.io/spring-framework/reference/core/beans/factory-scopes.html#beans-factory-scopes-singleton ","date":"2026年2月14日","externalUrl":null,"permalink":"/ja/posts/java-singleton-patterns/","section":"Posts","summary":"マルチスレッド環境でSingletonがなぜ壊れてしまうのか、Holderパターンがなぜ安全なのか、enumとSpring singletonをどこまで同じ概念として捉えるべきかを整理します。","title":"Singleton、そのまま実装したらいけないの？","type":"posts"},{"content":"このドキュメントは疑問に思っていた点をAIエージェントとの対話を通じて整理したメモです。Auto Scaling環境でJPAの1次キャッシュと2次キャッシュのスコープがどのように異なるか、どんな場合にデータ整合性の問題が生じうるかを整理します。\n結論から # キャッシュ スコープ Auto Scaling時の整合性 1次キャッシュ（永続性コンテキスト） Session / EntityManagerスコープ（一般的なSpringアプリでは通常トランザクションスコープ） ノード間で共有されないため、分散環境の整合性問題の直接の原因にはならない 2次キャッシュ（ローカル — Ehcacheなど） サーバーのJVM 崩れる可能性がある 2次キャッシュ（共有/分散 — Redisなど） 複数のサーバーが同じキャッシュを共有 ローカルキャッシュよりも有利だが、設定/無効化/外部変更の有無によってstale dataが起こりうる 1次キャッシュ — 永続性コンテキスト（Persistence Context） # 定義 # Hibernateの公式ドキュメントによると：\nA persistence context acts as a cache, often called the \u0026lsquo;first-level cache\u0026rsquo;. It maintains a unique mapping of entity identifiers to entity instances that have been read or made persistent within its scope.\n永続性コンテキストはエンティティID → エンティティインスタンスのマップとして動作する1次キャッシュだ。\nエンティティのライフサイクル # エンティティのlifecycleは通常4つの状態で説明する：\n┌──────────┐ persist() ┌──────────┐ detach()/clear() ┌──────────┐ │ Transient │ ───────────\u0026gt; │ Managed │ ─────────────────\u0026gt; │ Detached │ │ (非永続) │ │ (永続) │ │ (準永続) │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ │ remove() │ merge() │ ▼ │ │ ┌──────────┐ │ │ │ Removed │ │ │ │ (削除) │ │ │ └──────────┘ │ │ │ └──────────────────────────────────────────────────────────┘ merge()で再び永続状態に 状態 説明 Transient newで生成しただけの状態。永続性コンテキストと無関係 Managed persist()または照会によって永続性コンテキストに入った状態 Detached 永続性コンテキストから切り離された状態（トランザクション終了など） Removed remove()呼び出し。コミット時にDELETEを実行 核心：一般的なSpringアプリではトランザクションスコープとして理解してよい # @Service public class UserService { @Transactional // ← トランザクション開始 = 永続性コンテキストの生成 public void updateUser(Long id) { User user = userRepository.findById(id).get(); // DBから照会 → 1次キャッシュに保存 User same = userRepository.findById(id).get(); // 1次キャッシュから返却（SQLが発行されない） System.out.println(user == same); // true（同じインスタンス） user.setName(\u0026#34;田中太郎\u0026#34;); // ダーティチェックの対象 } // ← トランザクション終了 = flush + commit + 永続性コンテキストの消滅 } 一般的なSpring Boot + @Transactionalのリクエスト処理では、トランザクションが終了すると該当の永続性コンテキストも一緒に片付けられると理解してよい。\nただし正確に言うと1次キャッシュのスコープは**現在のSession / EntityManager**だ。つまり通常はトランザクションスコープで短く使われるが、extended persistence contextのような例外的な構成も可能だ。\n1次キャッシュが提供する機能 # Hibernate公式ドキュメント：\nPersistence contexts offer benefits such as preventing data aliasing, enabling automatic dirty checking, improving performance by caching entities, and facilitating transparent batching of database operations.\n機能 説明 同一性の保証 同じトランザクション内で同じIDのエンティティは常に同じインスタンス 繰り返し読み取り 同じエンティティを複数回照会してもSQLは1回だけ実行 ダーティチェック エンティティのフィールド変更時に自動的にUPDATEを検出 書き込み遅延 flushのタイミングまでSQLをまとめてバッチ実行 Auto Scalingでなぜ直接的な整合性問題の原因にならないのか # [Server A] [Server B] リクエスト1到着 リクエスト2到着 │ │ ▼ ▼ トランザクション開始 トランザクション開始 永続性コンテキストA生成 永続性コンテキストB生成 │ │ User(id=1)照会 → DB SELECT User(id=1)照会 → DB SELECT 1次キャッシュに保存 1次キャッシュに保存 │ │ トランザクション終了 トランザクション終了 永続性コンテキストA消滅 ← ここで消える 永続性コンテキストB消滅 各サーバーの1次キャッシュは完全に独立 通常リクエスト/トランザクション単位で生成・片付けられるため、サーバー間で共有するものがそもそも存在しない したがって**「Server Aの1次キャッシュのせいでServer Bが古い値を見る」といった問題は起きない** ただしこれが**「常に最新のデータを見る」という意味ではない。同じ永続性コンテキスト内ではすでに管理中のエンティティを返すことがあり、最新性は結局トランザクション分離レベル / 照会タイミング / refresh()の有無**にも影響を受ける。\n2次キャッシュ — サーバーレベルのキャッシュ # 定義 # 1次キャッシュとは異なり、トランザクションが終わっても存在し続けるキャッシュ。SessionFactory（アプリケーション）レベルで動作する。\nリクエスト → 1次キャッシュ（永続性コンテキスト） → miss → 2次キャッシュ → miss → DB hit ↓ そのまま返却 重要な制約が一つある。Hibernateの2次キャッシュはHibernateの外で発生したDBの変更を自動的に知ることができない。\nJDBCで直接変更 バッチプログラムが直接変更 別のアプリケーションが同じテーブルを変更 このような変更はキャッシュに自動反映されないため、必要な場合は明示的な無効化や期限切れポリシーが必要だ。\n設定方法 # Hibernateで2次キャッシュを有効にするには：\n# application.properties spring.jpa.properties.hibernate.cache.use_second_level_cache=true spring.jpa.properties.hibernate.cache.region.factory_class=jcache spring.jpa.properties.hibernate.javax.cache.uri=/ehcache.xml エンティティに@Cacheアノテーションを適用：\n@Entity @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) public class Product { @Id private Long id; private String name; private int price; } 並行性戦略（CacheConcurrencyStrategy） # Hibernate公式ドキュメント：\nThe second-level cache offers four concurrency strategies: READ_ONLY, NONSTRICT_READ_WRITE, READ_WRITE, and TRANSACTIONAL. READ_WRITE is generally recommended as a default for strong consistency.\n戦略 説明 適している場合 READ_ONLY 読み取り専用。変更時に例外 コードテーブル、設定値などの不変データ NONSTRICT_READ_WRITE 変更後にキャッシュを無効化（弱い一貫性） 偶発的な変更、わずかな不整合を許容 READ_WRITE soft lockベース。Hibernateが管理する修正に対して比較的強い一貫性を提供 一般的に推奨 TRANSACTIONAL JTAと統合するtransactional cache providerを前提 特殊なエンタープライズ環境 キャッシュ管理API # // SessionFactoryでキャッシュを直接制御 Cache cache = sessionFactory.getCache(); cache.evict(Product.class, 1L); // 特定のエンティティを削除 cache.evict(Product.class); // 該当タイプ全体を削除 cache.evictAllRegions(); // 全キャッシュを初期化 boolean cached = cache.contains(Product.class, 1L); // キャッシュの存在確認 2次キャッシュ + Auto Scaling = 整合性の問題 # 問題のシナリオ（ローカルキャッシュ使用時） # 時系列 Server A (Ehcache) Server B (Ehcache) DB ──────────────────────────────────────────────────────────────────────────────── T1 Product(id=1, price=1000) Product(id=1, price=1000) price=1000 2次キャッシュに保存 2次キャッシュに保存 T2 priceを2000にUPDATE （変更を知らない） price=2000 自分のキャッシュを更新: price=2000 キャッシュ: price=1000 ← 古い値！ T3 Product(id=1)を照会 → 2次キャッシュがhit → price=1000を返却 ← 間違った値！ 核心：ローカルの2次キャッシュは各JVMの中にしか存在しないため、他のサーバーの変更を知ることができない。\nコードで見る問題の状況 # // Server Aで実行 @Transactional public void updatePrice(Long productId, int newPrice) { Product product = productRepository.findById(productId).get(); product.setPrice(newPrice); // DBに反映 + Server Aの2次キャッシュを更新 // Server Bの2次キャッシュは？ → そのまま古い値 } // Server Bで実行（直後） @Transactional(readOnly = true) public Product getProduct(Long productId) { return productRepository.findById(productId).get(); // 2次キャッシュがhit → 古い価格を返却！ } これはAuto Scalingだけの問題ではない # サーバーが2台以上あれば同じ問題が発生する。 Auto Scalingはサーバー数が動的に変わることで問題の予測がより難しくなるだけだ。\n解決方法 # 方法1：2次キャッシュを使わない（最もシンプル） # # providerを設定していても全体的に無効にしたい場合 spring.jpa.properties.hibernate.cache.use_second_level_cache=false ほとんどのサービスは1次キャッシュ + 適切なクエリの最適化だけで十分だ。Hibernateはproviderのセットアップとエンティティマッピング（@Cache）がないと実際には2次キャッシュを活用しないため、多くのプロジェクトではそもそも積極的に使用していない。\n方法2：共有キャッシュを使用（Redisなど） # すべてのサーバーが1つの外部キャッシュを参照するようにすることで、ローカルキャッシュよりもノード間のstale dataの可能性を減らせる。\nServer A ──┐ Server B ──┼──\u0026gt; Redis（共有キャッシュ） ──\u0026gt; DB Server C ──┘ // Redisを2次キャッシュとして使用（redisson-hibernateなど） @Entity @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) public class Product { @Id private Long id; private String name; private int price; } # RedissonをHibernateの2次キャッシュとして設定 spring.jpa.properties.hibernate.cache.use_second_level_cache=true spring.jpa.properties.hibernate.cache.region.factory_class=org.redisson.hibernate.RedissonRegionFactory spring.jpa.properties.hibernate.cache.redisson.config=redisson.yaml 時系列 Server A Server B Redis DB ────────────────────────────────────────────────────────────────────────────────────── T1 Product(id=1)照会 Product(id=1)照会 → Redisにキャッシュ → RedisキャッシュがHIT price=1000 price=1000 T2 price=2000にUPDATE → Redisキャッシュを更新 price=2000 price=2000 T3 Product(id=1)照会 → RedisキャッシュがHIT → price=2000を返却可能 price=2000 ただし、この結論は以下の前提がある場合にのみ比較的成立する。\nすべての書き込みがHibernateを通じて行われる cache providerの無効化/ロック戦略が正しく設定されている Hibernateの外からDBを直接変更する経路がないか、別途無効化戦略がある つまり、共有Redisを使うからといって無条件に「問題なし」にはならない。 それでもサーバーごとにローカルキャッシュを別々に持つよりは一般的に安全だ。\n方法3：短いTTL + 許容可能な不整合 # \u0026lt;!-- ehcache.xml --\u0026gt; \u0026lt;cache alias=\u0026#34;products\u0026#34;\u0026gt; \u0026lt;expiry\u0026gt; \u0026lt;ttl unit=\u0026#34;seconds\u0026#34;\u0026gt;30\u0026lt;/ttl\u0026gt; \u0026lt;!-- 30秒後に期限切れ --\u0026gt; \u0026lt;/expiry\u0026gt; \u0026lt;heap unit=\u0026#34;entries\u0026#34;\u0026gt;1000\u0026lt;/heap\u0026gt; \u0026lt;/cache\u0026gt; 最大30秒間古いデータを見る可能性がある 商品リスト、記事の閲覧数など多少の不整合が許容される場合に適している これは整合性の保証ではなくstale dataが続く時間を減らす緩和策だ 方法4：Spring @Cacheable + Redis（2次キャッシュの代わりにアプリケーションレベル） # JPAの2次キャッシュの代わりにSpringのキャッシュ抽象化を使う方法もある。\n@Service public class ProductService { @Cacheable(value = \u0026#34;products\u0026#34;, key = \u0026#34;#id\u0026#34;) public Product getProduct(Long id) { return productRepository.findById(id).get(); // キャッシュmiss時のみ実行 } @Transactional @CacheEvict(value = \u0026#34;products\u0026#34;, key = \u0026#34;#id\u0026#34;) public void updateProduct(Long id, int newPrice) { Product product = productRepository.findById(id).get(); product.setPrice(newPrice); } } spring.cache.type=redis @CacheEvictで変更時にキャッシュを無効化 Redisのような共有キャッシュを使うとサーバーごとのローカルキャッシュの不整合を減らせる ただしSpring Cacheは抽象化層なので、TTL、伝播方式、即時性はRedis/CacheManagerの設定による 各方法の比較 # 方法 整合性 パフォーマンス 複雑度 適している場合 2次キャッシュを使わない キャッシュによるstaleリスクが最も低い 普通 低い ほとんどのサービス 共有キャッシュ（Redisなど） 適切に構成すると高い 高い 中程度 読み取り比率が高いサービス 短いTTL 弱い不整合 高い 低い 閲覧数、リストなど許容可能な場合 @Cacheable + Redis 適切に構成すると高い 高い 中程度 JPAの2次キャッシュより直感的 全体像 # 1次キャッシュ 2次キャッシュ （トランザクションスコープ） （サーバー/共有スコープ） │ │ [Server A] ── リクエスト → 永続性コンテキスト → miss → ローカル or Redis → miss → DB │ │ トランザクション終了で消滅 サーバーが起動中は維持 │ │ サーバー間で共有しない ローカル：サーバー間で共有しない Redis：サーバー間で共有する まとめ # 1次キャッシュ（永続性コンテキスト）は現在のSession / EntityManagerスコープであり、一般的なSpringアプリでは通常トランザクションスコープで短く使われる したがって1次キャッシュはノード間の共有キャッシュ不整合の直接の原因にはならない 2次キャッシュをローカル（Ehcacheなど）で使用すると、サーバーが2台以上のときにstale dataが発生しうる **共有キャッシュ（Redisなど）**はこの問題を減らせるが、外部DBの変更・無効化・providerの設定によってstale dataの可能性は残る そのため実務的な解決策は2次キャッシュを使わないか、共有キャッシュを慎重に構成するか、TTL/無効化戦略を明示的に持つことだ ","date":"2026年2月13日","externalUrl":null,"permalink":"/ja/posts/jpa-cache-autoscaling-consistency/","section":"Posts","summary":"Auto Scalingでサーバーが複数台のときにJPAの永続性コンテキストと2次キャッシュがデータ整合性を崩すのか、各キャッシュのスコープと解決方法をコード例と一緒に整理します。","title":"[JPA] 永続性コンテキストとキャッシュ — Auto Scaling環境でデータ整合性は大丈夫か？","type":"posts"},{"content":"","date":"2026年2月13日","externalUrl":null,"permalink":"/ja/tags/auto-scaling/","section":"Tags","summary":"","title":"Auto Scaling","type":"tags"},{"content":"","date":"2026年2月13日","externalUrl":null,"permalink":"/ja/tags/cache/","section":"Tags","summary":"","title":"Cache","type":"tags"},{"content":"","date":"2026年2月13日","externalUrl":null,"permalink":"/ja/tags/hibernate/","section":"Tags","summary":"","title":"Hibernate","type":"tags"},{"content":"このドキュメントは疑問に思っていた点をAIエージェントとの対話を通じて整理したメモです。InnoDBテーブルがディスクに保存される構造をtablespace、segment、extent、page、rowの順で整理します。\nまず前提として # この記事は**innodb_file_per_table=ONの一般的なInnoDB非パーティションテーブル**を基準に説明する。この前提があれば、.ibdファイル1つを中心に構造を理解しやすい。\nただし以下の場合は説明が変わる。\ninnodb_file_per_table=OFFであれば、新しいテーブルはシステムtablespaceに保存される。 general tablespaceを使うと、複数のテーブルが1つのtablespaceを共有できる。 パーティションテーブルは、パーティションごとに別のtablespace/fileが作られる場合がある。 各テーブルはファイル1つなのか？ # 通常はそうだが、正確には「テーブルはtablespaceに保存される」が正しい。\ninnodb_file_per_table=ONのとき、一般的なInnoDBテーブルはsingle-table tablespaceに保存され、そのtablespaceは通常1つの.ibdファイルとして存在する。\n/var/lib/mysql/mydb/ ├── user.ibd ├── orders.ibd └── product.ibd このモードでは、.ibd1つの中にそのテーブルのクラスタードインデックス（実際の行データ）とセカンダリインデックスが一緒に入る。\n核心は以下の通りだ。\nTable = Fileは厳密な原理ではなく、file-per-table構成でよく見られる結果だ。 より正確な表現はTable -\u0026gt; Tablespace -\u0026gt; backing file(.ibd)だ。 1つのファイルの中には複数のページがあるのか？ # Yes. .ibdファイルは同じサイズのdatabase pageが連続して配置された構造だ。\nuser.ibd ┌─────────┬─────────┬─────────┬─────────┬─────┐ │ Page 0 │ Page 1 │ Page 2 │ Page 3 │ ... │ └─────────┴─────────┴─────────┴─────────┴─────┘ pageサイズのデフォルト値は16KB innodb_page_sizeによって4KB、8KB、16KB、32KB、64KBが可能 ディスクI/OとBuffer Poolのキャッシュも結局このpage単位で行われる 全体の階層構造 # file-per-table基準で見ると、以下の式で理解するのが最も混乱しにくい。\nTable / Index └── Tablespace ├── backing file: .ibd └── Segment └── Extent └── Page └── Record(Row) ここで重要な点は次の2つだ。\ntablespaceが上位概念であり、ファイルはそのtablespaceを収める物理的なストレージだ。 segment / extent / pageはtablespace内部の空間管理構造だ。 各階層の詳細 # 1) Tablespaceとファイル # InnoDBはデータをまずtablespaceに配置し、そのtablespaceを1つ以上のファイルで保持する。\nsingle-table tablespace: テーブル1つ専用 system tablespace: 複数の内部構造やテーブルが共有 general tablespace: ユーザーが複数のテーブルをまとめて入れられる undo tablespace: undoログ用 つまり、.ibdファイルを中心に説明することはできるが、本質はファイルよりtablespaceだ。\n2) Segment # segmentはtablespace内部の論理的な保存単位だ。ここで混乱しやすいポイントがある。\nInnoDBは各インデックスごとに2つのsegmentを持つ。 1つはnon-leaf node用、もう1つはleaf node用だ。 ここで言うsegmentはrollback segmentとは異なる概念だ。 つまり、rollback segmentを同じ.ibd内の一般segmentとして一緒に語ると不正確になる。\nまた、leaf nodeに保存される内容もインデックスの種類によって異なる。\nインデックス leaf pageに保存されるもの Clustered Index 実際の行データ Secondary Index セカンダリインデックスキー + 主キーの値 そのため「leaf node = 実際のrow」はクラスタードインデックスに対してのみ正確な表現だ。\n3) Extent # extentはInnoDBが空間をまとめて管理する単位だ。\nPageサイズ Extentサイズ 構成 4KB 1MB 256 pages 8KB 1MB 128 pages 16KB 1MB 64 pages 32KB 2MB 64 pages 64KB 4MB 64 pages 補足すべき点は2つある。\nextentはtablespace内の連続したpageの束であり、ファイルシステム/ディスクの物理セクタまで必ず連続しているという意味ではない。 segmentは最初からextent単位でのみ大きくなるわけではない。最初の32 pageは1枚ずつ、それ以降からextent単位の割り当てが始まる。 つまり「extent = 常に実際の物理ディスクで完全に連続した大きな塊」と理解すると過剰だ。ただしInnoDBはextent単位の管理を通じて**論理的な局所性（locality）**と順次アクセスの可能性を高めようとしている。\n4) Page # pageはInnoDBの基本ディスクI/O単位でありBuffer Poolキャッシュ単位だ。\n正確性のために、page構造は「ページの種類によって少しずつ異なる」という前提を置くのが正しい。以下はindex page基準の単純化した図だ。\n┌──────────────────────────────────┐ │ FIL Header (38 bytes) │ ├──────────────────────────────────┤ │ Page Header (index page専用) │ ├──────────────────────────────────┤ │ Infimum / Supremum records │ ├──────────────────────────────────┤ │ User Records │ ├──────────────────────────────────┤ │ Free Space │ ├──────────────────────────────────┤ │ Page Directory │ ├──────────────────────────────────┤ │ FIL Trailer (8 bytes) │ └──────────────────────────────────┘ よく見るpage typeは以下の通りだ。\nタイプ 役割 FIL_PAGE_INDEX B-tree nodeページ FIL_PAGE_TYPE_FSP_HDR tablespaceヘッダーページ FIL_PAGE_IBUF_BITMAP change buffer / allocation bitmap FIL_PAGE_INODE segment inode情報 FIL_PAGE_UNDO_LOG undoログページ 5) Row / Record # InnoDBにおいて「row」は結局clustered index leaf recordとして保存される。\n大きな可変長カラムは常に同じ方式で処理されるわけではない。\nrow formatとpage sizeによって一部はページ内部、一部はoff-page/overflow pageに行く場合がある。 DYNAMIC / COMPRESSED row formatでは、長いカラムが20バイトのポインタだけを残して外部pageに移動する場合がある。 COMPACT / REDUNDANTは先頭768バイトをインラインに置き、残りを外部pageに保存する場合がある。 つまり「BLOB/TEXTは必ずoverflow pageに行く」や「常に20バイトポインタだけが残る」と断定すると、row formatの違いを見落とすことになる。\n実際のクエリ実行フロー # SELECT * FROM user WHERE id = 5; おおまかな流れは以下の通りだ。\n1. Buffer Poolに必要なpageがあるか確認 2. なければclustered indexのroot/branch/leaf pageをたどって降りる 3. 必要なpageをディスクから読み込んでBuffer Poolに載せる 4. leaf pageの中のrecordを見つけて返す ここで核心は、クエリがrow単位に見えても、実際のストレージエンジンはpage単位で読んでpage単位でキャッシュするという点だ。\nまとめ # 単位 性格 核心的な役割 Table 論理 ユーザー視点のテーブル Tablespace 論理+物理の接続 テーブル/インデックスが配置される保存空間 File (.ibd) 物理 single-table tablespaceのbacking file Segment 論理 インデックスleaf/non-leafの空間管理 Extent 空間割り当て単位 pageの束 Page I/O単位 ディスクの読み書きとBuffer Poolキャッシュの最小単位 Record(Row) データ単位 実際の行レコード 核心ポイントだけまとめると：\n「テーブル = ファイル」はfile-per-table環境でよく見られる結果であり、本質そのものではない。 InnoDBは実際にtablespace -\u0026gt; segment -\u0026gt; extent -\u0026gt; pageの構造で空間を管理する。 パフォーマンスの観点で最も重要な単位は結局pageだ。 References # MySQL 8.4 Reference Manual - File-Per-Table Tablespaces MySQL 8.4 Reference Manual - InnoDB Disk I/O and File Space Management MySQL 8.4 Reference Manual - The Physical Structure of an InnoDB Index MySQL 8.4 Reference Manual - InnoDB Row Formats ","date":"2026年2月7日","externalUrl":null,"permalink":"/ja/posts/mysql-innodb-storage-structure/","section":"Posts","summary":"InnoDBでテーブルがディスクにどのように保存されるか — File、Segment、Extent、Page、Row各階層の役割とサイズを整理します。","title":"[MySQL] InnoDBではテーブルは本当にファイル1つに保存されるのか？Tablespace、Segment、Extent、Page、Row","type":"posts"},{"content":"このドキュメントは疑問に思っていた点をAIエージェントとの対話を通じて整理したメモです。TCP接続がすぐに切れない理由と、TCP keepalive、HTTP keep-alive、HikariCP keepaliveの違いを整理します。\nまず区別すべきこと # このドキュメントで「keepalive」という言葉は実際には3つの異なるものを指す。\n名称 レイヤー 実際の意味 TCP keepalive L4 / OS アイドル状態のTCPソケットに対してカーネルが生存確認probeを送る HTTP keep-alive L7 / HTTP レスポンス後にTCP接続を閉じず、次のHTTPリクエストでも再利用する Pool keepalive アプリケーション HikariCPのようなpool実装がidle connectionを定期的にpingする この3つを混同すると「TCP keepalive = HikariCP keepalive = Nginx keepalive」のように見えてしまうが、実際には担当するレイヤーと目的が異なる。\nTCP接続は何も送らなくても維持されるのか？ # プロトコルの観点ではYes。\nTCP接続は3-way handshakeが終わってESTABLISHED状態に入ると、データがないという理由だけで自動的には切れない。 片側がFINまたはRSTを送るか、カーネル/アプリケーション/中間機器がtimeoutポリシーで整理するときに切れる。\nつまり「何もパケットを送らなければTCPが自動的に終了する」という説明は間違いだ。\nしかし実務では以下の主体たちがidle connectionを整理する。\nDBサーバー自体のidle timeout プロキシ/ロードバランサーのidle timeout NAT/ファイアウォールのconnection tracking timeout アプリケーションまたはconnection poolの自体のライフサイクルポリシー そのためTCPは維持できるが、実際のサービス経路全体が維持してくれるとは言えない。\n実際にidle connectionを切る主体たち # アイドル接続を誰が切るかは環境によって異なる。一つの数値を覚えてすべての区間に当てはめると間違えやすい。\n代表的な例はこのくらいだ。\n主体 例 DBサーバー MySQLのwait_timeoutデフォルト値は8時間 Application Load Balancer connection idle timeoutのデフォルト値は60秒 Network/Gateway Load Balancer TCP idle timeoutのデフォルトは350秒 NAT Gateway 350秒のアイドル後にtimeout AWS Security Group connection tracking Nitroベースインスタンスで設定可能。Nitrov6はデフォルト350秒で、他のインスタンスタイプはデフォルト5日（432000秒）の場合がある 特にALBはconnection idle timeout（デフォルト60秒）とHTTP client keepalive duration（デフォルト1時間）を別々に持つ。\nTCP Keepalive # TCP keepaliveはOSレベルの機能だ。アイドル状態のTCPソケットに対してカーネルがprobeを送り、接続がまだ生きているか確認する。\n重要なポイントは以下の通りだ。\nTCP keepaliveは自動的に常にオンにはなっていない。 ソケットにSO_KEEPALIVEがオンになっている必要がある。 probeはアプリケーションデータではなくカーネルレベルのTCP probeだ。 Linuxのデフォルトのsysctl値は通常以下の通りだ。\ntcp_keepalive_time = 7200 # 2時間のアイドル後に開始 tcp_keepalive_intvl = 75 # 75秒間隔 tcp_keepalive_probes = 9 # 9回失敗でdead判定 このデフォルト値はほとんどのプロキシ/LB/NATのidle timeoutより長い。そのためOSのデフォルト値のままにしていると、TCP keepaliveが動作する前に中間機器が先にidle connectionを整理してしまう可能性がある。\nここで言う中間機器のtimeoutとは、アプリケーションとサーバーの間にあるNAT、ロードバランサー、ファイアウォール、プロキシのような機器が「あまりにも長く静かな接続」を自社のポリシーで整理する時間を意味する。\n例えばNATが350秒間何もトラフィックがなければ接続状態を削除するが、LinuxのデフォルトのTCP keepaliveは**7200秒（2時間）**が過ぎてから初めてprobeを送る。するとkeepalive probeを送る前にNATが先に「この接続は長い間アイドルだった」と判断して整理してしまう可能性がある。\nもう一つ重要なことがある。\nTCP keepaliveは多くのNAT/ファイアウォール/NLBの層には助けになりうるが すべての製品がkeepalive probeを「有効なトラフィック」として見てidle timerをリセットするわけではない つまり「TCP keepaliveをオンにさえすれば、どんなmiddlebox（中間機器）のidle timeoutも必ず切れない」という理解は正しくない。製品によってkeepalive probeの処理方法が異なるからだ。\nHikariCPは何を担当するのか？ # HikariCPはTCPの機能ではなくJDBC connection poolだ。担当する役割は大きく2つだ。\nすでに開いているDB connectionを再利用する。 死んだconnectionを検出したり、古くなったconnectionを交換したりする。 つまりHikariCPはconnectionを「ずっと持っておくか」「いつ捨てるか」「いつpingするか」を管理する側だ。\n核心設定 # 設定 意味 補足説明 maxLifetime pool内でconnectionが留まれる最大寿命 生存確認の機能ではない。 DB/LBのtimeoutより少し短くすることが推奨される。 keepaliveTime idle connectionに対して定期的にpingを実行 maxLifetimeより小さくする必要がある。 connectionTestQuery カスタムのvalidation SQL JDBC4のisValid()をサポートしないlegacyドライバーに主に必要だ。 idleTimeout idle connectionの削除基準 minimumIdle \u0026lt; maximumPoolSizeのときのみ意味がある。 connectionTimeout poolからconnectionを待つ時間 この時間内にconnectionが取得できなければ失敗する。生存維持とは別の設定だ。 現在のHikariCP READMEの基準ではconnectionTimeout=30s、idleTimeout=10m、maxLifetime=30m、keepaliveTime=2mがデフォルト値として文書化されている。ただしkeepaliveTimeは古いバージョンの文書や記事では0と説明されている場合があるため、運用中のライブラリバージョンの文書を合わせて確認する方が安全だ。\nHikariCPが実際にpingする方法 # HikariCPはidle connectionをpingするとき以下のどちらかを使う。\nJDBC4のConnection.isValid() connectionTestQuery つまり「HikariCPはvalidation queryなしでまったく検査しない」と言うと不正確だ。より正確な説明はこうだ。\n最新のJDBCドライバーならば通常isValid()を使う。 legacyドライバーならconnectionTestQueryを使える。 keepaliveTimeやpoolから取り出す際の検証で必要なときにpingが発生する。 maxLifetime=30分だから30分間維持されるのか？ # 違う。\nmaxLifetimeは「30分間生かし続けることを保証する」値ではなく、pool内でconnectionをどれだけの間保管するかを決める値だ。つまりHikariCPの保管ポリシーであり、ネットワークやDBが実際のconnectionを30分間保存してくれるという保証ではない。\n例えばDB connectionの経路が以下のようになっているとしよう。\nApp -\u0026gt; NAT -\u0026gt; DB そしてポリシーが以下の通りだとする。\nNAT：350秒のアイドルなら状態を削除 DBサーバー：10分のアイドルならconnectionを終了 HikariCP：maxLifetime=30分 この場合、実際のconnectionの寿命は30分ではなく、最初に切断する側によって決まる。\nアプリケーションがDB connectionを一つ作ってpoolに入れる。 しばらくクエリがまったくない。 350秒ほど経つとNATが先に状態を削除する可能性がある。 10分ほど経つとDBサーバーもidle connectionを閉じる可能性がある。 HikariCPは30分まで保管しようとしていたが、実際のconnectionはその前にすでに死んでいる可能性がある。 逆にHTTP connectionの経路ではALBの60秒のidle timeoutのような別のポリシーが適用される。\nそのため運用では通常以下のように合わせる。\nそのconnectionが実際に通るDB/LB/NAT/ファイアウォールのtimeoutを確認する。 maxLifetimeをそれより少し短くする。 必要であればkeepaliveTimeやドライバー/OSのTCP keepaliveを一緒に設定する。 またHikariCPの公式READMEはpoolの安定性のためにTCP keepaliveも別途設定するよう推奨している。つまりHikariCPの設定だけでOSソケットのkeepaliveが自動的に設定されるわけではない。\nHTTP Keep-Alive vs TCP Keepalive # 名前が似ているだけで、実際には全く異なるメカニズムだ。\nHTTP keep-alive TCP keepalive レイヤー L7 L4 / OS 目的 同じTCP接続で複数のHTTPリクエスト/レスポンスを再利用する アイドルのTCP接続がまだ生きているか確認する デフォルトの動作 HTTP/1.1では持続接続がデフォルトでConnection: closeで終了 ソケットにSO_KEEPALIVEがオンの場合のみ動作 誰が担当するか ブラウザ、Nginx、WAS、プロキシ カーネル / ソケットオプション まとめると：\nHTTP keep-aliveは「レスポンス後に接続を閉じないこと」 TCP keepaliveは「長くアイドルな接続にprobeを送ること」 だ。\nNginx Keepaliveの構造 # Client ──[A]──\u0026gt; Nginx ──[B]──\u0026gt; WAS ここでAとBは互いに異なるkeepaliveポリシーを持つ。\n1) Downstream: Client ↔ Nginx # この区間は通常HTTP persistent connectionを指す。\nkeepalive_timeout 75; keepalive_requests 1000; keepalive_timeoutはクライアントとのidle HTTP接続をどれだけ開いておくか keepalive_requestsは1つの接続でどれだけのリクエストを処理するか を決める。\n2) Upstream: Nginx ↔ WAS # この区間はNginxがバックエンドへの接続をキャッシュして再利用する構造だ。\nここで言うupstream connection reuseを非常にシンプルに言うと：\nリクエスト1つを処理するたびにNginx -\u0026gt; WAS TCP connectionを毎回新しく作ってすぐ切る代わりに リクエスト処理が終わった後でもそのconnectionをしばらくidle状態で残しておき 次のリクエストが同じupstreamサーバーに行く必要があるときそのconnectionを再度使う ということだ。\nつまり核心は**「生きているTCP connectionを次のHTTPリクエストでも再利用する」ということだ。\nこれはパフォーマンスの最適化と接続の再利用ポリシー**に近く、後で出てくるTCP keepaliveとは目的が異なる。\nupstream backend { server 127.0.0.1:8080; keepalive 32; } location / { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection \u0026#34;\u0026#34;; } ここで注意すること：\nkeepalive 32の32という数は「現在処理中のリクエスト数」を数えるわけでも、「NginxからWASへの全接続数」を数えるわけでもない。 この数は各workerがリクエスト処理を終えた後に切らずにidle状態で残すupstream connectionの最大数を意味する。 つまり新しいリクエストが来たときに再利用できるよう再利用用に保管しておくconnectionをworker1つあたり最大32個まで持つという意味だ。 例えばworkerが4つなら、idle upstream connectionはworkerごとに最大32個ずつ持てるため、全体のidle connection数は32より多くなる可能性がある。 古い説明のようにkeepalive 32 = バックエンド接続合計32個と理解すると間違いになる。 例えばworker 1が127.0.0.1:8080に開いたconnectionを20個idle状態で持っているとしよう。その状態で新しいリクエストが来て同じupstreamサーバーへ送る必要があれば、Nginxはすでに開いていたidle connectionの一つを取り出して再利用できる。逆にidle connectionが一つもなければそのときに新しいconnectionを作る。\nつまりkeepalive 32は**「workerが一つが次のリクエストで再利用しようと切らずに持っているidle upstream connectionを最大32個まで保管する」**という意味で読めばよい。\nもう一つはバージョンの違いだ。\n長い間NginxはupstreamのHTTPプロキシのデフォルトがHTTP/1.0だったため、proxy_http_version 1.1とproxy_set_header Connection \u0026quot;\u0026quot;が事実上必須だった。 NGINX 1.29.7からはHTTP upstream keep-aliveがデフォルトの動作に変わった。 それでも設定ファイルを読む人の観点からは、上記の2行を明示的に維持する方がまだ理解しやすい。\n3) NginxのTCP keepaliveはさらに別 # NginxのUpstream connection reuseとOSのTCP keepaliveは別の話だ。\n二つの違いを先に分けて見ると理解しやすい。\nupstream connection reuse：Nginxがリクエスト処理が終わった後でもNginx -\u0026gt; WAS connectionを閉じずに残しておき、次のリクエストに再利用すること TCP keepalive：とても長くidle状態のTCPソケットがまだ生きているかカーネルがprobeで確認すること つまり前者は**「次のリクエストに再利用するか？」についてのポリシーであり、後者は「このソケットはまだ生きているか？」**を確認するOS機能だ。\n必要であれば別途：\nproxy_socket_keepalive on; のようにソケットのSO_KEEPALIVEをオンにできる。\nここでSO_KEEPALIVEはこのTCPソケットに対してOSのTCP keepalive機能をオンにするソケットオプションだ。\nもう少し詳しく言うと：\nNginxがバックエンドのWASとTCP connectionを1つ開く。 そのconnectionはOSの観点では1つのsocketだ。 proxy_socket_keepalive on;を設定するとNginxはそのソケットにSO_KEEPALIVEオプションをオンにする。 そするとその後のkeepalive probeの送信有無とタイミングはカーネルのTCP keepalive設定に従う。 重要なのはproxy_socket_keepalive on;だからといってNginxがアプリケーションデータを定期的に送るわけではないということだ。この設定は**OSに「このソケットにはTCP keepaliveを適用してください」**と伝えるものだ。\nつまり：\nkeepalive 32はworkerが一つがリクエストが終わった後でも切らずに残すupstream connectionを何個まで保管するか proxy_socket_keepalive onはそのTCPソケットにOSのkeepalive機能をオンにするか を決める。\nつまり、\nkeepalive、keepalive_timeoutはHTTP接続の再利用ポリシー proxy_socket_keepaliveはTCPソケットのkeepalive だ。\n「接続を維持する技術」の実際の意味 # 特別な魔法があるわけではなく、各レイヤーで接続を閉じずに再利用しようとするポリシーを設けることだ。\n1. リクエスト/クエリの処理完了 2. レスポンス後にすぐFINを送らない 3. 一定時間idle状態でconnectionを保管 4. 次のリクエストが来たら同じconnectionを再利用 5. timeoutまたはライフサイクルポリシーが過ぎたら整理 ただし実際の生存は以下がすべて合っている必要がある。\nアプリケーション/poolのライフサイクルポリシー DBサーバーのidle timeout プロキシ/LB/NAT/ファイアウォールのtimeout 必要に応じてTCP keepaliveまたはアプリケーションレベルのping Keepaliveのデメリット # デメリット 説明 メモリ消費 idle connectionもsocket bufferと各種per-connection stateを占有する ファイルディスクリプターの枯渇 アイドル接続が増えると新しい接続を受け入れるのが難しくなる可能性がある 古い接続の保持 相手が異常終了していても次の使用時点まで分からない場合がある バックエンド接続の偏り upstream keepaliveが特定のインスタンスにconnectionを長く保持する可能性がある 設定の衝突 maxLifetime、wait_timeout、LBのidle timeoutが互いに合わないと断続的なエラーが残る 例えば：\n同時idle接続100,000個 x 20KB = 約2GB のようにidle connection自体がリソースコストになりうる。この数値はあくまでも大まかな例であり、実際のメモリ使用量はカーネル/アプリケーション/プロトコルの実装によって異なる。\n全区間のKeepalive全体像 # Client ─── Nginx ─── WAS ─── HikariCP ─── MySQL │ │ │ │ │ │ │ └─ DBのidle timeout（例：wait_timeout） │ │ └─ poolの再利用 / keepalive / maxLifetime │ └─ downstream/upstream HTTP keep-alive └─ クライアント側の持続接続 追加で各TCPソケットには必要に応じてOSのTCP keepaliveをオンにできる まとめると：\nHTTP keep-aliveはリクエスト間の再利用 HikariCPはDB connectionの再利用と交換ポリシー TCP keepaliveはidle socketの生存確認 を担当する。\nこの3つは連携できるが、互いに同じ機能ではない。\n参考資料 # Linux Kernel Documentation - IP Sysctl (tcp_keepalive_time, tcp_keepalive_intvl, tcp_keepalive_probes) HikariCP README - Configuration NGINX Official Docs - keepalive_timeout, keepalive_requests NGINX Official Docs - ngx_http_upstream_module keepalive NGINX Official Docs - proxy_socket_keepalive NGINX Official Docs - CHANGES (1.29.7) MySQL 8.4 Reference Manual - wait_timeout AWS Docs - Application Load Balancer connection idle timeout AWS Docs - Network Load Balancer TCP idle timeout AWS Docs - Troubleshoot NAT gateways（350秒 idle timeout） AWS Docs - EC2 security group connection tracking AWS Docs - Classic Load Balancer idle timeout (TCP keep-alive probes do not prevent termination) ","date":"2026年2月1日","externalUrl":null,"permalink":"/ja/posts/tcp-connection-keepalive/","section":"Posts","summary":"TCP接続はなぜ切れないのか、HikariCPはvalidation queryなしでなぜ30分間持つのか、Nginx/LBのKeepaliveとは何が違うのかを整理します。","title":"[Network] TCP接続はどのように維持されるのか — TCP Keepalive、HTTP Keepalive、HikariCP","type":"posts"},{"content":"","date":"2026年2月1日","externalUrl":null,"permalink":"/ja/tags/hikaricp/","section":"Tags","summary":"","title":"HikariCP","type":"tags"},{"content":"","date":"2026年2月1日","externalUrl":null,"permalink":"/ja/tags/keepalive/","section":"Tags","summary":"","title":"Keepalive","type":"tags"},{"content":"","date":"2025年10月31日","externalUrl":null,"permalink":"/ja/tags/linux/","section":"Tags","summary":"","title":"Linux","type":"tags"},{"content":"この記事は生活コーディングのLinux講義を受講しながら、別途気になった部分を調査・追記してまとめた学習ノートです。\nLinuxシステムの構造を理解し、ターミナルで自由にコマンドを扱うための基礎学習資料です。\n1. Linuxファイルシステムの構造（Directory Structure） # Linuxはウィンドウと異なりドライブ（C:、D:）の概念がなく、ルート（/）という最上位ディレクトリからすべてが始まるツリー（Tree）構造を持ちます。\nディレクトリ 説明・用途 /（Root） ファイルシステムの最上位の起点です。 /bin User Binaries. 必須の基本コマンドが位置します。最新システムでは /usr/bin へのリンクである場合が多いです。 /sbin System Binaries. システム管理用の必須コマンドが位置します。最新システムでは /usr/sbin へのリンクである場合が多いです。 /lib Libraries. システムやプログラムの実行に必要な共有ライブラリファイルが位置します。 /home ユーザーホームディレクトリ。 ウィンドウの C:\\Users\\ に似ています。cd ~ コマンドですぐに移動できます。 /etc 設定ファイル（Configuration）。 プログラムやシステム全般の設定ファイルが集まっています。 /var 可変データ（Variable）。 ログファイル（log）、データベースなど内容が変化し続けるファイルが格納されます。 /tmp 一時ファイル（Temporary）。 多くのディストリビューションでは起動時または定期的に整理されますが、常に保証されるわけではないため機密データを置かないでください。 /usr User System Resources. ディストリビューションが提供するほとんどのユーザー用プログラムとライブラリがインストールされます。手動インストールは通常 /usr/local（または /opt）に置きます。 2. 必須基本コマンド（Navigation \u0026amp; File Operation） # ターミナルで現在地を把握しファイル/フォルダを操作するコマンドです。\n位置確認と移動 # pwd（Print Working Directory）: 現在どのパスにいるかを表示します。 pwd # 出力例: /home/ubuntu cd（Change Directory）: ディレクトリを移動します。 cd ~: 自分のホームディレクトリに即座に移動。 cd ..: 上位（親）ディレクトリに移動。 Tip: パスを入力するとき Tab キーを押すと自動補完されます。（タイプミス防止・速度向上） ファイルとディレクトリの操作 # ls（List）: 現在のディレクトリのファイル一覧を表示します。 ls -a: 隠しファイル（ドットで始まるファイル）もすべて表示。 ls -l: ファイルの権限、所有者、サイズなど詳細情報をリスト形式で表示。 ls -al: 上記2つのオプションを合わせて、隠しファイルを含むすべてのファイルの詳細情報を表示。 mkdir（Make Directory）: 新しいディレクトリを作成します。 mkdir -p dir1/dir2: 親ディレクトリまで一度に作成します。（-p はparents） cp（Copy）: ファイルやディレクトリをコピーします。 cp [元] [対象] cp -r [元_ディレクトリ] [対象_ディレクトリ] # ディレクトリのコピーには -r オプションが必須 mv（Move）: ファイルを移動したり名前を変更するときに使用します。 mv old_name.txt new_name.txt # 名前変更 mv file.txt /home/user/docs/ # ファイル移動 rm（Remove）: ファイルを削除します。 rm -r [ディレクトリ]: ディレクトリを削除します。（内部ファイルを含む） rm -f [ファイル]: 確認メッセージなしに強制削除します。（-f はforce） 非常に危険: rm -rf / のようなコマンドはシステム全体を削除する可能性があるため絶対に使用してはいけません。 ファイル内容の確認と作成 # touch: 空の新ファイルを作成したり、既存ファイルの最終修正時刻を現在の時刻に変更します。 touch new_file.txt cat: ファイルの全内容をターミナルに一度に出力します。短いファイルに便利です。 less: cat と異なり、長いファイルの内容をページ単位で快適に確認できます。（移動：方向キー、終了：q） less large_log_file.log head / tail: ファイルの前部または後部の10行をデフォルトで出力します。 head -n 5 log.txt: 前部5行だけ見る tail -f log.txt: ファイルに内容が追加されるたびにリアルタイムで表示し続ける（-f はfollow） file: ファイルの種類（テキスト、画像、実行ファイルなど）を教えてくれます。 file my_script.sh -\u0026gt; Bourne-Again shell script, ASCII text executable 3. システム権限とパッケージ管理 # 権限（Permissions） # sudo（SuperUser Do）: 管理者（Root）権限でコマンドを実行します。システム設定の変更やプログラムのインストール時に必須です。なぜ権限が必要かについては14章で詳しく説明します。 パッケージマネージャー（Package Manager） # アプリストアのようにプログラムを簡単にインストール、アップデート、削除してくれるツールです。OS別でコマンドが異なります。\n系統 代表OS パッケージマネージャー 主要コマンド Debian Ubuntu, Debian, Mint apt sudo apt update\nsudo apt install [パッケージ]\n（apt-get も広く使われる） Red Hat RHEL, Fedora, CentOS dnf sudo dnf install [パッケージ]\n（旧システムでは yum を使用） Arch Arch Linux, Manjaro pacman sudo pacman -Syu\nsudo pacman -S [パッケージ] macOS - Homebrew（brew） brew install [パッケージ]\nbrew upgrade Java 開発者 Tip: JDK のインストール Java（JDK）はパッケージマネージャーで簡単にインストールできます。\n# Ubuntu で OpenJDK 17 をインストール sudo apt install openjdk-17-jdk # 複数の Java バージョンがインストールされている場合、使用するバージョンを選択 sudo update-alternatives --config java 別の方法: SDKMAN! 複数の JDK バージョンを頻繁に切り替える必要がある場合は sdkman が便利です。\n# インストール curl -s \u0026#34;https://get.sdkman.io\u0026#34; | bash # 例) Temurin 21 のインストールとデフォルト設定 sdk install java 21-tem sdk default java 21-tem 企業/セキュリティ環境ではインストールスクリプトの内容を先に確認し、社内ポリシーを遵守してください。\nファイルのダウンロードとAPIテスト # wget # wget はURLを通じてファイルをダウンロードするシンプルで直感的なツールです。\nwget [URL]: URLのファイルを現在のディレクトリに元のファイル名そのままダウンロードします。 wget -O [保存するファイル名] [URL]: ダウンロードしたファイルの名前を指定して保存します。 curl（Web開発者の必需品） # curl（cURL）はHTTP、HTTPS、FTPなど多様なプロトコルを使ってデータを転送する強力なツールです。単純なファイルダウンロードだけでなく、Web開発時にAPIとやり取りするほぼすべての状況を模倣できるため「ターミナル界のPostman/Insomnia」と呼ばれます。\n基本GETリクエスト（APIエンドポイントの確認） APIが正常に動作しているかを最も簡単に確認するときに使います。\n# 特定のユーザー情報をリクエストするAPIをターミナルからすぐにテスト curl https://jsonplaceholder.typicode.com/users/1 ヘッダー情報の確認（-i、-I） HTTPステータスコード（200 OK、404 Not Foundなど）やレスポンスヘッダー（Content-Type、Cache-Controlなど）を確認するときに非常に便利です。\n# レスポンス本文とヘッダーを一緒に見る curl -i https://example.com # ヘッダー情報だけを見る（HEADリクエスト） curl -I https://example.com POSTリクエスト（APIにデータを送信） ログイン、会員登録、データ作成などクライアントがサーバーにデータを送る際の状況をテストします。\n# JSONデータをAPIサーバーに送信する例 curl -X POST \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;title\u0026#34;: \u0026#34;foo\u0026#34;, \u0026#34;body\u0026#34;: \u0026#34;bar\u0026#34;, \u0026#34;userId\u0026#34;: 1}\u0026#39; \\ https://jsonplaceholder.typicode.com/posts -X POST: HTTPリクエストメソッドを「POST」に指定します。（-X GET、-X PUT、-X DELETE なども可能） -H \u0026quot;Content-Type: ...\u0026quot;: リクエストヘッダーを設定します。REST APIは大抵JSONを使うため必須のオプションです。 -d '{\u0026quot;json\u0026quot;: \u0026quot;data\u0026quot;}': リクエストに含めて送るデータ（Request Body）を指定します。 リダイレクトを追う（-L） リクエストしたURLが別のアドレスにリダイレクトされるとき、その最終目的地まで追って内容を取得します。\n# http://t.co/... のような短縮URLの実際の内容を見るときに使用 curl -L http://t.co/I5YYd9dDA 4. 入出力制御とパイプライン（I/O Redirection） # コマンドの結果（出力）を画面ではなくファイルに保存したり、ファイルの内容をコマンドの入力として使う際に使います。\n核心概念：標準ストリーム # Standard Input（stdin、0）: 入力（主にキーボード） Standard Output（stdout、1）: 出力（主にモニター） Standard Error（stderr、2）: エラーメッセージ コマンドは実行時にこの3つの入出力通路を持ってスタートします。OSはこれらの通路に 0、1、2 という番号を付けて管理しますが、これを**ファイルディスクリプター（file descriptor、fd）**と呼びます。\nここで「file」という言葉があるため実際のファイルだけを指しているように見えますが、必ずしもそうではありません。ターミナル、ファイル、パイプ、ソケットのような入出力対象全体を指す番号札に近いです。\nそのためシェルのリダイレクション文法は次のように読めます。\n0 = 入力通路（stdin） 1 = 一般出力通路（stdout） 2 = エラー出力通路（stderr） つまり 2\u0026gt; のような文法は**「2番通路（stderr）を別の場所に送れ」**という意味です。\nなぜ数字と \u0026gt; が一緒に付くか # シェルは**「何番の通路をどこに送るか」**を数字と記号で表現します。\n\u0026gt; は実は 1\u0026gt; の省略形です。 つまり ls \u0026gt; result.txt は ls 1\u0026gt; result.txt と同じ意味です。 2\u0026gt; は**2番通路（stderr）**をファイルに送ります。 \u0026lt; はファイルの内容を**0番通路（stdin）**に接続します。 例えば：\nls \u0026gt; result.txt # stdout(1) -\u0026gt; result.txt rm nofile.txt 2\u0026gt; error.log # stderr(2) -\u0026gt; error.log sort \u0026lt; list.txt # list.txt -\u0026gt; stdin(0) \u0026amp; とは何か？なぜ 2\u0026gt;\u0026amp;1 のように使うか # 初心者が最も混乱するのがここです。\nまずコマンドの末尾に付く \u0026amp; と 2\u0026gt;\u0026amp;1 の中の \u0026amp; は同じ形ですが文脈が異なります。\nsleep 100 \u0026amp; の \u0026amp; はバックグラウンド実行を意味します。 2\u0026gt;\u0026amp;1 の \u0026amp; は**「ファイル名ではなく、別のファイルディスクリプターを指す」**という意味です。 つまり 2\u0026gt;\u0026amp;1 はこのように読めます。\n2\u0026gt;: 2番通路（stderr）を対象にする \u0026amp;1: ファイル名 1 ではなく、1番通路（stdout）が現在向かっている場所を指す そのため 2\u0026gt;\u0026amp;1 の意味は：\n「stderr（2）もstdout（1）が現在行く場所に送れ」\nです。\nこの違いを必ず区別する必要があります。\nrm nofile.txt 2\u0026gt;1 上のコマンドはエラーを 1 という名前のファイルに保存します。 つまり 1 をファイル名として見たわけです。\n一方：\nrm nofile.txt 2\u0026gt;\u0026amp;1 このコマンドはエラー（stderr）をstdoutと同じ場所に送ります。 ここで \u0026amp;1 はファイルではなく1番出力通路を意味します。\nリダイレクション記号 # \u0026gt;: 実行結果（stdout）をファイルに上書き保存します。（1\u0026gt; の省略形） ls -l \u0026gt; result.txt \u0026gt;\u0026gt;: 実行結果をファイルの末尾に追加します。 ls -al \u0026gt;\u0026gt; result.txt \u0026lt;: ファイルの内容をコマンドの入力（stdin）として使います。 sort \u0026lt; list.txt # list.txtの内容を並べ替えて画面に出力 2\u0026gt;: エラー（stderr）だけ別途ファイルに保存します。 rm nofile.txt 2\u0026gt; error.log \u0026amp;\u0026gt; または \u0026gt; ... 2\u0026gt;\u0026amp;1: 出力（stdout）とエラー（stderr）をすべて同じ場所に送ります。自動化スクリプトでログを残すときによく使います。 # コマンドのすべての結果（出力+エラー）をall.logに上書き # \u0026amp;\u0026gt; all.logは \u0026gt; all.log 2\u0026gt;\u0026amp;1の短縮表現（Bash/Zsh専用）です。 ls -l /nonexistent \u0026amp;\u0026gt; all.log # POSIX互換も考慮するならこの形がより汎用的です。 ls -l /nonexistent \u0026gt; all.log 2\u0026gt;\u0026amp;1 # コマンドのすべての結果をall.logに追加 ls -l /nonexistent \u0026gt;\u0026gt; all.log 2\u0026gt;\u0026amp;1 ここで重要なポイントがもう1つあります。2\u0026gt;\u0026amp;1 は**「stderrをstdoutと同じ場所に送れ」という意味であり、ファイルを直接指定する文法ではありません。そのため通常は \u0026gt; ファイル名 または \u0026gt;\u0026gt; ファイル名 と合わせて**使います。\n例えば：\ncommand \u0026gt; all.log 2\u0026gt;\u0026amp;1 この文はこのように読みます。\nstdout（1）をまず all.log に送る。 stderr（2）も**stdoutが現在行く場所（all.log）**に送る。 つまり最終的にstdoutとstderrがどちらも all.log に入ります。\n逆に順序を変えると結果が変わる場合があります。\ncommand 2\u0026gt;\u0026amp;1 \u0026gt; all.log この場合、まずstderrが現在stdoutが向かっている場所（通常ターミナル）を追い、その後でstdoutだけ all.log に変わります。 つまり初心者としては\u0026gt; all.log 2\u0026gt;\u0026amp;1 の順序を覚えておくのが安全です。\nパイプライン（Pipeline、|） # あるプログラムの**出力（stdout）を別のプログラムの入力（stdin）**に接続するコンベアベルトです。\n活用例： ps aux | grep apache: 現在実行中のすべてのプロセス（ps）の中から「apache」だけをフィルタリングして見ます。 ls -l | wc -l: ファイル一覧の行数を数えて現在のディレクトリのファイル/フォルダ数を確認します。 grep（フィルタリング） # パイプラインとよく一緒に使われ、目的の文字列が含まれる行（Line）だけを検索する強力なツールです。\nよく使うオプション： -i（Ignore case）: 大文字小文字を区別せずに検索します。 -r（Recursive）: 下位ディレクトリまですべて検索します。 -n（Line number）: 結果が何行目にあるかも一緒に出力します。 -w（Word）: 正確にその単語だけを検索します。（例：grep -w \u0026quot;is\u0026quot; は「this」を除外） -v（Invert）: その単語が含まれない行だけを出力します。 5. シェル（Shell）とカーネル（Kernel） # ユーザーが入力したコマンドがハードウェアまで伝達される過程です。\nUser: コマンドを入力（例：ls） Shell: ユーザーのコマンドを解釈してカーネルに伝達（翻訳家の役割） Kernel: ハードウェアを制御して実際の作業を実行 Hardware: 物理的な演算を実行 シェルの種類 # bash: ほとんどのLinuxシステムにデフォルトでインストールされている最も大衆的なシェル。 zsh: bash と互換性を持ちながら、強力なプラグイン（oh-my-zsh）とテーマ、自動補完機能で生産性を大きく向上させるシェル。 fish: 「Friendly Interactive SHell」の略で、別途設定なしでも便利な機能（自動サジェスト、文法ハイライトなど）をすぐ使えて初心者に親しみやすいシェル。 echo $0 または ps -p $$: 現在使用中のシェルを確認するコマンド。 シェルスクリプト（Shell Script） # 繰り返しの作業を自動化するためにコマンドをファイルにまとめて一度に実行する「台本」です。\nShebang（#!）: スクリプトファイルの最初の行に書き、このファイルをどのシェルで解釈するかを指定します。 #!/bin/bash # 上のコードは以下の内容を /bin/bash シェルで実行するという意味 echo \u0026#34;Hello Shell Script!\u0026#34; シェルスクリプトの実行 # 実行権限の付与: chmod +x my_script.sh スクリプトの実行: ./my_script.sh 6. Linuxファイル検索コマンドのまとめ # 1. locate（データベースベースの高速検索） # あらかじめ作られたファイル目録データベース（DB）を検索するため非常に速いですが、直前に作成したファイルは見つからない場合があります。\n使い方： locate [ファイル名] DBの更新（必要時）： sudo updatedb 2. find（リアルタイム精密検索） # 遅いですが現在のファイルシステムを直接探索して、名前、種類、サイズ、時間など非常に多様な条件で検索できます。\n基本構造： find [パス] [オプション] [式] パス: /（全体）、.（現在のディレクトリ）、~（ホームディレクトリ） 主要オプション： 名前: -name \u0026quot;*.log\u0026quot;（大文字小文字区別）、-iname \u0026quot;*.log\u0026quot;（大文字小文字無視） タイプ: -type f（ファイル）、-type d（ディレクトリ） サイズ: -size +100M（100MB超）、-size -10k（10KB未満） 時間: -mtime -7（7日以内に修正）、-mtime +7（7日以前に修正） 検索後に作業を実行（-exec、-delete）： -exec は検索されたファイルに対して別のコマンドを実行する強力な機能です。 {} は検索されたファイルを意味し、\\; はコマンドの終わりを知らせます。 # .tmpファイルを見つけて削除前に確認メッセージを表示（安全） find . -name \u0026#34;*.tmp\u0026#34; -exec rm -i {} \\; # .tmpファイルをそのまま削除（効率的） find . -name \u0026#34;*.tmp\u0026#34; -delete 7. ファイルの位置と実行の仕組み # 7.1. コマンドの実際の位置を見つける（which、whereis） # which [コマンド]: $PATH 環境変数に設定されたパスだけを検索して、実行されるコマンドの絶対パスを見つけてくれます。 $ which ls /bin/ls whereis [コマンド]: 実行ファイルだけでなく、ソースコード、マニュアルファイルの位置まで教えてくれます。 7.2. コマンド実行の仕組み（$PATH） # ls や java のように短く入力するだけでコマンドが実行される理由は、シェルが $PATH という環境変数に登録されたディレクトリを順番に検索して実行ファイルを見つけてくれるからです。\n確認方法: echo $PATH Java 開発者 Tip: JAVA_HOME と PATH Java インストール後に JAVA_HOME 環境変数を設定し、この変数を使って $PATH に JDK の bin ディレクトリを追加するのが標準的な方法です。こうすることで java、javac、jps など JDK のすべてのコマンドをどこからでも使えます。\n# ~/.bashrc または ~/.zshrc に追加 export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 export PATH=$JAVA_HOME/bin:$PATH 8. コンピューターの構造とプロセスの理解 # 8.1. プログラム vs プロセス # プログラム（Program）: ストレージ（HDD、SSD）に保存された、実行される前の静的なコードの塊です。（例：/bin/ls 実行ファイル） プロセス（Process）: プログラムが実行されてメモリに上がり、CPUによって処理されている動的な状態です。すべてのプロセスは固有のID（PID）を持ちます。 8.2. 親と子のプロセス（Parent-Child Process） # Linuxのすべてのプロセスは「家族関係」のように階層構造を形成します。この概念を理解すると export がなぜ重要かが完璧に理解できます。\n比喩：「たい焼き型とたい焼き」\n親プロセス（Parent Process）: 現在実行中のプロセスです。（例：コマンドを入力するターミナル（シェル）） 子プロセス（Child Process）: 親プロセスが生成した新しいプロセスです。 どのように作られるか？（Fork \u0026amp; Exec）\nターミナル（親）で java -jar my-app.jar というコマンドを実行すると、内部的に2つのことが瞬時に起こります。\nFork（複製）： ターミナル（親）が自分自身とほぼ同じコピー、つまり「子」を作ります。まるでたい焼き型で同じ形を型抜きするようなものです。この時点の子は親とほぼすべてを共有します。（特に環境変数！） Exec（実行）： 複製された「子」が自分自身を捨てて、java という新しいプログラムに変わります。今この子はJVMプロセスとなって my-app.jar を実行し始めます。型から出た生地にあんこを入れて本当のたい焼きとして完成させる過程と同じです。 Java 開発者にとってなぜ重要か？ まさにこの**Fork（複製）の段階で親の「環境変数」が子（JVM）に継承**されるからです。 ターミナル（親）で export SPRING_PROFILES_ACTIVE=prod のように設定すると、子の java プロセスはこの値をそのまま受け継ぎます。 Spring Boot アプリケーションは System.getenv() を通じてこの環境変数を読み取り、prod プロファイル用の application-prod.yml 設定をロードします。export を使わない「シェル変数」は親の部屋でだけ使われる秘密なので子に継承されません。\nプロセスの関係を確認する # ps コマンドにオプションを付けると親子関係を直接確認できます。PPID がまさに親プロセスのIDです。\n# 現在自分が実行したプロセスのID（PID）と親ID（PPID）を一緒に見る ps -o pid,ppid,comm # PID PPID COMMAND # 12345 1234 bash （自分のターミナル、親） # 54321 12345 ps （psコマンドを実行した子） pstree コマンドを使うとこの関係をツリー構造でよりわかりやすく見ることができます。（インストールが必要：sudo apt install psmisc）\npstree この親子関係と継承の概念はLinuxシステムの動作方式の核心の1つです。\n9. プロセスのモニタリングと管理 # 9.1. プロセス一覧の確認（ps） # ps -ef: システムのすべてのプロセスを標準フォーマットで詳しく照会します。ps aux と類似していますが、System V系（多くのLinuxディストリビューション）でより標準として使われます。 ps -ef | grep java: システムで実行中のすべての java プロセスをフィルタリングして確認します。 9.2. リアルタイムモニタリング（top \u0026amp; htop） # top: 従来のリアルタイムプロセスモニタリングツール。 htop: top より視覚的に見やすく使いやすいアップグレード版。（インストールが必要：sudo apt install htop） Load Average: システム負荷平均。CPUコア数より高ければ過負荷状態と見なせます。 Java 開発者 Tip: JDK 内蔵モニタリングツール ps の他に JDK が提供するツールを使うと JVM に特化した情報が得られます。\njps: 現在実行中の Java プロセスの PID と Main クラス名を表示します。ps -ef | grep java よりずっとすっきりしています。 jstat -gc [PID] 1000: 該当 PID の Java プロセスに対して GC（Garbage Collection）状態を1秒ごとに表示します。メモリリークや GC チューニング時に有用です。 10. マルチタスキングとバックグラウンド実行 # 10.1. フォアグラウンド vs バックグラウンド # フォアグラウンド（Foreground）: コマンドを実行するとターミナルの制御権を持ってユーザーの入力を待っている状態。 バックグラウンド（Background）: ターミナルの制御権なしに裏で静かに実行されている状態。 10.2. 作業制御 # プロセスの中断（Ctrl + C）: フォアグラウンドで実行中のプロセスに**終了シグナル（SIGINT）**を送って中断させます。 プロセスの一時停止（Ctrl + Z）: 実行中のプロセスを停止してバックグラウンドに送ります。（終了ではありません） バックグラウンド作業の確認（jobs）: バックグラウンドにある作業の一覧と番号（Job ID）を確認します。 バックグラウンドで実行（\u0026amp;）: コマンドの後に \u0026amp; を付けると最初からバックグラウンドで実行されます。 # ターミナルを閉じてもプロセスが終わらないように nohup と一緒に使う nohup java -jar my-app.jar \u0026amp; Tip: \u0026amp; だけ使うとターミナルセッションが切れるときプロセスも一緒に終了します。nohup（No Hang Up）コマンドと合わせて使うことでターミナル接続が切れてもプロセスを実行し続けられます。出力とエラーは nohup.out ファイルに自動保存されます。 より安定した運用のためには nohup より11章で学ぶ systemd を使うのが良いです。 作業の切り替え（fg、bg）: fg %[作業番号]: バックグラウンドの作業を再びフォアグラウンドに持ってきます。（Foreground） bg %[作業番号]: 一時停止した作業をバックグラウンドで実行し続けます。（Background） 10.3. プロセスの終了：kill の技術 # kill は単にプロセスを「殺す」だけでなく、特定の**「シグナル（Signal）」**を送る繊細なコマンドです。\nステップ1：PIDを見つける（feat. jps、pgrep） # プロセスを制御するにはまず固有番号である**PID（Process ID）**を知る必要があります。\n# 1. jps できれいに見つける（推奨） jps # 2. pgrep で名前で見つける pgrep -f \u0026#34;my-app.jar\u0026#34; # 出力例: 12345 ステップ2：シグナルを送る（状況に合わせて） # kill -15 [PID] または kill [PID]（最も一般的で安全な終了）\nSIGTERM（15） シグナルを送ります。これは「アプリよ、もう準備して丁寧に終了してください」という礼儀正しいリクエストです。 Java 開発者 Tip: よく作られた Spring Boot アプリはこのシグナルを受け取ると、進行中のHTTPリクエストを安全に終わらせ、DBコネクションプールを閉じ、メッセージキューリスナーを停止するなど**グレースフルシャットダウン（Graceful Shutdown）**を実行します。常にこの方法を先に試すべきです。 kill -9 [PID]（最後の手段、強制終了）\nSIGKILL（9） シグナルを送ります。これはプロセスが無視できない強制終了命令です。「今すぐすべてを止めて消えろ！」という意味です。 Java 開発者 Tip: kill -15 でも終了しない応答不能（Hang）プロセスを片付けるときだけ使用してください。すでに終了した後に親が回収できなかったゾンビプロセスは kill -9 でも消えないため、親プロセスを終了するか systemd が回収するように処理する必要があります。JVMが後処理（Shutdown Hook）をする時間すら与えずに即座に消えるため、データが破損したり不完全な状態で残る場合があります。 kill -1 [PID]（設定のリロード）\nSIGHUP（1） シグナルを送ります。多くのサーバープログラムはこのシグナルを受け取ると設定ファイルを再読み込みします（reload）。 Web 開発者 Tip: Nginx のようなWebサーバーの設定を変更した後、サーバーを中断させずに変更内容を適用したいときに使います。（Javaアプリ自体はこのシグナルを直接処理しないことが多いため、Javaアプリの再起動が不要なNginxの再設定に主に使われます。） ステップ3：名前で一度に制御（pkill） # PIDをいちいち探すのが面倒なとき、プロセス名を使ってシグナルを送ります。\n# \u0026#34;my-app.jar\u0026#34; を実行しているすべての java プロセスに丁寧な終了シグナルを送る pkill -f \u0026#34;my-app.jar\u0026#34; # \u0026#34;hung-app.jar\u0026#34; のように応答しないプロセスに強制終了シグナルを送る pkill -9 -f \u0026#34;hung-app.jar\u0026#34; 11. デーモン（Daemon）とサービス管理（systemd） # 11.1. デーモンの概念と systemd # デーモン（Daemon）: システムバックグラウンドで常に実行されて特定のリクエストを処理するために待機するプログラム。（例：Webサーバー、DBサーバー） systemd: 現代のLinuxディストリビューションの標準システム・サービスマネージャーです。過去の init.d スクリプトや service コマンドに代わってデーモン（サービス）を管理します。 11.2. サービスの制御（systemctl） # systemctl は systemd を制御する核心コマンドです。（大抵 sudo が必要）\nサービスの開始/停止: sudo systemctl start apache2.service sudo systemctl stop apache2.service サービスの再起動/状態確認: sudo systemctl restart apache2.service sudo systemctl status apache2.service （.service 拡張子は省略可能） 11.3. 起動時の自動実行設定 # 自動実行の有効化: sudo systemctl enable apache2 自動実行の無効化: sudo systemctl disable apache2 有効化の確認: sudo systemctl is-enabled apache2 これらのコマンドは過去に /etc/rc.d/ ディレクトリにシンボリックリンクを作る複雑な過程に代わります。 11.4. Javaアプリケーションのためのsystemdサービス登録（実務の核心） # nohup はシンプルですが、サーバーが再起動するとアプリが自動で実行されず、ログ管理も不便です。現代のLinuxでは systemd を使ってJavaアプリケーションを安定した「サービス」として登録・管理します。\nサービスファイルの作成: /etc/systemd/system/ パスに [サービス名].service ファイルを作成します。（例：my-app.service）\nsudo vim /etc/systemd/system/my-app.service サービスファイルの作成:\n[Unit] Description=My Spring Boot Application After=network.target [Service] User=myuser Group=mygroup ExecStart=/usr/bin/java -jar /home/myuser/app/my-app-0.0.1.jar --spring.profiles.active=prod SuccessExitStatus=143 Restart=on-failure RestartSec=10 [Install] WantedBy=multi-user.target Description: サービスの説明。 User/Group: サービスを実行するユーザーとグループ。セキュリティのために root ではなく専用アカウントを作るのが良いです。 ExecStart: アプリケーションを実行するコマンド。必ず絶対パスを使う必要があります。 SuccessExitStatus=143: kill -15（SIGTERM）で正常終了したときの終了コード（143）を成功とみなします。 Restart=on-failure: サービスが異常終了すると10秒後に自動的に再起動します。 WantedBy=multi-user.target: システムが起動するときにこのサービスを開始するように設定します。 環境変数管理のTip: サービスファイルに Environment=\u0026quot;SPRING_PROFILES_ACTIVE=prod\u0026quot; を追加するか EnvironmentFile=/etc/default/my-app で外部ファイルを読み込むようにすると、コードと秘密の値を分離して運用できます。（systemctl edit my-app でドロップインファイルを作るのが更新時に衝突を減らす安全な方法です。） サービスの適用と開始:\nsudo systemctl daemon-reload: 新たに作ったサービスファイルを systemd が認識するようにリロードします。 sudo systemctl start my-app: サービスを開始します。 sudo systemctl status my-app: サービスの状態を確認します。（緑色の active (running) を確認） sudo journalctl -u my-app -f: my-app サービスのログをリアルタイムで確認します。（tail -f と類似） 起動時の自動実行設定:\nsudo systemctl enable my-app: システムが起動するときに自動的にサービスが開始されるよう有効化します。 sudo systemctl disable my-app: 自動起動を無効化します。 12. 定期的な作業のスケジューリング（Cron） # 12.1. Cronの概念と設定 # 定期的に繰り返される作業（バックアップ、ログ整理など）を自動化するツールです。\n設定ファイルの編集: crontab -e 設定一覧の確認: crontab -l Tip: crontab -e 実行時にデフォルトのエディターが不便であれば export EDITOR=nano コマンドでエディターを変更してから使ってみてください。 12.2. 作成文法 # 形式: 分 時 日 月 曜日 コマンド（左から順番に）\nフィールド 意味 範囲 分 何分に 0-59 時 何時に 0-23 日 毎月何日に 1-31 月 何月に 1-12 曜日 何曜日に 0-7（0と7が日曜日） *: 「毎回」を意味します。 例： # 毎日深夜2時10分にバックアップスクリプトを実行 10 2 * * * /home/user/backup.sh \u0026gt; /var/log/backup.log 2\u0026gt;\u0026amp;1 13. シェル設定の自動化：自分だけの開発環境を構築する # 開発をしているとき git status を頻繁に入力したり、cd ~/projects/my-awesome-project のような長いパスに移動し続けたりと、毎回繰り返す作業が多くあります。また DATABASE_URL や API_KEY のような情報をターミナルに一時的に入力することもあります。\nターミナルを開くたびにこのような環境が自動的に設定されると非常に便利ですね。その役割をするのが「シェル起動スクリプト」を利用した設定の自動化です。\n13.1. シェル設定の核心：起動ファイル、変数、そしてエイリアス # 1）シェル起動ファイル：.bashrc と .zshrc（設定レシピ） # 新しいターミナルを実行すると、シェル（料理人）は ~/.bashrc や ~/.zshrc のような「レシピノート」を読んで書かれたコマンドを実行しながら作業環境を構成します。\n.bashrc: BASHシェルが起動するたびに読む個人のカスタム設定集です。 .zshrc: ZSHシェルが起動するたびに読む個人のカスタム設定集です。 両者の用途はまったく同じであり、どのシェルを使うかによって使う「設定集」の名前だけが異なります。以下のコマンドで現在使用中のシェルを確認できます。\n# 今自分が使っているシェルを確認（「bash」または「zsh」などが出力される） echo $0 2）変数の2種類：シェル変数 vs 環境変数（自分の部屋 vs 家全体） # export はシェル設定で最も重要で分かりにくい概念です。**「家全体」と「自分の部屋」**の比喩で確実に理解しましょう。\nターミナル（シェル） = 「自分」 シェル変数 (API_KEY=\u0026quot;12345\u0026quot;) = 「自分の部屋」だけで使う秘密の暗号。「自分」（現在のターミナル）だけが知っていて、「自分」が実行する他のプログラム（子プロセス）は知ることができません。 環境変数 (export API_KEY=\u0026quot;12345\u0026quot;) = 「家」のすべての家族が知っている共有Wi-Fiのパスワード。「自分」と「自分」が実行するすべての子（プログラム）が一緒に使えるように継承されます。 export コマンド = 「自分の部屋のパスワード」を「家族共有のパスワード」に昇格させて全員に知らせる拡声器の役割。 ここで初心者が最も多くする質問がこれです。\n「~/.bashrc に単に MY_VAR=hello と書いても、シェルが子を作るとき fork するから子もその値をコピーしてもらえるのではないか？」\n混乱しやすい質問ですが、核心は**fork だけでなく実際の外部コマンドの実行は通常 fork + exec と続く**という点です。\nもう少し正確に説明すると：\n現在のシェルが子プロセスを作るときまず fork() を行う。 その次に子は exec() で java、python、env のような別のプログラムに切り替わる。 このとき新しいプログラムに渡されるのはシェル内部の状態全体ではなく、exportされた環境変数のリストです。 つまり MY_VAR=hello のようにexport なしで作ったシェル変数は現在のシェル内では見えますが、exec() で実行された外部プログラムには基本的に渡されません。\nそのため以下の2行はまったく異なる動作をします。\nMY_VAR=hello export OTHER_VAR=world MY_VAR は現在のシェル内部でのみ知っている値 OTHER_VAR は現在のシェル + 以降に実行する子プロセスも知っている値 つまり違いは単純に「変数か環境変数か」で正しいです。ただし実務ではこの違いが子プロセスに渡されるかどうかを決めるため非常に重要です。\nJava 開発者に export が必須な理由 ターミナルで java -jar my-app.jar で Spring Boot アプリを実行すると、この java プロセスはターミナルの「子」プロセスになります。Spring Boot コードの application.yml で ${MY_API_KEY} のように環境変数を使ったり、コード内で System.getenv(\u0026quot;MY_API_KEY\u0026quot;) を通じて値を読み込もうとするとき、親のターミナルが export で MY_API_KEY を設定していなければ子の Java プロセスはこの値を絶対に知ることができません。そのため「API キーが定義されていません」のようなエラーが発生したり設定値が空になります。外部の子プロセスに値を渡すには必ず export を使う必要があります。\n3）変数の永続性：一時変数 vs 永久変数（付箋 vs レシピ） # ターミナルで設定した変数はなぜウィンドウを閉じると消えるのでしょう？一時記憶（メモリ）と永久記憶（ファイル）の違いだからです。\nターミナルで直接 export（一時、付箋） export MY_VAR=\u0026quot;hello\u0026quot; をターミナルに入力するのは、現在のターミナルのメモリ（RAM）に内容を一時的に記録することです。モニターに付箋を貼るのと同じで、ターミナルを閉じると一緒に消えます。\n設定ファイルに export を書く（永久、レシピ） 変数を永久に保存するには export MY_VAR=\u0026quot;hello\u0026quot; というコマンド自体を ~/.bashrc のような設定ファイルに記録する必要があります。このファイルは「シェルが起動するたびにこれらのコマンドを実行して環境を再構成せよ」と教えてくれる**「設定レシピ」**です。新しいターミナルが開くたびにシェルはこのレシピを読んで再び「付箋を貼って」くれます。\nここでも重要な違いがあります。\n# ~/.bashrc MY_VAR=\u0026#34;hello\u0026#34; このように書くだけでは、新しいターミナルが開くたびにそのシェル自身は MY_VAR を知るようになります。 つまりターミナル内で echo \u0026quot;$MY_VAR\u0026quot; は正常に動作する場合があります。\nしかしそのターミナルで実行した外部プログラムは異なります。\npython3 -c \u0026#39;import os; print(os.getenv(\u0026#34;MY_VAR\u0026#34;))\u0026#39; このようなプログラムは MY_VAR を見えない場合があります。なぜなら MY_VAR はまだシェル変数にすぎず、環境変数としてexportされていないからです。\n逆に：\n# ~/.bashrc export MY_VAR=\u0026#34;hello\u0026#34; このように書いておくと：\n新しいターミナルが開くとシェルが MY_VAR を作る。 同時にこの値を環境変数として登録する。 その後このシェルが実行する java、python、node、env のような子プロセスもその値を受け取れる。 つまり ~/.bashrc に書くという事実だけで子プロセスへの伝達が保証されるわけではありません。 **「設定ファイルに書いたか？」と「export したか？」**は別の問題です。\nまとめると：\n~/.bashrc に MY_VAR=hello を書く 新たに開いたシェル自身がその値を使用可能 ~/.bashrc に export MY_VAR=hello を書く 新たに開いたシェル + そのシェルが実行する子プロセスも使用可能 もう1つよく混乱する点があります。新しいターミナルが MY_VAR を知っている理由は、親シェルが無条件にその値を渡したからではなく、新しいシェルが自分の起動ファイル（~/.bashrc）を再度読んで実行したからかもしれません。 つまり「新しいターミナルでも見える」と「外部の子プロセスに渡される」は同じ話ではありません。\n4）自分だけのコマンド：エイリアス（Alias）を作る # よく使う長くて複雑なコマンドを簡単なショートカットキーのように作れます。\n# エイリアス（alias）設定の例 alias gs=\u0026#34;git status\u0026#34; alias ll=\u0026#34;ls -alF\u0026#34; alias bootRun=\u0026#34;./gradlew bootRun\u0026#34; このような alias 設定を ~/.bashrc や ~/.zshrc ファイルに追加しておくと、新しいターミナルからすぐに使えます。\n5）現在設定されている変数を確認する # 「それで今自分のターミナル（メモリ）にどんな「付箋」が貼られているか？」が気になるときに使うコマンドです。\nenv または printenv: 現在のシェルの環境変数（「家族共有のパスワード」）だけを選んで表示します。 set: 環境変数とシェル変数（「家族共有のパスワード」と「自分の部屋のパスワード」）をすべて表示します。 13.2. 発展：ログインシェルとインタラクティブシェル（.bash_profile vs .bashrc） # シェルは起動方式によって異なる設定ファイルを読みます。この微妙な違いを知っておくと良いです。\n.bash_profile（ログインシェル）\nいつ？: システムにログインするとき一度だけ実行されます。（例：ssh でリモートサーバーに初めて接続するとき） 何を？: 一度設定すれば良い重要な環境変数（JAVA_HOME など）を主に設定します。 .bashrc（インタラクティブシェル）\nいつ？: ログインではなく、新しいターミナルウィンドウを開くたびに実行されます。 何を？: ターミナルを便利に使うための設定（alias、プロンプトの形など）を主に設定します。 ベストプラクティス（これだけ使えばOK！） # 上の区別が分かりにくいなら、ほとんどのLinuxシステムで使われるベストプラクティスは以下の通りです。\nすべての設定を .bashrc にまとめます。（alias、export など）\n.bash_profile では .bashrc を読み込むだけにします。 以下のコードを ~/.bash_profile に追加するか、すでにあればコメントを外してください。\n# ~/.bash_profile if [ -f ~/.bashrc ]; then source ~/.bashrc fi 理由: こうすると ssh でログインするとき（.bash_profile が実行）も、新しいターミナルを開くとき（.bashrc が実行）も常に同一で一貫した環境で作業できるようになります。\n13.3. 設定の適用：source コマンド（更新） # .bashrc ファイルを修正しても、すでに開いているターミナルウィンドウには変更が適用されません。ターミナルは起動時だけ設定ファイルを読むからです。\nsource は**現在のシェルにスクリプトファイルの内容をすぐに適用（更新）**せよというコマンドです。\n# 1. ~/.bashrc に新しいエイリアスを追加（例: alias gp=\u0026#34;git push\u0026#34;） # 2. ターミナルを再起動しなくても、以下のコマンドですぐに適用！ source ~/.bashrc # 新しいエイリアス \u0026#39;gp\u0026#39; をすぐに使えます。 source はターミナルを再起動せずに設定をすぐに反映できる非常に便利なコマンドです。\n14. マルチユーザーと権限管理 # Linuxは複数のユーザーが同時に接続して使う「マルチユーザーシステム」です。そのため誰がどのファイルにアクセスして修正できるかを制御する「権限」の概念が非常に重要です。\n14.1. ユーザーの確認と切り替え # id: **自分（現在のユーザー）**のシステム情報（UID、GID、所属グループ）を確認します。 who: 現在システムにログインしているすべてのユーザーの一覧を表示します。 su - [ID]: そのユーザーへ完全に切り替え（ログイン）します。（- はそのユーザーの環境設定もすべて読み込む重要なオプション） 14.2. ユーザーの追加と管理 # sudo useradd -m [ID]: 新しいユーザーを追加します。（-m はホームディレクトリ作成オプション） sudo passwd [ID]: ユーザーのパスワードを設定または変更します。 sudo usermod -aG [グループ名] [ID]: ユーザーを特定のグループに追加します。（例：sudo usermod -aG sudo myuser は myuser に管理者権限を付与） sudo userdel -r [ID]: ユーザーを削除します。（-r はホームディレクトリまですべて削除） 14.3. ファイル権限（Permission）の完全理解 # ファイル権限とは、特定のファイルやディレクトリを誰が読み、書き、実行できるかを指定するルールです。権限は3種類のユーザーに対して定義されます。\nOwner（User）: ファイルの所有者。 Group: 所有者が属するグループ。（例：開発チーム、管理者グループなど） Other: 所有者でもなくグループにも属していない第三者（その他のすべてのユーザー）。 権限情報の解読（ls -l） # ls -l コマンドはファイルとディレクトリの詳細情報を表示し、このうち権限情報が最も重要です。\ndrwxr-x--- 8 keonhongkoo staff 256 1 15 2023 .android\nファイルタイプ（最初の文字）\nd: ディレクトリ（Directory） -: 一般ファイル（File） l: シンボリックリンク（Symbolic Link） 権限モード（次の9文字列：rwxr-x---）\nrwx（Owner）: 最初の3文字は所有者の権限です。読み取り（r）、書き込み（w）、実行（x）がすべて可能です。 r-x（Group）: 中間の3文字はグループの権限です。読み取り（r）と実行（x）のみ可能です。 ---（Other）: 最後の3文字はその他のユーザーの権限です。何の権限もありません。 その他の項目\n8: ハードリンク数 keonhongkoo: 所有者名 staff: グループ名 256: ファイルサイズ（Byte） 1 15 2023: 最終修正日 .android: ファイルまたはディレクトリ名 権限の意味：ファイル vs ディレクトリ # r、w、x 権限は対象がファイルかディレクトリかによって意味が異なります。\n権限 記号 ファイル（File）での意味 ディレクトリ（Directory）での意味 読み取り r ファイルの内容を見る（cat、vim） ディレクトリ内部のファイル一覧を参照（ls） 書き込み w ファイルの内容を修正/保存 ディレクトリ内のファイル作成、削除、名前変更（touch、rm、mv） 実行 x ファイルをプログラムとして実行 ディレクトリ内部への進入（アクセス）（cd） 注意: ディレクトリに x 権限がないと cd で入ることができません。また自分のファイルでも、そのファイルが含まれているディレクトリに w 権限がなければファイルを削除したり名前を変えることができません。 権限の変更：chmod（Change Mode） # chmod はファイルやディレクトリの権限を変更するコマンドです。2つの方式（Symbolic、Octal）があります。\n1）シンボリック（Symbolic）方式：直感的な文字を使用\nchmod [対象][演算子][権限] [ファイル名] の形で使います。\n対象: u（user）、g（group）、o（other）、a（all） 演算子: +（追加）、-（除去）、=（指定） 権限: r（read）、w（write）、x（execute） # my_script.shファイルに対して... # 所有者（u）に実行（x）権限を追加（+） chmod u+x my_script.sh # グループ（g）に書き込み（w）権限を除去（-） chmod g-w my_script.sh # すべてのユーザー（a）に読み取り（r）権限だけになるよう指定（=） chmod a=r my_script.sh 2）数字（Octal）方式：素早く正確な数字を使用\n権限を数字で表現して組み合わせます。\nr（Read） = 4 w（Write） = 2 x（Execute） = 1 3桁の数字を 所有者-グループ-その他 の順序で指定します。\nrwx = 4+2+1 = 7 rw- = 4+2 = 6 r-x = 4+1 = 5 r-- = 4 # 所有者はrwx（7）、グループはr-x（5）、その他のユーザーはr-x（5）の権限を付与 # 一般的な実行ファイルやWebディレクトリの権限としてよく使われる chmod 755 my_script.sh # 所有者はrw-（6）、グループはr--（4）、その他のユーザーはr--（4）の権限を付与 # 一般的なファイルの権限としてよく使われる chmod 644 my_document.txt # すべてのユーザーにすべての権限を付与（セキュリティ上注意が必要） chmod 777 shared_folder 再帰的変更（-R）: ディレクトリとその中のすべてのサブファイル/フォルダの権限を一度に変えるには -R オプションを使います。 # my_dir内部のすべてのファイルとフォルダに755の権限を適用 chmod -R 755 my_dir 所有権の変更：chown（Change Owner） # chown はファイルやディレクトリの所有者と所有グループを変更するコマンドです。（主に sudo が必要）\n# my_file.txtの所有者をnew_userに変更 sudo chown new_user my_file.txt # my_file.txtの所有グループをnew_groupに変更 sudo chown :new_group my_file.txt # my_file.txtの所有者とグループを一度に変更 sudo chown new_user:new_group my_file.txt # my_dirディレクトリとその中のすべての内容（-R）の所有者をnew_userに変更 sudo chown -R new_user my_dir 15. ネットワークの基礎とインターネットの仕組み（Network Basics） # サーバーを扱うためには、インターネットがどのように接続されているか、自分のコンピューターのアドレスは何かを知ることが必須です。\n15.1. クライアントとサーバー（Client \u0026amp; Server） # インターネットは巨大な対話です。この対話は情報をリクエストして応答する2つの主体で構成されています。\nクライアント（Client、お客様）: Webブラウザ（Chromeなど）やモバイルアプリ。情報を**リクエスト（Request）**します。 サーバー（Server、提供者）: Linuxシステム上で動いているWebサーバープログラム（Apache、Nginx、Node.jsなど）。リクエストされた情報を探して**レスポンス（Response）**します。 動作過程： クライアント：google.com を入力（リクエスト） サーバー：index.html ファイルを探して送信（レスポンス） クライアント：コードを解釈して画面に描画 15.2. アドレス体系：IPとドメイン、そしてDNS # IPアドレス（IP Address）: コンピューターが通信のために持つ実際の数字アドレス（例：172.217.25.78）。電話番号に例えられます。 ドメイン名（Domain Name）: 人が覚えやすい文字アドレス（例：google.com）。電話帳の名前に例えられます。 DNS（Domain Name System）: ドメイン名をIPアドレスに変換するシステムです。 Tip: ターミナルで ping google.com と入力するとそのドメインの実際のIPを確認できます。 15.3. 公開IP vs プライベートIP（重要） # サーバー開発者が最も混乱する部分です。ip addr で確認した自分のIPと外部から見た自分のIPが異なる場合があります。\n確認方法： 内部IP: ip addr（結果の中の inet の後の数字、例：10.0.x.x、192.168.x.x） 外部IP: curl ipinfo.io/ip（外部インターネットから自分を見るときに見えるIP） 種類 説明 比喩 特徴 公開IP（Public IP） 通信会社が付与した世界で唯一のアドレス 会社の代表電話番号 外部から直接アクセス可能 プライベートIP（Private IP） ルーターが内部機器に付与したアドレス 会社の内線番号 外部から直接アクセス不可 バックエンド開発者 Tip: クラウド（AWS）環境では？ AWS EC2 などのクラウドサーバーも同じです。インスタンスを作成すると Public IP（外部アクセス用）と Private IP（VPC内部通信用）がそれぞれ割り当てられます。DBサーバーはセキュリティのためにPrivate IPでのみ通信するように設定するのが一般的です。\n16. Webサーバーの構築と運営（Web Server） # 自分のLinuxコンピューターを世界中に情報を提供するサーバーにする過程です。従来のApacheサーバーを基準に説明しますが、原理はNginxなど他のサーバーも同じです。\n16.1. インストールと実行（Apache2） # Ubuntu基準のコマンドです。\nインストール: sudo apt update \u0026amp;\u0026amp; sudo apt install apache2 サービス管理（systemctl）: 実行: sudo systemctl start apache2 状態確認: sudo systemctl status apache2（緑色の active (running) が表示されれば正常） 接続テスト: ブラウザのアドレスバーに http://localhost または http://127.0.0.1 を入力。 「Apache2 Default Page」が見えれば成功です。 16.2. 設定ファイルと DocumentRoot # Webサーバーが「どこにあるファイルを見せるか」を決める設定です。\n設定ファイルの位置: /etc/apache2/sites-enabled/000-default.conf DocumentRoot: ユーザーが接続したときに見せるWebページファイル（index.html）が位置する最上位ディレクトリです。（デフォルト：/var/www/html） 実習: /var/www/html/index.html ファイルを sudo nano で開いて内容を修正してみてください。更新すると内容が変わります。 16.3. ログ（Log）の分析：サーバーのブラックボックス # サーバーに問題が発生したときや不正アクセスが疑われるときに最初に確認する場所です。\n位置: /var/log/apache2/ access.log: 誰が（IP）、いつ、何をリクエストしたかが記録されます。 error.log: サーバー内部のエラーや実行失敗の原因が記録されます。 リアルタイムモニタリング: tail -f /var/log/apache2/access.log Java 開発者 Tip: Apache vs Nginx（Reverse Proxy） 最近の実務では Apache より Nginx をより多く使う傾向があり、特に Java 陣営ではほぼ標準のように使われます。\nNginx の役割: Nginx は軽量で大容量トラフィック処理に非常に効率的な Web サーバーです。ユーザーのリクエスト（80、443ポート）を直接受ける「ドアマン」の役割をし、内部で実際のビジネスロジックを処理する Spring Boot アプリケーション（8080ポート）にリクエストを安全に転送する**リバースプロキシ（Reverse Proxy）**として主に活躍します。 なぜ必要か？: 性能と負荷分散: 静的ファイル（画像、CSS など）は Nginx が高速に処理し、動的な API リクエストだけを Spring Boot サーバーに渡して負荷を軽減します。複数の Spring Boot サーバーにリクエストを分散（ロードバランシング）することもできます。 セキュリティ: アプリケーションサーバー（WAS）を外部に直接露出しないためセキュリティが向上します。 無停止デプロイ: デプロイ時に Nginx がトラフィックを旧バージョンのアプリから新バージョンのアプリへ段階的に切り替えることでサービスを中断せずにデプロイが可能です。 17. リモートアクセスとセキュリティ（SSH） # サーバールーム（データセンター）に直接行かなくても、自宅やカフェからサーバーコンピューターを制御する技術です。\n17.1. SSH（Secure Shell）の概念 # Linuxサーバー管理の標準です。すべてのデータが暗号化されて送信されるため安全です。 SSH Server: 制御されるコンピューター（サーバー）にインストール（openssh-server）。 SSH Client: 制御するコンピューター（自分のMacBook/ノートPC）にインストール。 17.2. 接続方法 # 基本コマンド: ssh [ユーザーID]@[サーバーIP] ssh ubuntu@192.168.0.65 ポート指定: セキュリティのためにデフォルトポート（22）を別の番号に変えた場合は -p オプションを使います。 ssh -p 2222 ubuntu@192.168.0.65 バックエンド開発者 Tip: パスワードの代わりに「キーファイル（.pem）」を使用 実務（AWSなど）ではセキュリティ上パスワードログインを無効にして、**SSH キーファイル（Key Pair）**を使います。\n# -i オプションでキーファイルを指定 ssh -i my-key.pem ubuntu@3.12.34.56 このときキーファイルの権限は必ず 400（chmod 400 my-key.pem）である必要があります。（あまりにも公開された権限だとSSHが接続を拒否する）\n18. ポート、ファイアウォールと外部アクセス # 「サーバーを起動したのに外部からアクセスできません！」という問題の99%はこの部分の設定問題です。\n18.1. ポート（Port）とは？ # IPがコンピューター（建物）を見つけるアドレスなら、ポートはその中の**プログラム（部屋番号）**を見つけるアドレスです。（0〜65535番）\n主要ポート（Well-Known Ports）:\n22: SSH（リモートアクセス） 80: HTTP（Web） 443: HTTPS（セキュアWeb） 3306: MySQL / MariaDB 5432: PostgreSQL 6379: Redis 8080: Tomcat、Spring Boot などWASの代替/開発用ポート 1つのポートは1つのプログラムだけが使用できます。（すでに80番をApacheが使っていればNginxは80番を使えない）\nポート使用確認: 特定のポート（例：8080）を誰が使っているか知りたい場合は sudo ss -ltnp | grep 8080 または sudo lsof -i :8080 で確認します。Spring Bootサーバーが立ち上がらなかったりポートの衝突が起きるときに最初に確認してください。\n18.2. ファイアウォール（Firewall） # サーバーへのネットワークトラフィックを制御するセキュリティシステムで、許可されたポート以外はすべてのアクセスをブロックします。外部アクセス問題のもう1つの主な原因です。\nufw（Ubuntu基本ファイアウォール）コマンド: # ファイアウォールを有効化 sudo ufw enable # ファイアウォールの状態を確認 sudo ufw status # 特定のポートを許可（Nginx、SSH、Spring Boot App） sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw allow 22/tcp sudo ufw allow 8080/tcp # ルールの削除 sudo ufw delete allow 8080/tcp 18.3. ポートフォワーディング（Port Forwarding） # ルーターを使う環境（自宅、オフィス）で外部アクセスを可能にする技術です。\n問題: 外部からはルーターの公開IPまでしかアクセスできず、自分のコンピューターのプライベートIP（192.168...）には入ってこれません。 解決: ルーターに道標を立てます。 「外部から公開IPの9000番ポートに入ってきたら -\u0026gt; 自分のコンピューター（192.168.0.65）の8080番ポートに接続してください！」 18.4. 設定とテストの手順 # ファイアウォールの確認: サーバー自体のファイアウォール（ufw など）が望むポート（例：80、8080）を許可しているか確認します。 ルーターの設定ページへアクセス: 通常 192.168.0.1 などのアドレスで接続（Default Gatewayを確認）。 NAT/ポートフォワーディングの設定: 内部IP: サーバーコンピューターのプライベートIP 外部ポート: 9000（外部に公開する番号） 内部ポート: 8080（実際のサーバープログラムのポート） 接続テスト: スマートフォン（LTE/5Gモード）で http://[自分の公開IP]:9000 にアクセスを試みる。（Wi-Fi環境ではテストがうまくできない場合がある） バックエンド開発者 Tip: クラウドとDocker\nクラウド（AWS、GCP、Azureなど）: クラウド環境ではルーターの代わりに**「セキュリティグループ（Security Group）」や「ファイアウォールルール（Firewall Rules）」でインバウンド（Inbound）ルール**を設定してあげることで外部アクセスが可能になります。（例：80、443、22、8080ポートを許可） Docker: docker run -p 8080:8080 my-app での -p オプションがまさにポートフォワーディングの概念です。「ホスト（Linux）サーバーの8080番ポートに入ったリクエストを -\u0026gt; コンテナ内部の8080番ポートに接続せよ」という意味です。 ","date":"2025年10月31日","externalUrl":null,"permalink":"/ja/posts/linux-practice/","section":"Posts","summary":"Linuxファイルシステム、ターミナルコマンド、権限、プロセス、ネットワーク、SSHまで核心をまとめた入門ガイドです。","title":"Linux 入門者のための核心ガイド","type":"posts"},{"content":"","date":"2025年10月31日","externalUrl":null,"permalink":"/ja/tags/shell/","section":"Tags","summary":"","title":"Shell","type":"tags"},{"content":"","date":"2025年10月31日","externalUrl":null,"permalink":"/ja/tags/ssh/","section":"Tags","summary":"","title":"SSH","type":"tags"},{"content":"","date":"2025年10月31日","externalUrl":null,"permalink":"/ja/tags/systemd/","section":"Tags","summary":"","title":"Systemd","type":"tags"},{"content":"","date":"2025年4月12日","externalUrl":null,"permalink":"/ja/tags/datasource/","section":"Tags","summary":"","title":"DataSource","type":"tags"},{"content":"","date":"2025年4月12日","externalUrl":null,"permalink":"/ja/tags/h2/","section":"Tags","summary":"","title":"H2","type":"tags"},{"content":"","date":"2025年4月12日","externalUrl":null,"permalink":"/ja/tags/jdbc/","section":"Tags","summary":"","title":"JDBC","type":"tags"},{"content":"","date":"2025年4月12日","externalUrl":null,"permalink":"/ja/tags/jdbctemplate/","section":"Tags","summary":"","title":"JdbcTemplate","type":"tags"},{"content":"この記事はSpring 6の研究を進め、書いたコード中心の実践的な記録です。\n1. JDBCの基礎 # 1.1 JDBC APIの概要 # JDBCが登場した背景と必要性 # アプリケーションを開発する際に重要なデータはほとんどデータベースに保存されます。\nクライアントがアプリケーションサーバを介してデータを保存または照会すると、アプリケーションはデータベースと以下の通信プロセスを介してデータを送受信する。\n問題は？それぞれのデータベースごとにコネクションを接続する方法、SQLを渡す方法、そして結果を渡す方法の両方が異なるということ。\nしたがって、データベースを別の種類のデータベースに変更した場合は、アプリケーションサーバーで開発されたデータベース使用コードも一緒に変更する必要があります。\nこのような問題を解決するため、1997年にJDBCというJava標準が登場した。\nJDBCコアクラスとインタフェース # JDBC（Java Database Connectivity）は、Javaがデータベースに接続できるようにするJava APIです。\n代表的には Connection 、 Statement 、 ResultSet をインタフェースとして定義して提供する。\nところがインタフェースだけでは動作しない。各データベース会社は自分のDBに合うようにJDBCのインタフェースを実装して提供するが、これをJDBCドライバという。\nJDBCを使用するタスクの一般的な順序は次のとおりです。\nDB接続用のConnectionを取得します。 SQLを含むStatement（またはPreparedStatement）を作成します。 作成されたStatementを実行します。 照会の場合、SQL照会の実行結果をResultSetとして受け取り、情報を保管するオブジェクト（User、Post、…）に移動します。 ジョブ中に作成されたConnection、Statement、ResultSetなどのリソースは、ジョブの完了後に必ず閉じます。 JDBC API が生成する例外 ( Exception ) をキャッチして直接処理するか、メソッドに throws を宣言して例外が発生した場合、メソッドから投げるようにする。 DBコネクションという制限的なリソースを共有して使用するサーバーで動作するJDBCコードには、必ず守るべき原則がある。まさに例外処理だ。\n通常のJDBCコードの流れに従わず、途中で何らかの理由で例外が発生した場合でも、使用したリソースを必ず返すように作成しなければならないからだ。そうしないと、システムに深刻な問題が発生する可能性があります。\nStatement vs PreparedStatement\nPreparedStatementはStatementの子型ですが、疑問符（？）を介したパラメータバインディングを可能にします。\nSQLインジェクション攻撃を防ぐには、PreparedStatementを介したパラメータバインディング方式を使用する必要があります。\nDriverManagerは、使用するアプリケーションのライブラリに登録されているJDBC（Java Database Connectivity）ドライバのセットを管理し、接続を取得する機能を提供します。\nJDBCが提供するDriverManagerクラス\n出典：キム・ヨンハンのSpring DB 1編\n整理\nJDBCの登場により、アプリケーションロジックはJDBC標準インタフェースにのみ依存しています。\nしたがって、データベースを別の種類のデータベースに変更したい場合は、JDBC実装ライブラリを変更するだけです。\nJDBCを使用したH2 DBアクセス # H2 データベースのダウンロード\nhttps://www.h2database.com/html/download-archive.html\nMAC ユーザー\nディレクトリの移動：cd bin\n権限設定: chmod 755 h2.sh\n実行：./h2.sh\nWINDOW ユーザー\n実行：h2.bat\nデータベースファイルの作成方法\nJDBC URL: jdbc:h2:~/spring6\nユーザー名: sa\nパスワード：1234\nデータベースファイル作成後の接続方法\nJDBC URL: jdbc:h2:tcp://localhost/~/spring6\nテーブルの作成とデータの挿入\ndrop table post if exists cascade; create table post ( post_id bigint primary key, title varchar(255) not null, content varchar(255) not null, likes int not null ); Postクラスの作成\n@Getter @EqualsAndHashCode public class Post { private final Long postId; private final String title; private final String content; private int likes; private Post(Long postId, String title, String content, int likes) { this.postId = postId; this.title = title; this.content = content; this.likes = likes; } // 新規作成時に使用（良いデフォルト0強制） public static Post create(Long postId, String title, String content) { return new Post(postId, title, content, 0); } // DBなどからロードするときに使用 public static Post of(Long postId, String title, String content, int likes) { return new Post(postId, title, content, likes); } } PostRepository クラスの作成\n@Slf4j public class PostRepository { public void save(Post post) throws SQLException { Connection con = null; PreparedStatement pstmt = null; try { con = DriverManager.getConnection(\u0026#34;jdbc:h2:tcp://localhost/~/spring6\u0026#34;, \u0026#34;sa\u0026#34;, \u0026#34;1234\u0026#34;); pstmt = con.prepareStatement(\u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;); pstmt.setLong(1, post.getPostId()); pstmt.setString(2, post.getTitle()); pstmt.setString(3, post.getContent()); pstmt.setInt(4, post.getLikes()); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public Post findById(Long id) throws SQLException { Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; try { con = DriverManager.getConnection(\u0026#34;jdbc:h2:tcp://localhost/~/spring6\u0026#34;, \u0026#34;sa\u0026#34;, \u0026#34;1234\u0026#34;); pstmt = con.prepareStatement(\u0026#34;select * from post where post_id = ?\u0026#34;); pstmt.setLong(1, id); rs = pstmt.executeQuery(); rs.next(); return Post.of(rs.getLong(\u0026#34;post_id\u0026#34;), rs.getString(\u0026#34;title\u0026#34;), rs.getString(\u0026#34;content\u0026#34;), rs.getInt(\u0026#34;likes\u0026#34;)); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) { log.error(\u0026#34;resultSet close error\u0026#34;, e); throw e; } } if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public void deleteAll() { Connection con = null; PreparedStatement pstmt = null; try { con = DriverManager.getConnection(\u0026#34;jdbc:h2:tcp://localhost/~/spring6\u0026#34;, \u0026#34;sa\u0026#34;, \u0026#34;1234\u0026#34;); pstmt = con.prepareStatement(\u0026#34;delete from post\u0026#34;); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw new RuntimeException(e); } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw new RuntimeException(e); } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw new RuntimeException(e); } } } } } PostRepositoryTestの生成\nimport static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest class PostRepositoryTest { @AfterEach void clear() throws SQLException { PostRepository postRepository = new PostRepository(); postRepository.deleteAll(); } @DisplayName(\u0026#34;PostRepository saveテスト\u0026#34;) @Test void save() throws Exception { PostRepository postRepository = new PostRepository(); Post post = Post.create(1L, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;); postRepository.save(post); } @DisplayName(\u0026#34;PostRepository getテスト\u0026#34;) @Test void get() throws Exception { // given PostRepository postRepository = new PostRepository(); Long postId = 1L; Post post = Post.create(postId, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;); postRepository.save(post); // when Post savedPost = postRepository.findById(postId); // then assertEquals(post, savedPost); } @DisplayName(\u0026#34;PostRepository deleteAllテスト\u0026#34;) @Test void deleteAll() throws Exception { // given PostRepository postRepository = new PostRepository(); Post post = Post.create(1L, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;); postRepository.save(post); // when postRepository.deleteAll(); // then assertThrows(Exception.class, () -\u0026gt; postRepository.findById(1L)); } } 1.2 DataSourceの設定 # DataSourceの概念と必要性 # 従来はPostRepositoryがH2を直接使用するためにDB URL、USERNAME、PASSWORDなどの具体的な情報に依存している。\nしたがって、H2ではなくMySQLでデータベースを変更する場合は、PostRepositoryのコードを変更してください。\nまた、コネクションを取得する方法はコネクションプールを使用することもできる。この場合もPostRepositoryを直接変更する必要があります。\nこれらの問題を解決するために、DBコネクションを取得する機能をConnectionMakerインタフェースとして導入し、抽象化を進めてみよう。\nConnectionMaker インタフェースの導入により、PostRepository は、具体的な DB やコネクション獲得方法について知る必要なく、そのインタフェースにのみ依存すればよい。\nConnectionMaker\npublic interface ConnectionMaker { Connection makeConnection() throws SQLException; } DBConnectionMaker\npublic class DBConnectionMaker implements ConnectionMaker { @Override public Connection makeConnection() throws SQLException { return DriverManager.getConnection(\u0026#34;jdbc:h2:tcp://localhost/~/spring6\u0026#34;, \u0026#34;sa\u0026#34;, \u0026#34;1234\u0026#34;); } } PostRepository\n@Slf4j public class PostRepository { private final ConnectionMaker connectionMaker; public PostRepository(ConnectionMaker connectionMaker) { this.connectionMaker = connectionMaker; } public void save(Post post) throws SQLException { Connection con = null; PreparedStatement pstmt = null; try { con = connectionMaker.makeConnection(); pstmt = con.prepareStatement(\u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;); pstmt.setLong(1, post.getPostId()); pstmt.setString(2, post.getTitle()); pstmt.setString(3, post.getContent()); pstmt.setInt(4, post.getLikes()); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public Post findById(Long id) throws SQLException { Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; try { con = connectionMaker.makeConnection(); pstmt = con.prepareStatement(\u0026#34;select * from post where post_id = ?\u0026#34;); pstmt.setLong(1, id); rs = pstmt.executeQuery(); rs.next(); return Post.of(rs.getLong(\u0026#34;post_id\u0026#34;), rs.getString(\u0026#34;title\u0026#34;), rs.getString(\u0026#34;content\u0026#34;), rs.getInt(\u0026#34;likes\u0026#34;)); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) { log.error(\u0026#34;resultSet close error\u0026#34;, e); throw e; } } if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public void deleteAll() { Connection con = null; PreparedStatement pstmt = null; try { con = connectionMaker.makeConnection(); pstmt = con.prepareStatement(\u0026#34;delete from post\u0026#34;); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw new RuntimeException(e); } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw new RuntimeException(e); } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw new RuntimeException(e); } } } } } BeanFactory\n@Configuration public class BeanFactory { @Bean public PostRepository postRepository() { return new PostRepository(connectionMaker()); } @Bean public ConnectionMaker connectionMaker() { return new DBConnectionMaker(); } } PostRepositoryTest\nimport static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest class PostRepositoryTest { @Autowired private PostRepository postRepository; @AfterEach void clear() throws SQLException { postRepository.deleteAll(); } @DisplayName(\u0026#34;PostRepository saveテスト\u0026#34;) @Test void save() throws Exception { Post post = Post.create(1L, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;); postRepository.save(post); } @DisplayName(\u0026#34;PostRepository getテスト\u0026#34;) @Test void get() throws Exception { // given Long postId = 1L; Post post = Post.create(postId, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;); postRepository.save(post); // when Post savedPost = postRepository.findById(postId); // then assertEquals(post, savedPost); } @DisplayName(\u0026#34;PostRepository deleteAllテスト\u0026#34;) @Test void deleteAll() throws Exception { // given Post post = Post.create(1L, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;); postRepository.save(post); // when postRepository.deleteAll(); // then assertThrows(Exception.class, () -\u0026gt; postRepository.findById(1L)); } } 実際、Javaには、DBコネクションを取得するオブジェクトの機能を抽象化し、同様の目的で使用できるように作られたDataSourceというインタフェースがすでに存在している。\nしたがって、本番でConnectionMakerのようなインターフェースを作成して使用することはありません。\nDriverManagerとDataSource\nJDBCを使用する場合、DriverManagerとDataSourceは「DBコネクションをどのように管理するのですか？」という同じ問題に対処しますが、役割と抽象化レベルは異なります。\nDriverManager はコネクションプールや高度な管理ロジックを含まない、**“**JDBCドライバをどのように呼び出すか”程度を抽象化したわけです。そしてクラスだ。\nDataSourceインターフェースは、「エンタープライズ環境でコネクションを確実に取得する方法」の抽象化であり、実装によってはコネクションプールまで含めることができる。\nSpringはDriverManagerもDataSourceを介して使用できるようにDriverManagerDataSourceというDataSourceを実装したクラスを提供します。\nこれで、アプリケーションロジックはDataSourceインターフェースにのみ依存します。おかげでDriverManagerDataSourceを介してDriverManagerを使用し、接続プールを使用するようにコードを変更してもアプリケーションロジックは変更する必要はありません。\nConnectionConst\npublic class ConnectionConst { public static final String URL = \u0026#34;jdbc:h2:tcp://localhost/~/spring6\u0026#34;; public static final String USERNAME = \u0026#34;sa\u0026#34;; public static final String PASSWORD = \u0026#34;1234\u0026#34;; } BeanFactory\n@Configuration public class BeanFactory { @Bean public PostRepository postRepository() { return new PostRepository(dataSource()); } @Bean public DataSource dataSource() { return new DriverManagerDataSource(URL, USERNAME, PASSWORD); } } PostRepository\n@Slf4j public class PostRepository { private final DataSource dataSource; public PostRepository(DataSource dataSource) { this.dataSource = dataSource; } public void save(Post post) throws SQLException { Connection con = null; PreparedStatement pstmt = null; try { con = dataSource.getConnection(); pstmt = con.prepareStatement(\u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;); pstmt.setLong(1, post.getPostId()); pstmt.setString(2, post.getTitle()); pstmt.setString(3, post.getContent()); pstmt.setInt(4, post.getLikes()); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public Post findById(Long id) throws SQLException { Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; try { con = dataSource.getConnection(); pstmt = con.prepareStatement(\u0026#34;select * from post where post_id = ?\u0026#34;); pstmt.setLong(1, id); rs = pstmt.executeQuery(); rs.next(); return Post.of(rs.getLong(\u0026#34;post_id\u0026#34;), rs.getString(\u0026#34;title\u0026#34;), rs.getString(\u0026#34;content\u0026#34;), rs.getInt(\u0026#34;likes\u0026#34;)); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) { log.error(\u0026#34;resultSet close error\u0026#34;, e); throw e; } } if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public void deleteAll() { Connection con = null; PreparedStatement pstmt = null; try { con = dataSource.getConnection(); pstmt = con.prepareStatement(\u0026#34;delete from post\u0026#34;); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw new RuntimeException(e); } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw new RuntimeException(e); } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw new RuntimeException(e); } } } } } Spring BootでConnection PoolとDataSourceを設定する # DriverManagerは、以前から存在していたJDBCの初期のクラスです。したがって、Connection Poolingなどの高度な機能は含まれていません。\nDriverManagerDataSourceはSpringが提供するDataSourceインタフェース実装の1つで、内部的にDriverManagerを呼び出して毎回新しい接続を作成します。\nデータベースコネクションを獲得するときは、次のような複雑な過程を経る。\nアプリケーションロジックはDBドライバを介して接続を検索します。 DBドライバはDBとTCP / IP接続を接続します。もちろん、この過程で3way handshakeのようなネットワーク動作が発生する。 DBドライバは、TCP / IP接続が接続されると、ID、PWなどの他の追加情報をDBに転送します。 DBはID、PWを介して内部認証を完了し、内部にDBセッションを作成します。 DBコネクションは、作成が完了したという応答を送信します。 DBドライバは接続オブジェクトを作成してクライアントに返します。 このように新しい接続を作成するのは時間がかかり、TCP / IP接続にネットワークリソースが毎回使用されます。\n→ユーザーに悪い経験を与えることができる。\nこの問題を解決するために、接続を事前に生成して使用する接続プールというアイデアが現れました。\nSpring Boot 2.0以降、デフォルトのDataSourceに設定されているHikariCPは直接DriverManager.getConnection()を呼び出さず、内部的にJDBCドライバのconnect()メソッドを介して物理接続を取得して管理します。\nコネクションプールに含まれるコネクションはTCP/IPでDBとコネクションが接続されている状態なので、いつでもすぐにSQLをDBに渡すことができる。\nHikariCPコネクション獲得テスト\n@SpringBootTest public class DataSourceConnectionPoolTest { private static final String URL = \u0026#34;jdbc:h2:tcp://localhost/~/spring6\u0026#34;; private static final String USERNAME = \u0026#34;sa\u0026#34;; private static final String PASSWORD = \u0026#34;1234\u0026#34;; @DisplayName(\u0026#34;コネクションプール取得テスト\u0026#34;) @Test void dataSourceConnectionPool() throws InterruptedException { HikariDataSource dataSource = new HikariDataSource(); dataSource.setJdbcUrl(URL); dataSource.setUsername(USERNAME); dataSource.setPassword(PASSWORD); dataSource.setMaximumPoolSize(10); // コネクションプール最大サイズを10に設定 dataSource.setPoolName(\u0026#34;MyPool\u0026#34;); Thread.sleep(1000); //コネクションプールでコネクションが生成される時間を待機 // マルチスレッド構成（20スレッド） int taskCount = 20; CountDownLatch latch = new CountDownLatch(taskCount); ExecutorService executorService = Executors.newFixedThreadPool(taskCount); // 20スレッドを実行してコネクションを借り、ログ出力 for (int i = 0; i \u0026lt; taskCount; i++) { executorService.submit(() -\u0026gt; { // try-with-resources構文を使用して接続プールから接続を借りる（tryブロックの終了時に自動的に返却） try (Connection connection = dataSource.getConnection()) { // 接続情報の確認 System.out.println(\u0026#34;Thread: \u0026#34; + Thread.currentThread().getName() + \u0026#34;, Connection: \u0026#34; + connection); Thread.sleep(500); } catch (SQLException e) { throw new RuntimeException(e); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { latch.countDown(); } }); } // すべてのスレッド終了待ち latch.await(); executorService.shutdown(); // DataSourceの終了 dataSource.close(); } } application.ymlでDataSourceを設定\nspring: datasource: url: jdbc:h2:tcp://localhost/~/spring6 username: sa password: 1234 driver-class-name: org.h2.Driver hikari: maximum-pool-size: 10 Spring Bootがアプリケーションを起動すると、DataSourceAutoConfiguration内でクラスパスにあるHikariCPライブラリを検出してspring.datasource.*、spring.datasource.hikari.*などで始まる設定はDataSourceProperties、HikariDataSourceにバインドされます。\n// BeanFactory、ConnectionConstなどは削除します。 @Slf4j @Repository // コンポーネントスキャンでBeanを登録し、自動でDataSourceを注入される public class PostRepository { private final DataSource dataSource; public PostRepository(DataSource dataSource) { this.dataSource = dataSource; } ... } HikariCP Connection Pool Configuration # HikariCPの代表設定値としては、maximumPoolSize、minimumIdle、idleTimeout、connectionTimeout、maxLifetimeなどがある。\nmaximum-pool-size(default = 10)\nidle(inActive)-connection と使用中の in-use(active)-connection の両方を含め、プールが到達できる最大サイズを制御します。\nデフォルトでは、この値はデータベースとサーバーの接続への実際の最大接続数を決定します。\n接続プールがこのサイズに達し、使用可能なアイドル接続がない場合、getConnection()呼び出しは最大connection-timeoutミリ秒間ブロックされます。\n適切なコネクションプールのサイズ コネクションプールのサイズは小さくとられ、必要に応じて十分確保することが核心だ。\n- **HikariCPの推奨公式** \u0026gt; connections = (core_count * 2) + effective_spindle_count \u0026gt; core_count**:** 物理 CPU コアの数（ハイパースレッドコアは含まれません）\neffective_spindle_count**:** キャッシュが有効な場合は 0、それ以外の場合は実際のディスクスピンドルの数に近い値\nたとえば、ハードディスクが1つある小さな4コアi7サーバー\n→ 9 = ((4 * 2) + 1) - **Pool-Locking問題を回避するための公式** プールロックとは？ 1つのスレッドが複数の接続を同時に必要とする場合、プールのサイズが小さすぎるとデッドロック状態に陥る危険がある\n\u0026gt; pool-size = T * (C - 1) + 1 \u0026gt; T：最大同時実行可能スレッド数\nC：シングルスレッドが同時に保持する必要がある接続数\nたとえば、3つのスレッドがそれぞれ4つの接続を必要とする場合\n→ 10 = 3 * (4 − 1) + 1 - **しかし、実際に展開環境で適切な接続数** 監視環境を構築し（サーバーリソース、サーバースレッド、DBCPなど）、バックエンドシステムに負荷テストを適用して適切な数を見つけます。\nminimum-idle(default = maximum-pool-size)\npoolが保持する最小のアイドル接続数を制御します。 idle-connectionの数がminimum-idleの設定値より少なく、完全な接続数もmaximum-pool-sizeの設定値より少ない場合は、すぐに追加の接続を作成します。\nmaximum-pool-sizeとminimum-idleを同じにするには、maximum-pool-sizeを設定するだけです。\nアプリケーションが実行され、HikariCPが初期化されると、minimum-idle設定値と同じ接続を事前に作成します。\nアプリケーションがDBジョブ（クエリ実行など）のために接続を要求すると、HikariCPはプール内に事前に準備されたアイドル接続を1つ取り出して返します。\nこのコネクションはin-use(使用中)状態となり、DBクエリを実行したりトランザクション処理をするようになる。\nトラフィックが発生して接続が1つ使用されている場合、1つのアイドル接続が残ります。\nこのとき、HikariCPは内部的にidle-connectionをminimum-idle以上維持するため\nmaximum-pool-sizeの範囲内で新しい接続を作成します。\nidle-timeout\nプール内の接続がアイドル接続状態に維持できる最大時間を制御します。\nminimum-idleよりも多くのアイドル接続がある場合、HikariCPはidle-timeoutより古いアイドル接続を接続プールから削除し、接続プール内の接続数をminimum-idleに合わせます。\nconnection-timeout\nクライアント（つまり、ユーザー）がプール内の接続を待機する最大ミリ秒数を制御します。接続が利用できない状態でこのタイムアウトが発生すると、例外が発生します。\n上記の場合、Thread6がconnection-timeoutに設定された時間の間に接続を取得できない場合、例外が発生します。\napplication.yml\nspring: datasource: url: jdbc:h2:tcp://localhost/~/spring6 username: sa password: 1234 driver-class-name: org.h2.Driver # Hikari設定を追加する。 hikari: maximum-pool-size: 5 connection-timeout: 10000 PostRepository\n@Slf4j @Repository public class PostRepository { private final DataSource dataSource; public PostRepository(DataSource dataSource) { this.dataSource = dataSource; } public void save(Post post) throws SQLException { Connection con = null; PreparedStatement pstmt = null; try { con = dataSource.getConnection(); pstmt = con.prepareStatement(\u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;); pstmt.setLong(1, post.getPostId()); pstmt.setString(2, post.getTitle()); pstmt.setString(3, post.getContent()); pstmt.setInt(4, post.getLikes()); pstmt.executeUpdate(); Thread.sleep(20000); // 20秒待機を追加 } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } catch (InterruptedException e) { throw new RuntimeException(e); } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public Post findById(Long id) throws SQLException { Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; try { con = dataSource.getConnection(); pstmt = con.prepareStatement(\u0026#34;select * from post where post_id = ?\u0026#34;); pstmt.setLong(1, id); rs = pstmt.executeQuery(); rs.next(); return Post.of(rs.getLong(\u0026#34;post_id\u0026#34;), rs.getString(\u0026#34;title\u0026#34;), rs.getString(\u0026#34;content\u0026#34;), rs.getInt(\u0026#34;likes\u0026#34;)); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) { log.error(\u0026#34;resultSet close error\u0026#34;, e); throw e; } } if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public void deleteAll() { Connection con = null; PreparedStatement pstmt = null; try { con = dataSource.getConnection(); pstmt = con.prepareStatement(\u0026#34;delete from post\u0026#34;); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw new RuntimeException(e); } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw new RuntimeException(e); } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw new RuntimeException(e); } } } } } HikariTest\n@SpringBootTest public class HikariTest { @Autowired private PostRepository postRepository; @BeforeEach void init() { postRepository.deleteAll(); } @AfterEach void clear() { postRepository.deleteAll(); } @Test void testConnectionTimeout() throws InterruptedException { final int THREAD_COUNT = 6; // 最大プールサイズが5なので、6個以上のスレッドを同時に回してタイムアウトを誘導 ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); CountDownLatch latch = new CountDownLatch(THREAD_COUNT); for (int i = 0; i \u0026lt; THREAD_COUNT; i++) { int index = i; executorService.submit(() -\u0026gt; { try { // 単にpostIdをインデックスとしてPostオブジェクトを作成する Post post = Post.create((long) index, \u0026#34;title \u0026#34; + index, \u0026#34;content \u0026#34; + index); System.out.println(\u0026#34;Thread \u0026#34; + index + \u0026#34; starts saving post...\u0026#34;); // saveメソッド内で20秒（20000ms）待機する postRepository.save(post); System.out.println(\u0026#34;Thread \u0026#34; + index + \u0026#34; finished saving post.\u0026#34;); } catch (Exception e) { // ここで connection-timeout 発生時にログ確認可能 System.out.println(\u0026#34;Thread \u0026#34; + index + \u0026#34; error: \u0026#34; + e.getMessage()); } finally { latch.countDown(); } }); } latch.await(); // すべてのスレッド処理が終わるまで待機 executorService.shutdown(); System.out.println(\u0026#34;All threads finished.\u0026#34;); } } max-lifetime\n接続プール内の接続の最大寿命を制御します。 idle-connectionがmax-lifetimeを超えると、接続プールから直接削除され、active-connectionがmax-lifetimeを超えた場合は接続プールに返され、削除されます。\nただし、接続プールに返されないと、max-lifetimeは動作しません。 → max-lifetimeを引き継ぎ続け、コネクションが生きている可能性がある。\nDB の wait-timeout DBにはwait-timeoutという属性があります。この属性は、DBで接続が非アクティブな状況の場合、再び要求が来るまでどのくらいの時間を待ってから接続（セッション）を閉じるかを制御します。もしアプリケーションで異常なコネクション終了やリターンができない問題が発生すると、TCP接続を維持したまま下炎なしで待つコネクションがたまることになる。この問題を防ぐためにwait-timeoutが使用されます。\nこの時、MySQLのwait-timeoutが60秒に設定されていれば？ DBではコネクションを切ってしまう。\nそして、それから接続がプールに返されたら？そしてそのコネクションが再び再利用され、DBにリクエストを送ると例外が飛び出す。\nしたがって、書き込んだコネクションはプールで返すことが重要で、HikariCPのmax-lifetimeはDBのwait-timeoutより（2〜3秒または5秒程度）に短く設定する必要があります。\n2. Spring JDBC # 2.1 JdbcTemplateの使い方 # JdbcTemplateの構造と動作原理 # 既存のPostRepositoryには深刻な問題があります。まさに例外状況に対する処理だ。\ntry/catch/finally ブロックが二重に入れ子になって出てくるうえ、すべてのメソッドごとに繰り返される。\nこのようなコードを効果的に扱う方法はないだろうか？問題の核心は変わらないが、多くの場所で重複するコードとロジックによって、しばしば拡張され、頻繁に変化するコードをよく分離する作業だ。\n分離と再利用のためのテンプレートコールバックパターンの適用\n不変部分：try/catch/finallyブロック\n変化する部分: PrepareStatement を実行する部分\nMyJdbcTemplate\n@Slf4j public class MyJdbcTemplate { private final DataSource dataSource; public MyJdbcTemplate(DataSource dataSource) { this.dataSource = dataSource; } public \u0026lt;T\u0026gt; T execute(String sql, MyPreparedStatementCallback\u0026lt;T\u0026gt; callback) throws SQLException { Connection con = null; PreparedStatement ps = null; try { con = dataSource.getConnection(); ps = con.prepareStatement(sql); return callback.doInPreparedStatement(ps); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (ps != null) { try { ps.close(); } catch (SQLException e) { log.info(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.info(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } } PreparedStatementCallback\npublic interface MyPreparedStatementCallback\u0026lt;T\u0026gt; { T doInPreparedStatement(PreparedStatement ps) throws SQLException; } PostRepository\n@Repository public class PostRepository { private final MyJdbcTemplate myJdbcTemplate; public PostRepository(DataSource dataSource) { this.myJdbcTemplate = new MyJdbcTemplate(dataSource); } public void save(Post post) throws SQLException { String sql = \u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;; myJdbcTemplate.execute(sql, ps -\u0026gt; { ps.setLong(1, post.getPostId()); ps.setString(2, post.getTitle()); ps.setString(3, post.getContent()); ps.setInt(4, post.getLikes()); return ps.executeUpdate(); }); } public Post findById(Long id) throws SQLException { String sql = \u0026#34;select * from post where post_id = ?\u0026#34;; return myJdbcTemplate.execute(sql, ps -\u0026gt; { ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { rs.next(); return Post.of(rs.getLong(\u0026#34;post_id\u0026#34;), rs.getString(\u0026#34;title\u0026#34;), rs.getString(\u0026#34;content\u0026#34;), rs.getInt(\u0026#34;likes\u0026#34;)); } }); } public void deleteAll() throws SQLException { String sql = \u0026#34;delete from post\u0026#34;; myJdbcTemplate.execute(sql, ps -\u0026gt; ps.executeUpdate()); } } Springが提供するJdbcTemplate\nJdbcTemplateはスプリングが提供する機能で、JDBCをラップしてJDBCを直接使用するときに必要な整形化され、重複した処理に代わるものです。\n→Java標準であるJDBCをより簡単に使用できる方法を提供します。\nJdbcTemplateはテンプレートコールバックパターンを使用して、JDBCを直接使用するときに発生するほとんどの繰り返し操作を代わりに処理します。\n開発者はSQLを作成し、渡すパラメータを定義し、応答値をマッピングするだけです。\n私たちが考えることができるほとんどの繰り返し作業：\nConnectionを獲得 statement の準備と実行 結果を繰り返すループを実行する connection , statement , resultSet 終了 トランザクションを処理するためのコネクション同期 例外発生時にスプリング例外コンバータを実行する PostRepository\n@Repository public class PostRepository { private final JdbcTemplate jdbcTemplate; public PostRepository(DataSource dataSource) { this.jdbcTemplate = new JdbcTemplate(dataSource); } public void save(Post post) { String sql = \u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;; jdbcTemplate.update(sql, post.getPostId(), post.getTitle(), post.getContent(), post.getLikes()); } public Post findById(Long id) { String sql = \u0026#34;select * from post where post_id = ?\u0026#34;; return jdbcTemplate.query(sql, ps -\u0026gt; ps.setLong(1, id), rs -\u0026gt; { rs.next(); return Post.of(rs.getLong(\u0026#34;post_id\u0026#34;), rs.getString(\u0026#34;title\u0026#34;), rs.getString(\u0026#34;content\u0026#34;), rs.getInt(\u0026#34;likes\u0026#34;)); }); } public void deleteAll() { String sql = \u0026#34;delete from post\u0026#34;; jdbcTemplate.update(sql); } } 検索系の処理 # 検索系列処理を次の3つに分けて見てみましょう。\n1つの列を取得する場合 レコードをMapオブジェクトに変換して取得する場合 レコードをEntityオブジェクトに変換して取得する場合 @SpringBootTest public class JdbcTemplateTest { @Autowired private PostRepository postRepository; @Autowired private JdbcTemplate jdbcTemplate; @AfterEach void clear() { postRepository.deleteAll(); } @DisplayName(\u0026#34;1レコードの1カラムを取得\u0026#34;) @Test void queryForObject() throws Exception { // given Post post = Post.create(1L, \u0026#34;タイトル1\u0026#34;, \u0026#34;内容1\u0026#34;); postRepository.save(post); /** * 2番目の引数には、戻り値として返すオブジェクトのタイプを指定します。 * 3番目の引数は？記号にバインドする値です。 */ String title = jdbcTemplate.queryForObject(\u0026#34;select title from post where post_id = ?\u0026#34;, String.class, 1l); // then Assertions.assertThat(title).isEqualTo(\u0026#34;タイトル1\u0026#34;); } @DisplayName(\u0026#34;1レコードをMapオブジェクトに変換して取得\u0026#34;) @Test void queryForMap() throws Exception { // given Post post = Post.create(1L, \u0026#34;タイトル1\u0026#34;, \u0026#34;内容1\u0026#34;); postRepository.save(post); // when Map\u0026lt;String, Object\u0026gt; map = jdbcTemplate.queryForMap(\u0026#34;select * from post where post_id = ?\u0026#34;, 1l); // then System.out.println(map); } @DisplayName(\u0026#34;レコードをEntityオブジェクトに変換して取得\u0026#34;) @Test void queryFonEntity() throws Exception { // given Post post = Post.create(1L, \u0026#34;タイトル1\u0026#34;, \u0026#34;内容1\u0026#34;); postRepository.save(post); // when Post savedPost = jdbcTemplate.queryForObject(\u0026#34;select * from post where post_id = ?\u0026#34; , new DataClassRowMapper\u0026lt;\u0026gt;(Post.class), 1l); // then assertEquals(savedPost, post); } } BeanPropertyRowMapper vs DataClassRowMapper\nBeanPropertyRowMapperを使用する場合は、セッターとデフォルトコンストラクターが必要です。\nDataClassRowMapperはコンストラクタ注入に基づいて動作するため、setterやNoArgsConstructorなしで不変オブジェクトを簡単にマッピングできます。\n全体的に、不変オブジェクトを好むか、コードの簡潔さを重視する場合は、DataClassRowMapperが魅力的な選択になる可能性があります。\n更新系処理 # 次に、更新系（追加、変更、削除）処理について、次の3つに分けて説明する。\nINSERT文を実行する UPDATE文を実行する DELETE文を実行する /** * INSERT文を実行するときは、updateメソッドを使用してください。 SQL文はINSERTですが、メソッド名はupdateなので注意。 * その後、UPDATE、DELETEステートメントを実行するときにもupdateメソッドを使用します。 */ @DisplayName(\u0026#34;insertクエリ実行\u0026#34;) @Test void insert() throws Exception { // given Post post = Post.create(1L, \u0026#34;タイトル1\u0026#34;, \u0026#34;内容1\u0026#34;); jdbcTemplate.update(\u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;, post.getPostId(), post.getTitle(), post.getContent(), post.getLikes()); // when Post savedPost = jdbcTemplate.queryForObject(\u0026#34;select * from post\u0026#34;, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class)); // then assertEquals(savedPost, post); } @DisplayName(\u0026#34;updateクエリ実行\u0026#34;) @Test void update() throws Exception { // given Post post = Post.create(1L, \u0026#34;タイトル1\u0026#34;, \u0026#34;内容1\u0026#34;); postRepository.save(post); // when jdbcTemplate.update(\u0026#34;update post set title = ?, content = ? where post_id = ?\u0026#34;, \u0026#34;タイトル2\u0026#34;, \u0026#34;内容2\u0026#34;, 1L); // then Post updatedPost = jdbcTemplate.queryForObject(\u0026#34;select * from post where post_id = ?\u0026#34;, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class), 1L); assertEquals(updatedPost.getTitle(), \u0026#34;タイトル2\u0026#34;); assertEquals(updatedPost.getContent(), \u0026#34;内容2\u0026#34;); } @DisplayName(\u0026#34;deleteクエリ実行\u0026#34;) @Test void delete() throws Exception { // given Post post = Post.create(1L, \u0026#34;タイトル1\u0026#34;, \u0026#34;内容1\u0026#34;); postRepository.save(post); // when jdbcTemplate.update(\u0026#34;delete from post where post_id = ?\u0026#34;, 1L); // then List\u0026lt;Post\u0026gt; posts = jdbcTemplate.query(\u0026#34;select * from post\u0026#34;, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class)); assertEquals(posts.size(), 0); } 2.2 NamedParameterJdbcTemplateの活用 # NamedParameterJdbcTemplateを使う理由 # NamedParameterJdbcTemplateはSpringが提供するクラスで、SQLクエリで「？」記号を使用したバインディングの代わりに名前付きパラメータを使用できるようにします。このクラスは内部的にJdbcTemplateを活用して、実行時に名前付きパラメータをJDBCスタイルの「？」に置き換えます。\nパラメータバインディング方式 # NamedParameterJdbcTemplateを使用するときにパラメータをバインドする方法は、代表的には3つあります。\nMapSqlParameterSource BeanPropertySqlParameterSource Collections.singletonMap MapSqlParameterSource\n利点\nパラメータをMapと同様にkey-value形式に入れます。 値に追加のメタデータ（typeなど）を指定できます。 欠点\nすべてのパラメータを手動で設定する必要があるため、パラメータが多くなるとコードが長くなるという欠点が存在します。 MapSqlParameterSource params = new MapSqlParameterSource() .addValue(\u0026#34;id\u0026#34;, 1L) .addValue(\u0026#34;title\u0026#34;, \u0026#34;タイトル\u0026#34;); MapSqlParameterSource params = new MapSqlParameterSource() .addValue(\u0026#34;id\u0026#34;, 1L, Types.BIGINT) .addValue(\u0026#34;title\u0026#34;, \u0026#34;タイトル1\u0026#34;, Types.VARCHAR) .addValue(\u0026#34;created_at\u0026#34;, LocalDate.now(), Types.DATE); BeanPropertySqlParameterSource\n利点\nオブジェクトプロパティ名とSQLのパラメータ名が一致すれば、非常に簡潔でメンテナンスに便利です。 コードを減らし、オブジェクトとデータベース間のマッピングを直感的に処理することができ、生産性が良い。 短所\nプロパティ名が変更されると、SQLクエリまで変更が必要になり、密接に結合されます。 プロパティとSQLパラメータ名が正確に一致しないと、エラーが発生し、メンテナンスが困難になる可能性があります。 Post post = Post.create(1L, \u0026#34;タイトル\u0026#34;, \u0026#34;内容\u0026#34;); BeanPropertySqlParameterSource params = new BeanPropertySqlParameterSource(post); Map.of() または Collections.singletonMap\n利点\nコードを最小限に簡潔に保ちます。 パラメータが少ないと直感的に見えます。 短所\nタイプの追加のメタ情報を指定することはできません。 パラメータが多くなると可読性が低下する可能性があります。 Map\u0026lt;String, Object\u0026gt; params = Collections.singletonMap(\u0026#34;id\u0026#34;, 1L); Map\u0026lt;String, Object\u0026gt; params = Map.of(\u0026#34;id\u0026#34;, 1L, \u0026#34;title\u0026#34;, \u0026#34;タイトル\u0026#34;); 実践例 # @SpringBootTest public class NamedParameterJdbcTemplateTest { @Autowired private PostRepository postRepository; @Autowired private NamedParameterJdbcTemplate namedParameterJdbcTemplate; @AfterEach void clear() { postRepository.deleteAll(); } @DisplayName(\u0026#34;1レコードの1カラムを取得\u0026#34;) @Test void queryForObject() throws Exception { // given Post post = Post.create(1L, \u0026#34;タイトル1\u0026#34;, \u0026#34;内容1\u0026#34;); postRepository.save(post); // パラメータ設定 MapSqlParameterSource params = new MapSqlParameterSource(\u0026#34;postId\u0026#34;, 1L); // SQLの実行 String title = namedParameterJdbcTemplate.queryForObject( \u0026#34;SELECT title FROM post WHERE post_id = :postId\u0026#34;, params, String.class ); // then Assertions.assertThat(title).isEqualTo(\u0026#34;タイトル1\u0026#34;); } @DisplayName(\u0026#34;1レコードをMapオブジェクトに変換して取得\u0026#34;) @Test void queryForMap() throws Exception { // given Post post = Post.create(1L, \u0026#34;タイトル1\u0026#34;, \u0026#34;内容1\u0026#34;); postRepository.save(post); // パラメータ設定 Map\u0026lt;String, Object\u0026gt; params = Collections.singletonMap(\u0026#34;postId\u0026#34;, 1L); // SQLの実行 Map\u0026lt;String, Object\u0026gt; map = namedParameterJdbcTemplate.queryForMap( \u0026#34;SELECT * FROM post WHERE post_id = :postId\u0026#34;, params ); // then System.out.println(map); } @DisplayName(\u0026#34;レコードをEntityオブジェクトに変換して取得\u0026#34;) @Test void queryForEntity() throws Exception { // given Post post = Post.create(1L, \u0026#34;タイトル1\u0026#34;, \u0026#34;内容1\u0026#34;); postRepository.save(post); // パラメータ設定 Map\u0026lt;String, Object\u0026gt; params = Collections.singletonMap(\u0026#34;postId\u0026#34;, 1L); // SQLの実行 Post savedPost = namedParameterJdbcTemplate.queryForObject( \u0026#34;SELECT * FROM post WHERE post_id = :postId\u0026#34;, params, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class) ); // then assertEquals(savedPost, post); } @DisplayName(\u0026#34;複数レコードをEntityオブジェクトに変換して取得\u0026#34;) @Test void queryForEntityList() throws Exception { // given Post post1 = Post.create(1L, \u0026#34;タイトル1\u0026#34;, \u0026#34;内容1\u0026#34;); Post post2 = Post.create(2L, \u0026#34;タイトル2\u0026#34;, \u0026#34;内容2\u0026#34;); Post post3 = Post.create(3L, \u0026#34;タイトル3\u0026#34;, \u0026#34;内容3\u0026#34;); postRepository.save(post1); postRepository.save(post2); postRepository.save(post3); // SQLの実行 List\u0026lt;Post\u0026gt; posts = namedParameterJdbcTemplate.query( \u0026#34;SELECT * FROM post\u0026#34;, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class) ); // then assertEquals(posts.size(), 3); assertEquals(posts, List.of(post1, post2, post3)); } @DisplayName(\u0026#34;insertクエリ実行\u0026#34;) @Test void insert() throws Exception { // given Post post = Post.create(1L, \u0026#34;タイトル1\u0026#34;, \u0026#34;内容1\u0026#34;); // パラメータ設定 SqlParameterSource params = new BeanPropertySqlParameterSource(post); // SQLの実行 namedParameterJdbcTemplate.update( \u0026#34;INSERT INTO post(post_id, title, content, likes) VALUES(:postId, :title, :content, :likes)\u0026#34;, params ); // when Post savedPost = namedParameterJdbcTemplate.queryForObject( \u0026#34;SELECT * FROM post WHERE post_id = :postId\u0026#34;, Collections.singletonMap(\u0026#34;postId\u0026#34;, post.getPostId()), new DataClassRowMapper\u0026lt;\u0026gt;(Post.class) ); // then assertEquals(savedPost, post); } @DisplayName(\u0026#34;updateクエリ実行\u0026#34;) @Test void update() throws Exception { // given Post post = Post.create(1L, \u0026#34;タイトル1\u0026#34;, \u0026#34;内容1\u0026#34;); postRepository.save(post); // パラメータ設定 Map\u0026lt;String, Object\u0026gt; params = new HashMap\u0026lt;\u0026gt;(); params.put(\u0026#34;title\u0026#34;, \u0026#34;タイトル2\u0026#34;); params.put(\u0026#34;content\u0026#34;, \u0026#34;内容2\u0026#34;); params.put(\u0026#34;postId\u0026#34;, 1L); // SQLの実行 namedParameterJdbcTemplate.update( \u0026#34;UPDATE post SET title = :title, content = :content WHERE post_id = :postId\u0026#34;, params ); // then Post updatedPost = namedParameterJdbcTemplate.queryForObject( \u0026#34;SELECT * FROM post WHERE post_id = :postId\u0026#34;, Collections.singletonMap(\u0026#34;postId\u0026#34;, 1L), new DataClassRowMapper\u0026lt;\u0026gt;(Post.class) ); assertEquals(updatedPost.getTitle(), \u0026#34;タイトル2\u0026#34;); assertEquals(updatedPost.getContent(), \u0026#34;内容2\u0026#34;); } @DisplayName(\u0026#34;deleteクエリ実行\u0026#34;) @Test void delete() throws Exception { // given Post post = Post.create(1L, \u0026#34;タイトル1\u0026#34;, \u0026#34;内容1\u0026#34;); postRepository.save(post); // パラメータ設定 Map\u0026lt;String, Object\u0026gt; params = Collections.singletonMap(\u0026#34;postId\u0026#34;, 1L); // SQLの実行 namedParameterJdbcTemplate.update( \u0026#34;DELETE FROM post WHERE post_id = :postId\u0026#34;, params ); // then List\u0026lt;Post\u0026gt; posts = namedParameterJdbcTemplate.query( \u0026#34;SELECT * FROM post\u0026#34;, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class) ); assertEquals(posts.size(), 0); } } 3. トランザクション管理 # 3.1 @Transactionalアノテーションの使用 # トランザクションとは？ # もはや分けられない単位作業。\n単一のSQL命令を処理する場合は、DBがトランザクションを保証していると信じることができます。\nしかし、複数のSQLが使用されるジョブを1つのトランザクションとして扱わなければならない場合もある。これにより、データの不整合状態を防止することができる。\nトランザクションの開始時に複数のSQLを実行し、すべてのSQLが成功した場合は最後にコミットします。コミットすると、データベースにデータが保存されます。\nトランザクションの開始と終了は、デフォルトでConnectionオブジェクトを介して行われます。\n（JPA / HibernateはConnectionを直接使用せずにEntityManager / Sessionを使用し、独自のトランザクション管理APIを使用します。）\nJDBCでトランザクションを開始するには、自動コミットオプションをfalseにするだけです。 JDBCのデフォルト設定は、DB操作を実行した直後に自動的にコミットされるようになっています。\nトランザクションが一度開始されると、commit（）またはrollback（）メソッドが呼び出されるまでの操作は1つのトランザクションにまとめられます。\nトランザクション操作中に例外が発生した場合は、トランザクションをロールバックします。\nデータベースにpost_likeスキーマを作成\ndrop table post_like if exists cascade; CREATE TABLE post_like ( post_id BIGINT, user_id BIGINT, liked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (post_id, user_id) ); Post\n@Getter @EqualsAndHashCode public class Post { private final Long postId; private final String title; private final String content; private int likes; private Post(Long postId, String title, String content, int likes) { this.postId = postId; this.title = title; this.content = content; this.likes = likes; } public static Post create(Long postId, String title, String content) { return new Post(postId, title, content, 0); } public static Post of(Long postId, String title, String content, int likes) { return new Post(postId, title, content, likes); } // 追加 public void increaseLikes() { this.likes++; } } PostLike\n@Getter @EqualsAndHashCode @AllArgsConstructor public class PostLike { private Long postId; private Long userId; private LocalDateTime likedAt; } PostLikeRepository\n@Repository public class PostLikeRepository { private final JdbcTemplate jdbcTemplate; public PostLikeRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public void save(PostLike postLike) { String sql = \u0026#34;insert into post_like(post_id, user_id, liked_at) values (?, ?, ?)\u0026#34;; jdbcTemplate.update(sql, postLike.getPostId(), postLike.getUserId(), Timestamp.valueOf(postLike.getLikedAt())); } public boolean existsByPostIdAndUserId(Long postId, Long userId) { String sql = \u0026#34;select count(*) from post_like where post_id = ? and user_id = ?\u0026#34;; Integer count = jdbcTemplate.queryForObject(sql, Integer.class, postId, userId); return count != null \u0026amp;\u0026amp; count \u0026gt; 0; } public void deleteAll() { jdbcTemplate.update(\u0026#34;delete from post_like\u0026#34;); } } PostRepository\n@Repository public class PostRepository { private final JdbcTemplate jdbcTemplate; public PostRepository(DataSource dataSource) { this.jdbcTemplate = new JdbcTemplate(dataSource); } public void save(Post post) { String sql = \u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;; jdbcTemplate.update(sql, post.getPostId(), post.getTitle(), post.getContent(), post.getLikes()); } public void incrementLikes(Long postId) { String sql = \u0026#34;update post set likes = likes + 1 where post_id = ?\u0026#34;; jdbcTemplate.update(sql, postId); } public Post findById(Long id) { String sql = \u0026#34;select * from post where post_id = ?\u0026#34;; return jdbcTemplate.queryForObject(sql, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class), id); } public void deleteAll() { String sql = \u0026#34;delete from post\u0026#34;; jdbcTemplate.update(sql); } } PostLikeService\n@Service @RequiredArgsConstructor public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final DataSource dataSource; public void likePost(Long postId, Long userId) throws SQLException { Connection connection = dataSource.getConnection(); connection.setAutoCommit(false); try { // まず良い数を増やして postRepository.incrementLikes(postId); // その後、重複チェックで例外が発生すると、 // 例外後のロールバックによってlikesの数が戻ったかどうかを検証できます。 if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;すでにいいねを押した投稿です。\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); connection.commit(); } catch (Exception e) { connection.rollback(); throw e; } finally { connection.setAutoCommit(true); connection.close(); } } } PostLikeServiceTest\nimport static org.junit.jupiter.api.Assertions.*; @SpringBootTest class PostLikeServiceTest { @Autowired PostRepository postRepository; @Autowired PostLikeRepository postLikeRepository; @Autowired PostLikeService postLikeService; @BeforeEach void init() throws SQLException { postRepository.save(Post.create(1L, \u0026#34;トランザクションテスト\u0026#34;, \u0026#34;いいね機能テスト\u0026#34;)); } @AfterEach void clear() { postLikeRepository.deleteAll(); postRepository.deleteAll(); } @DisplayName(\u0026#34;投稿いいねテスト\u0026#34;) @Test void postLikeSave() throws SQLException { // given Long postId = 1L; Long userId = 100L; // when postLikeService.likePost(postId, userId); // then Post post = postRepository.findById(postId); assertEquals(1, post.getLikes()); assertTrue(postLikeRepository.existsByPostIdAndUserId(postId, userId)); } @DisplayName(\u0026#34;投稿に重複していいねできない\u0026#34;) @Test void duplicatePostLikeSave() throws SQLException { // given Long postId = 1L; Long userId = 100L; postLikeService.likePost(postId, userId); // 1回目のいいね成功 // when, then assertThrows(IllegalStateException.class, () -\u0026gt; { postLikeService.likePost(postId, userId); // 重複いいねを試行 }); // ロールバックを確認する：いいね！ Post post = postRepository.findById(postId); assertEquals(1, post.getLikes()); } } 上記のコードを実行すると、2番目のテストは失敗します。\nその理由は、PostLikeServiceで使用されているConnectionがPostRepository、PostLikeRepositoryで使用されているConnectionとは異なる接続であるためです。\nテストを成功させるには、PostLikeServiceと各Repositoryで同じConnectionを使用する必要があります。\nトランザクションを維持するには、トランザクションの開始から終了まで同じデータベース接続を維持する必要があります。\n結局、同じ接続を同期させるために、最も簡単にはパラメータで接続を渡すことができます。\nしかし、パラメータでコネクションを渡す方法はコードが汚れてしまうことはもちろん、コネクションをめくるメソッドとめくりないメソッドを重複して作らなければならないなど様々な欠点が多い。また、DBコネクションを含むリソースのきちんとした処理を可能にしたJdbcTemplateもこれ以上活用できない。\nこの問題を解決するためにSpringが提案する方法は、独立したトランザクション同期方式です。\nSpringが提供するトランザクション # トランザクション同期とは\nPostLikeServiceでトランザクションを開始するために作成したConnectionオブジェクトを特別なリポジトリ（ThreadLocal）に保存し、\n後で呼び出されるRepositoryのメソッドでは（正確にはJdbcTemplate）、保存されたConnectionを取得して使用させます。\nSpringでは、これにPlatformTransactionManagerを使用したトランザクション抽象化機能を提供します。\nトランザクション同期化されたまま JdbcTemplate を使用すると、JdbcTemplate のジョブで同期させた DB コネクションを使用することになる。\nPostLikeService\n@Service @RequiredArgsConstructor public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final PlatformTransactionManager transactionManager; public void likePost(Long postId, Long userId) throws SQLException { TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // まず良い数を増やして postRepository.incrementLikes(postId); // その後、重複チェックで例外が発生すると、 // 例外後のロールバックによってlikesの数が戻ったかどうかを検証できます。 if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;すでにいいねを押した投稿です。\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); throw e; } } } トランザクションを使用するロジックを見ると、次のパターンが繰り返されることがわかります。\n他のサービスでトランザクションを開始するには、try、catch、finallyを含む成功時にコミット、失敗時にロールバックコードが繰り返されます。\nSpringは、繰り返し問題を解決するためにテンプレートコールバックパターンを適用したTransactionTemplateを提供します。\nPostLikeService\n@Service public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final TransactionTemplate transactionTemplate; public PostLikeService(PostRepository postRepository, PostLikeRepository postLikeRepository, PlatformTransactionManager txManager) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; this.transactionTemplate = new TransactionTemplate(txManager); } public void likePost(Long postId, Long userId) { transactionTemplate.executeWithoutResult(status -\u0026gt; { postRepository.incrementLikes(postId); if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;すでにいいねを押した投稿です。\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); }); } } サービスロジックは、可能な限り重要なビジネスロジックのみでなければなりません。しかし、トランザクション技術を使用するには、やむを得ずトランザクションコードを出さなければなりません。どうすればこの問題を解決できますか？\n@Transactionalを使用すると、SpringはAOPを適用してこの問題を解決します。\nPostLikeService\n@Service public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; public PostLikeService(PostRepository postRepository, PostLikeRepository postLikeRepository) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; } @Transactional public void likePost(Long postId, Long userId) { postRepository.incrementLikes(postId); if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;すでにいいねを押した投稿です。\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); } } @Transactionalを有効にする設定\n@Transactionalが付いたクラスを検出してProxyオブジェクトを自動的に生成するには、@EnableTransactionManagementを有効にする必要があります。\nもしこの設定を忘れてしまえば、メソッドやクラスに @Transactional を付けてもProxyが生成されず、トランザクション制御にならないので注意しなければならない。\nただし、Spring Bootを使用すると、自動構成（Auto-Configuration）メカニズムによってトランザクション管理が有効になります。\nSpring Bootは@SpringBootApplicationに含まれている@EnableAutoConfigurationを介して複数のAuto-Configurationクラスを読み込み、そのうちの1つであるTransactionAutoConfigurationクラスでトランザクション管理を自動的に設定します。\nしたがって、@EnableTransactionManagementを直接指定する必要はありません。\n3.2 宣言型トランザクション対プログラミング型トランザクション # 宣言的トランザクションとプログラム的トランザクション # 宣言的トランザクションとは\nSpringフレームワークの宣言的トランザクション管理はSpring AOPを通じて可能です。\n過去にはxmlを利用して作成したりもした。\n動作方式\nSpringはプロキシオブジェクトを作成し、@Transactionalの付いたメソッドが呼び出されるとトランザクションを開始し、メソッドが終了するとトランザクションをコミットまたはロールバックします。\n例外の発生、メソッドの終了時点などに基づいてトランザクションを自動管理します。\n@Service public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; public PostLikeService(PostRepository postRepository, PostLikeRepository postLikeRepository) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; } @Transactional public void likePost(Long postId, Long userId) { postRepository.incrementLikes(postId); if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;すでにいいねを押した投稿です。\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); } } 利点\n簡潔さ：トランザクション制御コードを直接書く必要なしに、注釈を付けるだけです。 興味の分離：ビジネスロジックとトランザクションロジックが分離されました。コードの読みやすさと保守性の向上。 一貫性：フレームワークは一貫した方法でトランザクションを処理するため、ミスによる欠落や重複が少なくなります。 短所\nきめ細かい制御が難しい：基本的な動作に加えて、特別なトランザクション境界（部分コミット、非常に複雑なフロー）を制御するのは難しいです。 プロキシ制限：内部メソッド呼び出しには@Transactionalが適用されないなど、AOPプロキシ制限があります。 技術的理解の必要性：動的プロキシ、AOP、例外ロールバック規則などの理解が不足すると、予期しない動作が発生する可能性があります。 プログラミング的トランザクションとは\nコードレベルで明示的にトランザクションを開始、コミット、ロールバックする方法\nトランザクション関連コードを直接書くことで、Spring Frameworkはプログラム的なトランザクション管理のための2つの手段を提供します。\nTransactionTemplate or TransactionalOperator TransactionManagerを使用 Springチームは通常、プログラムによるトランザクション管理のためにTransactionTemplateを、リアクティブコードにはTransactionalOperatorをお勧めします。\nTransactionTemplateの例\n@Service public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final TransactionTemplate transactionTemplate; public PostLikeService(PostRepository postRepository, PostLikeRepository postLikeRepository, PlatformTransactionManager txManager) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; this.transactionTemplate = new TransactionTemplate(txManager); } public void likePost(Long postId, Long userId) { transactionTemplate.executeWithoutResult(status -\u0026gt; { postRepository.incrementLikes(postId); if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;すでにいいねを押した投稿です。\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); }); } } transactionTemplate.execute()ブロック内のコードをトランザクション範囲に設定し、終了時にコミットまたはロールバックを実行します。\nTransactionManager の例\n@Service @RequiredArgsConstructor public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final PlatformTransactionManager transactionManager; public void likePost(Long postId, Long userId) throws SQLException { TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // まず良い数を増やして postRepository.incrementLikes(postId); // その後、重複チェックで例外が発生すると、 // 例外後のロールバックによってlikesの数が戻ったかどうかを検証できます。 if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;すでにいいねを押した投稿です。\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); throw e; } } } 利点\n細かい制御：トランザクションの途中でコミット、ロールバックを決定したり、特定の条件に基づいてトランザクション範囲を動的に調整したりできます。 明示的なフロー：ロジックでトランザクション境界がどこにあるかを直感的に知ることができ、デバッグが簡単になります。 短所\nコードの重複：トランザクションの開始、コミット、ロールバックなどのコードを複数の場所で繰り返すことができます。 ビジネスロジックとの結合：トランザクションロジックがビジネスコードと混在し、可読性が低下する可能性があります。 追加の学習曲線：PlatformTransactionManager、TransactionDefinition、TransactionStatusなど、Spring Transaction APIの学習が必要です。 宣言的トランザクションとプログラミング的トランザクション\nプログラミングトランザクション管理は通常、トランザクションジョブ数が少ない場合にのみ使用することをお勧めします。\nたとえば、特定の更新タスクに対してのみトランザクションを必要とするWebアプリケーションがある場合、Spring AOPや他の技術を使用してプロキシを設定したくない場合があります。\nこの場合、TransactionTemplateを使用するのが良い方法かもしれません。\n一方、アプリケーションに多数のトランザクション操作がある場合、宣言的トランザクション管理は一般的に価値があります。トランザクション管理をビジネスロジックから分離して構成できます。\nSpringフレームワークを使用すると、宣言的トランザクション管理の構成コストが大幅に削減されます。\n一般的なWeb/サービス開発 ほとんどの場合、単に@Transactionalを使用する宣言的トランザクションがより効率的です。\nコードがきれいでメンテナンスしやすく、スプリングが一貫して管理してくれるから。\n特殊なトランザクションフロー たとえば、あるメソッドで複数回部分コミットしたり、特定の条件でのみロールバックする必要があるなどの複雑なシナリオでは、プログラミングトランザクションが柔軟になる可能性があります。\n混合を使用 ほとんどの場合、宣言的トランザクションを使用しますが、非常にきめ細かなトランザクション境界を必要とするいくつかのセクションでプログラミングトランザクションを使用する方法も可能です。\nきめ細かいトランザクション制御の例 # PostLikeProgrammaticService\n@Slf4j @Service @RequiredArgsConstructor public class PostLikeProgrammaticService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final PlatformTransactionManager transactionManager; /** * 複数の投稿で「いいね」をクリックするメソッド * @param partialAllowed trueの場合、一部の投稿が失敗したときにロールバックせずに残りの成功を許可する */ public void likeMultiplePosts(List\u0026lt;Long\u0026gt; postIds, Long userId, boolean partialAllowed) { TransactionTemplate txTemplate = new TransactionTemplate(transactionManager); if (!partialAllowed) { txTemplate.executeWithoutResult(status -\u0026gt; { for (Long postId : postIds) { // いいねロジックで例外が発生した場合はトランザクション全体をロールバック likeSinglePostOrThrow(postId, userId); } }); return; } // partialAllowed == trueの場合 - \u0026gt;部分的な成功を許可する // 「各投稿ごとに別々のトランザクション」として処理（ネストされたトランザクションまたは繰り返しトランザクション） for (Long postId : postIds) { try { txTemplate.executeWithoutResult(status -\u0026gt; { likeSinglePostOrThrow(postId, userId); }); } catch (Exception e) { // 特定の投稿が失敗してもログだけ残して次へ進む log.info(\u0026#34;投稿{}のいいね失敗: {}\u0026#34;, postId, e.getMessage()); } } } /** * 単一の投稿にいいねを行い、重複いいねの場合は例外を発生させる * （重複いいねの試行などで例外が発生してもそのままthrowしてロールバックを誘導） */ private void likeSinglePostOrThrow(Long postId, Long userId) { if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;すでにいいねを押した投稿です。 (postId=\u0026#34; + postId + \u0026#34;)\u0026#34;); } postRepository.incrementLikes(postId); postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); } } PostLikeProgrammaticServiceTest\n@SpringBootTest class PostLikeProgrammaticServiceTest { @Autowired PostLikeProgrammaticService postLikeProgrammaticService; @Autowired PostRepository postRepository; @Autowired PostLikeRepository postLikeRepository; @BeforeEach void init() throws SQLException { // 3つの投稿を事前に作成 postRepository.save(Post.create(1L, \u0026#34;Title1\u0026#34;, \u0026#34;Content1\u0026#34;)); postRepository.save(Post.create(2L, \u0026#34;Title2\u0026#34;, \u0026#34;Content2\u0026#34;)); postRepository.save(Post.create(3L, \u0026#34;Title3\u0026#34;, \u0026#34;Content3\u0026#34;)); } @AfterEach void clear() { postLikeRepository.deleteAll(); postRepository.deleteAll(); } @DisplayName(\u0026#34;All-or-Nothingモードで1つでも失敗したら全体をロールバック\u0026#34;) @Test void likeMultiplePostsAllOrNothingFail() throws SQLException { // given List\u0026lt;Long\u0026gt; postIds = Arrays.asList(1L, 2L, 3L); Long userId = 100L; // 2番の投稿にいいねを入れて重複状況を作る postLikeRepository.save(new PostLike(2L, userId, LocalDateTime.now())); // when - then // 2番の投稿で例外 -\u0026gt; 全体をロールバック assertThrows(IllegalStateException.class, () -\u0026gt; postLikeProgrammaticService.likeMultiplePosts(postIds, userId, false) // partialAllowed = false ); // then: 1, 3回 投稿 いいねも失敗しなければならない -\u0026gt; likes=0, post_like テーブルも変化なし assertEquals(0, postRepository.findById(1L).getLikes()); assertEquals(0, postRepository.findById(2L).getLikes()); // 既存のlikesだけ反映（ここでは0） assertEquals(0, postRepository.findById(3L).getLikes()); // 2番の投稿にはすでにいいねがある assertTrue(postLikeRepository.existsByPostIdAndUserId(2L, userId)); // 残りの投稿いいね！ assertFalse(postLikeRepository.existsByPostIdAndUserId(1L, userId)); assertFalse(postLikeRepository.existsByPostIdAndUserId(3L, userId)); } @Test @DisplayName(\u0026#34;PartialAllowed=true -\u0026gt; 失敗した投稿だけロールバックし、残りは成功\u0026#34;) void likeMultiplePostsPartialSuccess() throws SQLException { // given List\u0026lt;Long\u0026gt; postIds = Arrays.asList(1L, 2L, 3L); Long userId = 100L; // すでに1番の投稿にいいねがある -\u0026gt; 1番の投稿で例外が予想される postLikeRepository.save(new PostLike(1L, userId, LocalDateTime.now())); // when postLikeProgrammaticService.likeMultiplePosts(postIds, userId, true); // partialAllowed = true // then // 1) 1番の投稿 -\u0026gt; 失敗（重複）、ロールバック済み（likes変更なし） assertEquals(0, postRepository.findById(1L).getLikes()); // 2) 2番, 3番 投稿 -\u0026gt; 成功 (likes=1) assertEquals(1, postRepository.findById(2L).getLikes()); assertEquals(1, postRepository.findById(3L).getLikes()); // post_like -\u0026gt; 1番は既存の1レコードのみ、2番と3番追加 assertTrue(postLikeRepository.existsByPostIdAndUserId(1L, userId)); assertTrue(postLikeRepository.existsByPostIdAndUserId(2L, userId)); assertTrue(postLikeRepository.existsByPostIdAndUserId(3L, userId)); } } All-or-Nothing vs PartialSuccess\n1つのメソッドはすべての投稿のいいねを処理しますが、失敗した場合はすべてロールバックするか失敗した投稿のみをロールバックするかを動的に制御します。\n部分的な成功を許可する場合、投稿ごとに新しいトランザクションを開いていいねを試すことで、個別のロールバックが可能になります。\n内部で重複例外（ IllegalStateException ）が発生したらトランザクションをロールバックし、その例外を受け取って必要に応じてログだけ残して処理を続けることができます。\nもし、宣言的トランザクション（ @Transactional ）で単一のメソッドにまとめるだけで「All-or-Nothing」になってしまい、部分成功などのトランザクション範囲を柔軟に調整することが難しい。\n3.3 トランザクションの内部構造 # トランザクションマネージャとトランザクション同期マネージャ # PlatformTransactionManager（トランザクションマネージャ）\n出典：キム・ヨンハンの春DB1\n実装技術によるトランザクションの使用法\nトランザクションは、実装技術ごとに使用する方法が異なる。\nJDBC:\nconnection.setAutoCommit(false), connection.commit(), connection.rollback()\nJPA:\ntx = entityManager.getTransaction()\ntx.begin(), tx.commit(), tx.rollback()\nHibernate:\ntx = session.beginTransaction()\ntx.commit(), tx.rollback()\nデータベースアクセス技術をJDBCからJPAに変更する場合は、サービス層のトランザクション開始コードを変更する必要があります。\nこれらの問題を解決するために、PlatformTransactionManagerを使用したトランザクション抽象化技術をSpringが提供し、主に使用するデータアクセス技術のトランザクションマネージャの実装も提供します。\nさらに、Spring Bootはどのデータアクセス技術を使用しているかを自動的に認識し、適切なトランザクションマネージャを選択してSpring Beanに登録するため、トランザクションマネージャを選択して登録するプロセスも省略できます。\nたとえば、JdbcTemplate、MyBatisを使用するとDataSourceTransactionManager(JdbcTransactionManager)をスプリングビンとして登録し、JPAを使用するとJpaTransactionManagerをスプリングビンとして登録します。\nTransactionSynchronizationManager（トランザクション同期マネージャ）とDataSourceUtils\nSpringは、マルチスレッド環境でも安全なトランザクション同期方法を実装するためにTransactionSynchronizationManagerを提供します。これはスレッドローカル（ThreadLocal）を使用して接続を同期します。\n**「トランザクションマネージャ」**は内部でこの「**トランザクション同期マネージャ」**を使用します。\nトランザクション同期マネージャはスレッドローカルを使用するため、マルチスレッドの状況に安全に接続を同期できます。\n接続が必要な場合は、DataSourceUtilsが提供するgetConnection()メソッドを介してDB接続を取得できます。\nDataSourceからコネクションを直接取得せず、Springが提供するユーティリティメソッドを使う理由は、このDataSourceUtilsのgetConnection()メソッドがConnectionオブジェクトをDataSourceから取得するだけでなく、トランザクション同期に使うための保存先（ThreadLocal）へバインドしてくれるためです。\nトランザクション例の内部構造 # TransactionTemplateを使用した場合の内部構造\nJdbcTemplateは内部的にDataSourceUtilsを使用します。\n@Transactionalを使用した場合の内部構造\nトランザクションの適用範囲（メソッド、クラス単位） # スプリングでは、優先順位は常により具体的で詳細なものが高い優先順位を持っています。\nたとえば、メソッドとクラスにアノテーションを付けることができる場合、より具体的なメソッドはより高い優先順位を持ちます。\nインタフェースとそのインタフェースを実装したクラスにアノテーションを付けることができれば、より具体的なクラスがより高い優先順位を持つ。\n// test lombok testCompileOnly(\u0026#39;org.projectlombok:lombok\u0026#39;) testAnnotationProcessor(\u0026#39;org.projectlombok:lombok\u0026#39;) @SpringBootTest public class TxLevelTest { @Autowired LevelService service; @Test void orderTest() { service.write(); service.read(); } @TestConfiguration static class TxApplyLevelConfig { @Bean LevelService levelService() { return new LevelService(); } } @Slf4j @Transactional(readOnly = true) static class LevelService { @Transactional(readOnly = false) public void write() { log.info(\u0026#34;call write\u0026#34;); printTxInfo(); } public void read() { log.info(\u0026#34;call read\u0026#34;); printTxInfo(); } private void printTxInfo() { boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); log.info(\u0026#34;tx active={}\u0026#34;, txActive); boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); log.info(\u0026#34;tx readOnly={}\u0026#34;, readOnly); } } } クラスレベル：@Transactional(readOnly = true)\n基本的に、このクラス内のすべてのメソッドが「読み取り専用」トランザクションで動作するようになる。\nメソッドレベル：@Transactional(readOnly = false)\nクラスレベルで読み取り専用を設定しても、メソッドレベルで別の設定（ここではreadOnly = false）を宣言すると、このメソッドが優先されます。\nその結果、write()メソッドは読み取り専用ではなくトランザクションとして動作します。\nトランザクションAOPの注意事項1 # PostLikeService\n@Service public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; public PostLikeService(PostRepository postRepository, PostLikeRepository postLikeRepository) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; } @Transactional public void likePost(Long postId, Long userId) { // postRepository.incrementLikes(postId); Post post = postRepository.findById(postId); post.increaseLikes(); postRepository.update(post); if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;すでにいいねを押した投稿です。\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); } } PostRepository\n@Repository public class PostRepository { private final JdbcTemplate jdbcTemplate; public PostRepository(DataSource dataSource) { this.jdbcTemplate = new JdbcTemplate(dataSource); } public void save(Post post) { String sql = \u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;; jdbcTemplate.update(sql, post.getPostId(), post.getTitle(), post.getContent(), post.getLikes()); } public void incrementLikes(Long postId) { String sql = \u0026#34;update post set likes = likes + 1 where post_id = ?\u0026#34;; jdbcTemplate.update(sql, postId); } // 追加 public void update(Post post) { String sql = \u0026#34;update post set title = ?, content = ?, likes = ? where post_id = ?\u0026#34;; jdbcTemplate.update(sql, post.getTitle(), post.getContent(), post.getLikes(), post.getPostId()); } public Post findById(Long id) { String sql = \u0026#34;select * from post where post_id = ?\u0026#34;; return jdbcTemplate.queryForObject(sql, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class), id); } public void deleteAll() { String sql = \u0026#34;delete from post\u0026#34;; jdbcTemplate.update(sql); } } PostLikeSyncTest\n@SpringBootTest public class PostLikeSyncTest { @Autowired private PostRepository postRepository; @Autowired private PostLikeService postLikeService; @Autowired private PostLikeRepository postLikeRepository; @BeforeEach void init() { postRepository.deleteAll(); postLikeRepository.deleteAll(); // 初期投稿の生成 postRepository.save(Post.create(10L, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;)); } @AfterEach void clear() { postRepository.deleteAll(); postLikeRepository.deleteAll(); } @DisplayName(\u0026#34;投稿いいね同時実行テスト\u0026#34;) @Test void unLikeSync() throws Exception { // given int threadCount = 400; ExecutorService executorService = Executors.newFixedThreadPool(threadCount); CountDownLatch latch = new CountDownLatch(threadCount); // when for (int i = 0; i \u0026lt; threadCount; i++) { Long userId = (long) i; executorService.submit(() -\u0026gt; { try { postLikeService.likePost(10L, userId); } finally { latch.countDown(); } }); } latch.await(); executorService.shutdown(); // then Post post = postRepository.findById(10L); assertThat(post.getLikes()).isEqualTo(400); } } 上記のコードは、likes列で競合条件が発生する並行性の例であり、テストは失敗します。\nその理由は、同時に複数のスレッドが同じ投稿に対して「いいね」を押す過程で、DBから読み込んだ「likes」をそれぞれメモリに上げて1ずつ増えた後、再度更新するからだ。\nすなわち、次のような典型的なレースコンディション問題が発生する。\nスレッドAとスレッドBはそれぞれほぼ同時にpostRepository.findById(10L)を介して投稿を読み込みます。 たとえば、両方のスレッドが likes = 0 の状態の Post オブジェクトを取得するとします。\n各スレッドは、取得したPostオブジェクトのlikesを1増やした後（post.increaseLikes()）、postRepository.update(post)を呼び出します。 AスレッドはDBにlikes = 1に更新\nBスレッドはDBにlikes = 1に更新\nその結果、BがAの後にDB更新を実行すると、最終結果は1だけになり、2回分（スレッド数分）の増加が失われます。 コメントに変更した既存のincrementLikes()メソッドは、DBレベルで「likes」列を直接アトミックに増加させるクエリを使用しました。\nしたがって、「読み込み→メモリから+1→DBに書き換える」過程で発生するレース条件を避けることができた。\nしかし、別の方法でsynchronizedキーワードを使用する方法を考えることができます。\n同じJVM内で同時に入ってくる複数のスレッドはこのメソッドへ同時に進入できないため（一つのスレッドが終了するまで別のスレッドは待機する）、レースコンディションを避けることができる。\nPostLikeService\n@Service public class PostLikeServiceProxy { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; public PostLikeServiceProxy(PostRepository postRepository, PostLikeRepository postLikeRepository) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; } public void likePost(Long postId, Long userId) { // トランザクションの開始 // 複数のスレッドが待機 // PostLikeService.likePost() 呼び出し // トランザクション終了 } } @Service public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; public PostLikeService(PostRepository postRepository, PostLikeRepository postLikeRepository) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; } @Transactional public synchronized void likePost(Long postId, Long userId) { // 事業開始 // postRepository.incrementLikes(postId); Post post = postRepository.findById(postId); post.increaseLikes(); postRepository.update(post); if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;すでにいいねを押した投稿です。\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); } } しかし、それでもテストケースが失敗することがわかります。\nこれは、Springの@Transactionalアナテーションの動作方法によるものです。\nプロキシ方式を使用するため、プロキシクラスのlikePost()メソッドにはsynchronizedが適用されていません。\nしたがって、プロキシクラスのlikePost()メソッド内でトランザクションのコミットまたはロールバックが終了する前に、他のスレッドがpostLikeService.likePost()メソッドにアクセスして、前のスレッドがコミットする前の状態のリソースを見ることができます。\n参考までに@Transactionalアナテーションを削除すると、テストは合格します。\nトランザクションAOPの注意事項2 # @Transactionalを適用すると、プロキシオブジェクトは最初に要求を受け取り、トランザクションを処理し、実際のオブジェクトを呼び出します。\nしたがって、トランザクションを適用するには、常にプロキシを介してターゲットオブジェクト（Target）を呼び出す必要があります。\nこれにより、プロキシで最初にトランザクションを適用し、その後ターゲットオブジェクトを呼び出します。\nもし、プロキシを経ずに対象オブジェクトを直接呼び出すと、AOPは適用されず、トランザクションも適用されない。\nトランザクション制御ログの確認\nlogging: level: org: springframework: transaction: interceptor: TRACE jdbc: support: JdbcTransactionManager: TRACE TxInternalCallTest\n@Slf4j @SpringBootTest public class TxInternalCallTest { @Autowired private TxInternalCallService txInternalCallService; @DisplayName(\u0026#34;プロキシ確認\u0026#34;) @Test void printProxy() { log.info(\u0026#34;callService class={}\u0026#34;, txInternalCallService.getClass()); } @DisplayName(\u0026#34;externalMethodによるinternalMethod呼び出しテスト\u0026#34;) @Test void external() throws Exception { txInternalCallService.externalMethod(); } @DisplayName(\u0026#34;internalMethod単独呼び出しテスト\u0026#34;) @Test void internal() throws Exception { txInternalCallService.internalMethod(); } @TestConfiguration static class TxInternalCallTestConfig { @Bean public TxInternalCallService txInternalCallService() { return new TxInternalCallService(); } } @Service static class TxInternalCallService { public void externalMethod() { log.info(\u0026#34;call externalMethod()\u0026#34;); printTx(); internalMethod(); } @Transactional public void internalMethod() { log.info(\u0026#34;call internalMethod()\u0026#34;); printTx(); } private void printTx() { boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); log.info(\u0026#34;txActive: {}\u0026#34;, txActive); } } } 予想どおり、internalMethod()ではトランザクションがまったく適用されませんでした。\nなぜこのような問題が発生するのか？\n@Transactionalが1つでもあれば、トランザクションプロキシオブジェクトが作成されます。\nそして、TxInternalCallService Beanを注入すると、トランザクションプロキシオブジェクトは代わりに注入されます。\nそれではどうすれば解決できますか？\n最も簡単な方法はinternalMethod()を別のクラスに分割することです。\n@Slf4j @SpringBootTest public class TxInternalCallTest { @Autowired private ExternalCallService externalCallService; @DisplayName(\u0026#34;プロキシ確認\u0026#34;) @Test void printProxy() { log.info(\u0026#34;callService class={}\u0026#34;, externalCallService.getClass()); } @DisplayName(\u0026#34;externalMethodによるinternalMethod呼び出しテスト\u0026#34;) @Test void external() throws Exception { externalCallService.externalMethod(); } @TestConfiguration static class TxInternalCallTestConfig { @Bean public ExternalCallService txInternalCallService() { return new ExternalCallService(internalCall()); } @Bean public TxInternalCall internalCall() { return new TxInternalCall(); } } static class ExternalCallService { private final TxInternalCall txInternalCall; public ExternalCallService(TxInternalCall txInternalCall) { this.txInternalCall = txInternalCall; } public void externalMethod() { log.info(\u0026#34;call externalMethod()\u0026#34;); printTx(); txInternalCall.internalMethod(); } private void printTx() { boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); log.info(\u0026#34;txActive: {}\u0026#34;, txActive); } } static class TxInternalCall { @Transactional public void internalMethod() { log.info(\u0026#34;call internalMethod()\u0026#34;); printTx(); } private void printTx() { boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); log.info(\u0026#34;txActive: {}\u0026#34;, txActive); } } } この方法に加えて、SpringAOPをプロキシベースではなくコンパイル時やクラスロード時にJavaコードを操作する方法もある。\n3.4 トランザクションの伝播属性 # トランザクションの伝播と属性 # トランザクション伝播は、あるトランザクション内で別のトランザクションメソッドを呼び出すときにそれらの関係をどのように管理するかを決定するためのメカニズムです。\n複数のレイヤ（コントローラ、サービス、リポジトリなど）や複数のモジュールにわたってトランザクションがかけられている可能性があるため、「現在トランザクションが既に存在する場合には合流するのか、新しいトランザクションを開くのか、エラーで処理するのか」などを明示的に設定する必要がある。\nSpringはさまざまなトランザクション伝播オプションを提供します。伝播オプションに別の設定がない場合は、REQUIREDがデフォルトとして使用されます。\nREQUIRED 最もよく使うデフォルト設定だ。既存のトランザクションがなければ作成し、あれば参加する。トランザクションが必須という意味で理解すればよい。\nREQUIRES_NEW 常に独立した新しいトランザクションを作成します。既存のトランザクションがある場合はしばらく中断し、内部タスクを別々にコミットまたはロールバックします。\nSUPPORTS トランザクションをサポートするという意味だ。既存のトランザクションがなければ、ないまま進行して、あれば参加する。\nNOT_SUPPORTED トランザクションをサポートしていないという意味だ。\nMANDATORY 義務事項だ。トランザクションが必要です。既存のトランザクションがないと例外が発生します。\nNEVER トランザクションを使わないという意味だ。既存のトランザクションがあると例外が発生します。既存のトランザクションも許可しません。\nNESTED 既存のトランザクションなし：新しいトランザクションを作成します。 既存のトランザクションあり：入れ子になったトランザクションを作成します。\n伝播属性の使用例 # Log スキーマの生成\ndrop table log if exists cascade; create table log ( log_id bigint primary key auto_increment, msg varchar(255), created_at timestamp ); Log\n@ToString @NoArgsConstructor @Getter public class Log { private Long logId; private String msg; private LocalDateTime createdAt; private Log(String msg, LocalDateTime createdAt) { this.msg = msg; this.createdAt = createdAt; } private Log(Long logId, String msg, LocalDateTime createdAt) { this.logId = logId; this.msg = msg; this.createdAt = createdAt; } public static Log create(String msg) { return new Log(msg, LocalDateTime.now()); } } LogRepository\n@Slf4j @RequiredArgsConstructor @Repository public class LogRepository { private final JdbcTemplate jdbcTemplate; @Transactional(propagation = Propagation.REQUIRES_NEW) public void save(Log lg) { log.info(\u0026#34;save log: {}\u0026#34;, lg); String sql = \u0026#34;insert into log(msg, created_at) values(?, ?)\u0026#34;; jdbcTemplate.update(sql, lg.getMsg(), Timestamp.valueOf(lg.getCreatedAt())); } public List\u0026lt;Log\u0026gt; findAll() { log.info(\u0026#34;find all logs\u0026#34;); String sql = \u0026#34;select * from log\u0026#34;; return jdbcTemplate.query(sql, new DataClassRowMapper\u0026lt;\u0026gt;(Log.class)); } public void deleteAll() { log.info(\u0026#34;delete all logs\u0026#34;); String sql = \u0026#34;delete from log\u0026#34;; jdbcTemplate.update(sql); } } PostLikeLogService\n@Service public class PostLikeLogService { private final PostLikeService postLikeService; private final LogRepository logRepository; public PostLikeLogService(PostLikeService postLikeService, LogRepository logRepository) { this.postLikeService = postLikeService; this.logRepository = logRepository; } @Transactional public void likePost(Long postId, Long userId) { postLikeService.likePost(postId, userId); logRepository.save(Log.create(\u0026#34;Post liked. postId=\u0026#34; + postId + \u0026#34;, userId=\u0026#34; + userId)); throw new RuntimeException(\u0026#34;強制例外発生!\u0026#34;); } } PostLikeLogServiceTest\n@SpringBootTest class PostLikeLogServiceTest { @Autowired private PostLikeLogService postLikeLogService; @Autowired private PostRepository postRepository; @Autowired private PostLikeRepository postLikeRepository; @Autowired private LogRepository logRepository; @BeforeEach void before() { postRepository.deleteAll(); postLikeRepository.deleteAll(); logRepository.deleteAll(); postRepository.save(Post.create(10L, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;)); } @AfterEach void clear() { postRepository.deleteAll(); postLikeRepository.deleteAll(); logRepository.deleteAll(); } @DisplayName(\u0026#34;Postいいねログテスト\u0026#34;) @Test void postLikeLog() throws Exception { assertThrows(RuntimeException.class, () -\u0026gt; postLikeLogService.likePost(10L, 1L)); assertEquals(1, logRepository.findAll().size()); } } 重要なのは、同じトランザクション内で発生した例外によって全体がロールバックされても、REQUIRES_NEWを使用したトランザクション操作はコミットされてデータベースに残ることです。\nそして、この記事のようにJdbcTemplateとDataSourceTransactionManagerを使用する環境では、外側のトランザクションがすでに接続（Connection1）を占有している間、REQUIRES_NEWが内部トランザクション用の接続（Connection2）をさらに取得します。\nつまり、1つのHTTPリクエスト内でも、この区間ではコネクションが最大2つまで同時に占有できる。\n全体の流れ\nクライアントはPostLikeLogService.likePost()を呼び出します。 このメソッドは@Transactional（デフォルト：REQUIRED）なので、論理トランザクション1（→物理トランザクション1）で囲みます。\nPostLikeService.likePost()を呼び出して投稿のいいねを処理します。 PostLikeService.likePost()も@Transactional（デフォルト：REQUIRED）で同じトランザクションに参加します。\nクエリは同じConnection（Connection1）を介してDBに反映されます。\nLogRepository.save() を呼び出します。 このメソッドは@Transactional(propagation = Propagation.REQUIRES_NEW)なので、新しい論理トランザクション2（→物理トランザクション2）を作成し、別のConnection（Connection2）を取得します。\nこのとき、外部トランザクションのConnection1は終了するのではなく、しばらく中断されたままになり、内部トランザクションはConnection2で実行されます。\nログを挿入した後、このREQUIRES_NEWトランザクションはすぐにコミットされます。\nPostLikeLogService.likePost()内でRuntimeExceptionを強制します。 この例外のため、物理トランザクション1はロールバックされます。\nしかし、すでにREQUIRES_NEWで処理されたログ保存は、別々の物理トランザクションでコミットされたため、ロールバックされずに残ります。\nなぜログは残り、いいねはロールバックされるのか?\n基本 ( REQUIRED ) トランザクションに属する良いジョブは、例外が発生したときにロールバックされます。\nただし、REQUIRES_NEWは「親トランザクションをしばらく中断し、完全に独立した新しいトランザクションとして動作する」ため、ログ保存はすでに他の物理トランザクションでコミットされます。\nその結果、親トランザクションがロールバックされても、ログデータはロールバックされずにDBに残ります。\nしたがって、REQUIRES_NEWを頻繁に使用すると、同時に必要なコネクションの数が増加するため、コネクションプールのサイズとトランザクション設計を一緒に考慮する必要があります。\n逆に、外側のトランザクションがない状態でREQUIRES_NEWメソッドのみが単独で呼び出される場合、最終的にはトランザクション1個、コネクション1個だけを使用する。\nREQUIRES_NEWを使用していない場合は\nLogRepository.save() に @Transactional(propagation = Propagation.REQUIRED) のように別途伝播設定なしで親トランザクションと同じように動作する場合、次のような結果が出る。\nPostLikeLogService.likePost() → postLikeService.likePost() → logRepository.save() が同じトランザクションで囲まれます。 RuntimeExceptionのため、すべてのジョブが1つのトランザクション内でロールバックされます。 したがって、いいねもロールバックされ、ログもDBに残らない。 4. 練習：Spring JDBCを使用したCRUDの実装 # 4.1 プロジェクトの構成 # MySQLデータベースのインストール\nMACユーザー：\nbrew update brew install mysql mysql.server start WINDOWユーザー：\nhttps://dev.mysql.com/downloads/mysql/\nIntelliJ Database接続\nmirco-blog スキーマの生成\nプロジェクトの作成\napplication.yml\nspring: datasource: url: jdbc:mysql://localhost:3306/micro-blog username: root password: driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 4.2 CRUDとJoinを利用したマイクロブログの例の作成 # DBスキーマの作成\nDROP TABLE IF EXISTS comment; DROP TABLE IF EXISTS post; CREATE TABLE post ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(255) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE comment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, post_id BIGINT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_comment_post FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE ); -- サンプルデータ INSERT INTO post (title, content) VALUES (\u0026#39;最初の投稿\u0026#39;, \u0026#39;ブログ例を開始!\u0026#39;), (\u0026#39;2番目の投稿\u0026#39;, \u0026#39;thymeleafとjdbcを使った例\u0026#39;); INSERT INTO comment (post_id, content) VALUES (1, \u0026#39;最初のコメント!\u0026#39;), (1, \u0026#39;2番目のコメント\u0026#39;), (2, \u0026#39;2番目の投稿の最初のコメント\u0026#39;); Post\n@ToString @Getter @Setter @NoArgsConstructor @EqualsAndHashCode public class Post { private Long id; private String title; private String content; private LocalDateTime createdAt; private Post(Long id, String title, String content, LocalDateTime createdAt) { this.id = id; this.title = title; this.content = content; this.createdAt = createdAt; } public static Post create(String title, String content) { return new Post(null, title, content, LocalDateTime.now()); } public static Post of(Long id, String title, String content, LocalDateTime createdAt) { return new Post(id, title, content, createdAt); } } Comment\n@ToString @Getter @Setter @NoArgsConstructor @EqualsAndHashCode public class Comment { private Long id; private Long postId; private String content; private LocalDateTime createdAt; private Comment(Long id, Long postId, String content, LocalDateTime createdAt) { this.id = id; this.postId = postId; this.content = content; this.createdAt = createdAt; } public static Comment create(Long postId, String content) { return new Comment(null, postId, content, LocalDateTime.now()); } } PostRepository\n@Repository @RequiredArgsConstructor public class PostRepository { private final JdbcTemplate jdbcTemplate; // 投稿を保存 public void save(Post post) { String sql = \u0026#34;insert into post (title, content, created_at) values (?, ?, ?)\u0026#34;; jdbcTemplate.update(sql, post.getTitle(), post.getContent(), post.getCreatedAt()); } // 投稿を編集 public void update(Post post) { String sql = \u0026#34;update post set title = ?, content = ? where id = ?\u0026#34;; jdbcTemplate.update(sql, post.getTitle(), post.getContent(), post.getId()); } // 記事の短い検索 public Post findById(Long id) { String sql = \u0026#34;select * from post where id = ?\u0026#34;; return jdbcTemplate.queryForObject(sql, new BeanPropertyRowMapper\u0026lt;\u0026gt;(Post.class), id); } // すべての投稿を見る（コメント数Join） public List\u0026lt;PostWithCommentCount\u0026gt; findAllWithCommentCount() { String sql = \u0026#34;\u0026#34;\u0026#34; select p.*, count(c.id) as comment_count from post p left join comment c on p.id = c.post_id group by p.id order by p.id desc \u0026#34;\u0026#34;\u0026#34;; return jdbcTemplate.query(sql, new BeanPropertyRowMapper\u0026lt;\u0026gt;(PostWithCommentCount.class)); } // 投稿を削除 public void deleteById(Long id) { String sql = \u0026#34;delete from post where id = ?\u0026#34;; jdbcTemplate.update(sql, id); } } CommentRepository\n@Repository @RequiredArgsConstructor public class CommentRepository { private final JdbcTemplate jdbcTemplate; // コメントを保存 public void save(Comment comment) { String sql = \u0026#34;insert into comment (post_id, content, created_at) values (?, ?, ?)\u0026#34;; jdbcTemplate.update(sql, comment.getPostId(), comment.getContent(), comment.getCreatedAt()); } // 特定の投稿のすべてのコメントを表示 public List\u0026lt;Comment\u0026gt; findByPostId(Long postId) { String sql = \u0026#34;select * from comment where post_id = ? order by id desc\u0026#34;; return jdbcTemplate.query(sql, new BeanPropertyRowMapper\u0026lt;\u0026gt;(Comment.class), postId); } // コメントを削除 public void deleteById(Long id) { String sql = \u0026#34;delete from comment where id = ?\u0026#34;; jdbcTemplate.update(sql, id); } } PostService\n@Transactional @RequiredArgsConstructor @Service public class PostService { private final PostRepository postRepository; private final CommentRepository commentRepository; public List\u0026lt;PostWithCommentCount\u0026gt; getPostsWithCommentCount() { return postRepository.findAllWithCommentCount(); } public PostDetailDto getPostDetail(Long id) { Post post = postRepository.findById(id); List\u0026lt;Comment\u0026gt; comments = commentRepository.findByPostId(id); return PostDetailDto.of(post, comments); } public void createPost(Post post) { postRepository.save(post); } public void updatePost(Post post) { postRepository.update(post); } public void deletePost(Long id) { postRepository.deleteById(id); } } CommentService\n@Transactional @RequiredArgsConstructor @Service public class CommentService { private final CommentRepository commentRepository; public void createComment(Comment comment) { commentRepository.save(comment); } public void deleteComment(Long id) { commentRepository.deleteById(id); } } PostController\n@Controller @RequiredArgsConstructor @RequestMapping(\u0026#34;/posts\u0026#34;) public class PostController { private final PostService postService; @GetMapping public String list(Model model) { List\u0026lt;PostWithCommentCount\u0026gt; posts = postService.getPostsWithCommentCount(); model.addAttribute(\u0026#34;posts\u0026#34;, posts); return \u0026#34;post_list\u0026#34;; } @GetMapping(\u0026#34;/{id}\u0026#34;) public String detail(@PathVariable(name = \u0026#34;id\u0026#34;) Long id, Model model) { PostDetailDto postDetailDto = postService.getPostDetail(id); model.addAttribute(\u0026#34;postDetail\u0026#34;, postDetailDto); return \u0026#34;post_detail\u0026#34;; } @GetMapping(\u0026#34;/new\u0026#34;) public String newForm(Model model) { model.addAttribute(\u0026#34;post\u0026#34;, new Post()); // フォームバインディング用 return \u0026#34;post_form\u0026#34;; } @PostMapping public String create(@RequestParam String title, @RequestParam String content) { Post post = Post.create(title, content); postService.createPost(post); return \u0026#34;redirect:/posts\u0026#34;; } @GetMapping(\u0026#34;/{id}/edit\u0026#34;) public String editForm(@PathVariable(name = \u0026#34;id\u0026#34;) Long id, Model model) { Post post = postService.getPostDetail(id).toPost(); model.addAttribute(\u0026#34;post\u0026#34;, post); return \u0026#34;post_form\u0026#34;; } @PostMapping(\u0026#34;/{id}/edit\u0026#34;) public String update(@PathVariable(name = \u0026#34;id\u0026#34;) Long id, @RequestParam String title, @RequestParam String content) { Post post = Post.of(id, title, content, null); postService.updatePost(post); return \u0026#34;redirect:/posts/\u0026#34; + id; } @PostMapping(\u0026#34;/{id}/delete\u0026#34;) public String delete(@PathVariable(name = \u0026#34;id\u0026#34;) Long id) { postService.deletePost(id); return \u0026#34;redirect:/posts\u0026#34;; } } CommentController\n@Controller @RequiredArgsConstructor @RequestMapping(\u0026#34;/posts/{postId}/comments\u0026#34;) public class CommentController { private final CommentService commentService; @PostMapping public String createComment(@PathVariable(name = \u0026#34;postId\u0026#34;) Long postId, @RequestParam String content) { Comment comment = Comment.create(postId, content); commentService.createComment(comment); return \u0026#34;redirect:/posts/\u0026#34; + postId; } @PostMapping(\u0026#34;/{commentId}/delete\u0026#34;) public String deleteComment(@PathVariable(name = \u0026#34;postId\u0026#34;) Long postId, @PathVariable(name = \u0026#34;commentId\u0026#34;) Long commentId) { commentService.deleteComment(commentId); return \u0026#34;redirect:/posts/\u0026#34; + postId; } } PostDetailDto\n@ToString @Getter @EqualsAndHashCode public class PostDetailDto { private final Long id; private final String title; private final String content; private final LocalDateTime createdAt; private final List\u0026lt;Comment\u0026gt; comments; private PostDetailDto(Long id, String title, String content, LocalDateTime createdAt, List\u0026lt;Comment\u0026gt; comments) { this.id = id; this.title = title; this.content = content; this.createdAt = createdAt; this.comments = comments; } public static PostDetailDto of(Post post, List\u0026lt;Comment\u0026gt; comments) { return new PostDetailDto( post.getId(), post.getTitle(), post.getContent(), post.getCreatedAt(), comments ); } public Post toPost() { return Post.of(id, title, content, createdAt); } } PostWithCommentCount\n@ToString @Getter @Setter @NoArgsConstructor @EqualsAndHashCode public class PostWithCommentCount { private Long id; private String title; private String content; private LocalDateTime createdAt; private int commentCount; private PostWithCommentCount(Long id, String title, String content, int commentCount) { this.id = id; this.title = title; this.content = content; this.commentCount = commentCount; } } post_list.html\n\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html xmlns:th=\u0026#34;http://www.thymeleaf.org\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;投稿一覧\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;投稿一覧\u0026lt;/h1\u0026gt; \u0026lt;a th:href=\u0026#34;@{/posts/new}\u0026#34;\u0026gt;新規投稿作成\u0026lt;/a\u0026gt; \u0026lt;table border=\u0026#34;1\u0026#34;\u0026gt; \u0026lt;tr\u0026gt; \u0026lt;th\u0026gt;ID\u0026lt;/th\u0026gt; \u0026lt;th\u0026gt;タイトル\u0026lt;/th\u0026gt; \u0026lt;th\u0026gt;コメント数\u0026lt;/th\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;tr th:each=\u0026#34;post : ${posts}\u0026#34;\u0026gt; \u0026lt;td th:text=\u0026#34;${post.id}\u0026#34;\u0026gt;\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt; \u0026lt;a th:href=\u0026#34;@{/posts/{id}(id=${post.id})}\u0026#34; th:text=\u0026#34;${post.title}\u0026#34;\u0026gt;タイトル\u0026lt;/a\u0026gt; \u0026lt;/td\u0026gt; \u0026lt;td th:text=\u0026#34;${post.commentCount}\u0026#34;\u0026gt;\u0026lt;/td\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;/table\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; post_detail.html\n\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html xmlns:th=\u0026#34;http://www.thymeleaf.org\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;投稿詳細\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1 th:text=\u0026#34;${postDetail.title}\u0026#34;\u0026gt;タイトル\u0026lt;/h1\u0026gt; \u0026lt;p th:text=\u0026#34;${postDetail.content}\u0026#34;\u0026gt;内容\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;作成日: \u0026lt;span th:text=\u0026#34;${#temporals.format(postDetail.createdAt, \u0026#39;yyyy-MM-dd HH:mm\u0026#39;)}\u0026#34;\u0026gt;\u0026lt;/span\u0026gt; \u0026lt;/p\u0026gt; \u0026lt;hr/\u0026gt; \u0026lt;h2\u0026gt;コメント\u0026lt;/h2\u0026gt; \u0026lt;div th:each=\u0026#34;comment : ${postDetail.comments}\u0026#34;\u0026gt; \u0026lt;p th:text=\u0026#34;${comment.content}\u0026#34;\u0026gt;コメント内容\u0026lt;/p\u0026gt; \u0026lt;small th:text=\u0026#34;${#temporals.format(comment.createdAt, \u0026#39;yyyy-MM-dd HH:mm\u0026#39;)}\u0026#34;\u0026gt;\u0026lt;/small\u0026gt; \u0026lt;form th:action=\u0026#34;@{/posts/{postId}/comments/{commentId}/delete(postId=${postDetail.id}, commentId=${comment.id})}\u0026#34; method=\u0026#34;post\u0026#34;\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;削除\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;hr/\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;!-- コメント作成 --\u0026gt; \u0026lt;form th:action=\u0026#34;@{/posts/{id}/comments(id=${postDetail.id})}\u0026#34; method=\u0026#34;post\u0026#34;\u0026gt; \u0026lt;textarea name=\u0026#34;content\u0026#34; rows=\u0026#34;3\u0026#34; cols=\u0026#34;40\u0026#34;\u0026gt;\u0026lt;/textarea\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;コメント作成\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;hr/\u0026gt; \u0026lt;!-- 投稿の編集、削除 --\u0026gt; \u0026lt;form th:action=\u0026#34;@{/posts/{id}/edit(id=${postDetail.id})}\u0026#34; method=\u0026#34;get\u0026#34;\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;編集する\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;form th:action=\u0026#34;@{/posts/{id}/delete(id=${postDetail.id})}\u0026#34; method=\u0026#34;post\u0026#34;\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;削除する\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;a th:href=\u0026#34;@{/posts}\u0026#34;\u0026gt;一覧に戻る\u0026lt;/a\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; post_form.html\n\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html xmlns:th=\u0026#34;http://www.thymeleaf.org\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;投稿の作成/編集\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;投稿の作成/編集\u0026lt;/h1\u0026gt; \u0026lt;form th:if=\u0026#34;${post.id == null}\u0026#34; th:action=\u0026#34;@{/posts}\u0026#34; method=\u0026#34;post\u0026#34;\u0026gt; \u0026lt;p\u0026gt;タイトル: \u0026lt;input type=\u0026#34;text\u0026#34; name=\u0026#34;title\u0026#34;/\u0026gt;\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;内容: \u0026lt;textarea name=\u0026#34;content\u0026#34; rows=\u0026#34;5\u0026#34; cols=\u0026#34;40\u0026#34;\u0026gt;\u0026lt;/textarea\u0026gt;\u0026lt;/p\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;作成\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;form th:if=\u0026#34;${post.id != null}\u0026#34; th:action=\u0026#34;@{/posts/{id}/edit(id=${post.id})}\u0026#34; method=\u0026#34;post\u0026#34;\u0026gt; \u0026lt;p\u0026gt;タイトル: \u0026lt;input type=\u0026#34;text\u0026#34; name=\u0026#34;title\u0026#34; th:value=\u0026#34;${post.title}\u0026#34;/\u0026gt;\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;内容: \u0026lt;textarea name=\u0026#34;content\u0026#34; rows=\u0026#34;5\u0026#34; cols=\u0026#34;40\u0026#34; th:text=\u0026#34;${post.content}\u0026#34;\u0026gt;\u0026lt;/textarea\u0026gt;\u0026lt;/p\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;編集\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;a th:href=\u0026#34;@{/posts}\u0026#34;\u0026gt;一覧に戻る\u0026lt;/a\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; ソース：\nトビーの春\n絵で学ぶスプリング6入門\nキム・ヨンハンの春DB 1、2\nhttps://docs.spring.io/spring-framework/reference/data-access.html\nhttps://github.com/brettwooldridge/HikariCP?tab=readme-ov-file\n","date":"2025年4月12日","externalUrl":null,"permalink":"/ja/posts/jdbc-transaction/","section":"Posts","summary":"JDBC APIの概要、DataSource設定、JdbcTemplateの使い方、@Transactionalによるトランザクション管理と伝播属性まで整理します。","title":"JDBC基礎とトランザクション管理","type":"posts"},{"content":"","date":"2025年2月18日","externalUrl":null,"permalink":"/ja/tags/nio-connector/","section":"Tags","summary":"","title":"NIO Connector","type":"tags"},{"content":"","date":"2025年2月18日","externalUrl":null,"permalink":"/ja/tags/performance/","section":"Tags","summary":"","title":"Performance","type":"tags"},{"content":"","date":"2025年2月18日","externalUrl":null,"permalink":"/ja/tags/thread-pool/","section":"Tags","summary":"","title":"Thread Pool","type":"tags"},{"content":"","date":"2025年2月18日","externalUrl":null,"permalink":"/ja/tags/tomcat/","section":"Tags","summary":"","title":"Tomcat","type":"tags"},{"content":"前の記事： Spring Boot Tomcat threads.maxとHikariCP maximum-pool-sizeをデフォルトに設定したときに現れる現象\n前回の記事でTomcatのthreads.maxを200から60に減らし、HikariCPのmaximum-pool-sizeを10から40に増やして応答時間を5〜10倍改善しました。\nしかし一つの疑問が残りました。\n仮想ユーザー（VUser）が300人同時にトラフィックを発生させているのに、TomcatのThread Poolのサイズを60個に減らしたら、残りの240個のリクエストはスレッドを取得するために競合しないのか？\nスレッドを減らしたのに性能が良くなったとすれば、減ったスレッドで同じトラフィックをどのように処理できたのかを理解する必要があります。\nよくある誤解 — 「1 Connection = 1 Thread」 # クライアントが接続するかリクエストを送ると → TomcatがすぐにWorker Threadを一つ割り当て → そのスレッドがコネクションのライフサイクル全体を担当する。\nSpring MVCの同期サーブレット処理では、実際に実行中のリクエスト1つがWorker Threadを1つ占有することは正しいです。Controller、Service、DB I/Oを実行している間、そのリクエストは1つのWorker Thread上で処理されます。\nしかしこれを接続済みクライアント数や到着したリクエスト数だけWorker Threadがすぐに必要になるという意味で理解すると誤解になります。このモデルではVUser 300人が同時に接続するとWorker Threadも300個必要だと見なすことになり、スレッドが60個なら残りの240個のリクエストは拒否されるか失敗しなければなりません。\nしかし実際にはそうではありませんでした。VUser 300人の負荷でもリクエストは消えず、Error Rateは0%でした。\nこの違いを理解するには、Tomcatが**コネクション（Connection）とスレッド（Thread）**をどのように管理するかを知る必要があります。\nTomcatの大きな構造 — CoyoteとCatalina # Spring Bootの内蔵Tomcatは大きく2つのコンポーネントに分かれます。\n**Coyote（コネクター）**はソケットI/OとHTTPプロトコル処理を担当します。ブラウザが接続を確立するとソケットからバイトストリームを読み込んでHTTPをパースし、HttpServletRequest / HttpServletResponseオブジェクトを生成します。その後Catalinaのinvoke()を呼び出して制御権を渡し、応答が完成するとまたバイトにシリアライズしてソケットに送信します。\n**Catalina（サーブレットコンテナ）**はサーブレットのライフサイクルとリクエストのルーティングを管理します。Spring Bootではアプリ一つがCatalinaのContext一つに該当します。DispatcherServletはSpring MVCのフロントコントローラーで、urlPatterns = \u0026quot;/\u0026quot;でこのContextに登録されているため、アプリケーションに入ってくるほとんどのWebリクエストがまずこのサーブレットを通過します。\nブラウザ → TCPソケット接続 → Coyote：ソケットI/O → HTTPパース → Req/Resオブジェクト生成 → invoke() → Catalina（Context = Spring Bootアプリ） → DispatcherServlet → HandlerMapping → @Controller → 応答 → Coyote → ソケット → ブラウザ この構造を理解すれば、以下で扱うNIO ConnectorがCoyote内部のソケットI/O処理方式であることがわかります。\nTomcat NIO Connectorアーキテクチャ # Spring Boot 3.xで使用する組み込みTomcatはデフォルトでNIO（Non-blocking I/O）Connectorを使用します。NIO Connectorはリクエストを処理するために3段階のパイプライン構造で動作します。\n第1段階：Acceptor Thread # AcceptorはスレッドがTCPが1〜2個で構成されます。OSのaccept queueからTCP接続を受け入れ、受け入れられたソケットチャンネル（SocketChannel）をPollerに登録する役割を担います。\nClient → TCP 3-way handshake → OS accept queue → Acceptor.accept() → Pollerに登録 Acceptorは接続を受け入れるだけで、リクエストデータを読んだりビジネスロジックを実行したりしません。\n第2段階：Poller Thread # PollerはJava NIOのSelectorを使用して少数のスレッドで数千個のコネクションを監視します。\nPollerの役割は登録されたソケットチャンネルを監視して、特定のチャンネルに読み込めるデータが到着したら、そのソケットをWorker Thread Poolに渡すことです。\n// Pollerの核心動作（概念的なコード） Selector selector = Selector.open(); while (running) { // 登録された数千個のチャンネルのうちデータが準備できたチャンネルを検知 selector.select(); Set\u0026lt;SelectionKey\u0026gt; selectedKeys = selector.selectedKeys(); for (SelectionKey key : selectedKeys) { if (key.isReadable()) { // データが到着したソケットだけをWorker Thread Poolに渡す workerThreadPool.execute(new SocketProcessor(key)); } } } ここでselector.select()がどのようにたった一度の呼び出しで数千個のコネクションを監視できるかをもう少し詳しく見てみましょう。\nAcceptorが新しいコネクションを受け入れると、そのソケットチャンネルはselector.register(channel, OP_READ)を通じてSelectorに登録されます。Selectorは内部的に登録されたすべてのチャンネルのファイルディスクリプター（fd）を管理します。\nselector.select()が呼び出されると、JavaはこのタスクをOSカーネルのI/Oマルチプレクシングシステムコールに委任します。\nLinux：epoll_wait() — カーネルが登録されたfdの状態変化を監視し、データが到着したfdだけを返す macOS：kqueue — 同じ原理でイベントが発生したfdだけを返す これらのシステムコールは**イベントベース（event-driven）**で動作します。登録された数千個のコネクションを一つずつ巡回して確認するのではなく、カーネルがネットワーク割り込みを通じてデータが到着したソケットをすでに知っているため、準備ができたコネクションだけをすぐに返せます。\nPollerスレッドの動作フロー： selector.select() ← 準備できたチャンネルがなければここでブロッキング（CPU消費なし） │ ├── チャンネルAにデータが到着 → Worker Thread Poolに渡す ├── チャンネルBにデータが到着 → Worker Thread Poolに渡す │ （残りの数百個のチャンネルはまだSelectorに登録されたまま待機） │ └── 再びselector.select()呼び出し → 次のイベントを待機 つまり、Pollerスレッド自体が監視スレッドです。コネクションごとに別の監視スレッドが割り当てられるのではなく、1〜2個のPollerスレッドがselector.select() → イベント処理 → selector.select()のループを繰り返しながらすべてのコネクションを管理します。準備できたチャンネルがなければselect()でブロッキングされてCPUを消費せず、イベントが発生すれば目覚めて該当チャンネルだけをWorker Thread Poolに渡します。\n核心： Pollerは1〜2個のスレッドだけで数千個のコネクションを監視します。これが可能な理由はselector.select()がOSカーネルのI/Oマルチプレクシング（epoll/kqueue）を活用して、一度の呼び出しで登録されたすべてのチャンネルの状態を確認するからです。コネクションを維持するために別のWorkerスレッドは必要ありません。\n第3段階：Worker Thread Pool # Worker Thread Poolはthreads.max（デフォルト値200、現在の設定60）分のスレッドで構成されます。Pollerから渡されたソケットからHTTPリクエストを読み込み、DispatcherServletを呼び出して実際のビジネスロジックを実行します。\nPoller → Workerスレッド割り当て → HTTPリクエストパース → DispatcherServlet → Controller → Service → DB I/O → 応答作成 → Workerスレッドを返す Workerスレッドはビジネスロジックの実行が終わるとプールに返され、次のリクエストを処理する準備をします。\nでは残りの240個のリクエストはどこへ行ったのか？ # VUser 300人が同時にリクエストを送り、Worker Threadが60個のときの流れを整理すると以下のようになります。\n300個のTCPコネクションがTomcatに確立されます。max-connectionsのデフォルト値は8192（NIO/NIO2）のため、300個は全容量の3.7%に過ぎません。すべてのコネクションが問題なく収容されます。\nPollerが300個のコネクションをSelectorに登録して監視します。このときWorkerスレッドは消費されません。\n300個のコネクションからデータが到着すると、Pollerはこれを検知してWorker Thread Poolにタスクを渡します。\n60個のWorkerスレッドが先に到着した60個のリクエストを処理します。残りの240個のリクエストはWorker Thread PoolのTaskQueueで待機します。\nWorkerスレッドが一つのリクエスト処理を完了すると、TaskQueueで待機している次のリクエストを取り出して処理します。\n結論： 240個のリクエストは消えたのではなく、NIO Pollerがコネクションを維持した状態でWorker Thread Poolのキューで順番を待っていました。\nリクエストが待機する3つの階層 # Tomcat NIO Connectorにはリクエストが待機できる3つの階層が存在します。各階層は互いに異なる設定値で制御されます。\n階層 設定値 デフォルト値 役割 OS TCP backlog server.tomcat.accept-count 100 max-connections到達時のOSカーネルレベルのTCP接続待機列 Tomcat NIO server.tomcat.max-connections 8192（NIO/NIO2） サーバーが受け入れて処理する最大コネクション数 Worker Thread Pool server.tomcat.threads.max 200 実際にリクエストを処理するワーカースレッド数 各階層の動作 # 第1階層 — Worker Thread Pool（threads.max）\nすべてのWorkerスレッドが忙しければ、新しいリクエストはWorker Thread Poolの内部TaskQueueに積み上がります。スレッドが返されるとキューで待機中のタスクを一つずつ処理します。\n第2階層 — Tomcat NIO（max-connections）\nTomcatが受け入れてPollerに登録できる最大コネクション数です。Tomcatの公式ドキュメントによるとNIO/NIO2のデフォルト値は8192です。この値に達するとAcceptorは新しいコネクションの受け入れを一時停止し、既存のコネクションが閉じるまで待ちます。\n第3階層 — OS TCP backlog（accept-count）\nmax-connectionsに達してAcceptorが受け入れを停止すると、その後に入ってくるTCP接続リクエストはOSカーネルのTCP backlogキューに積み上がります。このキューのサイズがaccept-count（デフォルト値100）です。このキューまでいっぱいになると、OSは新しい接続リクエストを**拒否（Connection Refused）**します。\n[VUser 300] │ ▼ [OS TCP Backlog] ─── accept-count：100（max-connections到達時のみ使用） │ ▼ [Acceptor Thread] ─── コネクション受け入れ │ ▼ [Poller（Selector）] ─── max-connections：8192（コネクション監視、スレッド消費なし） │ ▼ [Worker Thread Pool] ─── threads.max：60（実際のリクエスト処理） │ ▼ [DispatcherServlet → Controller → Service → DB] ではTomcatが拒否せずに受け入れられる最大接続数は？ # 3階層の設定値を合算するとTomcatがConnection Refusedなしに収容できる総TCP接続数を計算できます。\n収容可能な最大接続数 = max-connections + accept-count = 8192 + 100 = 8292個 ここで注意すべき点は、この8292個の接続がすべて同時に「処理」されるわけではないということです。各階層での状態が異なります。\n階層 接続数 状態 Worker Thread Pool threads.max個（60個） 実際にビジネスロジックを実行中 Tomcat NIO Poller 最大8192個 Selectorに登録されて監視中（スレッド消費なし） OS TCP Backlog 最大100個 OSカーネルでTCP接続だけが確立されたまま待機 つまり、8292個の接続を拒否なしに受け入れることができますが、そのうち実際に同時にリクエストを処理するのはthreads.max個だけです。残りはPollerで監視されるかWorker Thread PoolのTaskQueueで待機する状態です。\n**8293番目のTCP接続からはOSがConnection Refusedを返します。**3階層すべてが満杯の状態でこれ以上接続を収容する空間がないためです。\n私たちの状況では？ # VUser 300人の場合：\n300接続 \u0026laquo; 8192（max-connections） → すべてのコネクションが収容可能、全容量の3.7%に過ぎない OS TCP backlog（accept-count：100）はmax-connectionsに達したときだけ使用される待機列のため、このシナリオではキューが空の状態 60個のWorkerスレッドが300個のリクエストを順次処理し、残りの240個はTaskQueueで待機 BIO vs NIO — なぜ以前のモデルではこれが不可能だったのか？ # Tomcat 7以前はBIO（Blocking I/O）Connectorがデフォルトでした。BIOではコネクションとスレッドが1:1でマッピングされます。\n項目 BIO（Blocking I/O） NIO（Non-blocking I/O） Connection：Threadの比率 1:1 N:1 max-connectionsのデフォルト値 = threads.max 8192 アイドルコネクションのコスト スレッド1個を占有（高コスト） Selectorに登録（低コスト） threads.max=60の場合 最大60個のコネクションのみ可能 8192個のコネクションが可能 BIOではthreads.max=60なら同時に60個のコネクションしか維持できませんでした。61番目の接続はスレッドが返されるまで待たなければなりませんでした。この構造ではスレッドを減らすと即座に処理できるコネクション数が減るため、同じトラフィックで性能が低下する可能性がありました。\nしかしNIOではコネクション管理とリクエスト処理が分離されています。Pollerが数千個のコネクションをスレッドなしで維持するため、threads.maxを減らしてもコネクションの収容能力には影響がありません。\nSpring Boot 3.xの組み込みTomcatはNIO Connector（Http11NioProtocol）をデフォルトで使用します。\nコードで直接確認する # 理論だけでは確信しにくいです。実際にコネクション数とスレッド数が独立して動作するかをコードで確認してみましょう。\nテスト実行環境： 以下のテストはアプリケーションをまず実行した状態でlocalhost:8080に直接HTTPリクエストを送る方式です。@SpringBootTestを使った統合テストではないため、テスト前にapplication.ymlの設定を変更してからサーバーを実行する必要があります。\nテスト1：Slow Endpointを使ったバッチ処理の確認 # まず、意図的に遅いエンドポイントを作ってWorkerスレッドが占有された状態を維持します。\n@RestController public class SlowEndpointController { @GetMapping(\u0026#34;/test/slow\u0026#34;) public String slowEndpoint() throws InterruptedException { String threadName = Thread.currentThread().getName(); long startTime = System.currentTimeMillis(); Thread.sleep(3000); // 3秒間スレッドを占有 long elapsed = System.currentTimeMillis() - startTime; return String.format(\u0026#34;Thread: %s, Elapsed: %dms\u0026#34;, threadName, elapsed); } } そしてapplication.ymlでthreads.max=5に設定した後、同時に20個のリクエストを送ります。\n# application.yml server: tomcat: threads: max: 5 @Test @DisplayName(\u0026#34;threads.maxより多い同時リクエストを送ってもすべてのリクエストが処理される — Connection ≠ Thread の確認\u0026#34;) void batchProcessing() throws Exception { // given — スレッド（5個）より4倍多い同時リクエストを準備 int totalRequests = 20; ExecutorService executor = Executors.newFixedThreadPool(totalRequests); CountDownLatch latch = new CountDownLatch(totalRequests); List\u0026lt;Future\u0026lt;String\u0026gt;\u0026gt; futures = new ArrayList\u0026lt;\u0026gt;(); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); long testStart = System.currentTimeMillis(); // when — 20個のリクエストを同時に送信 for (int i = 0; i \u0026lt; totalRequests; i++) { futures.add(executor.submit(() -\u0026gt; { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(\u0026#34;http://localhost:8080/test/slow\u0026#34;)) .GET() .build(); HttpResponse\u0026lt;String\u0026gt; response = client.send(request, HttpResponse.BodyHandlers.ofString()); long elapsed = System.currentTimeMillis() - testStart; return String.format(\u0026#34;[%5dms] Status: %d, Body: %s\u0026#34;, elapsed, response.statusCode(), response.body()); } finally { latch.countDown(); } })); } latch.await(30, TimeUnit.SECONDS); // then — すべてのリクエストが5個ずつバッチで処理されて応答を受け取る for (Future\u0026lt;String\u0026gt; future : futures) { System.out.println(future.get()); } executor.shutdown(); } 結果 # 結果を見ると正確に5個ずつ同じ時間に応答が返ってくるのではなく、同じバッチ内でも数ミリ秒の差が発生します。これは20個のクライアントスレッドがリクエストを送るタイミングがわずかに異なり、Workerスレッドがタスクを完了した後にTaskQueueから次のリクエストを取り出すタイミングもOSのスレッドスケジューリングによって変わるためです。重要なのは全体的なパターン — 約3秒、約6秒、約9秒、約12秒の区間にわたって5個ずつまとめて処理され、20個のリクエストが一つも消えなかったという点です。Connection ≠ Threadであることが確認できます。\nテスト2：Tomcatの内部状態の照会 # Tomcatの内部状態をコードで直接確認することもできます。まずTomcatのコネクション数とスレッドプールの状態をJSONで返すエンドポイントを作ります。AbstractProtocolのgetConnectionCount()メソッドはTomcatの公式APIで提供されており、JMXを通じてもアクセスできます。\n@RestController @RequiredArgsConstructor public class TomcatInternalController { private final WebServerApplicationContext webServerAppContext; @GetMapping(\u0026#34;/tomcat/internals\u0026#34;) public Map\u0026lt;String, Object\u0026gt; getTomcatInternals() { TomcatWebServer tomcatWebServer = (TomcatWebServer) webServerAppContext.getWebServer(); Connector connector = tomcatWebServer.getTomcat() .getConnector(); AbstractProtocol\u0026lt;?\u0026gt; protocol = (AbstractProtocol\u0026lt;?\u0026gt;) connector.getProtocolHandler(); ThreadPoolExecutor executor = (ThreadPoolExecutor) protocol.getExecutor(); Map\u0026lt;String, Object\u0026gt; info = new LinkedHashMap\u0026lt;\u0026gt;(); // Connectorレベル info.put(\u0026#34;maxConnections\u0026#34;, protocol.getMaxConnections()); info.put(\u0026#34;currentConnections\u0026#34;, protocol.getConnectionCount()); info.put(\u0026#34;acceptCount\u0026#34;, protocol.getAcceptCount()); // Thread Poolレベル info.put(\u0026#34;maxThreads\u0026#34;, executor.getMaximumPoolSize()); info.put(\u0026#34;currentThreads\u0026#34;, executor.getPoolSize()); info.put(\u0026#34;activeThreads\u0026#34;, executor.getActiveCount()); info.put(\u0026#34;queueSize\u0026#34;, executor.getQueue().size()); return info; } } テスト1と同じthreads.max=5の設定で、20個のslowリクエストでスレッドを占有した後に内部状態を照会します。\n@Test @DisplayName(\u0026#34;コネクション数とアクティブスレッド数が独立して管理されることを確認する\u0026#34;) void tomcatInternals() throws Exception { int totalRequests = 20; ExecutorService executor = Executors.newFixedThreadPool(totalRequests); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); // 20個のslowリクエストを同時に送信（各3秒かかる） for (int i = 0; i \u0026lt; totalRequests; i++) { executor.submit(() -\u0026gt; { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(\u0026#34;http://localhost:8080/test/slow\u0026#34;)) .GET() .build(); client.send(request, HttpResponse.BodyHandlers.ofString()); } catch (Exception e) { e.printStackTrace(); } }); } // リクエストがサーバーに到達するまでの時間を確保した後に内部状態を照会 Thread.sleep(1000); HttpRequest internalsRequest = HttpRequest.newBuilder() .uri(URI.create(\u0026#34;http://localhost:8080/tomcat/internals\u0026#34;)) .GET() .build(); HttpResponse\u0026lt;String\u0026gt; response = client.send(internalsRequest, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); executor.shutdown(); } 結果 # 2回実行した結果はどちらもcurrentConnections: 22、currentThreads: 5で同じですが、activeThreadsは1回目で2個、2回目で4個と異なりました。これは/tomcat/internalsを照会するタイミングがバッチ間の切り替えタイミングによって変わるためです。queueSizeが0なのも同じ理由で、照会時点ですでに待機中のリクエストがWorkerスレッドに渡された後だったためです。\n核心はコネクションが22個維持されている間もスレッドは最大5個しか使用されないという点です。残りのコネクションはPollerがスレッドなしで管理しており、これがNIO Connectorの核心的な動作です。\nテスト3：コネクションの限界を超えると？ # NIOでも限界は存在します。max-connectionsとaccept-countを極端に減らして限界を確認してみましょう。\n# application.yml server: tomcat: max-connections: 10 accept-count: 5 threads: max: 3 この設定で20個の同時リクエストを送ると：\n区間 コネクション数 状態 1〜10番目 10個 Tomcatが受け入れ、Pollerで管理 11〜15番目 5個 OS TCP backlogキューで待機 16〜20番目 5個 収容不可 — OSが接続拒否またはタイムアウト @Test @DisplayName(\u0026#34;max-connections + accept-countを超えるリクエストは失敗する\u0026#34;) void connectionLimit() throws Exception { // given — max-connections（10） + accept-count（5） = 15個を超える20個のリクエストを準備 int totalRequests = 20; ExecutorService executor = Executors.newFixedThreadPool(totalRequests); CountDownLatch latch = new CountDownLatch(totalRequests); AtomicInteger successCount = new AtomicInteger(0); AtomicInteger failCount = new AtomicInteger(0); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); long testStart = System.currentTimeMillis(); // when — 20個のリクエストを同時に送信 for (int i = 0; i \u0026lt; totalRequests; i++) { final int requestId = i; executor.submit(() -\u0026gt; { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(\u0026#34;http://localhost:8080/test/slow\u0026#34;)) .GET() .build(); HttpResponse\u0026lt;String\u0026gt; response = client.send(request, HttpResponse.BodyHandlers.ofString()); long elapsed = System.currentTimeMillis() - testStart; successCount.incrementAndGet(); System.out.printf(\u0026#34;[%5dms] Request %d: SUCCESS (status=%d)%n\u0026#34;, elapsed, requestId, response.statusCode()); } catch (Exception e) { long elapsed = System.currentTimeMillis() - testStart; failCount.incrementAndGet(); System.out.printf(\u0026#34;[%5dms] Request %d: FAILED (%s)%n\u0026#34;, elapsed, requestId, e.getMessage()); } finally { latch.countDown(); } }); } latch.await(120, TimeUnit.SECONDS); // then System.out.printf(\u0026#34;%n成功：%d、失敗：%d%n\u0026#34;, successCount.get(), failCount.get()); executor.shutdown(); } 結果 # タイムスタンプを基準に結果を分析すると：\nタイミング 数 状態 約3秒 3個 SUCCESS 1回目のバッチ（threads.max=3） 約5秒 5個 FAILED connectTimeout（5秒）超過でタイムアウト 約6秒 3個 SUCCESS 2回目のバッチ 約9秒 3個 SUCCESS 3回目のバッチ 約12秒 1個 SUCCESS 4回目のバッチ（最初の10個のうちの最後） 約35秒 3個 SUCCESS backlogのリクエスト — keep-alive期限切れ後に受け入れ 約38秒 2個 SUCCESS backlogのリクエスト — keep-alive期限切れ後に受け入れ 最終結果は成功15個、失敗5個です。\nまず、Tomcatが受け入れた10個のコネクションはthreads.max：3によって3個ずつバッチで処理されて約3秒、約6秒、約9秒、約12秒にわたってすべて成功します。この間にTCP backlogキューのリクエストのうち5個はクライアントのconnectTimeout（5秒）を超えてクライアントが先にタイムアウトで接続を諦めます。\n興味深いのは約12秒に最初の10個の処理がすべて終わったのに、残りのbacklogのリクエストが約35秒にやっと処理されるという点です。約23秒の空白が生じた理由はHTTP/1.1 keep-aliveのためです。応答が終わってもコネクションがすぐに閉じないためmax-connections：10の場所がずっと占有されており、TomcatのデフォルトのkeepAliveTimeout（20秒）が期限切れになって既存のコネクションが解放された後にやっとbacklogのリクエストが受け入れられます。\nこのテストはNIOでもコネクションの収容限界が存在することを示しています。デフォルト値（max-connections=8192、accept-count=100）では合計8292個の同時コネクションを収容できるため、VUser 300は全く問題にならなかったのです。\n前の記事の結果を再解釈する # NIO Connectorの構造を理解したので、前の記事の結果を再び見てみましょう。\nthreads.max=200のとき（前の記事のデフォルト設定） # 前の記事で確認したGrafanaダッシュボードをNIOの観点から再解釈してみましょう。\n200個のWorkerスレッドがすべてアクティブになってリクエストを処理 HikariCPのコネクションは10個だけ → 200個のスレッドが10個のコネクションを競合 190個のスレッドがtimed-waiting状態でコネクションを待機 2個のvCPUで200個のスレッド → 過度なコンテキストスイッチング 結果：GET 1秒、POST 1.2秒 threads.max=60のとき（前の記事の最適設定） # 60個のWorkerスレッドだけがアクティブ 残りの240個のコネクションはNIO Pollerがスレッドなしで管理 HikariCPのコネクションは40個 → 60個のスレッドが40個のコネクションを使用（はるかに少ない競合） 2個のvCPUで60個のスレッド → コンテキストスイッチングコストの削減 結果：GET 200ms、POST 125ms **スレッドを減らしても性能が良くなった本当の理由は、NIOがコネクション管理とリクエスト処理を分離してくれたためです。**おかげで少数のスレッドがリソース競合なしに効率的に働くことができました。\n性能向上は2つの要因が共に作用した結果です：\nNIOがコネクション管理を分離：スレッドを減らしても300個のコネクションはすべて維持される スレッド-コネクションプールの比率改善：200:10から60:40へ → timed-waitingの競合が大幅に減少 まとめ # この記事では「スレッドを減らしたのになぜ性能が良くなったのか？」という質問に対して、Tomcat NIO Connectorのアーキテクチャを通じて答えを見つけました。\n核心内容をまとめると：\nConnection ≠ Thread：NIO Connectorではコネクションとスレッドが1:1でマッピングされません。PollerがSelectorを使用して少数のスレッドで数千個のコネクションを管理します。\n3階層キューイングモデル：リクエストはOS TCP backlog → Tomcat NIO（Poller） → Worker Thread Poolの3階層を経て処理されます。各階層はaccept-count、max-connections、threads.maxで制御されます。\n**threads.maxは並列処理数を制御するものであり、サーバーが収容できるコネクション数を制御するものではありません。**コネクションの収容能力はmax-connections（デフォルト値8192）が決定します。\nスレッド数の削減 → 性能向上のメカニズム：NIOがコネクション管理を分離してくれたため、スレッドを減らしてもコネクションは維持されながらコンテキストスイッチングとリソース競合だけが減少しました。\n最初は単純に設定値を調整することが性能にどのような影響を与えるかだけが気になっていました。しかしその原因をソケットレベルまで掘り下げていく中で、Tomcatがどのように同時実行性を管理するのか、そしてなぜSpring MVCベースのブロッキングモデルでも少数のスレッドで多くのリクエストを処理できるのかを理解することができました。\n","date":"2025年2月18日","externalUrl":null,"permalink":"/ja/posts/tomcat-nio-connector-vuser-thread-mystery/","section":"Posts","summary":"Tomcat NIO ConnectorのAcceptor-Poller-Worker構造を分析して、60個のスレッドで300人の同時リクエストを処理する原理を説明します。","title":"VUser 300人、Tomcat Thread 60個 — 残り240個のリクエストはどこへ行ったのか？","type":"posts"},{"content":"","date":"2025年1月8日","externalUrl":null,"permalink":"/ja/tags/performance-tuning/","section":"Tags","summary":"","title":"Performance Tuning","type":"tags"},{"content":"","date":"2025年1月8日","externalUrl":null,"permalink":"/ja/tags/spring-boot/","section":"Tags","summary":"","title":"Spring Boot","type":"tags"},{"content":"前回の記事： MySQL全文検索の限界、Elasticsearchの導入で検索APIのパフォーマンスを改善する\n前回の記事でElasticsearchを導入し、検索APIのパフォーマンスを大幅に向上させました。\n設定したパフォーマンス目標は平均応答時間 \u0026lt; 200ms、Error Rate \u0026lt; 0.1%でした。ESの導入によってMySQL全文検索の構造的な限界は克服しましたが、VUser 300名の負荷でGET 876ms、POST 1,210msとなり、目標には届いていない状態です。\nパフォーマンステスト中にトラフィックが集中する状況でリクエストを1件直接投げてみたときも約1秒かかり、目標の200msには程遠い状態でした。特にPOSTリクエスト（1200ms）は目標の6倍に達していました。\n原因を把握するためにGrafanaダッシュボードのさまざまな指標を確認しました。\nこのとき、いくつか目立つ指標が見えました。JVMのスレッドの状態とHikariCPのコネクションの状態でした。\ntimed-waiting状態のスレッド：197個 HikariCPのコネクションを待っているスレッド：189個 Tomcatスレッドプールの最大サイズのデフォルト値は200個、HikariCPコネクションプールの最大サイズのデフォルト値は10個です。\nこれらの値をすべてデフォルト値で使用したとき、上記のダッシュボードのような結果が現れました。\nまず結論から：デフォルト設定でどんなボトルネックが生じたか？ # この記事の核心は以下の3点です。\nTomcatのthreads.max=200とHikariCPのmaximum-pool-size=10の組み合わせでは、多くのリクエストスレッドがDBコネクションを待ちながらtimed-waiting状態に縛られました。 このテスト環境（vCPU 2個、メモリ8GB）では、Tomcat60、HikariCP40の組み合わせが最も速い応答時間を示しました。 ただし、スレッド数を極端に減らしたり増やしたりすると、それぞれスループットの低下や外部I/Oの競合増加といった別のボトルネックが現れました。 スレッドたちは実際にどこで待機しているのか？ # ConnectionsのPending値を見ると、おおよそtimed-waiting状態にあるスレッドたちはコネクションプールを取得するために待機していると推定されます。\nしかし100%確信することは難しかったです。なぜなら、テスト中のAPIではElasticsearchも使用していたからです。\nElasticsearchクライアントの設定コードを確認すると：\nDEFAULT_MAX_CONN_PER_ROUTE：単一ノードに許可される最大同時接続10個 DEFAULT_MAX_CONN_TOTAL：すべてのノードに許可される総同時接続30個 上記の値をデフォルトで使用しています。\nコネクションを取得するためにPendingしているスレッドは189個なのに、timed-waitingスレッドの数は197個です。8個ほど差があります。そしてwaiting状態にあるスレッドも9個ほどあります。これらのスレッドが何をしているのか把握しにくい状況でした。\nそのため、Thread dumpを使って各スレッドの状態をより詳しく確認する必要がありました。\nThread dumpから見ると実際の原因は何か？ # Thread dumpはjcmdコマンドで取得し、分析はfastthread.ioサイトを利用しました。\nrunnable状態のスレッドは何をしていたのか？ # 3つのスレッドダンプを取得して比較してみると、runnable状態のスレッドはほとんどがJVM内部の処理とGCを担当するスレッドで構成されており、実際にコネクションからデータを読んでいるスレッドは2個ほどでした。\nMySQLを読んでいるスレッド1個とElasticsearchを読んでいるスレッド1個、またはMySQLを読んでいるスレッド2個とElasticsearchを読んでいるスレッド0個というケースがありました。\nwaiting状態のスレッドはなぜ発生したのか？ # waitingに該当するスレッドは合計11個で、そのうち8個のスレッドがElasticsearchのデータをwaiting状態で待っていました。\nソースコードを見ると、Elasticsearchはリクエストを処理する際に非同期で呼び出して結果を待つと書かれています。\ntimed-waiting状態のスレッドはなぜ多かったのか？ # 合計190個のスレッドがHikariCPのコネクションを取得するためにtimed-waiting状態にありました。\n例えばThread1〜Thread9+が同時にHikariDataSource.getConnection()メソッドを呼び出した場合、Thread1〜Thread5だけがコネクションを取得し、残りはtimed-waiting状態で指定された時間（hikari.connection_timeout デフォルト30秒）の間、返却されたコネクションを取得するために待機します。\nなぜTomcat 200スレッドとHikariCP 10コネクションがボトルネックになったのか？ # 現在テストで使用しているサーバーのスペックはvCPU 2個、メモリ8GBの単一インスタンスです。\nサーバーインスタンスのCPU数に対してTomcatスレッドの数が多すぎます。 基本的にアプリケーションサーバーはI/O Bound処理がほとんどですが、2個のCPUに対して200個のスレッドは多すぎると考えられます。 リクエストを処理するスレッドは200個なのに対し、HikariCPのコネクションは最大10個です。 コネクションを取得できない多数の残りのスレッドは待機することになり、これが最終的にリクエストとレスポンスの間の処理時間が増加する問題につながります。 設定を変えるとどれくらい改善されるのか？ # 適切なスレッド数とコネクション数の決め方を調べましたが、決まったルールはないようでした。\nI/O Boundの処理は一般的にCPU使用量よりもネットワークI/Oのような待機時間が多い処理に依存します。そのため、CPUコア数より多くのスレッドを作成してもコンテキストスイッチのコストは相対的に小さいです。\nそれならば、コネクション待ち時間を減らすためにスレッドとコネクションプールのサイズの差を調整して、複数回のテストを通じて比較してみます。\n結果を確認すると：\nTomcatのmax-thread：60個 HikariCPのmax-connection-pool：40個 上記の設定値を適用したときに応答時間が約200msと最も速い応答時間が出ました。\n目標との比較： GET 200msは目標の平均応答時間 \u0026lt; 200msに到達しました。デフォルト設定（Tomcat 200、HikariCP 10）比較で5倍の改善です。\n画像には追加していませんが、Tomcatのmax-thread：30個、HikariCPのmax-connection-pool：20個の場合もテストしました。しかし別の問題点があったので、以下で別途確認します。\nコネクションプールを増やすとDBは大丈夫か？ # HikariCPのコネクションプールサイズを増加させてDBのコネクション数を高く維持することは、基本的にDBに負荷をかけます。\nしかし見ての通り、大きな有意な差はありませんでした。runningに該当するコネクションのほとんどは1〜2個程度しか必要なく、DBインスタンスのCPU使用量が若干増加しました。\n応答時間改善の代償として何が増えたのか？ # 応答時間は減少しましたが、runnable状態のスレッド数とアプリケーションサーバーのCPU負荷が若干増加しました。\nなぜPOSTリクエストはまだ500msだったのか？ # ここまで実際の状況を想定して、リクエストの比率はGET 90%、POST 10%に設定しました。そしてproductを保存するときのMySQLとElasticsearchのデータ同期はアプリケーションで直接管理する最もシンプルな方式を選びました。\nコードレベルでは、1つのスレッドがElasticsearchにデータを保存した後に続けてMySQLにデータを保存します。\nElasticsearchのデータ保存時に即時のレスポンスが必要でなければ、この処理を非同期処理にすることで応答時間を短縮できると考えました。\n結果を確認すると、GETリクエストは2回のテストでいずれも200ms程度を維持し、POSTリクエストは約500msから125msまで減少しました。\n最終結果はどう変わったのか？ # リクエスト 改善前 改善後 目標 改善率 GETリクエスト 1秒 200ms \u0026lt; 200ms 5倍改善、目標達成 POSTリクエスト 1200ms 125ms \u0026lt; 200ms 10倍改善、目標達成 VUser 300名という極限の負荷でもGET 200ms、POST 125msを達成しました。現実的なピーク（15 RPS）基準では目標を十分に超過達成できるレベルです。\n3回にわたるパフォーマンス改善はどのように続いたのか？ # ステージ GET応答時間 POST応答時間 TPS Error Rate MySQL FullText（2回目） 30秒以上 - 2.1 51.3% ES導入（2回目） 876ms 1,210ms 179.6 0% スレッド/コネクションのチューニング（3回目） 200ms 125ms ~300 0% 目標 \u0026lt; 200ms \u0026lt; 200ms \u0026gt; 15 \u0026lt; 0.1% 追加で確認したトレードオフと後続課題 # ここまでが核心的な改善結果で、以下はテストを繰り返しながら追加で確認したトレードオフと後続課題です。\n重いAPIはなぜ他のAPIまで遅くするのか？ # この現象の原因はGrafanaダッシュボードの欠損（断絶？）現象の記事で扱いました。\n上記の記事で原因を把握したところ、パフォーマンステストの過程ですべてのスレッドがリクエストの処理に使われることでスレッドの返却にも時間がかかるようになっていました。\nこれによりPrometheusのメトリクスを取得するリクエストに対するスレッド取得時間が増加し、最終的に応答時間も増加する現象が現れました。\nこの現象はPrometheusのリクエストだけでなく、他のAPIのリクエストにも現れると考えてテストを行いました。\n従来はElasticsearch関連リソースであるproductに対するGETとPOSTリクエストのみを処理していましたが、今回はこれとは無関係の他のリクエストをシナリオに追加しました。\n結果はすべてのリクエストの応答時間が増加し、800ms以上かかりました。（スレッドプールとHikariCPコネクションプールを調整する前のテスト結果です。）\n赤いボックスで囲まれたGET /store、GET /store/{store_id}リクエストは、単独でテストした場合に100〜200ms程度の平均応答時間を示す非常に軽いリクエストです。\nしかし比較的重い処理であるGET /productリクエストとPOST /product/newリクエストと一緒にテストを行うと、最終的にリクエストの応答時間が増加する問題につながりました。\nこの現象が起きた理由はSpring MVC + TomcatがブロッキングI/O方式で動作するからです。\nリクエストを処理するとき、Tomcatスレッドがサーブレットコンテナへのリクエストをそれぞれ1件ずつ受け持って処理し、そのロジックが終わるまでスレッドを占有します。そのためそのスレッドはElasticsearchとDBのI/O処理が終わるまで、他のリクエスト処理に使われずに待機することになります。\nこのため最悪の場合、特定のAPIのリクエストが長時間I/O処理に縛られると、Tomcatのスレッドプールが枯渇し、他のリクエストまで待機しなければならない状況になります。\nこれを解決するには：\nノンブロッキング（Non-Blocking）I/Oベースのサーバーアプリケーション（Spring Webflux）を構成する スレッドプールを分離する サーバーを分離する などの方法を検討できます。\nスレッドを30個に減らすとなぜRPSが下がるのか？ # Tomcatのmax-thread：30個、HikariCPのmax-connection-pool：20個の設定値を適用したとき、応答時間がやや速くなりました。\nしかし1つ気になる点がありました。\nmax-thread 30個、HikariCPのmax-connection-poolが20個の場合、1秒あたりの処理量（RPS）はむしろ低くなりました。\n簡単に説明すると、スレッド数を減らすとコンテキストスイッチやコネクションの競合といったオーバーヘッドが減ります。そのためCPUの使用量も減り、少ない数のリクエストだけがサーバー内部のリソースを余裕を持って使えるため、単件の応答時間は速く出ることができました。\nしかし同時に受け入れられるリクエストが制限されるため、多くのリクエストが来るとスレッドプールが満杯になって新しいリクエストを速く処理できなくなります。その結果RPSが低い結果になりました。トレードオフの現象です。\nスレッドを200個に増やすとなぜまた遅くなるのか？ # Tomcatのmax-thread：200個 HikariCPのmax-connection-pool：180個 ここではスレッドだけを200個に増やしたのではなく、前述のボトルネック要因だったTomcatのスレッド数とHikariCPのコネクションプールサイズの大きな差を縮めるために、HikariCPのコネクションプールも180個に増やしました。つまり、デフォルト設定のように200個のリクエストスレッドが10個のコネクションを待つ状況ではなく、コネクション取得待ち自体はできるだけ減らした状態で再び遅くなったケースです。\nこの場合はRPSの差はほとんどありませんでしたが、応答時間がかなり長くなりました。\nその理由を上記のトレードオフの状況と同様に、スレッドが増加してCPUなどのリソースの競合がより頻繁になり、OSレベルでのコンテキストスイッチのコストが増加して応答時間が長くなったと考えました。\nしかしさらに調べてみると、従来の問題であったtimed-waiting状態のスレッドは減りましたが、むしろwaiting状態のスレッドは増加していました。\nスレッドダンプを分析してみると、180個のwaitingスレッドのうち140個のスレッドがElasticsearchの非同期レスポンスを受け取るためにこの状態にありました。Elasticsearchの総同時接続は30個であることは上でソースコードを示しました。140個のスレッドが30個の接続を取得するために競合が発生したと見られます。\nまた、Connection Usage Timeが非常に高くなりました。コネクションプールの数が余裕があったためConnection Acquire Timeは低く出ましたが、その後の使用時間が大きく出ました。\nrunnableスレッドを確認するとMySQLからデータを読み込んでいるスレッドは他の場合と同様に最大4個ほど現れました。コネクションを取得した後なぜ速く処理できないのか、waitingスレッドを含めて関係をもう少し詳しく確認する必要があります。\n結論：デフォルト値は出発点に過ぎなかった # 最初にAPIを開発したとき、単純にレビュー数をproductとreviewテーブルをJOINして欲しい結果だけを取得すれば十分だと考えていました。\nしかし全文検索インデックスを使う中でクエリの実行時間が大幅に増加する問題が発生しました。実行計画を分析してクエリの構造を改善して実行時間を短縮しましたが、まだ問題が残っていました。\nAPIで提供しなければならない要件が増えるほど、これを無理やりクエリに追加していくことでクエリが複雑になる問題でした。\nまた、productテーブルを探索することは全文検索を使う場合だけでなく、様々な場合にも使われうるものでした。\nその都度、複雑なサブクエリやJOINを通じてreviewの数とtagを取得するクエリを書くことは非効率だと判断し、プロセスとテーブル構造をより柔軟に再利用できるよう改善する必要性を感じました。\nしかしテーブル構造を変更すると新しいデータを入れなければならず、以前のテーブル構造と異なるデータを持つ状態でクエリ時間を比較することは不適切だと判断しました。より正確な基準を立てようと悩んでいる中で、最初から解決しようとしていた問題が何だったかを振り返り、そのクエリを含むAPIのパフォーマンスを改善することが望んでいた目標だったと気づきました。\n最初はクエリチューニングだけで問題を解決しようとしました。しかしクエリチューニングは問題を解決するための複数のツールの一つの方法であり、問題そのものではありませんでした。その後、より広く問題を定義すると考えられる選択肢が増え、既存のAPI開発方式の問題点も把握することができました。\nそして結果的に使用可能なレベルのAPIを開発することができるようになりました。\nただし負荷テストを確認する中で、まだ解けていない疑問が1つ残りました。仮想ユーザー（VUser）300名が同時にトラフィックを発生させる中でTomcatのThread Poolサイズを60個に減らした場合、残り240個のリクエストはスレッドを取得するために競合するのではないでしょうか？ この疑問はこの実験を理解するうえで続きとなる重要なポイントだったため、次の記事ではTomcat NIO Connectorの観点から続けて確認しました。\n次の記事： VUser 300名、Tomcatスレッド60個 — 残り240個のリクエストはどこへ行ったのか？\n","date":"2025年1月8日","externalUrl":null,"permalink":"/ja/posts/tomcat-thread-hikaricp-pool-tuning/","section":"Posts","summary":"Tomcatスレッド200個とHikariCPコネクション10個のデフォルト設定がパフォーマンスのボトルネックになる原因を分析し、最適な設定を見つけたプロセスをまとめます。","title":"Spring Boot Tomcatのthreads.maxとHikariCPのmaximum-pool-sizeをデフォルトで設定すると現れる現象","type":"posts"},{"content":"","date":"2025年1月5日","externalUrl":null,"permalink":"/ja/tags/grafana/","section":"Tags","summary":"","title":"Grafana","type":"tags"},{"content":" 負荷テストを実施中にGrafanaのすべてのダッシュボードで断線のような症状が現れ、上の画像のようにデータが欠落し続ける現象が発生しました。\n最初は、アプリケーションのターゲットDBサーバーにCPU負荷が100%かかるテストを行っている最中に発生した問題だったため、\nDBの問題か？\nと思いましたが、DBサーバーの負荷問題を解決した後もテスト中に同じ現象が続けて現れました。\nさらに、Prometheusの仕組みを考えると、DBへのアクセスがないのにこのような現象が発生したことから、別の原因があるはずです。\nまず、負荷テスト中にActuatorのメトリクス情報が欠落してデータが表示されなかったのかどうか確認するため、actuator/metrics URLに直接アクセスしてみました。\nこのとき、メトリクスの値は正常に返ってきていましたが、レスポンス時間がおよそ1〜2秒程度遅延しているのに気づきました。この遅延が怪しいと思い、負荷テストを終了してから再度リクエストしたところ、レスポンスは即座に返ってきました。\nあ！これはタイムアウトが発生していたんだ。\nPrometheusのネットワーク設定を見ると、タイムアウト値（scrape_timeout）はデフォルトで10秒ですが、スクレイプ周期（scrape_interval）より大きくはできないとのことです。\nそこでprometheus.ymlファイルを確認すると、scrape_intervalを1秒に設定していることが分かり、その結果タイムアウトも1秒として適用されていました。\nPrometheusの初期設定時に、変化を素早く確認するためscrape_intervalの周期を短く設定していました。しかし負荷テストの過程で全スレッドが負荷テストのリクエスト処理に使われ、スレッドの返却にも時間がかかるようになりました。その結果、Prometheusのメトリクスリクエストを処理するためのスレッド取得時間が伸び、レスポンス時間も増加し、最終的に1秒というタイムアウトを超えてしまったのでした。\nその後この値を10秒に変更したところ、Grafanaダッシュボードにデータが正常に表示されるようになりました。\n","date":"2025年1月5日","externalUrl":null,"permalink":"/ja/posts/grafana-dashboard-missing-data/","section":"Posts","summary":"負荷テスト中にGrafanaダッシュボードでデータが欠落する現象の原因と解決方法をまとめます。","title":"Grafanaダッシュボードのデータ欠落（断線？）現象","type":"posts"},{"content":"","date":"2025年1月5日","externalUrl":null,"permalink":"/ja/tags/infrastructure/","section":"Tags","summary":"","title":"Infrastructure","type":"tags"},{"content":"","date":"2025年1月5日","externalUrl":null,"permalink":"/ja/tags/monitoring/","section":"Tags","summary":"","title":"Monitoring","type":"tags"},{"content":"","date":"2025年1月5日","externalUrl":null,"permalink":"/ja/tags/prometheus/","section":"Tags","summary":"","title":"Prometheus","type":"tags"},{"content":"前の記事： [MySQL] Left JoinをSubqueryに変更してクエリ性能を30倍向上させる\n前回の記事で全文検索（FullText Search）を使用するクエリの構造を変更してクエリの実行時間を30倍短縮しました。\nしかしレビュー件数とユーザーが多く選んだ上位タグなどの追加情報を表示しなければならないという要件を反映するためのクエリを作成したところ、実行時間が少し増加するというデメリットがありました。\nそして何よりもこのようなビジネス的な要件がクエリの中に溶け込んでいるため、DBアクセス技術を変更したりビジネスロジックを修正する必要が生じたとき、クエリを直接修正しなければならないため、メンテナンスの観点から柔軟性が低いという問題点が存在します。\nしかしその前に解決すべき問題を明確に定義する必要がありました。\nどのような問題を解決したいのか？ # 改善すべき事項は単純にクエリの実行時間を短縮する部分だけではありません。クエリの実行時間の改善はAPIの性能を向上させるツールの一つに過ぎません。\nもう少し大きく見て問題を定義すると、既存のAPIはクエリの実行時間によりPostmanでリクエストした際の応答まで大よそ6.85秒かかっていましたが、結局APIの性能が出ていないことを問題として定義できます。\nそしてここから目標はAPIの性能が出ていないことを解決することです。\nまずビジネス要件を定義します # /product?search=\u0026quot;xxx\u0026quot; 検索をリクエストします。またpageとsizeパラメーターでページングを適用します。 各productに該当するreviewの件数を表示します。 各productに対する上位3つのタグを表示します。ユーザーがレビュー作成時に自由に選んだタグの中で最も多く選ばれた3つを表示します。 性能目標の設定 # APIの性能を改善するには、まず**目標が必要です。**目標なしにテストを進めると結果が良いのか悪いのか判断する基準がないからです。\nデータ規模からサービス規模を推定する # 現在のテスト環境に保存されているデータは以下のとおりです。\nProduct：150万件 Review：1,000万件 ProductDietTag：2,000万件 Productはシードデータとユーザー登録が混在していると仮定すると、DAUと直接比例しません。一方Reviewはユーザーが直接作成するデータのため、逆にDAUを推定できます。\n運用期間1年と仮定：10,000,000件 / 365日 ≈ 日平均27,000件のレビュー生成 食事記録アプリの特性上、DAUのうちレビューを作成する比率は高い方（記録行為自体がコア機能） 1人あたりの日平均レビュー数：約0.6〜1.35件（アクティブユーザー基準） DAU ≈ 27,000 / 0.6 〜 27,000 / 1.35 ≈ 約20,000〜45,000人 保守的にDAU 20,000人を基準として設定します。\nトラフィックの推定 # 食事記録サービスの特性上、検索APIは1日15〜25回/人呼び出されます。（各食事記録時に製品検索＋探索5〜8回と仮定）\n日次総呼び出し：20,000人 × 20回 = 400,000 req/day ピーク時間帯（昼11:30〜13:30、夜18:00〜20:00）：全体の50%以上が4時間に集中 ピークRPS：約15 RPS 瞬間バースト（ピークの3〜5倍）：約45〜75 RPS 応答時間の目標 # 指標 目標 根拠 平均応答時間 \u0026lt; 200ms ユーザーが遅延を感じないレベル Error Rate \u0026lt; 0.1% 検索失敗時にユーザー離脱が高い テスト負荷の設定 # 今回のテストではVUser 300人を使用します。これは現実的なピーク（15 RPS）よりも高い負荷で、システムの限界点を把握するためのストレステストの性格です。目標負荷でのパフォーマンスではなく、極限状況においてシステムがどこまで耐えられるかを確認することが目的です。\nどのようにAPIの性能を比較するか？ # 上記の要件を満たすAPIを作成するために、従来はDataGripを使用してクエリの実行時間を分析していました。しかしプロセスの実装方式やテーブル構造が変更される場合、データ構造も一緒に変わるため、単純に数百万件のデータでクエリの実行時間を比較することは正確な基準になりにくいです。\nしたがってより正確な性能測定のための基準が必要になりました。\nAPIの性能を正確にテストするには、Postmanで単一の応答時間を測定するだけでは不十分で、より多くのリクエストが必要でした。また検索機能をテストする際に同じキーワードだけでは限界がありました。MySQL InnoDBのキャッシュ機能を考慮すると様々なデータへのアクセスが必要だったからです。このような理由からnGrinderを使って様々なデータにアクセスするトラフィックを発生させ、PrometheusとGrafanaを活用したモニタリングを通じてAPIの性能を評価することにしました。\nテスト構成 # 1. DBのスキーマ構成 # 前述のクエリ構造変更の記事から持ってきたDDLをそのまま使用します。\n-- 150万件のデータ -- n-gramアルゴリズムを適用した全文検索インデックスを使用 CREATE TABLE product ( product_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(255) NOT NULL, product_corp VARCHAR(255), created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, FULLTEXT INDEX fulltext_idx (product_name, product_corp) WITH PARSER ngram ); -- 1,000万件のデータ CREATE TABLE review ( review_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, content VARCHAR(255), rating TINYINT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE INDEX idx_product_id ON review (product_id); -- 2,000万件のデータ CREATE TABLE product_diet_tag ( product_diet_tag_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT, diet_tag_id BIGINT, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE INDEX idx_product_id ON product_diet_tag (product_id); -- 10件のデータ CREATE TABLE diet_tag ( diet_tag_id BIGINT PRIMARY KEY AUTO_INCREMENT, diet_tag_name VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); 現在MySQL DBには：\nproductテーブル 150万 rows reviewテーブル 1,000万 rows product_diet_tagテーブル 2,000万 rows diet_tagテーブル 10件の rows が保存されています。\n2. モニタリングとnGrinderのためのクラウド構築 # 最適なリソースを割り当てるためにサーバーはそれぞれのインスタンスに配置し、PrometheusとGrafanaのみを同じインスタンスに配置しました。\nインスタンス スペック Targetアプリケーションサーバー vCPU 2EA、Memory 8GB Target MySQL vCPU 2EA、Memory 8GB モニタリング vCPU 2EA、Memory 8GB nGrinder Controller vCPU 2EA、Memory 8GB nGrinder Agent vCPU 2EA、Memory 8GB 3. テストシナリオ # 負荷テストは5分間実施し、仮想ユーザーは最大300人で以下のイメージのように30秒ごとに段階的に増加させました。\nAPI呼び出しシナリオでは、検索キーワード、size、pageの値をランダムな文字列や数字で様々に適用しました。\n1回目のテスト：全文検索（FullText Search）のみを使用したテスト # 要件をすべて満たすAPIをテストする前に、全文検索機能だけを単独で使用したときにどの程度のAPIパフォーマンスが出るか気になったのでテストを実施してみました。\n以下のコードはAPIテストに使用されたリポジトリクラスです。1回のAPI呼び出しでページングのためにクエリは2種類使用され、今回のテストではproductテーブル以外の他のテーブルは触れていません。\n@Repository @RequiredArgsConstructor @Transactional public class JdbcTemplateProductSearchRepository { private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; private final DataClassRowMapper\u0026lt;ProductSearchResponse\u0026gt; beanPropertyRowMapper = new DataClassRowMapper\u0026lt;\u0026gt;(ProductSearchResponse.class); public Page\u0026lt;ProductSearchResponse\u0026gt; findFullTextSearch(String keyword, Pageable pageable) { Integer total = getTotalCount(keyword); // OFFSETとLIMITを適用しました。 String sql = \u0026#34;SELECT \u0026#34; + \u0026#34;p.product_id, p.product_name, p.product_corp \u0026#34; + \u0026#34;FROM product p \u0026#34; + \u0026#34;WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)\u0026#34; + \u0026#34;LIMIT :offset, :limit\u0026#34;; MapSqlParameterSource parameters = new MapSqlParameterSource() .addValue(\u0026#34;keyword\u0026#34;, keyword) .addValue(\u0026#34;offset\u0026#34;, pageable.getOffset()) .addValue(\u0026#34;limit\u0026#34;, pageable.getPageSize()); List\u0026lt;ProductSearchResponse\u0026gt; queried = namedParameterJdbcTemplate.query(sql, parameters, beanPropertyRowMapper); return new PageImpl\u0026lt;\u0026gt;(queried, pageable, total); } // ページング処理のために全件数を照会するCOUNT(*)クエリ。 private Integer getTotalCount(String keyword) { String sql = \u0026#34;SELECT COUNT(*) \u0026#34; + \u0026#34;FROM product p \u0026#34; + \u0026#34;WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)\u0026#34;; MapSqlParameterSource parameters = new MapSqlParameterSource() .addValue(\u0026#34;keyword\u0026#34;, keyword); return namedParameterJdbcTemplate.queryForObject(sql, parameters, Integer.class); } } テスト結果 # テストの結果は非常に深刻でした。\n平均TPS（Transaction Per Second）が2.1となり、応答時間は徐々に増加して30秒が続きました。またエラー率が51.3%（958件中491件失敗）となりました。\nエラーの原因はアプリケーションのログを確認したところconnection timeoutエラーが発生していました。これはHikariCPのコネクションプールのすべてのコネクションが使用中の状況で、新しいコネクションを取得するための待機時間がHikariCPのデフォルト設定値30秒を超えたためです。\nNCPが提供するDBインスタンスモニタリングを通じて問題を分析した結果、CPU使用量が最大値に達しており、これが性能のボトルネック地点であることが確認できました。\n目標対比分析： 目標である平均応答時間 \u0026lt; 200ms、Error Rate \u0026lt; 0.1%と比べて圧倒的に低い性能です。VUser 300人という高い負荷の下でMySQLの全文検索がCPUを100%占有してTPSが2.1しか出ませんでした。現実的なピーク（15 RPS）でも安定したサービスが難しいと判断されます。\n番外テスト # APIの実際の性能を測定するためにHikariCPのconnection_timeoutを余裕を持って300秒に変更した後、コネクション取得タイムアウトエラーが発生しないテストを実施しました。\nAPIの応答はすべて成功しましたが、平均TPSが1.9、平均応答時間は大よそ1.67分を維持、最大1.79分となりました。\n目標対比分析： エラーはなくなりましたが平均応答時間が約1分です。目標200msの約300倍に達するため、タイムアウトの緩和では根本的な問題を解決できません。\nそして次の番外テストとして、DBインスタンスのスペックを段階的にScale Upしてみました。\n段階 スペック 既存 vCPU 2EA、Memory 8GB 1回目Scale Up vCPU 8EA、Memory 32GB 2回目Scale Up vCPU 32EA、Memory 128GB テスト結果、2回目のScale Upスペック（vCPU 32EA、Memory 128GB）でテストを実施しても平均TPS 10.5、平均応答時間は大よそ15〜20秒かかりました。ただしCPU使用量は30%まで減少しました。\n目標対比分析： 16倍Scale Up（vCPU 2→32）でもTPS 10.5、応答時間15秒です。目標ピークRPS 15にも満たず、応答時間は目標の75倍です。したがってMySQL全文検索だけではこの規模のデータで目標性能の達成が難しいと判断しました。ただしElasticsearchに移行する前に、実際のサービス要件をすべて含めたMySQLのベースラインも一度測定しておきます。\n（Grafanaダッシュボードで途中に値が欠損する現象が続けて発見されました。これについての記事はGrafanaダッシュボードの欠損（断絶？）現象で取り上げています。）\n2回目のテスト：すべての要件を満たしたテスト # Elasticsearchに切り替えたときの改善幅を比較するために、前回の記事で使用したクエリを適用して、すべての要件を満たすMySQLのベースラインテストをもう一度実施しました。\nSELECT p.product_id, p.product_name, p.product_corp, (SELECT COUNT(*) FROM review r WHERE r.product_id = p.product_id) AS review_count, (SELECT GROUP_CONCAT(diet_tag_name) FROM (SELECT diet_tag_name FROM product_diet_tag pdt WHERE pdt.product_id = p.product_id GROUP BY diet_tag_name ORDER BY COUNT(diet_tag_name) DESC LIMIT 3) AS top3_diet_tag) AS top3_diet_tag_names FROM product p WHERE MATCH(p.product_name, p.product_corp) AGAINST(:keyword) LIMIT :offset, :limit; 予想通りテスト結果は惨憺たるもので、全文検索を単独で使用した場合と比較しても大きく変わりません。\nなぜこれほど低い性能が出るのか？ # これは全文検索インデックスの特性を考慮する必要があります。\n全文検索には既存のMySQL InnoDBストレージエンジンが提供する一般的な用途のB-treeインデックスを使用できません。\n全文検索（Full Text search）インデックスは文書全体を分析・検索するための逆インデックス構造のインデックスアルゴリズムです。\nそしてインデックス技法に応じて大きく**「単語の語根分析」と「n-gram分析」**アルゴリズムに分けられます。\n**「単語の語根分析」**は英語のように単語の変形がある場合に、その単語の根である名詞または語根を見つけてインデックスを作成します。\nしかし韓国語、中国語、日本語はこのような単語の変形がほとんどないため、本文を一定の長さで切り取ってトークンとしてインデックスを作成する**「n-gram分析」**を活用すると精度と効率性を向上させることができます。\nそしてproductテーブルにはn-gram分析を活用した全文検索インデックスが作成されています。\nしかしこのような方式にも欠点があります。\nMySQLサーバーは全文検索クエリが来ると、インデックス作成時と同様に検索語をトークンサイズに合わせて切り取ります。そして切り取られたトークンに対して一致する単語の件数、頻度などを確認して一致率を計算します。\nそして各トークンの結果に対して比較演算が実行され、この過程で重み付けの計算とソートが行われます。\nしたがって検索語が長いほどより多くのトークンが生成され、各トークンの結果を集めて比較演算を実行するため、CPUにかかる負荷が大きくなります。\nまた、実行計画を分析してみるとOFFSETが2000、LIMITが10の場合に2,010件のrowを読んだことが示されます。\nSELECT p.product_id, p.product_name, p.product_corp FROM product p WHERE MATCH (p.product_name, p.product_corp) AGAINST (\u0026#39;鶏むね肉\u0026#39;) LIMIT 2000, 10; しかし実際に内部的に計算される行の数は単純にOFFSETとLIMITで指定した件数だけではありません。\nMySQLは全文検索でマッチングされるすべてのrowsに対して重みを計算してソートした後、ソート結果から上位2,000番目から2,010番目までを返します。\nしたがってページングを最適化するための方法であるNo Offset、カバリングインデックス、Total Count最適化のような技法を使用することも難しい状況でした。\n実際にOFFSETを0に設定した場合と比較してみましたが性能上の有意差はなく、ページングのためのCOUNT(*)クエリを除去したときも僅かな性能改善しかありませんでした。（単純に一つのトランザクションで2つのクエリが発行されていたものを1つのクエリだけ発行するよう変えたので性能が大よそ2倍程度良くなるという非常に当然の現象…）\nTPS：2.1 -\u0026gt; 3.7 エラー率：51.3% -\u0026gt; 28.6%（1,372件中393件失敗） Elasticsearchエンジンの導入 # 従来はElasticsearchの導入をコスト負担が大きいというデメリットのために選択せず、MySQLが提供する全文検索を選択しました。\nしかし全文検索を活用した場合、100万件を超えるデータで性能が十分に出ないという致命的なデメリットが発見されました。\nまたアプリケーションでユーザーが呼び出すAPIのパターンを考慮してみたとき、食品検索APIはユーザーが非常に頻繁に呼び出すAPIでサービスにおいてコアな役割を担っていると判断しました。したがってコストを投資してでも性能を引き上げる必要が生じました。\n前回のテストでDBエンジンをScale Upしましたが、それでもTPSは平均10程度、応答時間は15秒に近いままでした。限られたコストの中ではScale UpよりもElasticsearchの導入の方がより効果的だと判断しました。\n当初は外部ストレージを活用したキャッシュも検討しましたが、キャッシュは検索条件が多様すぎて適用が難しく、データの特性と使用パターンの分析を先行しなければ効果的な部分キャッシングが可能だと判断したため選択しませんでした。\nElasticsearchエンジンはGCPが提供するElastic Cloudサービスを利用 # インスタンススペック：vCPU 5個、Memory 4GB KIBANAのための1GB メモリ無料提供 総コスト：1時間 0.3321$ -\u0026gt; 0.3321 × 24 × 30 = 239$ = 345,594ウォン（レート1,446ウォン） 現在プロジェクトのクラウドサーバーを構成したNCPもSearch Engine Serviceというサービスを提供していますが、なぜかクラスター接続ができない問題が続けて発生しました。\nNCPはSearch Engine Serviceクラスターを構成するのに最低4台のインスタンスが必要で、vCPU 2EA、Memory 8GBのインスタンスを4台使用するならば価格はGCPのElastic Cloudと似ていますが、性能はより優れていると予想されます。（ただしUIとクラスター構成の利便性はElastic Cloudが圧倒的に優れています。また、新規加入すると40万ウォンのクレジットも付与されます。）\n直接インスタンスにElasticsearchを構築する方法も検討しましたが、初めて使う技術について何も知らない状態でパイプラインを構成するのに時間とコストがかかりそうだと感じました。\nしたがって優先的にElasticsearchエンジンを使用するとどの程度APIの性能が出るかを確認することに重点を置いて、素早く構成できるクラウドサービスを選択しました。\nElasticsearchエンジンを使用した3回目のテスト # 上記のテスト結果は、レビュー件数と上位タグ表示のような要件なしにproductへの検索（GETリクエスト）のみを実施したテスト結果です。\nテスト結果を見ると、MySQLの全文検索を使用した場合と比較して（connection timeoutエラーを除去した場合）：\n平均TPS： 1.9 -\u0026gt; 192.5（約100倍向上） 応答時間： 大よそ1.67分 -\u0026gt; 800ms（800ms/100,200ms = 1/125に短縮） 目標対比分析： MySQL対比TPS 100倍、応答時間1/100レベルに劇的に改善されました。VUser 300人の負荷において平均926msのため、現実的なピーク（15 RPS）では平均200ms以内という目標達成が十分に可能なレベルです。\nクエリにあった要件をビジネスレイヤーに移動 # 前回の記事ではクエリの中にすべての要件が入っていました。\n要件は以下のとおりです。\n各productに該当するreviewの件数を表示します。 各productに対する上位3つのタグを表示します。ユーザーがレビュー作成時に自由に選んだタグの中で最も多く選ばれた3つを表示します。 既存のテーブル構造の問題点 # productとdiet_tagは多対多の関係を持ち、中間にproduct_diet_tagテーブルを置いて各productに関連するdiet_tagを保存します。\nユーザーはproductに対するreviewを作成する際、希望するdiet_tagを複数選択して保存します。したがって一つのreviewが作成されると、product_diet_tagのデータは複数保存されます。\n例えば、productが100万件あって各productに100件のreviewがあると仮定してみます。そしてユーザーたちが各reviewごとに3件のdiet_tagをバランスよく選ぶなら、product_diet_tagのデータはおよそ3億件保存されます。\nこのような設計は問題があると判断して、テーブル構造と保存プロセスの改善が必要だと考えました。\n改善されたテーブル構造 # ユーザーたちがreviewを作成するときにproduct_diet_tagにtag_countフィールドを作ってその値を増加させれば、データ容量をはるかに減らすことができます。\n再び例として、productが100万件あってdiet_tagが10件だと仮定してみます。この場合product_diet_tagのデータは最大1,000万件に減ります。product_diet_tagの件数はproductの件数とdiet_tagの件数にのみ依存し、reviewの件数には影響を受けません。ユーザーがreviewをどれだけ多く登録してもtag_countの値を増加させるだけでよいです。\n既存のコード（一発クエリ） # 既存のコードは以下のようにJdbcTemplateを利用してRepositoryですべてのロジックを処理していました。\n@Repository @RequiredArgsConstructor @Transactional public class JdbcTemplateProductSearchRepository { private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; private final DataClassRowMapper\u0026lt;ProductSearchResponse\u0026gt; beanPropertyRowMapper = new DataClassRowMapper\u0026lt;\u0026gt;(ProductSearchResponse.class); public Page\u0026lt;ProductSearchResponse\u0026gt; findFullTextSearch(String keyword, Pageable pageable) { Integer total = getTotalCount(keyword); String sql = \u0026#34;SELECT \u0026#34; + \u0026#34;p.product_id, p.product_name, p.product_corp, \u0026#34; + \u0026#34;(SELECT COUNT(*) FROM review r WHERE r.product_id = p.product_id) AS review_count, \u0026#34; + \u0026#34;(SELECT AVG(r.rating) FROM review r WHERE r.product_id = p.product_id) AS review_avg_rating, \u0026#34; + \u0026#34;(SELECT GROUP_CONCAT(diet_tag_name) FROM (SELECT dt.diet_tag_name FROM product_diet_tag pdt JOIN diet_tag dt ON pdt.diet_tag_id = dt.diet_tag_id where pdt.product_id = p.product_id ORDER BY pdt.tag_count DESC LIMIT 3) AS top3_diet_tag) AS top3_diet_tag_names \u0026#34; + \u0026#34;FROM product p \u0026#34; + \u0026#34;WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)\u0026#34; + \u0026#34;LIMIT :offset, :limit\u0026#34;; MapSqlParameterSource parameters = new MapSqlParameterSource() .addValue(\u0026#34;keyword\u0026#34;, keyword) .addValue(\u0026#34;offset\u0026#34;, pageable.getOffset()) .addValue(\u0026#34;limit\u0026#34;, pageable.getPageSize()); List\u0026lt;ProductSearchResponse\u0026gt; queried = namedParameterJdbcTemplate.query(sql, parameters, beanPropertyRowMapper); return new PageImpl\u0026lt;\u0026gt;(queried, pageable, total); } private Integer getTotalCount(String keyword) { String sql = \u0026#34;SELECT COUNT(*) \u0026#34; + \u0026#34;FROM product p \u0026#34; + \u0026#34;WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)\u0026#34;; MapSqlParameterSource parameters = new MapSqlParameterSource() .addValue(\u0026#34;keyword\u0026#34;, keyword); return namedParameterJdbcTemplate.queryForObject(sql, parameters, Integer.class); } } リファクタリングされたコード # 上記のコードをリファクタリングして以下のコードに変更しました。\n// クエリにIN句を使用して各productIdに該当するレビューの件数を一度に照会します。 public interface ReviewRepository extends CrudRepository\u0026lt;Review, Long\u0026gt; { @Query(\u0026#34;SELECT product_id, COUNT(*) AS review_count FROM review WHERE product_id IN (:productIds) GROUP BY product_id\u0026#34;) List\u0026lt;ProductReviewCount\u0026gt; countReviewsByProductIds(@Param(\u0026#34;productIds\u0026#34;) List\u0026lt;Long\u0026gt; productIds); } // クエリにIN句を使用して各productIdに該当するproduct_diet_tagをすべて照会します。 // diet_tagテーブルとジョインしてdiet_tag_nameを取得します。 public interface ProductDietTagRepository extends CrudRepository\u0026lt;ProductDietTag, Long\u0026gt; { @Query(\u0026#34;SELECT pdt.product_id, dt.diet_tag_name, pdt.tag_count \u0026#34; + \u0026#34;FROM product_diet_tag pdt \u0026#34; + \u0026#34;LEFT JOIN diet_tag dt on pdt.diet_tag_id = dt.diet_tag_id \u0026#34; + \u0026#34;WHERE product_id IN (:productIds)\u0026#34; ) List\u0026lt;ProductDietTagDto\u0026gt; findByProductIds(@Param(\u0026#34;productIds\u0026#34;) List\u0026lt;Long\u0026gt; productIds); } @RequiredArgsConstructor @Transactional(readOnly = true) @Service public class ProductSearchService { // Spring Data Elasticsearchリポジトリ private final ProductDocumentRepository productDocumentRepository; private final ReviewRepository reviewRepository; private final ProductDietTagRepository productDietTagRepository; private final ProductSearchResponseMapper productSearchResponseMapper; public Page\u0026lt;ProductSearchResponse\u0026gt; search(String keyword, Pageable pageable) { // ProductDocumentはElasticsearchに保存された各データをマッピングします。 // Elasticsearchを使ってkeywordに該当するProductDocumentを検索します。 Page\u0026lt;ProductDocument\u0026gt; productDocuments = productDocumentRepository.findByProductName(keyword, pageable); // 検索結果がなければ例外を返します。 if (productDocuments.isEmpty()) { throw new BusinessException(PRODUCT_NOT_FOUND); } // ProductのIDリストを抽出します。 List\u0026lt;Long\u0026gt; productIds = productDocuments.stream() .map(ProductDocument::getId) .toList(); List\u0026lt;ProductDietTagDto\u0026gt; tags = productDietTagRepository.findByProductIds(productIds); List\u0026lt;ProductReviewCount\u0026gt; productReviewCounts = reviewRepository.countReviewsByProductIds(productIds); // productSearchResponseMapperクラスが // 各productIdに合わせてtagsをcount値の降順でソートした後3件を選択する。 // また各productIdに合わせてreview件数もマッピングして結果を返す。 return productSearchResponseMapper.toResponseList(productDocuments, tags, productReviewCounts); } } 結果として一発クエリで解決していたものを3回のDB接続に分割しましたが、複雑なクエリが不要になり、ビジネスロジックとデータアクセスロジックが強く結合していたものを分離してコードの可読性が向上しました。\nリファクタリングされたコードは各役割（製品照会、レビュー照会、タグ照会）を個別に処理し、サービス層で結果を組み合わせる方式で再利用性も向上しました。\n最後のテストの実施 # 前回のテストとの違い # 上記の要件をすべて反映。 既存のGET（検索）リクエストとともにPOST（保存）リクエストを追加しました。実際の状況を想定してリクエストの比率はGET 90%、POST 10%に設定しました。データ保存時のMySQLとElasticsearchのデータ同期はアプリケーションで直接管理する最もシンプルな方式を選択しました。 テスト時間を25分に増加。前回のテストで要件のないElasticsearch単独テストですでにある程度の性能が出ることを確認したため、少し長い時間テストを実施しました。 ユーザーは段階的に増加し、4分程度経過した後300人を維持しました。\nテスト結果 # 要件なしの検索（GET）リクエストテストと比較したとき、TPSが192.5から179.6にわずかに減少しました。\n各APIリクエスト別に平均応答時間を見てみると、GETリクエストは876ms、POSTリクエストはGETリクエストより約400ms程度多くかかる**1.21秒（1,210ms）**の結果が現れました。POSTリクエストはElasticsearchエンジンへの書き込み作業（読み込みに比べて相対的に遅い）とMySQLへの書き込み作業を同期的に処理しており、この部分でより多くの時間がかかったと判断されます。\nアプリケーションサーバーのCPU使用量は大よそ20〜30%程度となりました。\nそして全文検索のみを使用するときに軽く100%になっていたMySQLサーバーのCPU使用量も**20〜25%**程度に減少しました。\n目標対比分析： GET 876ms、POST 1,210msでVUser 300人の負荷基準です。応答時間の最小値がGET 144ms、POST 254msであることを考えると、負荷が低い状況では目標に近づくことが可能です。ただしVUser 300人での平均応答時間はまだ目標を超えているため、追加の改善が必要です。\n今回のテストで不十分だった点 # 今回のテストを実施して一つ残念だった点がありました。\n目標を設定する際、現実的なピークRPSを約15と推定しましたが、正作**その負荷レベルでのテストは実施しませんでした。**すべてのテストをVUser 300人という高い負荷でのみ実施したため、目標負荷で実際にどの程度の性能が出るか確認できませんでした。\nストレステストを通じてシステムの限界点を把握したことは意義がありましたが、正しい性能テストの順序は以下のようであるべきでした。\n目標負荷テスト：現実的なピーク（15 RPS）で目標応答時間を達成するか確認 ストレステスト：段階的に負荷を高めてシステムがどこで限界に達するかを把握 限界分析と改善：ボトルネック地点を分析して改善後、再び1番から繰り返す 今回は2番から直接実施したことになります。今後同様の性能テストを実施するなら、目標負荷での検証を先に実施した後、段階的に負荷を高める順序でアプローチする方がより適切だと判断します。\n次の記事： Spring Boot Tomcat threads.maxとHikariCP maximum-pool-sizeをデフォルトに設定したときに現れる現象\n","date":"2024年12月22日","externalUrl":null,"permalink":"/ja/posts/mysql-fulltext-to-elasticsearch-performance/","section":"Posts","summary":"MySQL FullText Searchの限界を分析し、Elasticsearchを導入して検索API性能を改善した過程をまとめます。","title":"MySQL全文検索の限界、Elasticsearchの導入で検索API性能を改善する","type":"posts"},{"content":"","date":"2024年12月22日","externalUrl":null,"permalink":"/ja/tags/ngrinder/","section":"Tags","summary":"","title":"NGrinder","type":"tags"},{"content":"","date":"2024年12月22日","externalUrl":null,"permalink":"/ja/tags/performance-testing/","section":"Tags","summary":"","title":"Performance Testing","type":"tags"},{"content":" 問題の状況 # MySQLの全文検索（FullText Search）を活用した検索APIを実装し、Postmanでテストを実施しました。\nしかしテスト結果、応答時間がなんと6.85秒もかかりました。検索APIはユーザーが食事を記録するたびに呼び出される重要なAPIです。一般的に検索APIの応答時間は200〜500ms以内が適切とされていますが、6.85秒はこの基準を大きく超える数値です。\nすぐに全文検索インデックスの適用状況を確認しましたが、全文検索インデックスは正常に作成されていました。\nインデックスは作成されているがインデックスが正しく使われていないのでは？\nという疑問が生じ、ここからその原因を追跡していきます。\nAPIの要件とDDL # APIの要件として、productを検索する際に検索結果とともに各productに関連するreviewの総数も表示しなければなりません。\n現在productテーブルのデータは100万件、reviewテーブルのデータは1,000万件保存されています。\n各テーブルのDDLは以下のとおりです。外部キーは使用せず、代わりにインデックスは別途設定しています。\n-- ngramアルゴリズムを活用する全文検索インデックスを作成 CREATE TABLE product ( product_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(255) NOT NULL, product_corp VARCHAR(255), created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, FULLTEXT INDEX fulltext_idx (product_name, product_corp) WITH PARSER ngram ); CREATE TABLE review ( review_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, content VARCHAR(255), rating TINYINT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE INDEX idx_product_id ON review (product_id); 問題のクエリ # SELECT p.product_id, p.product_name, p.product_corp, IFNULL(review_count, 0) AS review_count FROM product p LEFT JOIN ( SELECT product_id, COUNT(*) AS review_count FROM review GROUP BY product_id ) r ON p.product_id = r.product_id WHERE MATCH (p.product_name, p.product_corp) AGAINST (\u0026#39;クリスピー\u0026#39;) LIMIT 1, 20; 上記の要件のために作成したクエリは上記のとおりで、例として「クリスピー（クリスピー）」という単語で全文検索を実行しました。\nしかし上記のクエリを適用してテストしてみたところ**3913（ms）**秒かかるほど深刻に遅いパフォーマンスを示しています。\n実行計画の分析 # 実行順序は赤い番号の順で、以下の基準で読むとよいです。\nインデント（-\u0026gt;の形の矢印）が同じインデントレベルでは上に位置する行が先に実行される。（2番と6番の中で2番が先に実行される） インデントが異なるレベルでは最も右に位置する行が先に実行される。（2番と1番の中で1番が先に実行される） 最初の実行作業である「Full-text index search on p using fulltext_idx」を基準に実行計画の用語を説明すると：\ncost: 総クエリコストにおけるこのクエリの寄与度。 rows: 予想される結果のレコード数。 actual time=n..m: nは各loopで最初のレコードを読むまでの平均時間、mは最後のレコードを読むまでの平均時間。 actual rows: 1回のloopでproduct_name=\u0026ldquo;クリスピー\u0026quot;を使用して実際に読んだ平均レコード数。 actual loops: product_name=\u0026ldquo;クリスピー\u0026quot;を使用してproductテーブルからレコードを探す作業を繰り返した回数。 では実行計画を辿ってみましょう。\nまず、productテーブルに全文検索を実行します。この部分では26件のレコードを取得するのに平均22（ms）かかっており、パフォーマンス低下に影響を与えていません。\n次にreviewテーブルでCovering index scanを実行しますが、この際にテーブルから読んだrowsが**1,000万件（10e+6）で、最後のrowを取得するのにかかった時間がなんと3220（ms）**でした。\nGROUP BY product_idとCOUNT()を活用して関連するreviewの件数を集計する際、前の作業ですべてのreviewのproduct_idインデックスを探索するのに時間が多くかかりました。\nその後reviewの件数に対する集計演算を実行し、LEFT JOINを処理するのに大よそ**4418（ms）**の時間がかかりました。（EXPLAIN ANALYZEを使用したため実際のクエリよりも時間が少し余分にかかっています。）\n1回目のクエリ修正 # 問題点を確認してみたところ、reviewテーブルでindex full scanが処理されています。この問題を解決するには、全文検索で探索されたproductに関連するreviewだけを選択すれば多くのrowsの値を減らせるでしょう。\nSELECT p.product_id, p.product_name, p.product_corp, COUNT(*) AS review_count FROM product p LEFT JOIN review r ON p.product_id = r.product_id WHERE MATCH(p.product_name, p.product_corp) AGAINST(\u0026#39;クリスピー\u0026#39;) GROUP BY p.product_id LIMIT 1, 20; LEFT JOINの中にあったサブクエリを外に出し、reviewテーブルをジョインするように変更しました。そしてGROUP BYを全文検索の後に行うよう変更しました。\nしかしそれでもクエリが**3642（ms）**程度で非常に低いパフォーマンスを示していました。\n再び実行計画を分析してみたところ、予想どおり全文検索で探索されたproductのレコードに対してのみreviewテーブルを探索していることがわかります。\nしかし問題があります。\n全文検索で探索されて読み込まれたproductのデータが90,911件です。\nproduct1件あたりに読み込まれたreviewレコードは平均10件で、Nested loop left joinをするときに大よそ90万件のデータを処理することになり、最後のレコードを読むのに3065（ms）かかっており依然としてパフォーマンス低下が見られます。\n実行計画をもう少し詳しく見ると、908,796件のレコードをジョイン結果として得て、GROUP BYのような集計演算のために一時テーブルを作成した後、LIMIT処理を実行します。\n変更前のクエリはlimit Optimizationが行われて全文検索を実行しながら必要なレコードの数だけ読んでいましたが、今回はこの作業が行われませんでした。\nlimit Optimization関連参考資料：\nhttps://www.percona.com/blog/mysql-explain-limits-and-errors/ https://dev.mysql.com/doc/refman/8.4/en/limit-optimization.html WHERE LIKE句との比較 # SELECT p.product_id, p.product_name, p.product_corp, COUNT(*) AS review_count FROM product p LEFT JOIN review r ON p.product_id = r.product_id WHERE p.product_name LIKE \u0026#34;%クリスピー%\u0026#34; GROUP BY p.product_id LIMIT 1, 20; 追加で一つ疑問が生じて、同じ構造のクエリで全文検索の代わりにWHERE LIKE句を使ってみました。すると驚くことに**35（ms）**という非常に速いパフォーマンスが出ました。\n最初にproductテーブルで主キー（PRIMARY）を活用したインデックススキャンが実行されました。product_nameにはインデックスがかかっていません。（インデックスを設定してもワイルドカード（%）が前にあればインデックスを活用できません）したがって主キーを活用してレコードを読み込みながら「クリスピー」が含まれるレコードをフィルタリングします。\nここでMySQLは順次レコードをスキャンしながらlimit 20に少し余裕を持たせて22件のレコードを見つけた瞬間に探索を終了します。その過程で合計240件のレコードを探索しました。つまり、limit optimizationが行われ、別途一時テーブルの作成なしにGROUP BY集計演算が実行されました。\n全文検索を活用する際はMATCH AGAINSTでマッチングされたすべてのレコードをまず評価した後、GROUP BY集計が完了してはじめてLIMITを適用できるため、順次スキャンのように途中で探索を中断するlimit optimizationが行われません。\nしかしだからといってこのような方式の検索を活用しようということではありません。テーブルのレコードが非常に多く、見つけたいデータが非常に後ろにある場合、Index full scanをするときに非常に多くの時間がかかる可能性があります。\n実はこのような現象はダミーデータを挿入する際にJdbcTemplateのbatch insertを使用したことでデータに規則性が生まれ、これによってlimitの件数が素早く満たされました。\nまた全文検索の際には、このような規則性によって一つの全文検索インデックスに関連するデータが過度に多く保存されて（例の場合90,911件）スキャン対象が増える問題が発生しました。\n2回目のクエリ修正 # SELECT p.product_id, p.product_name, p.product_corp, (SELECT COUNT(*) FROM review r WHERE r.product_id = p.product_id) AS review_count FROM product p WHERE MATCH(p.product_name, p.product_corp) AGAINST(\u0026#39;クリスピー\u0026#39;) LIMIT 1, 20; ここではJOINを使用せず、Subqueryを使用しました。まず、productテーブルに全文検索結果を探索し、その結果に該当するreviewだけの件数を求めました。\n実行計画を確認したところ、全文検索の結果からlimit 20に近いレコード数を読み込みました。そしてここで総クエリの実行時間は**84（ms）**程度となりました。\n結果として、インデックスを修正するよりも、クエリの構造をジョインからサブクエリに変更することで実行時間が3000〜4000（ms）から100（ms）未満へ、30倍以上向上しました。\n要件の追加 # 実はもう一つ要件が存在していました。\n各productに対する上位3つのタグを表示します。ユーザーがレビュー作成時に自由に選択したタグの中で最も多く選ばれた3つを表示します。\nCREATE TABLE product_diet_tag ( product_diet_tag_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT, diet_tag_id BIGINT, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE INDEX idx_product_id ON product_diet_tag (product_id); CREATE TABLE diet_tag ( diet_tag_id BIGINT PRIMARY KEY AUTO_INCREMENT, diet_tag_name VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); diet_tagテーブルにはあらかじめ定められた10件のデータが存在し、product_diet_tagテーブルには2,000万件のデータが保存されています。\nDMLは上記のように記述しましたが、要件を満たすクエリを作成する途中で複雑すぎたため、diet_tagテーブルにあったdiet_tag_name列をproduct_diet_tagテーブルに非正規化しました。\nSELECT p.product_id, p.product_name, p.product_corp, (SELECT COUNT(*) FROM review r WHERE r.product_id = p.product_id) AS review_count, (SELECT GROUP_CONCAT(diet_tag_name) FROM (SELECT diet_tag_name FROM product_diet_tag pdt WHERE pdt.product_id = p.product_id GROUP BY diet_tag_name ORDER BY COUNT(diet_tag_name) DESC LIMIT 3) AS top3_diet_tag) AS top3_diet_tag_names FROM product p WHERE MATCH(p.product_name, p.product_corp) AGAINST(\u0026#39;クリスピー\u0026#39;) LIMIT 1, 20; 前述のクエリと同様に、ここでもSubqueryで解決しました。\nクエリの実行時間は平均的に**150〜250（ms）**となりました。各作業一つ一つには大きなコストはかかりませんが、複数の過程が累積すると大よそ2〜3倍程度時間が増えました。\nより良い方法はないのか？ # ここでクエリをさらに最適化する方法を探すこともできますが、果たしてこのような一発クエリが適切かどうか考える必要があります。\nここでの問題点は実行時間が増加した部分もありますが、サービスのビジネスロジックがクエリの中に溶け込んでいます。\n例えば「評点4点以上のレビューのみ集計する」や「上位タグを3つから5つに変更する」という要件が生じた場合、SQLのサブクエリも一緒に修正しなければなりません。 一方、このロジックをアプリケーション層（サービスまたはドメインレイヤー）に移して — 例えばproduct検索結果を受け取った後にレビュー数とタグ情報を別途照会して加工する方式で処理するなら、ビジネスロジックの変更がSQLではなくJavaコードのみに限定されてメンテナンスがより容易になります。\nまたテストコードを書くことも難しくなります。リポジトリのテストコードを作成するなら、一つのテストに検索の有無、レビュー件数、tag情報のすべてを反映したテストを作成しなければならず、要件が増えるほどリポジトリのテストコードも継続して修正していかなければなりません。\n次回は、クエリに溶け込んでいるビジネスロジックをドメイン領域とビジネス領域に移してクエリを改善していきます。\n次の記事： MySQL全文検索の限界、Elasticsearchの導入で検索API性能を改善する\n","date":"2024年10月31日","externalUrl":null,"permalink":"/ja/posts/mysql-left-join-to-subquery-optimization/","section":"Posts","summary":"MySQL全文検索APIの応答時間を6.85秒から84msへ、Left JoinをSubqueryに変更することで約30倍向上させた過程をまとめます。","title":"[MySQL] Left JoinをSubqueryに変更してクエリ性能を30倍向上させる","type":"posts"},{"content":"","date":"2024年10月31日","externalUrl":null,"permalink":"/ja/tags/fulltext-search/","section":"Tags","summary":"","title":"FullText Search","type":"tags"},{"content":"","date":"2024年10月23日","externalUrl":null,"permalink":"/ja/tags/ci/cd/","section":"Tags","summary":"","title":"CI/CD","type":"tags"},{"content":"","date":"2024年10月23日","externalUrl":null,"permalink":"/ja/tags/jenkins/","section":"Tags","summary":"","title":"Jenkins","type":"tags"},{"content":"","date":"2024年10月23日","externalUrl":null,"permalink":"/ja/tags/naver-cloud/","section":"Tags","summary":"","title":"Naver Cloud","type":"tags"},{"content":"","date":"2024年10月23日","externalUrl":null,"permalink":"/ja/tags/ncp/","section":"Tags","summary":"","title":"NCP","type":"tags"},{"content":" 第1編に続いて第2編を書いていきます。\n第1編はこちらのリンクをクリックしてください！\nJenkinsサーバーの作成 # NCPのServerに入り、Jenkins用として使う新しいサーバーを作成します。\n新しく作成したJenkinsサーバーではGitHubのwebhookリクエストを受け取ります。 これを許可するために、GitHub webhookのリクエストIPをACGに設定する必要があります。\nhttps://api.github.com/meta\n上のリンクにアクセスしてhooksを検索し、4つのIPv4アドレスをinboundルールに追加します。\nそして作成したJenkins用サーバーに接続します。\nhttps://www.jenkins.io/doc/book/installing/linux/#debianubuntu\n上のリンクを参考にしてJenkinsをインストールします。\nsudo systemctl status jenkins\nサーバーのインストール後に上のコマンドを実行して、緑色のactive(running)が表示されればJenkinsのインストールは成功です。\n後でJenkinsがプロジェクトをビルドするために、自分のプロジェクトで使っているJDKバージョンに合わせてJDKもインストールします。\n私はJenkinsインストール時と同様にJDK17を使用しているので、別途インストールはしません。\nObject Storageバケットの作成 # https://console.ncloud.com/objectStorage/objectStorageList\n上のリンクにアクセスしてバケットを作成します。\nJenkinsサーバーへのAWS CLIインストール # NCPのObject StorageはAWS S3の上に作られたプラットフォームなので、AWS S3のCLIと互換性があります。\nhttps://cli.ncloud-docs.com/docs/guide-objectstorage\n上のリンクを参考にしてインストールだけ行います。認証も一緒に行ってもいいですが、後でSourceDeployを使う時にもNCP認証情報が必要なので、後回しにします。\n参考として、Ubuntu22.04はデフォルトでインストールされているPythonが3.10バージョンのため、AWS CLIを使用するにはpython 3.8以下をインストールする必要があります。\nSourceDeployの作成 # SourceDeployを使用するには、SourceDeploy agentをデプロイ先のサーバーにインストールしておく必要があります。この部分は第1編で適切にインストールされているはずだと思います。\nSourceDeployに入り、デプロイプロジェクト作成をクリックします。\n好きな名前を指定して\nデプロイ環境設定でデプロイターゲットをAuto Scalingに選択します。\nそして第1編で作成したAuto Scaling Groupを選択します。 私の場合は以前に作っていたプロジェクトなので、Auto Scaling Group名が第1編とは異なる点をご了承ください:)\nデプロイプロジェクトが作成されたらクリックしてデプロイシナリオを作成します。\nゼロダウンタイムデプロイ戦略を選択します。Auto Scalingに適用するゼロダウンタイムデプロイ戦略は、基本（Rolling）またはブルー/グリーンデプロイ戦略のみ可能です。カナリアデプロイ戦略はncloud kubernetesで可能だと思います。\n詳細はこちらの公式ドキュメントリンクをクリックしてください！\nそしてデプロイ後に既存のAuto Scaling Groupをどうするかを選択できます。便宜上、維持するようにします。\nデプロイファイルを取得する場所を選択します。ここではJenkinsがビルド後にObject Storageにビルドされたファイルをアップロードするので、Object Storageを選択します。\nそしてデプロイファイルを選択するのですが、\n設定していると、デプロイファイルを事前にアップロードしておく必要があったため、zipに圧縮したjarファイルをあらかじめアップロードしておきました。\nzip -r nutridiary.zip build/libs/nutridiary-0.0.1-SNAPSHOT.jar 圧縮コマンドは上のコマンドを参考にしてください！\n各ステップごとのコマンドを説明すると、\n「デプロイ前実行」はブルーグリーンデプロイ方式によって新しく作成されたAuto Scaling Groupのサーバーに適用されます。サーバーが新しく作成されたとき、以前作っておいたinit scriptが実行されて既存のデプロイ前のjarファイルが実行されているはずです。そのためこれを停止する必要があり、新しいjarファイルとの競合を防ぐために既存のjarファイルを削除します。\nkill -15 $(pgrep -f nutridiary-0.0.1-SNAPSHOT.jar) \u0026amp;\u0026amp; rm /root/deploy/nutridiary-0.0.1-SNAPSHOT.jar 2番目の「ファイルデプロイ」はObject Storageからサーバーへファイルを転送するステップです。Object StorageでzipF圧縮されたフォルダを基準に自分のjarファイルの位置を正確に記入し、サーバーに保存するjarファイルの位置を指定してください。\n3番目の「デプロイ後実行」で新しいjarファイルをバックグラウンドで実行します。\nnohup java -jar /root/deploy/nutridiary-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod \u0026gt; /root/deploy/output.log 2\u0026gt;\u0026amp;1 \u0026amp; 上のコマンドを入力します。\n最後にデプロイシナリオを作成します。\nさて、Jenkinsに戻りましょう！\nGitHub Access Tokenの発行 # JenkinsサーバーからGitHubリポジトリにアクセスするにはaccess tokenを発行する必要があります。 またはJenkinsサーバーでSSHキーを生成して認証する方法もありますが、ここではaccess token形式で行います。\n自分のGitHubアカウントのSettingsでDeveloper Settingsをクリックします。\n右側のGenerate new tokenボタンをクリックします。\naccess token名を指定して、repoとadmin:repo_hookを選択します。\n下部のGenerate tokenを選択すると、上のトークン値が表示されます。\nトークン値は一度しか表示されないので安全な場所にコピーしておき、 JenkinsでGitHub Repositoryにアクセスできるようにcredentialを登録する必要があります。\nJenkinsへのGitHubトークン登録 # Webブラウザで「JenkinsサーバーのパブリックIP:8080」にアクセスします。\nそして上の赤い部分に表示されたURLを参考にして、Jenkinsの初期パスワードをコピーして貼り付けます。\n私は最初のボタンを選択し、必要なプラグインをすべてインストールします。\nその後会員登録を進めて、\nJenkins画面にアクセスしたら「Jenkinsの管理」をクリックして「Credential」をクリックします。\n赤いボックスのglobalをクリックします。\n右上の「Add Credential」をクリックします。\nkindはUsername with passwordを選択して、UsernameにGitHubのID、Passwordに発行したトークン値を貼り付けます。 ID値は任意に指定します。\n追加でNCP情報もCredentialに事前登録します # リンクにアクセスしてAccess keyとSecret keyをコピーします。\nそしてkindはSecret textを選択して、SecretにそれぞれコピーしたNCPの値を入力し、IDには任意に決めます。\n後でJenkins pipelineスクリプトでそのIDで環境変数を設定することを事前に参考にしてください！\nすべて登録すると上の画像のようになります。\n実際にはこのようにNCPの情報をJenkinsサーバーに保存することは危険なことがあります。もしJenkinsサーバーがハッキングされたら、Access keyが全部漏洩してしまいます。\nAWSの場合はAMIベースのRoleを持ってJenkinsサーバーからAWSサービスにアクセスすることを許可できます。しかしNCPはお問い合わせの結果、そのような機能はサポートされていないようですね…\nGitHub webhookの設定 # Jenkinsの管理に入り、Pluginsをクリックします。\nGitHub integrationプラグインを検索して選択し、インストールします。\nGitHubプロジェクトのリポジトリのSettingsに入ります。 そしてWebhooksをクリックしてAdd webhookをクリックします。\nPayload URLに自分のJenkinsサーバーのアドレスと後ろに「/github-webhook/」を追加します。\nこれで自分のGitHubリポジトリにコミットが発生すると、Jenkinsサーバーにwebhookが送信されます。\nJenkinsパイプラインの構成 # Jenkinsサーバーにアクセスして「新しいアイテム」をクリックします。\n名前を指定してPipelineを選択します。\n自分のGitHubリポジトリのアドレスを入力します。 入力しなくても構いませんが、入力しておくと、 右の画像のようにJenkinsプロジェクトのタブからプロジェクトのGitHubリポジトリに移動するボタンが生成されます。\n各項目を上のように構成して、Credentialは先に登録したものを使用します。\nビルドするブランチはmainと記入します。\n最後にScript PathはJenkinsfileと記入します。\nJenkinsスクリプトの作成 # pipeline { agent any environment { API_ACCESS_KEY = credentials(\u0026#39;ncp-api-access-key\u0026#39;) API_SECRET_KEY = credentials(\u0026#39;ncp-api-secret-key\u0026#39;) } stages { stage(\u0026#39;Prepare\u0026#39;) { steps { echo \u0026#39;Preparing...\u0026#39; git branch: \u0026#39;main\u0026#39;, url: \u0026#39;https://github.com/f-lab-edu/nutri-diary.git\u0026#39; } post { success { echo \u0026#39;Preparation completed successfully!\u0026#39; } failure { echo \u0026#39;Preparation failed!\u0026#39; } } } stage(\u0026#39;Build\u0026#39;) { steps { sh \u0026#39;./gradlew clean build\u0026#39; } post { success { echo \u0026#39;Build completed successfully!\u0026#39; } failure { echo \u0026#39;Build failed!\u0026#39; } } } stage(\u0026#39;Upload\u0026#39;) { steps { sh \u0026#39;chmod +x ./script/upload.sh\u0026#39; sh \u0026#39;./script/upload.sh\u0026#39; } post { success { echo \u0026#39;Upload completed successfully!\u0026#39; } failure { echo \u0026#39;Upload failed!\u0026#39; } } } stage(\u0026#39;Deploy\u0026#39;) { steps { sh \u0026#39;chmod +x ./script/deploy.sh\u0026#39; sh \u0026#39;./script/deploy.sh\u0026#39; } post { success { echo \u0026#39;Deploy completed successfully!\u0026#39; } failure { echo \u0026#39;Deploy failed!\u0026#39; } } } } post { success { echo \u0026#39;Build, Test, and Deploy completed successfully!\u0026#39; } failure { echo \u0026#39;Build or Deploy failed!\u0026#39; } } } 最初の行ではJenkinsに登録しておいたNCPのCredential情報を環境変数に設定します。\nそして各ステップごとにビルド後にupload.sh、deploy.shを実行します。 （upload.sh、deploy.shスクリプトは以下にあります）\nJenkins pipelineスクリプトは上のように作成し、ファイル名は「Jenkinsfile」（拡張子なし）と指定します。 そしてJenkinsfileはプロジェクトの最上位に置き、upload.sh、deploy.shはscriptフォルダを作ってその中に入れます。\nupload.sh # #!/bin/bash zip -r nutridiary.zip build/libs/nutridiary-0.0.1-SNAPSHOT.jar aws configure set aws_access_key_id ${API_ACCESS_KEY} aws configure set aws_secret_access_key ${API_SECRET_KEY} aws --endpoint-url=https://kr.object.ncloudstorage.com s3 rm s3://${your_bucket_name}/nutridiary.zip aws --endpoint-url=https://kr.object.ncloudstorage.com s3 cp nutridiary.zip s3://${your_bucket_name}/nutridiary.zip まずビルドされたファイルを圧縮します。\nこのときファイルの圧縮パスとSourceDeployコマンドのパスに注意してください！\n2〜3番のコマンドは環境変数に登録したaccesskeyとsecretkeyで認証を行います。\n4〜5番のコマンドはそれぞれ既存のObject Storageにあるzipファイルを削除して、新しいzipファイルをアップロードします。\ndeploy.sh # #!/bin/bash # Naver Cloud Platform API 基本設定 SOURCEDEPLOY_API_URL=\u0026#34;https://vpcsourcedeploy.apigw.ntruss.com\u0026#34; PROJECT_NAME=\u0026#34;nutridiary\u0026#34; # ヘッダー設定（API認証） get_auth_headers() { local method=\u0026#34;$1\u0026#34; local uri=\u0026#34;$2\u0026#34; local api_timestamp=$(perl -MTime::HiRes -e \u0026#39;printf(\u0026#34;%d\\n\u0026#34;, Time::HiRes::time()*1000)\u0026#39;) local signature=$(generate_signature \u0026#34;${method}\u0026#34; \u0026#34;${uri}\u0026#34; \u0026#34;${api_timestamp}\u0026#34;) # ヘッダーを明示的に配列で管理してcurlに渡す headers=( -H \u0026#34;x-ncp-apigw-timestamp: ${api_timestamp}\u0026#34; -H \u0026#34;x-ncp-iam-access-key: ${API_ACCESS_KEY}\u0026#34; -H \u0026#34;x-ncp-apigw-signature-v2: ${signature}\u0026#34; ) } # Signature生成関数（API呼び出しに必要） generate_signature() { local method=\u0026#34;$1\u0026#34; local uri=\u0026#34;$2\u0026#34; local time_stamp=\u0026#34;$3\u0026#34; local nl=$\u0026#39;\\\\n\u0026#39; SIG=\u0026#34;${method}\u0026#34;\u0026#39; \u0026#39;\u0026#34;${uri}\u0026#34;${nl} SIG+=\u0026#34;${time_stamp}\u0026#34;${nl} SIG+=\u0026#34;${API_ACCESS_KEY}\u0026#34; SIGNATURE=$(echo -n -e \u0026#34;${SIG}\u0026#34;|iconv -t utf8 |openssl dgst -sha256 -hmac ${API_SECRET_KEY} -binary|openssl enc -base64) echo \u0026#34;${SIGNATURE}\u0026#34; } # 1. プロジェクトIDを取得 get_project_id() { local project_name=\u0026#34;$1\u0026#34; local uri=\u0026#34;/api/v1/project?projectName=${project_name}\u0026#34; # ヘッダー準備 get_auth_headers \u0026#34;GET\u0026#34; \u0026#34;${uri}\u0026#34; # プロジェクト一覧を取得 response=$(curl -s -X GET \u0026#34;${SOURCEDEPLOY_API_URL}${uri}\u0026#34; \u0026#34;${headers[@]}\u0026#34;) # プロジェクトIDをパース project_id=$(echo \u0026#34;${response}\u0026#34; | jq -r \u0026#39;.result.projectList[0].id\u0026#39;) # エラー処理: project_idがない場合はエラーメッセージを出力 if [[ -z \u0026#34;${project_id}\u0026#34; || \u0026#34;${project_id}\u0026#34; == \u0026#34;null\u0026#34; ]]; then echo \u0026#34;Error: プロジェクトIDが見つかりません。\u0026#34; exit 1 fi # project_idを返す echo \u0026#34;${project_id}\u0026#34; } # 2. ステージIDを取得（プロジェクトIDが必要） get_stage_id() { local project_id=\u0026#34;$1\u0026#34; local uri=\u0026#34;/api/v1/project/${project_id}/stage\u0026#34; # ヘッダー準備 get_auth_headers \u0026#34;GET\u0026#34; \u0026#34;${uri}\u0026#34; # ステージ一覧を取得 response=$(curl -s -X GET \u0026#34;${SOURCEDEPLOY_API_URL}${uri}\u0026#34; \u0026#34;${headers[@]}\u0026#34;) # ステージIDをパース stage_id=$(echo \u0026#34;${response}\u0026#34; | jq -r \u0026#39;.result.stageList[0].id\u0026#39;) # エラー処理: stage_idがない場合はエラーメッセージを出力 if [[ -z \u0026#34;${stage_id}\u0026#34; || \u0026#34;${stage_id}\u0026#34; == \u0026#34;null\u0026#34; ]]; then echo \u0026#34;Error: ステージIDが見つかりません。\u0026#34; exit 1 fi # stage_idを返す echo \u0026#34;${stage_id}\u0026#34; } # 3. シナリオIDを取得 get_scenario_id() { local project_id=\u0026#34;$1\u0026#34; local stage_id=\u0026#34;$2\u0026#34; local uri=\u0026#34;/api/v1/project/${project_id}/stage/${stage_id}/scenario\u0026#34; # ヘッダー準備 get_auth_headers \u0026#34;GET\u0026#34; \u0026#34;${uri}\u0026#34; # シナリオ一覧を取得 response=$(curl -s -X GET \u0026#34;${SOURCEDEPLOY_API_URL}${uri}\u0026#34; \u0026#34;${headers[@]}\u0026#34;) # シナリオIDをパース scenario_id=$(echo \u0026#34;${response}\u0026#34; | jq -r \u0026#39;.result.scenarioList[0].id\u0026#39;) # エラー処理: scenario_idがない場合はエラーメッセージを出力 if [[ -z \u0026#34;${scenario_id}\u0026#34; || \u0026#34;${scenario_id}\u0026#34; == \u0026#34;null\u0026#34; ]]; then echo \u0026#34;Error: シナリオIDが見つかりません。\u0026#34; exit 1 fi # scenario_idを返す echo \u0026#34;${scenario_id}\u0026#34; } # 4. デプロイ開始リクエスト start_deploy() { local project_id=\u0026#34;$1\u0026#34; local stage_id=\u0026#34;$2\u0026#34; local scenario_id=\u0026#34;$3\u0026#34; local uri=\u0026#34;/api/v1/project/${project_id}/stage/${stage_id}/scenario/${scenario_id}/deploy\u0026#34; # ヘッダー準備 get_auth_headers \u0026#34;POST\u0026#34; \u0026#34;${uri}\u0026#34; response=$(curl -s -X POST \u0026#34;${SOURCEDEPLOY_API_URL}${uri}\u0026#34; \u0026#34;${headers[@]}\u0026#34;) # レスポンスからhistoryIdを抽出 history_id=$(echo \u0026#34;${response}\u0026#34; | jq -r \u0026#39;.result.historyId\u0026#39;) # エラー処理: history_idがない場合はエラーメッセージを出力 if [[ -z \u0026#34;${history_id}\u0026#34; || \u0026#34;${history_id}\u0026#34; == \u0026#34;null\u0026#34; ]]; then echo \u0026#34;Error: デプロイリクエストに失敗しました。\u0026#34; exit 1 fi # デプロイ成功 echo \u0026#34;デプロイが開始されました。History ID: ${history_id}\u0026#34; } # 実行フロー PROJECT_ID=$(get_project_id \u0026#34;${PROJECT_NAME}\u0026#34;) STAGE_ID=$(get_stage_id \u0026#34;${PROJECT_ID}\u0026#34;) SCENARIO_ID=$(get_scenario_id \u0026#34;${PROJECT_ID}\u0026#34; \u0026#34;${STAGE_ID}\u0026#34;) ## デプロイ開始 start_deploy \u0026#34;${PROJECT_ID}\u0026#34; \u0026#34;${STAGE_ID}\u0026#34; \u0026#34;${SCENARIO_ID}\u0026#34; https://api.ncloud-docs.com/docs/devtools-sourcedeploy\ndeploy.shで実行するSourceDeploy APIの詳細情報は上のリンクを参考にしてください！\n追加で上のスクリプトではJSON値をパースするために「jq」ライブラリを使用しているので、Jenkinsサーバーに接続してapt-get install jqでインストールしてください！\nまとめ # これですべて完了しました。テストするためにGitHubプロジェクトに任意のコミットを適用すると、NCPのSourceDeployを活用したゼロダウンタイムデプロイまで適用されるはずです。\nもし不足している部分があればコメントで教えてください！\n","date":"2024年10月23日","externalUrl":null,"permalink":"/ja/posts/ncp-infra-jenkins-cicd-sourcedeploy/","section":"Posts","summary":"NCPでJenkinsサーバーを構築し、SourceDeployと連携してCI/CDパイプラインとゼロダウンタイムデプロイを構成する方法をまとめます。","title":"NCP（Naver Cloud Platform）を使ったインフラ構成 第2編. JenkinsとSourceDeployを使ったCI/CDとゼロダウンタイムデプロイの構成","type":"posts"},{"content":"","date":"2024年10月23日","externalUrl":null,"permalink":"/ja/tags/sourcedeploy/","section":"Tags","summary":"","title":"SourceDeploy","type":"tags"},{"content":" CI/CDと無停止デプロイのインフラをNCPを使って構成しました。公式ドキュメントや各種ブログも参考にしましたが、NCP関連の情報がAWSと比べて少ないため、NCPでインフラを構築される方のお役に立てればと思います。\n特に、Dockerなしの伝統的なハイパーバイザーベースのインフラを構成したい方の参考になればと思い、NCPのAuto ScalingとLoad Balancer、SourceDeploy、Object Storage CLIをJenkinsで使う方法を構成してみます。\n1. VPCの構成 # まず最初に行うべきことはVPCの構成です。\nVPCのIPアドレス範囲は任意で172.16.0.0/16に設定しました。\nVPCを作成すると自動的にNetwork ACLが作成されます。一種のファイアウォールのような役割を果たし、デフォルトではすべてのトラフィックを許可しているため、特に設定は変更しません。\n2. Public Subnetの構成 # 次にVPCを細分化してサブネットを作成します。 セキュリティのためPrivateサブネットを構成してもよいですが、利便性のためにまずPublicサブネットを作成します。VPCのIPアドレス範囲172.16.0.0/16を細分化して、サブネットの範囲は172.16.1.0/24に設定しました。\n3. サーバーの作成 # いよいよサーバーを作成する番ですが\u0026hellip; ここで作成するサーバーは、後でAuto Scaling Groupのためのサーバーイメージを作るために仮のサーバーとして作成します。\nサーバーイメージのOSはお好みのものを選択してください。私はここでUbuntu 22.04を選択しました。\nVPCとSubnetは先ほど作成したものを選択します。\n認証キーを作成してダウンロードします。\nACGを設定するために、ACGを作成します。\n私はすでに作成済みのACGがあったため、既存のものを使用しました。\nここで重要なのは、InboundルールとOutboundルールを設定する必要があることです。 ACGのデフォルトポリシーはNetwork ACLと異なり、すべてのトラフィックをブロックします。 そのため、許可するIPアドレス範囲を設定します。 私はPublic Subnetの範囲である172.16.0.0/16と自分のローカルIPアドレスを、InboundとOutbound両方に同じように設定しました。\nACGを作成して選択したら、最終的にサーバーを作成します。\n4. デプロイするプロジェクトの準備 # @RestController public class HealthCheckController { @GetMapping(\u0026#34;/\u0026#34;) public ResponseEntity\u0026lt;String\u0026gt; healthCheck() { return new ResponseEntity\u0026lt;\u0026gt;(\u0026#34;OK\u0026#34;, HttpStatus.OK); } } ここで注意すること # Springプロジェクトでは、ロードバランサーのヘルスチェック用のAPIが必ず必要です。私は上記のエンドポイントを別途構成しました。URLとして設定した「/」のアドレスを覚えておき、後でロードバランサーを設定するときに登録する必要があります。\n自分のローカルコンピューターでSpringプロジェクトをビルドします。\n次にビルドしたファイルを作成したサーバーに転送する必要がありますが… このとき、作成したNCPサーバーの管理者パスワードが必要です。\nNCPのサーバーを作成するときにダウンロードされた???.pemファイルを貼り付けます。\nでは、ファイルを転送してみましょう。\nscp ${your_project_path}/${file_name} 管理者名@パブリックIP:仮想サーバーに保存する場所\n上記のコマンドを自分の状況に合わせて入力します。仮想サーバーのアドレスは、プロジェクト作成時に設定したパブリックIPアドレスです。\n右下に100%が表示されれば正常に転送されており、何も表示されなければ転送に失敗しているので、もう一度確認してみてください。\n5. 作成したサーバーに接続 # 次に作成したサーバーのパブリックIPアドレスを使ってサーバーに接続します。\nターミナルでssh root@${パブリックIPアドレス}を入力します。\nファイルを確認すると、ローカルPCから転送したプロジェクトファイルがあることを確認できます。\nテストを兼ねてプロジェクトファイルを実行してみましたが、Javaがインストールされていないと表示されます。 自分のプロジェクトで設定したJavaをインストールして、テストを兼ねて実行してみます。\nSpringプロジェクトであればデータベース接続が必要になりますが、この部分は別途設定しないとエラーが出ます..!\n追加：SourceDeployを使用するために # https://guide.ncloud-docs.com/docs/devtools-devtools-4-4#2%EC%84%A4%EC%B9%98%EB%AA%85%EB%A0%B9%EC%96%B4%EC%9E%91%EC%84%B1\n後でSourceDeployを使用するために必ず必要な部分なので、上記のリンクにアクセスしてSourceDeploy用のAgentをインストールします！\n6. ロードバランサーの作成 # プロジェクトではhttpを使った通信を行うため、アプリケーションロードバランサーを選択します。\nロードバランサーには既存のPublicサブネットとは別のロードバランサー用サブネットが必要です。サブネット作成ボタンを押して、先ほどと同様にロードバランサー用のサブネットを作成します。\nロードバランサーが処理するHTTPプロトコルのポート番号80を開放します。\n次のステップに進むとTarget Groupが必要です。ボタンをクリックしてTarget Groupを作成します。\nプロトコルはHttpに設定し、Springプロジェクトは主に8080ポートを使用するので上記のように設定します。\n次にヘルスチェックを設定します。 ヘルスチェックもhttpプロトコルを選択し、Springプロジェクトの8080ポートを選択します。 ここでURL Pathにはプロジェクトのヘルスチェック用エンドポイントのアドレスを入力します。\nこの記事に従ってきた場合、先ほど作成したサーバーが1つ表示されるはずです。 しかし、Auto Scaling GroupにロードバランサーをAttachするので、ここでは選択せずに進んでも構いません。 この部分は後でAuto Scaling Groupを作成して設定します。\n6. Auto Scalingを適用してみましょう # 6.1 Launch Configurationの作成 # JavaとプロジェクトファイルがセットアップされたNCPサーバーを選択して「マイサーバーイメージの作成」を行います。\nAuto Scalingを使用するには、テンプレートの役割を果たすLaunch Configurationを作成する必要があります。\nマイサーバーイメージタブに入り、先ほど作成したサーバーイメージを選択します。\nこのとき、Springプロジェクトを実行するためのinit Scriptが必要なので、init Scriptを作成します。\nnohup java -jar /root/deploy/nutridiary-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod \u0026gt; /root/deploy/output.log 2\u0026gt;\u0026amp;1 \u0026amp; Springプロジェクトをバックグラウンドで実行するスクリプトを記述します。\nLaunch Configurationまで作成できたら、次にAuto Scaling Groupを作成します。\n6.2 Auto Scaling Groupの作成 # 名前を設定し、これまで作成したVPC、Subnet、そして希望するインスタンスの容量を設定します。そしてヘルスチェックの種類にはロードバランサーを選択し、先ほど作成したTarget Groupを選択します。\nスケールアウトポリシーや通知はお好みの設定にしてください。私はとりあえず後で設定するとしてすべて選択しました。\n7. ロードバランサー Target Groupの接続 # 次に作成されたAuto Scaling Groupのインスタンスを、ロードバランサーのTarget Group設定に入って追加します。\nしばらくしてヘルスチェックを確認したときに、Target StatusとTarget Responseが上記のように表示されれば接続完了です。\nそしてロードバランサーの接続情報のドメインにAPIリクエストを送ると、正常に動作することが確認できます。（パブリックIPアドレスでアクセスしても構いません。）\nNCPを使ったインフラ構築 第2回. JenkinsとSourceDeployを使ったCI/CDと無停止デプロイの構成\n","date":"2024年9月25日","externalUrl":null,"permalink":"/ja/posts/ncp-infra-auto-scaling-load-balancer/","section":"Posts","summary":"NCPでAuto ScalingとLoad Balancerを構成して、CI/CDと無停止デプロイのインフラを構築するプロセスをまとめます。","title":"NCP（Naver Cloud Platform）を使ったインフラ構築 第1回. Auto ScalingとLoad Balancerの構成","type":"posts"},{"content":" 要点まとめ # H2とMySQLはJSONタイプのデータをJDBCドライバーレベルでそれぞれ異なるJavaタイプにマッピングします。H2はbyte[]、MySQLはStringです。この差異のために、MySQL向けに書いたString\u0026lt;-\u0026gt;オブジェクトConverterがH2のテスト環境では動作しません。\n問題の状況 # プロジェクトをテストしていたところ、以下の画像のようにConverterが見つからないというエラーが発生しました。\n現在Spring Data JDBCを使用しており、Converterが正しく登録されていないのかどうか確認しましたが、Converterは正常に登録されていました。\n状況をより具体的に把握するため、Data JPA・Data JDBC・H2・MySQLを切り替えながら確認したところ、このエラーはMySQLで開発しているときには発生せず、H2に接続したテストコードの実行時のみ発生していました。\nData JPAを使用する場合:\nError attempting to apply AttributeConverter … Caused by: java.lang.RuntimeException: com.fasterxml.jackson.databind.exc.MismatchedInputException\nData JDBCを使用する場合:\nNo converter found capable of converting from type [byte[]] to type ~~\nというエラーメッセージが表示されます。\n状況を再現するために、以下のようなコードを用意しました。\npublic class Member { @Id @Column(\u0026#34;MEMBER_ID\u0026#34;) private Long id; private String username; private Address address; public Member(String username, Address address) { this.username = username; this.address = address; } } // Data JPAのConverterとData JDBCのConverterで // JacksonライブラリのObjectMapper.writeValueAsString()を使って // Address \u0026lt;-\u0026gt; String の変換を行う public class Address { private String street; private String city; private String state; private String zip; public Address(String street, String city, String state, String zip) { this.street = street; this.city = city; this.state = state; this.zip = zip; } } -- AddressオブジェクトをJSONタイプとして保存 CREATE TABLE MEMBER ( MEMBER_ID BIGINT PRIMARY KEY AUTO_INCREMENT, USERNAME VARCHAR(255) NOT NULL, ADDRESS JSON ); 結論から言うと、これはH2データベースがJSONタイプを処理する方式がMySQLと異なることで発生するエラーでした。\nH2とMySQLのJSONタイプ処理の違い # H2のJSONタイプ処理 # -- String リテラルとして保存 → エスケープされたJSON String形式で保存される INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(\u0026#39;テスト名\u0026#39;, \u0026#39;{\u0026#34;city\u0026#34;:\u0026#34;seoul\u0026#34;, \u0026#34;street\u0026#34;:\u0026#34;nowon\u0026#34;}\u0026#39;); -- FORMAT JSON を明示 → きれいなJSON Object形式で保存される INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(\u0026#39;テスト名\u0026#39;, JSON \u0026#39;{\u0026#34;city\u0026#34;:\u0026#34;seoul\u0026#34;, \u0026#34;street\u0026#34;:\u0026#34;nowon\u0026#34;}\u0026#39;); H2は上のように1つ目のクエリを実行すると、MEMBER_ID 3番の行のようにADDRESSカラムにエスケープされたJSON String形式のデータが保存されます。\n（以下ではMEMBER_IDを省略して1番・2番・3番と呼ぶことにします。）\nそして2つ目のクエリのようにJSONというフォーマットを指定すると、1番・2番の行のようにきれいな形式で保存されます。\n3番のようなエスケープされたJSON String形式をH2ではJSON Stringと呼んでいます。\nH2ではこの動作について以下のように説明しています。\nFORMAT JSONなしでStringを保存すると、文字列リテラルの中にJSON文字列が入った形式に暗黙的に変換されます。（\u0026rsquo;text\u0026rsquo; -\u0026gt; JSON \u0026lsquo;\u0026ldquo;text\u0026rdquo;\u0026rsquo;）\nJSONテキストを含むStringリテラルを渡したい場合は、明示的にFORMAT JSONを指定する必要があります。\nINSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ?); -- 2番目のバインド変数にFORMAT JSONを明示 INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ? FORMAT JSON); Data JPAやData JDBCを使ってデータアクセスメソッドを実行すると、1つ目のクエリのようにバインド変数形式のクエリが発行されることを見かけたことがあると思います。\n1つ目のクエリでは3番の行のようにエスケープされたJSON String形式でデータが保存されます。\n2つ目のクエリはバインド変数の後にFORMAT JSONを明示しています。この場合、1番・2番の行のようにきれいに保存されます。\n追記として、H2のドキュメントには以下のように記載されています。\nTo set a JSON value with java.lang.String in a PreparedStatement use a FORMAT JSON data format (INSERT INTO TEST(ID, DATA) VALUES (?, ? FORMAT JSON)) or use setObject(parameter, jsonText, H2Type.JSON) instead of setString().\n— H2 Documentation\nまとめると、純粋なJDBCのPreparedStatementを直接使う場合は、以下の2つの方法でこの問題を解決できます。\n// 方法1: SQLにFORMAT JSONを明示 PreparedStatement ps = conn.prepareStatement( \u0026#34;INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ? FORMAT JSON)\u0026#34; ); ps.setString(2, jsonString); // 方法2: setObject()にタイプヒントを指定 PreparedStatement ps = conn.prepareStatement( \u0026#34;INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ?)\u0026#34; ); ps.setObject(2, jsonString, H2Type.JSON); しかしSpring Data JPAやData JDBCは、PreparedStatementをフレームワークが内部的に生成しパラメーターバインディングまで管理するため、開発者が特定のパラメーターに対してFORMAT JSONやsetObject()のタイプヒントを直接指定することができません。結局Spring Dataの環境では上記の2つの方法はいずれも適用しづらいです。\n以下のSQLログを見ると、Spring DataはAddressの値をjava.lang.String型としてバインドしています。そのためH2ではエスケープされたJSON String形式で保存されます。\nそしてこのデータをSELECTして取得すると、以下のようにエスケープされた文字列リテラルがさらにもう一重包まれた形になっています。\nMySQLのJSONタイプ処理 # INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(\u0026#39;テスト名\u0026#39;, \u0026#39;{\u0026#34;city\u0026#34;:\u0026#34;seoul\u0026#34;, \u0026#34;street\u0026#34;:\u0026#34;nowon\u0026#34;}\u0026#39;); INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ?); MySQLでJSONタイプを扱う方法はいくつかありますが、上記のクエリのように特定のフォーマット形式を指定しなくても、以下の画像のようにエスケープされたString形式ではなくきれいなJSON形式でDBに保存されます。\nそのためSpring Dataの技術を使っても問題ありません。\nbyte[] vs String — 保存方式が異なる理由 # まず、エスケープ処理されたデータもJSONタイプとして正しいデータです。実際に通常のString（varchar）はJSONとして有効ではないため、JSONタイプを指定したH2のカラムにはINSERTできません。\nH2ではJSONをbyte[]またはStringとして扱います。保存した結果にも差が出て、byte[] でH2のJSONカラムに保存するとJSON Object（エスケープされていないきれいな形式）が保存されます。\nしかし、AddressオブジェクトのようにJacksonライブラリのObjectMapper.writeValueAsString()を使ってStringに変換し、JSONテキスト形式のリテラルをH2のJSONカラムに保存すると、Stringリテラルにエスケープ処理が施されて保存されます。\nこの差異がSELECT時のConverterエラーの直接的な原因となります。Data JDBCとData JPAのそれぞれでどのように問題が発生するか見ていきましょう。\nData JDBCにおけるエラーの原因と解決 # エラーの原因 — H2ドライバーのbyte[]マッピング # Data JDBCはConverter（DBのソース値 -\u0026gt; Address）を選ぶとき、各DBドライバーが提供するタイプのフォーマットを基にConverterを選択します。つまり、H2では保存された値とJava Objectのマッピングにおいて、JSONタイプはbyte[]型にマッピングします。\nH2ドライバー内部のValueToObjectConverterを見ると、JSONタイプはvalue.getBytes()を呼び出してbyte[] 型として返していることが確認できます。\nそしてData JDBCはクエリ実行後に得た結果（H2 ResultSet）をエンティティに変換しようとするときにConverterが必要であり、適切なConverterのタイプを探す際にH2 ResultSetに保存された値のタイプをヒントとして取得します。H2はこの過程でJSON値の場合にbyte[]型を渡します。\n図で大まかに表すと以下のような流れです。（実際よりかなり単純化していますので、理解のための参考として見てください。）\nそのため、以下のようなString -\u0026gt; Address Converterを登録していても、それが見つからないというエラーが発生します。\n// Data JDBCのConverter @Slf4j @RequiredArgsConstructor @ReadingConverter public class JsonToAddressConverter implements Converter\u0026lt;String, Address\u0026gt; { private final ObjectMapper objectMapper; @Override public Address convert(String source) { try { return objectMapper.readValue(source, Address.class); } catch (IOException e) { log.info(\u0026#34;JSON型をAddressオブジェクトへの変換に失敗しました。\u0026#34;); throw new RuntimeException(e); } } } H2はJSONデータをbyte[]にマッピングするため、INSERTのときにString型ではなくbyte[]でマッピングすればJSONタイプの読み書きが両方正常に動作します。しかしこの場合、MySQLではINSERT時にエラーが発生します。そしてH2のためだけにJSONタイプとして扱われるオブジェクトをbyte[]に変えることは適切な解決策とはいえません。\nMySQLでエラーが発生しない理由 # MySQLはこの過程でJSON値の場合にString型をマッピングします。 そのため例外は発生しません。（追記として、INSERTのときもString型で入れる必要があります。そのためbyte[]型はエラーになります。）\n解決方法 # Data JDBCではまだこの問題が解決されていないようです。個人的にオープンソースへ貢献したいという気持ちから原因を調べましたが、\nデータをINSERTするとき、H2はJSONフォーマットを指定する必要があります。そうでなければbyte[]型に変換して保存する必要がありますが → MySQLではエラーが発生します H2とMySQLのドライバーはJSONタイプを互いに異なるフォーマットで渡します → マッチングするConverterのタイプが変わります。H2はbyte[]とオブジェクト間の変換、MySQLはStringとオブジェクト間の変換戦略を設定する必要があります このような複数のDBエンジンの型変換の差異を汎用的に処理するためには、単純にソースコードを数行修正するだけでは解決できないことが分かりました。\nそのため、現在選択できる最善の方法は3つあります。\n1つ目は、Spring Boot Profileを分離して、テストコードではH2専用Converterを登録する方法を適用することです。このConverterはbyte[] ↔ Addressを変換します。\n2つ目は、H2とMySQLのJSONタイプに設定したフィールドをVARCHARまたはTEXTタイプに変更することです。TEXTタイプを指定したカラムにJSON形式の文字列を保存することで、上記の問題を解決できます。ただしこの方法ではDBレベルのJSONバリデーションが失われます。JSON_EXTRACT()のようなJSON関数も使えなくなりますが、アプリケーションでJSONデータに対するDBクエリ（検索、フィルタリングなど）を行う必要がなく、単純にシリアライズされたオブジェクトを保存・取得する用途のみであれば、このトレードオフは十分許容できます。\n3つ目は、Dockerコンテナ上でテストコードを実行する方法もあります。TestContainersを使えばDockerコンテナ上でMySQL DBサーバーを起動し、その上でテストコードを実行できます。この方法は本番環境とテスト環境を一致させられる点で優れていますが、テストを一度実行するのに時間がかかるというデメリットがあります。\n私の場合は2つ目の方法を選択しました。1つ目のProfile分離でH2用Converterを作成する方法は、今後の管理が複雑になる恐れがあると判断したためです。JSONに変換して管理すべきクラスが増えるほど、それらすべてに対して別途テスト用Converterを用意しなければなりません。プロジェクトでJSONカラムに対してDBレベルのクエリが不要だったため、H2とMySQLの両方で差異を妥協できるTEXTタイプで保存する2つ目の方法を選択しました。\nData JPAにおけるエラーの原因と解決 # エラーの原因 — エスケープされた文字列のデシリアライズ失敗 # JPAはConverterを選ぶとき、Data JDBCとは異なる方法で動作します。Data JDBCは接続しているDBのドライバーにタイプを問い合わせるのに対し、JPAは変換しようとするタイプをあらかじめ把握しています。そしてそのタイプに合わせてResultSetから値を取り出します。\n図で簡単に表すと以下のようになります。\nこのためData JPAはData JDBCと異なり、Converterが見つからないというエラーは発生しません。JPAはAttributeConverter\u0026lt;Address, String\u0026gt;でDBカラムのタイプがStringであることを既に知っているので、ResultSetからgetString()を呼び出して値を取得します。\nしかしDBから読み取った値をString値に変換した後、以下のようなConverterを使うことになります。\n// Data JPAのConverter @Slf4j @RequiredArgsConstructor @Converter(autoApply = true) public class AddressConverter implements AttributeConverter\u0026lt;Address, String\u0026gt; { private final ObjectMapper objectMapper; @Override public String convertToDatabaseColumn(Address address) { try { return objectMapper.writeValueAsString(address); } catch (Exception e) { log.info(\u0026#34;AddressタイプをJsonに変換できません。\u0026#34;); throw new RuntimeException(e); } } @Override public Address convertToEntityAttribute(String source) { try { return objectMapper.readValue(source, Address.class); } catch (Exception e) { log.info(\u0026#34;JsonタイプをAddressタイプに変換できません。\u0026#34;); throw new RuntimeException(e); } } } この過程でDBから読み取ったString型のvalueは以下のようになっています。\nJacksonライブラリのObjectMapperは、エスケープ処理された文字列をさらに一重包んだStringリテラルをAddressオブジェクトに変換できません。\nそのため、Addressオブジェクトを生成する過程でMismatchedInputExceptionのエラーメッセージが出力されました。\n補足として、JPAは1番・2番の行のようにきれいに保存されたJSONデータについては、ConverterのソースタイプをStringにしてもbyte[]にしても両方読み取ることができます。JPAのJdbcValueExtractorがConverterで宣言されたタイプに応じてgetString()またはgetBytes()を適切に呼び出し、H2ドライバーがJSONデータに対して両方のメソッドをサポートしているためです。\nしかしData JDBCは、1番・2番の行のようにきれいに保存されたJSONデータであっても、Address ↔ Stringの変換戦略の場合、byte[]との変換戦略ではないという理由で変換ができません。\n解決方法 — hypersistence-utils # JPAでは2021年に、MySQL・Oracle・PostgreSQL・H2のようなDBを使う際にJSONタイプの変換を可能にしたhypersistence-utilsをVlad Mihalcea氏が作ってくださっています。\n@Getter @NoArgsConstructor @ToString @Table(name = \u0026#34;MEMBER\u0026#34;) @Entity public class JpaMember { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = \u0026#34;MEMBER_ID\u0026#34;) private Long id; private String username; @Type(JsonType.class) // このアノテーション一つでH2でもJSONタイプとして保存 private Address address; public JpaMember(String username, Address address) { this.username = username; this.address = address; } } @Type(JsonType.class)を宣言すると、HibernateがJsonType内部でシリアライズ/デシリアライズを処理するため、別途@Convert(converter = AddressConverter.class)を一緒に使う必要はありません。JsonTypeが各DBエンジンに合った保存/取得戦略を内部で分岐処理しているためです。\n以下のリンクを参考にしてください。\nHow to map a JSON column with H2, JPA, and Hibernate\nhypersistence-utilsの内部を分解する # 実際にデバッグしてJsonTypeの内部を覗いてみると、核心はJsonJdbcTypeDescriptor内部のresolveJdbcTypeDescriptor()メソッドでした。\nこのメソッドは接続されたDBの方言（Dialect）を検出してJDBCタイプの処理戦略を分岐します。H2Dialectの場合はJsonBytesJdbcTypeDescriptor.INSTANCEを返しますが、これは先ほど確認した「H2ではbyte[]で保存するとエスケープなしのきれいな形で保存される」という原理をそのまま適用したものです。\n結局hypersistence-utilsはDBの方言を検出して、H2であればbyte[]ベースの戦略を、その他のDBでは各環境に合った戦略を選択することで、DBエンジンごとに異なるJSON処理の差異を内部で吸収していたのでした。\n","date":"2024年8月21日","externalUrl":null,"permalink":"/ja/posts/h2-json-column-converter-error/","section":"Posts","summary":"H2とMySQLのJSONタイプのマッピング差異（byte[] vs String）によるConverterエラーの原因と解決方法をまとめます。","title":"[H2 Database] JSONカラムに対するConverterエラーの状況","type":"posts"},{"content":"","date":"2024年8月21日","externalUrl":null,"permalink":"/ja/tags/converter/","section":"Tags","summary":"","title":"Converter","type":"tags"},{"content":"","date":"2024年8月21日","externalUrl":null,"permalink":"/ja/tags/spring-data/","section":"Tags","summary":"","title":"Spring Data","type":"tags"},{"content":"過去のプロジェクトを進める際、会員登録とログイン処理のためにFirebaseを利用したことがあります。\nAndroidアプリで認証が完了すると、バックエンドはログインと会員登録を簡単に処理できます。この過程でSpringのInterceptorとArgumentResolverを利用して認証を処理しました。\n実装要件 # バックエンドでログインや会員登録APIを作る必要はありませんでした。\nフロントエンドでFirebaseを利用してメールまたはGoogleログインで認証を行い、その結果として得たidTokenのみをAuthorizationヘッダーに格納してバックエンドサーバーにリクエストを送ります。\nするとサーバーでトークンを抽出後、Firebase SDKを利用して有効なトークンかどうか確認します。トークンが有効であれば、その後DBにリクエストを送って**該当会員が存在すれば会員を返す（ログイン）、なければ会員を作成して返す（自動的に会員登録）**します。\nそしてその後ログインしたユーザー情報が必要であれば、ArgumentResolverを通じて注入しました。\nここで核心部分をコードで説明すると\nRepositoryのfindメソッドでOptional\u0026lt;会員\u0026gt;オブジェクトを返し、 なければorElseGet()を通じて新しい会員オブジェクトの生成・保存・返却ロジックを実行します。\nこのロジックが一つの過程として束ねられ（原子化）、自動的に会員登録が行われてログイン処理がされることを望んでいました。\nしかし実装途中に会員登録が重複して処理され、同一の会員がDBに重複して保存される問題が発生し、この問題はトランザクション処理をしていないために発生したものと考えました。\nしかし、トランザクション処理をしてもなお重複保存の問題は発生しました。 明らかに同じトランザクション内で照会と保存を実行したにもかかわらず、なぜこの過程が原子性を保証されなかったのか、そしてどのような理由からなのかを見ていきましょう。\nシステム構造 — Interceptorを通じたFirebaseトークン検証 # @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = \u0026#34;member_id\u0026#34;) private Long id; @Column private String username; @Column(nullable = false) private String email; @Column private String uid; } Memberエンティティは上記のように構成しました。\nuidフィールドはFirebaseがユーザーを識別するために提供するid値です。 プロジェクトのDBでもuidを通じて各メンバーを区別します。\npublic class FirebaseTokenInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // ヘッダー値の抽出 String token = getAuthorizationToken(request); // トークン検証 FirebaseToken decodedToken = decodeToken(token); // 検証済みトークンをHttpServletRequestオブジェクトに保存する request.setAttribute(\u0026#34;decodedToken\u0026#34;, decodedToken); return true; } // リクエストからAuthorizationヘッダーの値（idToken）を抽出します。 private static String getAuthorizationToken(HttpServletRequest request) { String header = request.getHeader(\u0026#34;Authorization\u0026#34;); if (header == null || !header.startsWith(\u0026#34;Bearer \u0026#34;)) { throw new IllegalArgumentException(\u0026#34;No Token or Invalid Token.\u0026#34;); } return header.split(\u0026#34; \u0026#34;)[1]; } // Firebaseサーバーにトークンを送って検証します。 private static FirebaseToken decodeToken(String token) { FirebaseToken decodedToken; try{ // idTokenを検証する decodedToken = FirebaseAuth.getInstance().verifyIdToken(token); } catch (FirebaseAuthException e) { throw new IllegalArgumentException(\u0026#34;Invalid Token.\u0026#34;); } return decodedToken; // 検証済みトークンの結果を返す } } InterceptorでFirebase SDKを利用してAuthorizationヘッダーにあるトークンの有効性を検証します。\n検証済みのFirebaseToken型のdecodedTokenにはユーザーの情報が入っており、その値をHttpServletRequestに保存します。 HttpServletRequestに保存したdecodedTokenは、ArgumentResolverで必要なときに取り出して使用するために保存しました。\n@RequiredArgsConstructor public class LoginMemberArgResolver implements HandlerMethodArgumentResolver { private final MemberRepository memberRepository; // @Loginアノテーションがあれば値を注入する @Override public boolean supportsParameter(MethodParameter parameter) { boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class); boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType()); return hasLoginAnnotation \u0026amp;\u0026amp; hasMemberType; } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); // requestオブジェクトからdecodedTokenを取得 FirebaseToken decodedToken = (FirebaseToken) request.getAttribute(\u0026#34;decodedToken\u0026#34;); // AuthServiceを呼び出す return authService.joinAndLogin(decodedToken); } } @RequiredArgsConstructor @Service public class AuthService { private final MemberRepository memberRepository; public Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException { String uid = decodedToken.getUid(); // uidでメンバーを照会し、DBに登録されていないuidであれば、 // 新しいメンバーを作成して自動会員登録されます。 return memberRepository.findByUid(uid) .orElseGet(() -\u0026gt; memberRepository.save(Member.builder() .username(decodedToken.getName()) .email(decodedToken.getEmail()) .uid(uid) .build())); } } // Controller // パラメーターに@Loginアノテーションがあるので // HandlerMethodArgumentResolverを使用してMemberを初期化する。 @PostMapping(\u0026#34;/post/new\u0026#34;) public Post createPost(@RequestBody Post post, @Login Member member) { ... } ArgumentResolverは上記のようにコントローラーのパラメーターに@Loginアノテーションがあればログイン済みのメンバーを注入するようにしました。\ndecodedTokenにはFirebaseが管理するユーザー識別Id（先述のuid）情報があります。\nこれを利用してプロジェクトのDBでユーザーを照会して返す。もし、該当するユーザーがいなければ新しいMemberエンティティを作成してDBテーブルにメンバーを保存して返します。\nつまり、ユーザーがアプリケーションを「初めて」使用するときに会員登録が自動的に行われます。\nアプリケーションはすべてのリクエストにログイン済みのメンバーのみが使用できるという要件を持ちます。したがってすべてのAPIリクエストにはAuthorizationヘッダーの値があります。\n問題発生 — 同一会員が重複保存される # Androidクライアントと接続してアプリケーションを実際に使用する途中、時々ログインが正常にできない問題が発生しました。\nなぜそうなのかリクエストのログをすべて確認したとき、上記のようなログが現れました。\n（画像は過去のプロジェクトのログなので現在の構成と少し異なります！）\n問題の状況は\n「一人のユーザーが初めてアプリケーションを起動したときに複数のリクエストを同時に発生させた場合」でした。\nそしてその結果\nDBに会員が存在すればそのまま返し、なければ該当会員エンティティを生成、DB保存後に返す。\nという処理が一つの処理として実行されなかったため、データベースには以下のように2つの同じuidを持つ会員が保存されました。\nその後ユーザーが新しいリクエストを送ると、MemberRepositoryのfindByUid()メソッドは一人の会員ではなく、2つの会員レコードを照会してログインが正常にできないエラーの状況が発生しました。\n解決試み — @Transactional、synchronized # 以下のコードは上でお見せしたAuthServiceクラスです。\n@RequiredArgsConstructor @Service public class AuthService { private final MemberRepository memberRepository; public Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException { String uid = decodedToken.getUid(); // memberRepositoryでuidでメンバーを照会し、DBに登録されていないuidであれば、 // 新しいメンバーを作成して自動会員登録されます。 return memberRepository.findByUid(uid) .orElseGet(() -\u0026gt; memberRepository.save(Member.builder() .username(decodedToken.getName()) .email(decodedToken.getEmail()) .uid(uid).build())); } } Springフレームワークは一つのリクエストを一つのスレッドが担当します。 したがって前述のスクリーンショットに示された2つのリクエストは互いに異なるスレッドが処理します。\n2つのリクエストがどちらも同じクライアントのリクエストである場合を例として説明します。\n第1のスレッドがmemberRepository.findByUid()メソッドを通じてデータを照会している間に他のスレッドもmemberRepository.findByUid()メソッドを実行し、同じuidでfindByUid()を実行することができます。\nこれを防ぐためには\nDBに会員が存在すればそのまま返し、なければ該当会員エンティティを生成、DB保存後に返す。\nという過程を一つのトランザクションにまとめ、uid列は会員を一意に識別するためにUNIQUE制約が必要だと判断しました。\nそれなら@Transactionalアノテーションをつければいいのでは？\n@RequiredArgsConstructor @Service public class AuthService { private final MemberRepository memberRepository; // @Transactionalを追加！ @Transactional public Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException { String uid = decodedToken.getUid(); // uidでメンバーを照会し、DBに登録されていないuidであれば、 // 新しいメンバーを作成して自動会員登録されます。 return memberRepository.findByUid(uid) .orElseGet(() -\u0026gt; memberRepository.save(Member.builder() .username(decodedToken.getName()) .email(decodedToken.getEmail()) .uid(uid).build())); } } しかし同時実行をテストしてみると、依然として同じ問題は解決されませんでした。\nトランザクションが先に原子的に処理されることを確認するためにUNIQUE制約はまだ追加していませんでした。UNIQUE制約がなくても原子的に処理されれば重複して保存されないだろうと考えました。\nトランザクションが動作しているのは明らかに確認できましたが、問題は解決されませんでした。\nあ！トランザクションが原子性を提供するが、これは一つのスレッドがfindByUid()を呼び出すとき、他のスレッドがfindByUid()の呼び出しを防ぐのではなかった！\nという考えも生まれ、synchronizedキーワードも追加してみました。\nそしてここからログを出力しながら詳しく見ていくために、findByUid() 〜 orElseGet()メソッドを以下のようにOptionalに展開して記述します。\n@RequiredArgsConstructor @Service public class AuthService { private final MemberRepository memberRepository; // synchronizedを適用 @Transactional public synchronized Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException { String uid = decodedToken.getUid(); // uidでメンバーを照会し、DBに登録されていないuidであれば、 // 新しいメンバーを作成して自動会員登録されます。 Optional\u0026lt;Member\u0026gt; memberOptional = memberRepository.findByUid(uid); if (memberOptional.isPresent()) { log.info(\u0026#34;既存の会員です。\u0026#34;); return memberOptional.get(); } log.info(\u0026#34;新しい会員です。\u0026#34;); Member member = Member.builder() .username(decodedToken.getName()) .email(decodedToken.getEmail()) .uid(uid).build(); memberRepository.save(member); return member; } } synchronizedを追加しても依然として解決されない # MySQLはレコードベースでロック（lock）をかけるが、レコードがなければロック自体をかけられないのではないか？簡単に言えば、最初のfindByUid()に該当するレコードがないのでトランザクションを開始してもロックをかけたりレコードの変更ログを保存する対象自体がないのではないか？\nfindByUid()メソッドを呼び出すときにトランザクションが開始すると考えていたが…ログが出力されたのを見ると何かおかしい…それならトランザクションの開始はどこだ？5つのリクエストを同時に送ったとき、トランザクションが同時に開始するのか？それなら…JPAはDBの基本トランザクションのIsolation levelを選択し、私はMySQLを使っているのでRepeatable ReadというIsolation Levelがデフォルトで適用されているはずで…\nトランザクションの開始時点がAuthServiceのjoinAndLogin()ではなくAOPプロキシのinvoke()によってトランザクションが実行されるなら…実際のjoinAndLogin()メソッドが実行される前に5つのリクエストがすべて同時に進んでいてsynchronizedキーワードによって実際のオブジェクトのメソッドだけが順次実行されたのか…？\n原因分析 # アプリケーションの原子性とトランザクションの原子性は異なる # この言葉はすなわち、Javaアプリケーションコードの原子性を保証するものではありません。\nDBに会員が存在すればそのまま返し、なければ該当会員エンティティを生成、DB保存後に返す。\n上記のJavaコードのメソッドをsynchronizedキーワードで原子化するのは、DBトランザクションの原子性とは別の過程です。\nsynchronizedがない場合、以下のような状況が発生します。\nMySQLのRepeatable Read Isolation Levelでは、MVCC（Multi Version Concurrency Control）を利用します。このときconsistent read view（スナップショット）はトランザクションが開始（5番）するタイミングではなく、**そのトランザクション内で最初のSELECTが実行されるタイミング（8番のfindByUid()）**に生成されます。ただし、START TRANSACTION WITH CONSISTENT SNAPSHOT構文を使用するとトランザクションの開始と同時にスナップショットを生成できますが、Springの@Transactionalはこの構文を使用しません。\n5つのトランザクションがほぼ同時に開始（5番）し、各トランザクションの最初のSELECTであるfindByUid()（8番）もほぼ同時に実行されます。スナップショットはこの最初のSELECT時点に生成されるため、まだどのトランザクションもINSERTを実行していない状態ですべてのトランザクションが同様に**「該当するレコードが存在しません」**という結果を受け取ることになります。\nしたがってfindByUid()メソッドの結果は**「該当するレコードが存在しません」**という同じ結果を示すことになります。\nMySQLのMVCCは一つのトランザクションが先に照会後に会員オブジェクトを保存してコミットしても、他のトランザクションの結果は本人の最初のSELECT時点を基準として、その前に記録されたデータのスナップショットを参照します。\nsynchronizedを適用した場合も、MySQLのMVCCとトランザクション隔離レベルによって同じ問題が発生します。\n@Transactionalによってトランザクションの開始とコミットはプロキシオブジェクトで実行されますが、synchronizedキーワードはプロキシのメソッドに継承されず、実際のjoinAndLogin()メソッドのみに適用されます。\nしたがってスレッド1が実際のjoinAndLogin()メソッドのクリティカルセクション（critical section）を抜けると、プロキシオブジェクトでトランザクションをコミットする前に別のスレッドが実際のjoinAndLogin()メソッドに進入できます。\nここで2つのトランザクションのBEGINの順序やsynchronized進入の試み順序は問題発生の有無に影響を与えません。スナップショットはBEGINではなく最初のSELECT時点に生成されるため、T2がT1よりも先にBEGINするか、先にsynchronized進入を試みても結果は同じです。重要なのはT2の最初のSELECTがT1のCOMMITより前に実行されるかどうかです。\nトランザクション2のSELECT時点でトランザクション1のINSERTはまだコミットされていないデータです。コミットされていないデータはRepeatable ReadはもちろんのことRead Committedでも見えません。Read Committedは「コミットされたデータを読む」という隔離レベルであり、コミット前のデータを読むものではないからです。\nもしそれでもデータが見えることを確認してみたいなら、最も低いトランザクション隔離レベルであるRead Uncommitted Isolation Levelを適用すればよいです。この場合コミットしていなくても他のトランザクションで保存されたデータをすぐに見ることができます。ただしdirty readによってロールバックされたデータを読む危険があるため、適切な解決策ではありません。\nまたsynchronizedキーワードは単一のJVMインスタンス内でのみ有効です。サーバーを2台以上運用する分散環境ではsynchronizedで同時実行を制御できないため、この方法自体が根本的な解決策にはなりません。\nDBロックでは解決できないのか？ # MySQLのInnoDBは照会時、基本的にロック（Lock）ではなくMVCCでRepeatable Readを処理しますが、以下のようにロックをかけることができます。\nSELECT * FROM MEMBER WHERE uid=\u0026#34;ASDW12SD3\u0026#34; FOR UPDATE; -- 書き込みロック SELECT * FROM MEMBER WHERE uid=\u0026#34;ASDW12SD3\u0026#34; FOR SHARE; -- 読み込みロック したがって現在のトランザクションでロックをかけて照会するとき、他のトランザクションで任意のデータが追加されないようにMySQLのレコードロック、ネクストキーロックなどが適用される可能性があります。InnoDBのロックはインデックスを基準に動作します。uid列にインデックス（またはUNIQUE制約）がない状態でSELECT ... FOR UPDATEを実行すると、意図したuid値だけを精密にロックすることが難しくなります。なお、InnoDBはレコードが存在しなくてもギャップロックを通じてその範囲への挿入をブロックできますが、インデックスがなければテーブル全体にロックがかかるなど意図しない範囲がロックされる可能性があり、実用的ではありません。\n解決方法 — UNIQUE制約とリトライロジック # 実はここまでの過程はすべてが正常に動作している中で発生したことです。ただ、プロジェクトの要件を満たす過程でアプリケーションレベルで特定のロジックを一つの過程として処理しようとしたものの、意図した結果が現れませんでした。そしてその過程を単純に@Transactionalを追加することで解決しようとしたことが問題でした。\n少しのテストでアプリケーションレベルで原子性を保証することを確認するには、AuthServiceクラスの@TransactionalアノテーションをSpring AOPが適用されないよう削除して、synchronizedキーワードを使用してアプリケーションコードを原子化すればよいです。\n@RequiredArgsConstructor @Service public class AuthService { private final MemberRepository memberRepository; // synchronizedを適用 public synchronized Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException { String uid = decodedToken.getUid(); // uidでメンバーを照会し、DBに登録されていないuidであれば、 // 新しいメンバーを作成して自動会員登録されます。 Optional\u0026lt;Member\u0026gt; memberOptional = memberRepository.findByUid(uid); if (memberOptional.isPresent()) { log.info(\u0026#34;既存の会員です。\u0026#34;); return memberOptional.get(); } log.info(\u0026#34;新しい会員です。\u0026#34;); Member member = Member.builder() .username(decodedToken.getName()) .email(decodedToken.getEmail()) .uid(uid).build(); memberRepository.save(member); return member; } } この場合トランザクションを使わなくてもほとんど正常に動作しますが、予期しない状況でデータの整合性を保証するためにトランザクションを使用することが望ましいです。適切な解決策ではありません。\n私の場合、簡単な解決策としてuidで会員を区別するという要件に合わせてデータベース「uid列」に対するUNIQUE制約を追加しました。\nそして重複したuid値を持つ会員の保存が発生した場合に例外を投げるように処理しました。その後、例外発生時にリトライする方式で処理し、エラーなしに自動会員登録とログインが正常に動作するようにしました。\n追加でUNIQUE制約は照会時にインデックスを活用してより速い性能を発揮できます！\n// build.gradleに追加 implementation \u0026#34;org.springframework.retry:spring-retry\u0026#34; // FirebaseApplication.java @EnableRetry // ここを追加 @SpringBootApplication public class FirebaseApplication {} // AuthService.java @RequiredArgsConstructor @Service public class AuthService { private final MemberRepository memberRepository; // ここも追加！ @Retryable( retryFor = {DataIntegrityViolationException.class}, backoff = @Backoff(delay = 1000) ) @Transactional public Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException { String uid = decodedToken.getUid(); // uidでメンバーを照会し、DBに登録されていないuidであれば、 // 新しいメンバーを作成して自動会員登録されます。 Optional\u0026lt;Member\u0026gt; memberOptional = memberRepository.findByUid(uid); if (memberOptional.isPresent()) { log.info(\u0026#34;既存の会員です。\u0026#34;); return memberOptional.get(); } log.info(\u0026#34;新しい会員です。\u0026#34;); Member member = Member.builder() .username(decodedToken.getName()) .email(decodedToken.getEmail()) .uid(uid).build(); memberRepository.save(member); return member; } } この際に注意すべき点は@Retryableと@TransactionalのAOP適用順序です。@Retryableが@Transactionalよりも外側でラップしなければ（先に適用されなければ）、リトライするたびに新しいトランザクションが開かれません。順序が逆であれば、すでにロールバックされたトランザクション内でリトライが発生するため意味がありません。@RetryableのデフォルトのorderはOrdered.LOWEST_PRECEDENCEですので、必要に応じて@EnableRetry(order = ...)で順序を明示的に指定する必要があります。\n本当に解決したのか？ # リトライロジックの問題点 # DataIntegrityViolationExceptionはUNIQUEキー違反の例外だけでなく、他の例外でも発生する可能性があります。\n例えば外部キー制約違反、データ型不一致、NOT NULL制約違反などでもDataIntegrityViolationExceptionが発生する可能性があります。\nこの場合、リトライロジックが継続して実行される可能性があります。\nより根本的な問題 — 誤ったプロセス # 上記の過程はプロセス自体が誤っていました。ArgumentResolverはコントローラーのパラメーターを解析して注入する役割であり、会員作成のようなビジネスロジック（副作用）を実行する場所ではありません。副作用のあるロジックをパラメーター解析の段階に入れたため、すべてのAPI呼び出しのたびに会員登録が試みられるという異常な構造が生まれたのです。\n問題が発生している状況で何とかして要件を実装レベルで解決しようとしたため、不必要なリトライロジックまで処理することになりました。\n現在のAPIたちは会員を検証する部分とビジネスを実行する部分が一つのAPIで処理されています。したがって検証に失敗すると会員検証をリトライしてビジネスロジックを実行するようになっています。\nこの部分でプロセスを分離すれば不必要なリトライロジックも必要なかったでしょう。\nまず正常なケースとしてプロセスを変えるなら、従来は自動会員登録またはログインAPIが必要ないと考えて作らなかったのですが、今は作る必要があります。\nそして自動会員登録またはログインAPIを呼び出し、同時に入ってきたとすればその一方を失敗処理にすればよいのです。\nその後ビジネスロジックAPIを呼び出せばいい問題でした。\n数ヶ月が経過したプロジェクトで現れた問題ですが、解決のためにトランザクションやDBなどを学んでいく中で遅まきながら根本的な問題点まで発見したとき、あまりにも異常なプロセスだったので、当時きちんと作れなかったことへの悔しさが残りもします。\nそれでも意義を見出すとすれば、アプリケーションレベルの原子性とトランザクションについてより深く理解する契機を作ってくれた問題でした。\n参考資料 # Java ORM標準JPAプログラミング\nReal MySQL\nキム・ヨンハンのSpring DB 1、2編\nFirebase認証REST API\nSpring @Transactional mistakes everyone did\n","date":"2024年8月1日","externalUrl":null,"permalink":"/ja/posts/spring-transactional-atomicity/","section":"Posts","summary":"アプリケーションレベルの原子性とDBトランザクションの原子性の違いを@Transactionalを通じて分析し、同時実行問題の解決方法を解説します。","title":"[Spring] アプリケーションの原子性とトランザクションの原子性は異なる（Feat. @Transactional）","type":"posts"},{"content":"","date":"2024年8月1日","externalUrl":null,"permalink":"/ja/tags/atomicity/","section":"Tags","summary":"","title":"Atomicity","type":"tags"},{"content":"","date":"2024年8月1日","externalUrl":null,"permalink":"/ja/tags/transactional/","section":"Tags","summary":"","title":"Transactional","type":"tags"},{"content":"プロジェクトを進めながらクエリを確認していた際、不思議な点を発見したことがあります。 明らかにAとBという互いに異なるエンティティの関連関係のローディング戦略をLazyにしたのに、なぜAオブジェクトを照会するときにBオブジェクトも一緒にローディングするクエリが発行されるのでしょうか？\n~~ToOne関係のデフォルトのローディング戦略 # JPAを使用する場合、~~ToOne関係のローディング戦略のデフォルト値はFetchType.EAGERになっています。しかしこのような戦略はN+1問題のように意図しないクエリが発行される場合があるため、FetchType.LAZYに設定して使用することが推奨されます。 しかし明らかにFetchType.LAZYに設定したにもかかわらず、意図しないクエリが発生しました。一体なぜそうなるのでしょうか？この問題はJPAが遅延ローディング（LazyLoading）のためにプロキシオブジェクトを使用することによって発生します。\n事前知識 # DBとオブジェクトの関連関係パラダイムの不一致 # 本格的な分析に先立って、DBテーブルとJavaオブジェクトの関連関係パラダイムの不一致問題を整理しておきましょう。\nDBテーブルは主テーブルであれ対象テーブルであれ、どちらか一方に外部キーがあれば双方向から照会できます。このような関係を双方向関係と言います。 以下の2つのSQL文はどちらも実行できます。\nselect * from species s join mission m on s.species_id = m.species_id select * from mission m join species s on m.species_id = s.species_id しかしオブジェクトは参照を使って他のオブジェクトと関連関係を持ち、参照にアクセスして関連するオブジェクトを照会します。Speciesオブジェクトにmissionオブジェクトの参照が存在すればmissionオブジェクトにアクセスできますが、Missionオブジェクトにspeciesオブジェクトに該当する参照が存在しなければ、Missionオブジェクトはspeciesオブジェクトにアクセスできません。このような単方向関係で両方のオブジェクトに互いの参照を設定して一つのオブジェクトで外部キーを管理するなら、2つのオブジェクトはデータベースと同様に双方向関係で表現できます。（実際には単方向関係が2つです。）\nJPAのようなORM技術は、このようなテーブルとオブジェクト間の関連関係パラダイムの不一致などの様々な問題を解決するために生まれた技術です。\n関連関係のオーナー、@JoinColumn、mappedBy # JPAで双方向関係を設定する際は、「誰がFK（外部キー）を管理するか？」を必ず指定する必要があります。これを関連関係のオーナーと言い、常にFKを持つ側がオーナーになります。\n以下は理解を助けるために、一対多（1:N）関係のチーム（Team）と学生（Student）を例として説明します。\n**@JoinColumn**はオーナー側につけ、「私がこのFK列を管理する」という宣言です。オーナー側で値を設定してはじめてDBに反映されます。\n@ManyToOne @JoinColumn(name = \u0026#34;team_id\u0026#34;) // STUDENT テーブルの team_id 列が FK private Team team; mappedByはオーナーでない側につけ、「相手方のフィールドがこの関係を管理する」という意味です。ここで値を設定してもDBには反映されません。mappedByに指定する値は相手エンティティのフィールド名です。\n@OneToMany(mappedBy = \u0026#34;team\u0026#34;) // Student クラスの \u0026#39;team\u0026#39; フィールドがオーナー private List\u0026lt;Student\u0026gt; students = new ArrayList\u0026lt;\u0026gt;(); 一方が@JoinColumnであれば反対側は必ずmappedByです。両側ともに@JoinColumnを使用するとFKが2つ生成されるので注意してください。\nサンプル構成 # 説明のために確認するサンプルDBテーブルは以下のとおりです。 MISSIONテーブルでSPECIESの主キー値を外部キーとして持っています。\nJPAエンティティは以下のように定義しました。 Missionエンティティで@JoinColumnを利用してspecies_id値を外部キーとして設定します。そして互いに双方向参照を持っています。\n@Entity public class Species { @Id @Column(name = \u0026#34;species_id\u0026#34;) private Long id; // FetchType.LAZYを適用する // MissionエンティティのspeciesフィールドをmappedByして関連関係のオーナーとして適用する @OneToOne(fetch = FetchType.LAZY, mappedBy = \u0026#34;species\u0026#34;) private Mission mission; private String speciesName; } @Entity public class Mission { @Id @Column(name = \u0026#34;mission_id\u0026#34;) private Long id; @OneToOne(fetch = FetchType.LAZY) // FetchType.LAZYを適用 @JoinColumn(name = \u0026#34;species_id\u0026#34;) private Species species; private String missionName; } 両方のオブジェクトは互いに参照するフィールドを持っており、テーブルは一対一の関係を持っています。Missionオブジェクトが関連関係のオーナーとして外部キーを管理し、MISSIONテーブルとマッピングされています。\nオブジェクトはオブジェクトグラフ（互いに異なるオブジェクト間の参照を通じて接続されたチェーンが作るネットワーク）で関連するオブジェクトを探索できます。しかしオブジェクトがデータベースに保存されているため、関連するオブジェクトを自由に探索することは難しいです。メモリ上のオブジェクトなら参照を辿って自由に移動できますが、DBに保存されたデータは実際に照会クエリを実行するまで取得できないからです。つまり、mission.getSpecies()を呼び出す瞬間にDBにSELECTクエリが発行されなければSpeciesデータを取得する方法がありません。\nプロキシと遅延ローディング # JPAはこの問題を解決するために、関連関係にあるオブジェクトたちを一度にDBから照会して互いに探索が可能になるよう最適化します。しかしこのような方法は予期しないクエリが発生する可能性があり、時には照会にコストがかかる場合があります。JPAはこの問題を解決するためにプロキシという技術を使用します。そして関連するオブジェクトを最初からデータベースから照会するのではなく、実際に使用するタイミングでデータベースから照会できます。このような方式を遅延ローディング（Lazy Loading）と言います。遅延ローディング機能を使用するには実際のエンティティオブジェクトの代わりにDB照会を遅延できるダミーオブジェクトが必要で、これをプロキシオブジェクトと言います。プロキシオブジェクトはDBへのアクセスを委任されており、実際に使用されるときにDBを照会して実際のエンティティオブジェクトを生成します。\n問題の確認 # 照会されるオブジェクトが実際のエンティティかプロキシかテストするために、以下のようなコードを作成して実行してみました。 まず、関連関係のオーナーであるMissionエンティティをDBから照会し、Speciesエンティティは参照（reference）を通じて照会してみました。\nLong missionId = 1001L; Long speciesId = 9001L; Mission newMission = new Mission(missionId,\u0026#34;ミッション1\u0026#34;); // id, missionName Species newSpecies = new Species(speciesId, \u0026#34;種1\u0026#34;); // id, speciesName newMission.setSpecies(newSpecies); speciesRepository.save(newSpecies); // speciesを先に保存する必要がある。MissionがSpeciesのspecies_id（FK）を参照するため、SpeciesがDBに先に存在しなければ外部キー制約違反が発生する missionRepository.save(newMission); entityManager.clear(); // 1次キャッシュをクリアして以降の照会時にDBから実際にSELECTクエリが発生するよう強制する。クリアしなければキャッシュからすぐに返されてプロキシ生成の有無を確認できない Mission mission = missionRepository.findById(missionId).get(); String missionClassName = mission.getClass().getName(); String speciesClassName = mission.getSpecies().getClass().getName(); System.out.println(\u0026#34;missionClassName = \u0026#34; + missionClassName); System.out.println(\u0026#34;speciesClassName = \u0026#34; + speciesClassName); Speciesエンティティはプロキシオブジェクトが生成されたことが確認できます。 今度は逆に、関連関係のオーナーでないSpeciesエンティティをDBから照会し、Missionエンティティを参照を通じて照会してみましょう。\nSpecies species = speciesRepository.findById(species.getId()).get(); String speciesClassName = species.getClass().getName(); String missionClassName = species.getMission().getClass().getName(); System.out.println(\u0026#34;speciesClassName = \u0026#34; + speciesClassName); System.out.println(\u0026#34;missionClassName = \u0026#34; + missionClassName); 2つの実行コードは互いに異なるエンティティをDBから照会するという点を除いて構造が完全に同じです。各エンティティはOneToOneの関連関係をマッピングする際にFetchType.LAZYを適用しましたが、Speciesエンティティをフォームから照会するときにMissionエンティティの照会クエリも一緒に発生し、Missionオブジェクトはプロキシではなく実際のエンティティが生成されました。\n原因分析：プロキシの限界 # プロキシを使用して遅延ローディングを適用するとき、外部キーを直接管理しないオブジェクトの一対一関係は遅延ローディングに設定しても即時ローディングします。 この問題はプロキシの限界のために発生する問題です。\n実際のエンティティオブジェクトのダミーオブジェクトプロキシを生成するときは、実際のエンティティに関する情報を持っていなければなりません。ダミーオブジェクトが実際のオブジェクトのふりをするためにプロキシを使用しますが、実際のオブジェクトが存在しなければダミーオブジェクトの存在意義はなくなります。したがってエンティティをプロキシで照会するときに実際のエンティティの識別子値（PK）をパラメーターとして渡し、プロキシオブジェクトは**この識別子値（PK）**を情報として持っています。\n実際に\nMission mission = missionRepository.findById(missionId).get() コードを利用して照会したMissionエンティティをデバッガーで確認すると、上でspeciesId値（PK）として定義した9001LをSpeciesプロキシオブジェクトが持っていることを確認できます。この情報を利用して実際に使用されるときにSpeciesエンティティを初期化します。\nもう一度オブジェクトとテーブルの関係を確認しましょう。\nMissionエンティティは実際に使用するときにDBからMISSIONテーブルを照会します。 MISSIONテーブルには外部キーを管理する列が存在するため、テーブルの照会だけでSPECIESテーブルと関連関係があることがわかります。これによってspecies_id値を取得したり、関連するデータがないこと（null）がわかります。 （なお、@OneToOneのoptional属性は関連するエンティティの存在を強制するかどうかを指定します。デフォルト値はtrueで、関連するエンティティがなくてもよく、FK列にnullが許可されます。optional = falseに設定すると関連するエンティティが必ず存在しなければならず、FK列がNOT NULLとして処理されます。）\nしかしSpeciesエンティティは実際に使用されるときにSPECIESテーブルを照会することで、MISSIONテーブルと関連関係があるかどうかわかりません。FK列のように他のテーブルとの関連関係の存在を確認する列がないため、nullかどうかさえわかりません。 したがってMISSIONテーブルとの関連関係を確認するためにMISSIONテーブルにSELECTクエリを送らなければなりません。\nJPAはspecies_idと関係を持っているMISSIONテーブルのデータを探します。SELECTクエリでデータの存在確認だけを行うのは非効率なため、JPAはSELECTクエリとともにデータも取得してプロキシの代わりに実際のエンティティを生成します。こうして結果的にFetchType.LAZYが無視されてFetchType.EAGERが適用されます。\nそしてSELECTクエリを発行して関連するデータがなければ以下のようにnullになります。\n@OneToManyではなぜこの問題が発生しないのか？ # 一（1）対多（N）の場合は常に**多（N）**側が外部キーを持ちます。この場合、一（1）に該当するオブジェクトは関連関係のオーナーではなく、マッピングされたテーブルを照会したとき、前述の場合と同様に関連関係の有無を確認する列が存在しません。つまりこの場合も多（N）に該当するテーブルにSELECTクエリを送らなければならないのでは？と考えるかもしれません。\n一（1）に該当するエンティティの多（N）に該当する関連関係フィールドはコレクション型です。遅延ローディングを適用するときにエンティティにコレクションがあれば、コレクションを追跡・管理する目的でHibernateはコレクションラッパーというものを提供します。これがコレクションに対するプロキシの役割を果たします。したがってSELECTクエリを使用して存在確認をする必要なく、遅延ローディングが正常に動作します。\n解決方法 # 実のところ解決方法はあまりありません。そのまま即時ローディングになることを受け入れて使用するか、不必要に双方向関連関係を設定する必要がないのであれば以下のように単方向関連関係に設定してもよいです。 またはプロキシの代わりにバイトコードを操作するライブラリを使用する方法も存在するようです。\nそして実際に単方向マッピングだけでもテーブルとオブジェクトの関連関係マッピングは完了しています。双方向マッピングは単方向マッピングと比較して関連関係のオーナーも決めなければならず、2つの単方向関連関係を双方向にするためのロジックもしっかり管理しなければならないほど複雑です。双方向の利点は関連関係のオーナーでない方向から関連関係のオーナー方向へのオブジェクトグラフ探索機能が追加されただけです。\nしたがって逆方向への探索機能が必要でなければ、単方向を優先して使用することを推奨します。\n[参考資料]\nJava ORM標準JPAプログラミング\nJPA @OneToOne 一対一関連関係まとめとLazyLoading問題\nJPA @OneToOneではFetchType.LAZYが効かないことがある？\nHow can I make a JPA OneToOne relation lazy\nSome explanations on lazy loading (one-to-one)\n","date":"2024年7月23日","externalUrl":null,"permalink":"/ja/posts/jpa-onetoone-lazy-loading-issue/","section":"Posts","summary":"JPA @OneToOne関係で遅延ローディングが動作せず即時ローディングされる原因を、プロキシメカニズムを通じて分析します。","title":"[JPA] @OneToOneに遅延ローディングを適用したのに、なぜ遅延ローディングが機能せず即時ローディングになるのか？","type":"posts"},{"content":"","date":"2024年7月23日","externalUrl":null,"permalink":"/ja/tags/lazy-loading/","section":"Tags","summary":"","title":"Lazy Loading","type":"tags"},{"content":"","date":"2024年7月23日","externalUrl":null,"permalink":"/ja/tags/onetoone/","section":"Tags","summary":"","title":"OneToOne","type":"tags"},{"content":"SpringプロジェクトでDTOとEntityクラスを作る際、Lombokアノテーションを習慣的に付けていませんか？\n@Getter、@Setter、@NoArgsConstructor、@RequiredArgsConstructorなど便利なアノテーションが多くありますが、DTOとEntityは役割が異なるため、適用すべきアノテーションも異なります。\nこの記事では各アノテーションの動作を整理し、DTOとEntityにどのように適用するのが適切かをコード例と一緒に見ていきます。\nまず、各アノテーションが何をするかを整理しよう # @Getter # すべてのフィールドに対してgetterメソッドを生成します。\n@Getter public class Product { private String name; private double price; } // 以下のコードが自動生成される // public String getName() { return this.name; } // public double getPrice() { return this.price; } @Setter # すべてのフィールドに対してsetterメソッドを生成します。\n@Setter public class Product { private String name; private double price; } // 以下のコードが自動生成される // public void setName(String name) { this.name = name; } // public void setPrice(double price) { this.price = price; } @NoArgsConstructor # パラメーターなしのデフォルトコンストラクターを生成します。\n@NoArgsConstructor public class Product { private String name; private int price; } // 以下のコードが自動生成される // public Product() {} accessオプションでアクセス修飾子を設定できます。\n@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Product { private String name; private int price; } // 以下のコードが自動生成される // protected Product() {} @RequiredArgsConstructor # finalフィールドのみをパラメーターとして受け取るコンストラクターを生成します。\n@RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final PaymentService paymentService; private String tempValue; // finalでないのでコンストラクターのパラメーターに含まれない } // 以下のコードが自動生成される // public OrderService(OrderRepository orderRepository, PaymentService paymentService) { // this.orderRepository = orderRepository; // this.paymentService = paymentService; // } @AllArgsConstructor # すべてのフィールドをパラメーターとして受け取るコンストラクターを生成します。\n@AllArgsConstructor public class User { private String username; private int age; private String email; } // 以下のコードが自動生成される // public User(String username, int age, String email) { // this.username = username; // this.age = age; // this.email = email; // } Entityにはどのアノテーションを使うべきか？ # 推奨の組み合わせ # @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String email; @Column(nullable = false) private String name; @Builder public Member(String email, String name) { this.email = email; this.name = name; } } なぜこの組み合わせなのか？ # 1. @Getter - 使用\nService、ControllerなどでEntityのデータをアクセスするときはGettingメソッドが必要です。\n2. @Setter - 使用しない\nEntityに@Setterを開放するとどこからでもEntityの状態を変更できるようになります。\n// @Setterがあるとこんなコードが可能になる - 危険！ member.setName(\u0026#34;newName\u0026#34;); member.setEmail(\u0026#34;new@email.com\u0026#34;); こうなるとEntityの状態がいつ、どこで、なぜ変更されたかを追跡しにくくなります。\n代わりに、意味のあるビジネスメソッドを通じて状態を変更すべきです。\n@Entity public class Member { // ... フィールド省略 // setterの代わりに意味のあるメソッドを定義 public void updateProfile(String name, String email) { this.name = name; this.email = email; } public void deactivate() { this.active = false; this.deactivatedAt = LocalDateTime.now(); } } setName()という名前だけではこの変更がどのビジネスコンテキストで発生するのか分かりませんが、updateProfile()やdeactivate()は意図が明確です。\n3. @NoArgsConstructor(access = AccessLevel.PROTECTED) - 使用\nJPAの仕様上、EntityクラスはJPAがリフレクションを通じてオブジェクトを生成するため、デフォルトコンストラクター（no-args constructor）が必須です。\nしかしpublicで開放すると、どこからでも空のEntityオブジェクトを作れるため、AccessLevel.PROTECTEDで制限します。\n// publicにするとこんなコードが可能になる - 不完全なオブジェクト生成の危険 Member member = new Member(); // すべてのフィールドがnull // protectedに制限すると外部から直接呼び出し不可 // 同じパッケージまたは継承関係でのみ使用可能（JPAが使用する範囲） 4. @RequiredArgsConstructor - 使用しない\nEntityはSpring Beanではないため、依存性注入は不要です。finalフィールドを使う機会もほとんどないため@RequiredArgsConstructorはEntityには適していません。\nまたJPA EntityのフィールドをJPAがリフレクションでオブジェクトを生成した後にフィールド値をセットできなくなります。\n5. @AllArgsConstructor - 使用しない\nすべてのフィールドのコンストラクターを開放すると、idのように自動生成されるべきフィールドまで外部から注入できるようになります。フィールドの順序が変わるとコンパイルエラーなしに値が入れ替わるバグも発生しえます。\n代わりに@Builderを使って必要なフィールドのみを明示的に設定するのが安全です。\nDTOにはどのアノテーションを使うべきか？ # DTOはデータを転送することが目的のオブジェクトです。Entityとは性質が異なるため、異なるアノテーションが必要です。\nリクエスト（Request）DTO # @Getter @NoArgsConstructor public class MemberCreateRequest { @NotBlank(message = \u0026#34;メールアドレスは必須です\u0026#34;) private String email; @NotBlank(message = \u0026#34;名前は必須です\u0026#34;) private String name; } なぜこの組み合わせなのか？\n@Getter - 使用： ControllerからServiceへデータを渡すときにフィールドの値を読む必要があります。 @Setter - 不使用： リクエストデータは一度バインドされると変更する理由がありません。 @NoArgsConstructor - 使用： Jacksonはデフォルトコンストラクターでオブジェクトを生成してから、リフレクションでフィールドの値をバインドします。publicなデフォルトコンストラクターが必要です。 Jacksonのデシリアライズの流れ：\nデフォルトコンストラクターで空のオブジェクトを生成 JSONフィールドに対応するJavaフィールドにリフレクションで値を注入 ただし@Setterが必要な場合もある - @ModelAttribute # 上で@RequestBodyでJSONを受け取るときは@Setterが必要ないと言いました。しかし**@ModelAttributeでフォームデータやクエリパラメーターをバインドする場合は話が変わります。**\nSpringのDataBinderはデフォルトコンストラクターでオブジェクトを作った後、setterメソッドを呼び出して値を注入します。リフレクションでフィールドに直接アクセスするJacksonとは動作方式が異なります。\n// GET /members?name=山田太郎\u0026amp;email=yamada@test.com @GetMapping(\u0026#34;/members\u0026#34;) public ResponseEntity search(@ModelAttribute MemberSearchRequest request) { // ... } @Getter @Setter // @ModelAttributeバインドに必要！ @NoArgsConstructor public class MemberSearchRequest { private String name; private String email; } @SetterがないとDataBinderが値を注入できず、すべてのフィールドがnullのままになります。\nリクエストDTOで@Setterを使うかどうかはどんな方式でデータを受け取るかによって変わります。無条件に@Setterを除外するのではなく、バインドのメカニズムを理解して必要な場合に適用することが重要です。\nレスポンス（Response）DTO # @Getter @RequiredArgsConstructor public class MemberResponse { private final Long id; private final String email; private final String name; public static MemberResponse from(Member member) { return new MemberResponse( member.getId(), member.getEmail(), member.getName() ); } } または@Builderパターンを活用することもできます。\n@Getter @Builder public class MemberResponse { private final Long id; private final String email; private final String name; public static MemberResponse from(Member member) { return MemberResponse.builder() .id(member.getId()) .email(member.getEmail()) .name(member.getName()) .build(); } } なぜこの組み合わせなのか？\n@Getter - 使用： レスポンスJSONにシリアライズするときに必要です。 @RequiredArgsConstructor - 使用： レスポンスDTOは生成時点ですべてのデータが確定するため、finalフィールド + @RequiredArgsConstructorで不変オブジェクトを作るのが良いです。 @Setter - 不使用： レスポンスデータは一度生成されたら変更されるべきではありません。 @NoArgsConstructor - 不使用： レスポンスDTOはJacksonのデシリアライズ対象でないため、デフォルトコンストラクターは不要です。 Java 16+ならRecordを検討しよう # Java 16から導入されたrecordを使うと、Lombokなしでも不変なDTOを簡潔に作れます。\npublic record MemberResponse( Long id, String email, String name ) { public static MemberResponse from(Member member) { return new MemberResponse( member.getId(), member.getEmail(), member.getName() ); } } recordは自動的にgetter、equals()、hashCode()、toString()を生成し、すべてのフィールドがfinalです。Spring Data JPAでもDTO Projectionとしてrecordをサポートしています。\npublic record UserDto(String firstname, String lastname) {} interface UserRepository extends Repository\u0026lt;User, Long\u0026gt; { @Query(\u0026#34;SELECT u FROM User u WHERE u.lastname = :lastname\u0026#34;) List\u0026lt;UserDto\u0026gt; findByLastname(String lastname); } ","date":"2024年5月2日","externalUrl":null,"permalink":"/ja/posts/lombok-annotations-dto-entity/","section":"Posts","summary":"DTOとEntityの役割の違いに応じて、Lombokアノテーション（@Getter、@Setter、@NoArgsConstructor、@RequiredArgsConstructor）をどう選ぶべきか、コード例と一緒にまとめます。","title":"[Spring] DTOとEntityに@Getter、@Setter、@RequiredArgsConstructor、@NoArgsConstructorをどれを使えばいいのか？","type":"posts"},{"content":"","date":"2024年5月2日","externalUrl":null,"permalink":"/ja/tags/dto/","section":"Tags","summary":"","title":"DTO","type":"tags"},{"content":"","date":"2024年5月2日","externalUrl":null,"permalink":"/ja/tags/entity/","section":"Tags","summary":"","title":"Entity","type":"tags"},{"content":"","date":"2024年5月2日","externalUrl":null,"permalink":"/ja/tags/lombok/","section":"Tags","summary":"","title":"Lombok","type":"tags"},{"content":"","date":"2024年2月18日","externalUrl":null,"permalink":"/ja/tags/homebrew/","section":"Tags","summary":"","title":"Homebrew","type":"tags"},{"content":"","date":"2024年2月18日","externalUrl":null,"permalink":"/ja/tags/macos/","section":"Tags","summary":"","title":"MacOS","type":"tags"},{"content":"macOSでMySQLを立ち上げようとすると、brew services start mysqlも見かけるし、mysql.server startも見かけるので、最初はどちらが何なのかかなり混乱しました。\nどちらも結局MySQLサーバーを起動することには変わりませんが、誰がプロセスを管理するか、どんな状況で使いやすいかが違います。整理すると、こう理解するのが一番わかりやすかったです。\nふだんローカル開発用に常時起動しておくならbrew services start mysql、必要なときだけ手動で起動・停止したいならmysql.server start\n2つのコマンドの違いから見ると # 項目 brew services start mysql mysql.server start 管理主体 Homebrew + macOSのlaunchd MySQLが提供するスクリプト 起動方式 バックグラウンドサービスとして登録して起動 手動起動 自動起動 可能 基本的になし 管理コマンド brew services list、stop、restart mysql.server status、stop 個人的なおすすめ状況 ふだんのローカル開発用 ちょっと起動して確認・テストするとき brew services start mysqlはいつ使うと便利か？ # HomebrewがmacOSのlaunchdにMySQLをサービスとして登録して管理する方式です。\n一度起動しておいて継続的に使うならこちらのほうがはるかに楽です。ログイン後も再起動するように管理できますし、brew services listで他のローカルサービスと一緒に状態を確認できるので、管理自体がシンプルになります。\nローカルでMySQLをよく使う状況であれば、通常はこちらを先に使います。\nbrew services start mysql brew services stop mysql brew services restart mysql brew services list メリットも明確です。\n毎回ターミナルから手動で起動する必要がありません。 Homebrewでインストールした他のサービスと一緒に管理しやすいです。 ふだんの開発環境を維持するときの手間が減ります。 mysql.server startはいつ使う方がいいか？ # このコマンドは、MySQLが提供する起動スクリプトを直接呼び出す方式です。\nサービス登録というより「今必要だから直接起動する」という感覚に近いです。そのため、自動起動よりも必要なときに起動して、終わったら止めるという流れに向いています。\nmysql.server start mysql.server stop mysql.server status こちらの方が使いやすい場面もあります。\nMySQLを常時起動しておく必要がないとき 起動と停止を自分で直接制御したいとき 起動状態を確認しながらテストや確認をするとき ただし、インストール方法やPATHの設定によってはmysql.serverコマンドがすぐに認識されないケースもあります。その点まで考えると、Homebrewでインストールした環境ではbrew servicesの方が混乱しにくかったです。\nではmysqld_safeとは何か？ # ときどきmysqld_safe \u0026amp;のようなコマンドも一緒に見かけることがあります。\nこれはより低いレベルでMySQLデーモンを起動する方式で、mysql.serverが内部的に使用する側に近いです。ふだんのローカル開発では、このレベルまで直接触る機会はあまりありませんでした。\n個人的にはこう区別するだけで十分でした。\n常時使うローカルDBならbrew services ちょっと起動してテストするならmysql.server 起動の流れを非常に直接的に制御したいときだけmysqld_safe まとめ # 結論だけもう一度書くと、macOSでHomebrewからMySQLをインストールしたなら、通常はbrew services start mysqlが一番楽です。\n逆に、MySQLをたまにしか使わなかったり、自分で起動・停止する流れの方が好みならmysql.server startでも十分です。\n","date":"2024年2月18日","externalUrl":null,"permalink":"/ja/posts/mysql-mac-commands/","section":"Posts","summary":"macOSでMySQLを起動するときにbrew servicesとmysql.serverがどう違うのか、どんな状況でどちらを使えばいいかをまとめます。","title":"macOSでMySQLを起動するときの`brew services`と`mysql.server`の違い","type":"posts"},{"content":"","date":"2023年12月3日","externalUrl":null,"permalink":"/ja/tags/gradle/","section":"Tags","summary":"","title":"Gradle","type":"tags"},{"content":"プロジェクトのセットアップ中にQueryDSLの設定に関する記事をいくつか調べていたのですが、記事によって設定の内容が少しずつ異なっていました。\nある記事にはannotationProcessorの設定が書かれていて、別の記事ではプラグインのバージョンの話が先に出てきました。そこで自然とこんな疑問が浮かびました。\nなぜSpringやQueryDSLを使っているのに、Gradleのバージョンまで確認する必要があるんだろう？\n最初は自分もSpringがGradleに直接依存しているのかと思っていましたが、整理してみるとそうではありませんでした。\nこの記事は、そのときに混乱した内容を自分なりの理解でまとめた記録です。\nGradleは結局何をしてくれるのか？ # Gradleはビルドツールです。\nJavaプロジェクトを実行可能な状態にするまでに必要な作業を代わりに処理してくれます。\n自分の理解では、ライブラリの取得・Javaコードのコンパイル・テストの実行・最終的にjarなどの成果物としてまとめるところまでを担うツールだと捉えると、少しすっきりしました。\nたとえばbuild.gradleに以下のように記述しておくと\ndependencies { implementation \u0026#39;org.springframework.boot:spring-boot-starter-web\u0026#39; } Gradleがリポジトリから該当ライブラリを取得し、必要な下位依存関係も一緒に持ってきてくれます。\nそして以下のコマンドを実行すると\n./gradlew test テストも自動的に実行してくれます。\nつまり、開発者が毎回手動でやっていた作業を順序に従って実行してくれるツールだと理解すると分かりやすかったです。\nなぜ設定の記事にはGradleのバージョンの話が一緒に出てくるのか？ # 自分が混乱していたのはここでした。\nアプリケーションはSpring Bootで作っているのに、なぜGradleのバージョン互換性の話が出てくるのかが分かりませんでした。\n理由は、アプリケーションがGradleに依存しているのではなく、ビルド過程で使用するプラグインや設定がGradleのバージョンに影響を受けるからです。\nたとえばSpring BootをGradleで使う場合、通常はプラグインを追加します。\nplugins { id \u0026#39;org.springframework.boot\u0026#39; version \u0026#39;3.2.0\u0026#39; } このプラグインはGradleの上で動作します。\nつまり、Gradleが提供する機能や仕組みに合わせて作られています。そのため、Gradleのメジャーバージョンが変わると、古い方式で書かれたプラグインや設定コードが動かなくなるケースが出てきます。\nだからSpring Bootの公式ドキュメントでも、対応しているGradleのバージョンを一緒に確認するようになっているのだと思います。\nQueryDSLはなぜより敏感に感じたのか？ # QueryDSLは特に、設定に関する記事ごとの差異が大きく感じられました。\n理由は、QueryDSLが単にライブラリを追加するだけでは終わらず、ビルド時にQClassを生成する工程が入るからです。\nたとえばMemberエンティティがあると、ビルド時にQMemberのようなクラスが生成されます。\nこの作業は通常、annotation processingの過程で処理されますが、この部分がGradleのバージョンによって設定方法が微妙に変わります。\nそのため、古い記事をそのまま参考にすると、現在のプロジェクトでは動かないケースが出てきます。\n実際に調べた記事もGradleのバージョンがそれぞれ異なっていたため、設定コードが少しずつ違っていました。\nまとめ # 結局、自分が理解したのはこういうことです。\nSpringやQueryDSLが実行中にGradleを使っているわけではありません。Gradleはあくまでビルド時に必要なツールです。\nただし、ビルド過程に含まれるプラグインやannotation processingの設定はGradleのバージョンに影響を受けます。だからプロジェクトのセットアップ時にライブラリのバージョンだけでなく、Gradleのバージョンも一緒に確認する必要があったわけです。\n最終的に、デプロイされたjarファイルの中にGradleが入っているわけではありません。\nGradleはビルドが終われば役目を終え、実際の実行はSpringアプリケーションが担います。\nこれを理解して初めて、なぜ公式ドキュメントや設定の記事で「Spring Bootのバージョン」「Gradleのバージョン」「Javaのバージョン」を一緒に確認するよう言われているのか、少し感覚がつかめました。\n","date":"2023年12月3日","externalUrl":null,"permalink":"/ja/posts/gradle-explained/","section":"Posts","summary":"プロジェクトのセットアップ中に混乱したGradleの役割と、Spring BootやQueryDSLの設定でGradleのバージョンを一緒に確認しなければならない理由を整理します。","title":"Gradleとは何か？役割を整理する","type":"posts"},{"content":"","date":"2023年12月3日","externalUrl":null,"permalink":"/ja/tags/querydsl/","section":"Tags","summary":"","title":"QueryDSL","type":"tags"},{"content":"","date":"2023年11月2日","externalUrl":null,"permalink":"/ja/tags/ide/","section":"Tags","summary":"","title":"IDE","type":"tags"},{"content":"","date":"2023年11月2日","externalUrl":null,"permalink":"/ja/tags/intellij/","section":"Tags","summary":"","title":"IntelliJ","type":"tags"},{"content":" ナビゲーション / 移動 # ショートカット 機能 Command + B または Command + Click 宣言部へ移動（Go to Declaration） Command + Option + B または Command + Option + Click 実装部へ移動（Go to Implementation） Command + O プロジェクト内のクラス検索 Command + O（2回） ライブラリを含む全クラス検索 Command + F12 現在のクラス内のメソッド一覧を表示（File Structure） Command + E 最近開いたファイルの履歴 Shift + F4 現在のファイルタブを新しいウィンドウで分離して開く（Open Source in New Window） Command + 1 左側のProjectウィンドウを開く/閉じる Shift + F2 前のエラー位置へ移動 F2 次のエラー位置へ移動 編集 # ショートカット 機能 Option + Enter Quick Fix / エラー修正の提案 / Auto import / Static importなど Command + N または Control + Enter コードの生成（getter、setter、constructorなど） Control + I インターフェースの実装メソッド一覧を表示 Command + D 現在の行を下にコピー＆ペースト（Duplicate Line） Shift + Option + Up / Down 現在の行を上/下に移動 Command + Shift + Enter 現在の文を自動補完（Complete Current Statement） Control + Space コードの自動補完（Basic Completion） Command + P パラメーター情報の表示 リファクタリング # ショートカット 機能 Shift + F6 変数 / メソッド / クラス名の一括変更（Rename） Renameの例（Shift + F6）\n変更前 — 変数名がハイライトされて一括修正の対象を表示:\n変更後 — すべての参照が一度に修正される:\nショートカット 機能 Command + Option + V 変数の抽出（Extract Variable） Command + Option + M メソッドの抽出（Extract Method） Command + Option + N インライン変換 — 変数を直接returnに（Inline） Command + Option + P パラメーターの抽出（Extract Parameter） Command + Option + C 定数の抽出（Extract Constant） Control + Option + O 不要なimportの整理（Optimize Imports） 検索 # ショートカット 機能 Command + Shift + F 全ソースコードからテキスト検索（Find in Files） テスト / 実行 # ショートカット 機能 Command + Shift + T 現在のクラスのテストクラスへ移動 / 生成 Control + Shift + R 現在のファイルを実行（Run context configuration） プロジェクト設定 # ショートカット 機能 Command + ; プロジェクト構造画面を開く（Project Structure） Project Structure画面（Command + ;）\nLive Template（自動補完コードスニペット） # テンプレート 生成されるコード psvm + Tab public static void main(String[] args) {} sout + Tab System.out.println() soutv + Tab System.out.println(\u0026quot;変数名 = \u0026quot; + 変数名) soutm + Tab System.out.println(\u0026quot;クラス名.メソッド名\u0026quot;) iter + Tab 拡張for文を生成 参考リンク # JetBrains公式 - よく使うショートカット TOP 15 IntelliJショートカットまとめブログ control+space自動補完が効かないときの解決法 ","date":"2023年11月2日","externalUrl":null,"permalink":"/ja/posts/intellij-shortcuts/","section":"Posts","summary":"macOS基準のIntelliJ IDEA必須ショートカットキー集 — ナビゲーション、編集、リファクタリング、検索、テスト、Live Templateまで一目で整理","title":"IntelliJのショートカットまとめ（macOS基準）","type":"posts"},{"content":"","date":"2023년 11월 2일","externalUrl":null,"permalink":"/tags/%EB%8B%A8%EC%B6%95%ED%82%A4/","section":"Tags","summary":"","title":"단축키","type":"tags"},{"content":"","date":"2023年11月2日","externalUrl":null,"permalink":"/ja/tags/%E3%82%B7%E3%83%A7%E3%83%BC%E3%83%88%E3%82%AB%E3%83%83%E3%83%88/","section":"Tags","summary":"","title":"ショートカット","type":"tags"},{"content":"","externalUrl":null,"permalink":"/ja/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","externalUrl":null,"permalink":"/ja/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","externalUrl":null,"permalink":"/search/","section":"검색","summary":"","title":"검색","type":"search"}]