目的と前提
「アプリ側で Stream に流すか、DB 側で SQL に任せるか」を正しく判断できると、性能・コスト・保守性が一気に安定します。根本の考え方はシンプルです。データ量が大きいほど「近いところで削る」が正解で、フィルタ・集約・結合のように「行数を減らす処理」は、可能なら DB で先にやる。アプリ側の Stream は、持ってきた後の「軽い整形」「ドメインロジック」「I/O 連携」に向いています。
Stream と SQL の役割の違い
データの所在と転送コスト
SQL は「データの隣」で動きます。テーブルの行数を減らす処理(WHERE、GROUP BY、JOIN、ORDER BY、LIMIT)が DB 内で完結すれば、アプリに渡す行数が大幅に減ります。一方、Stream は「受け取った後のメモリ上の行列」を加工します。大量行をアプリへ転送してからフィルタするのは、ネットワーク帯域・ヒープ容量の両面で損です。
言語の得意分野
SQL は集合演算の達人です。半結合/反結合、ウィンドウ関数、集計、重複排除、索引を効かせた範囲抽出など、行数を劇的に減らす操作は SQL が得意。Stream は「宣言的パイプライン」で、入出力や JSON 化、軽い変換、ビジネス条件の組み合わせなどが書きやすい。つまり「データ削減は SQL」「最終整形は Stream」と役割分担します。
並列化とリソース管理
DB はクエリプランとインデックス、結合順序の最適化を持ち、サーバ資源を集中管理します。アプリ側並列(parallel stream)は CPU を使い切るには有効ですが、データ転送を減らすことはできません。負荷分散の観点では「DB で絞って小さくしてからアプリに渡す」ことが第一優先です。
いつ DB で処理すべきか(判断基準)
行数削減が大きいなら必ず SQL
WHERE、JOIN、GROUP BY、DISTINCT、LIMIT でデータを 10 万行→数百行にできるなら、DB で削ってから渡します。転送量・ヒープ・GC の全てで勝ちます。アプリ側で同じことをすれば、余計な行の移動と確保が発生します。
集約・ランキング・トップ N は SQL の守備範囲
SUM/AVG/COUNT、ランキング(ORDER BY + LIMIT/OFFSET)、日次集計(GROUP BY 日付)などは DB の索引・ソート最適化を活用でき、I/O が劇的に減ります。「上位 100 件」や「最新 1000 件」は必ず DB 側で limit を掛けます。
大規模 JOIN・存在チェックは DB が圧倒的に強い
外部キーの存在確認(EXISTS/IN)、多対多の結合、半結合(IN/EXISTS)による絞り込みは DB が最適化します。アプリに 2 テーブルを取ってきて Stream で突き合わせるのは、転送・メモリの二重負担です。
ウィンドウ関数や時間集約は DB に任せる
移動平均、ランク、パーティション内集約(OVER/PARTITION BY)は SQL のウィンドウ関数が直球で解けます。アプリ側で Deque を使った windowing は便利ですが、行数が多いなら DB で前処理し、縮んだ結果を受け取るのが安全です。
いつ Stream で処理すべきか(判断基準)
ドメイン整形・最終フォーマット・外部連携
DTO へのマッピング、JSON/XML 生成、ファイルやメッセージへの逐次書き出しは Stream が得意です。collect は最小限にし、forEach で外へ流す構成にするとメモリピークを抑えられます。
ビジネスルールの複雑な組み合わせ
可読性重視の複合 Predicate や、多段のマップ・フィルタはコードで表現しやすい。テストもしやすい。データ量が小さくなってから(DB で絞ってから)適用するのが定石です。
DB にない関数・外部サービスとの合成
機械学習スコア計算、複雑な正規表現、外部 API 照会など、DB に寄せづらい処理は Stream のパイプラインで段階的に組み合わせます。必ず limit と短絡を前段に入れて、呼び出し回数を抑えます。
比較をコードで体感(SQL と Stream の責務分担)
トップ N と整形を分担
-- DB側(行数削減を最優先)
SELECT id, name, score
FROM users
WHERE active = TRUE AND age >= 20
ORDER BY score DESC
LIMIT 100;
Java// アプリ側(最終整形と出力)
record UserRow(long id, String name, int score) {}
List<UserRow> top = fetchTopUsers(); // 上のSQLを実行して取得
String report = top.stream()
.map(u -> String.format("%d,%s,%d", u.id(), u.name(), u.score()))
.collect(java.util.stream.Collectors.joining("\n"));
JavaDB がデータを 100 件に縮め、アプリは軽い整形だけを行います。これが負荷分散の基本形です。
日次集計を DB、警告抽出を Stream
-- DB側(日次で集約)
SELECT DATE(created_at) AS day, COUNT(*) AS cnt
FROM orders
WHERE status = 'ERROR'
GROUP BY day;
Java// アプリ側(閾値超過日の抽出と通知)
record DayStat(java.time.LocalDate day, long cnt) {}
List<DayStat> stats = fetchErrorPerDay();
stats.stream()
.filter(s -> s.cnt() >= 100)
.forEach(this::notifyOps); // 通知はアプリ側責務
Java集約は DB、ビジネス判断と通知はアプリ。責務が明確になります。
存在チェックは DB、最終フィルタはアプリ
-- DB側(EXISTSで絞る)
SELECT p.id, p.name
FROM products p
WHERE EXISTS (
SELECT 1 FROM inventory i
WHERE i.product_id = p.id
AND i.stock > 0
);
Java// アプリ側(細かいビジネス条件)
List<Product> inStock = fetchInStockProducts();
List<Product> vipOnly = inStock.stream()
.filter(p -> p.vipFlag() && p.price() >= 30000)
.toList();
Java存在チェックを DB に寄せることで、転送行数を最小化してからビジネス条件を適用します。
深掘り:設計の勘所(正しさ・性能・保守性)
近いところで削る(データローカリティ)
データ量が大きい操作ほど、データの近く(DB)で実行します。WHERE、JOIN、GROUP、DISTINCT、LIMIT は必ず DB 優先。これだけでネットワーク転送、ヒープ、GC の波を小さくできます。アプリは「削った後」の軽い処理へ集中します。
パイプライン順序を意識してピークを下げる
アプリ側では、filter → limit/takeWhile → map/sorted → 終端の順に並べ、短絡と絞り込みを前段に置きます。collect は最小限にし、可能な限り逐次出力(forEach)へ。大量データでは toList/toMap/groupingBy は禁物です。
インデックスとクエリプランを前提にする
DB 側の効果はインデックスとクエリプランで決まります。WHERE の列に適切な索引、JOIN キーの索引、ORDER BY と LIMIT の組み合わせを整える。アプリ側の並列より、適切なインデックスのほうが圧倒的に効きます。
一貫性と再現性(同一ロジックを二重に書かない)
同じ条件を SQL と Stream の両方に重複記述すると、バグ温床になります。抽出条件は SQL に寄せ、アプリ側は「付加条件」だけに限定するか、条件を共有関数にまとめて責務分離を綺麗に保ちます。
逐次・ページング・ストリーミングで受ける
大量行はページング(LIMIT/OFFSET やキー継続)で受け取り、ページ単位で Stream に流して逐次処理します。ResultSet を全材質化しない。ファイルやネットへの書き出しを終端にして、アプリのピークメモリを一定に保ちます。
テンプレート(そのまま流用できる分担雛形)
基本分担:SQL で絞る → Stream で整形
-- 絞る・集約・ソート・トップN(DBで実施)
SELECT id, name, score
FROM users
WHERE active = TRUE AND age >= 20
ORDER BY score DESC
LIMIT 100;
Java// 整形・出力(アプリで実施)
try (var w = java.nio.file.Files.newBufferedWriter(java.nio.file.Paths.get("top.csv"))) {
fetchTopUsers().stream()
.map(u -> "%d,%s,%d".formatted(u.id(), u.name(), u.score()))
.forEach(line -> {
try { w.write(line); w.newLine(); }
catch (java.io.IOException e) { throw new java.io.UncheckedIOException(e); }
});
}
Java日次・月次集計の基本形
SELECT DATE(ts) AS day, SUM(amount) AS total
FROM sales
WHERE ts BETWEEN :start AND :end
GROUP BY day
ORDER BY day;
JavaList<DayTotal> rows = fetchDayTotals();
double avg = rows.stream().mapToDouble(DayTotal::total).average().orElse(0.0);
Javaページング受信で逐次処理
int page = 0, size = 10_000;
while (true) {
List<Row> chunk = fetchPage(page, size);
if (chunk.isEmpty()) break;
chunk.stream()
.filter(this::cheapCond)
.forEach(this::writeOut);
page++;
}
Javaまとめ
負荷分散の判断は「どこで行数を減らすか」に尽きます。集合演算・集約・ランキング・存在チェックなど「削る処理」は DB に寄せ、結果を小さくしてからアプリへ。アプリ側では Stream で軽い整形・ビジネスロジック・外部連携を宣言的に組む。ページングと逐次出力でメモリピークを一定に保ち、インデックスとクエリプランを前提にクエリを設計する。これらを徹底すれば、速度・安定性・保守性のすべてで、無理なく勝てます。
