大量データを export する時のあれこれ

例えば、一定期間に登録・更新したデータを export したい、って要件はまずまずあると思います。 場合によっては期間で絞り込んでも数万件とか対象になることもあるわけです。 そんなデータをメモリにどかんとのせたらどうなるか?OOM 発生が容易に想像できます。

その分メモリを積むことで対処できなくはないとは思いますが、メモリを追加し続けるのにも限度があります。

アプリ側でなんとかするにはいくつか方法が考えられます。

1. アプリ側で適切なサイズに区切る

limit, offset を駆使して、抽出対象のレコードを絞り込む方法です。また、offset だと後ろのデータを取得する時の読み飛ばしに時間がかかるので、抽出した塊の最後のレコードの次から塊を抽出する方法もあります(シーク法と言われているみたいですね)。

データの塊毎に SQL を発行するので、SQL 自体が遅いと最後のページにたどり着くまでにめっちゃ時間がかかるわけです。

そもそもの対象データを範囲指定で絞り込むに加えてデータの並び替えも発生します。

RDBMS によっては絞り込みとソート両方に index が効かせるのが難しいこともあります。index が効くように table の構造を変えたり、複数のカラムを結合したソート用のカラムを作ってみたり、色々厳しい対応が必要になるかもしれません。 既に大量のデータが入ってたりすると、構造変える migration に何時間もかかるという苦行の運用も待っているかもしれません。そんなのは嫌だ。

2. 絞り込みとソートで別の index 効かせる

無理矢理にでも index 効かせちゃえ、ということで

  • 対象データを抽出し、TEMPORARY TABLE に突っ込む(ソート順は意識しない)
    • その table にはソート用の index を張る
  • データの塊を取得するのは TEMPORARY TABLE からソートしつつ取得する

みたいな方法を考えたこともあります。多分動くとは思うのですが、TEMPORARY TABLE の管理が面倒な気がして他に良い案はないか模索していました。

3. サーバーサイドのカーソルを使う

やっと本題です。 RDBMS 側の対象データ Set をちょっとずつ返す方法があることを知りました。

言語やドライバでの違いはあるようですが、JDBC ドライバでいうと、fetchSize に値を設定することで

  • 全件メモリに配置する
  • 少しずつ RDBMS から fetch する

を制御できるようです。で、受け取る側は Stream で処理するようにしておくと、少しずつ RDBMS から fetch しつつ処理を行うことができそうです。

Doma2 でやってみた

大好きな Doma2で簡単な検証用のプログラムと環境(PostgreSQL)を作りました。

@Dao
public interface SampleDao {

  @Select
  @Sql("SELECT id, data FROM sample")
  List<Sample> selectAll();

  @Select(fetchSize = 20)
  @Sql("SELECT id, data FROM sample")
  @Suppress(messages = { Message.DOMA4274 })
  Stream<Sample> selectAllForStream();
}

selectAll の方は、結果データを全てメモリに配置するケース。 selectAllForStream の方は、fetchSize 分だけサーバから取得して処理を行えるケースです。 Stream 処理の中で export ファイルに書き込む処理を行えば、対象データが多くてもメモリも少なく済みます。

テストクラスは以下のようなイメージです。

@Test
@DisplayName("大量の ResultSet を取得する場合、OOM になること")
public void testSelectAll() {
  var tm = config.getTransactionManager();

  tm.required(
        () -> {
            System.out.println("start");
            sut.selectAll();
            System.out.println("end");
        });
}

@Test
@DisplayName("大量の ResultSet を取得する場合、Stream にしておくと取得できること")
public void testSelectAllForStream() {
  var tm = config.getTransactionManager();
  tm.required(
        () -> {
            System.out.println("start");
            var increment = new AtomicLong();
            try (var stream = sut.selectAllForStream()) {
                // 本来はここで export ファイルに書き込む処理が入る想定
                stream.forEach((v)-> increment.incrementAndGet());
            }
            System.out.println("end: " + increment.get());
        });
}

RDBMS 側に大量データ入れておいて、

# select count(*) from sample;
  count
----------
 48828130

テストクラス実行の java 引数に -Xmx30m を設定して testSelectAllForStream を実行すると、

end: 48828130

が表示されました。

testSelectAll を実行すると、

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2371)
    at org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:368)
    at org.postgresql.jdbc.PgStatement.executeInternal(PgStatement.java:498)
    ...

と表示されました。ヒープが小さいのに5000万件のデータをメモリに展開しようとしているので想定通りですね。

まとめ

というわけで、絞り込んでも大量データの export 処理を時間をかけずに行うには?の選択肢としてサーバーサイドのカーソルがあることをお話しました。

RDBMS や言語、ドライバで振る舞いが違うようなので、実際に使用するときは調査が必要だとは思います。 RDBMS のリソースをその分消費するのですが、毎回 SQL を発行するよりはトータルで時間がかからない良い選択なんじゃないかと思います。 パフォーマンスを上げたい人の参考になれば幸いです。