Java 逆引き集 | Stream と SQL の比較(いつ DB で処理すべきか) — 負荷分散判断

Java Java
スポンサーリンク

目的と前提

「アプリ側で 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"));
Java

DB がデータを 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;
Java
List<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 で軽い整形・ビジネスロジック・外部連携を宣言的に組む。ページングと逐次出力でメモリピークを一定に保ち、インデックスとクエリプランを前提にクエリを設計する。これらを徹底すれば、速度・安定性・保守性のすべてで、無理なく勝てます。

タイトルとURLをコピーしました