【試行錯誤編③】億り人をおくりびとした話

公開日:2023-07-17
最終更新日:2023-07-17

Java

Spring-boot

Spring Batch

MyBatis

シングルインサート vs バルクインサート

みなさん、バルクインサート使ってますか?使ってますよね?ね?(脅迫)

たかだか数件だとか数十件程度であればシングルインサートだろうがバルクインサートであろうが、そこまで差はないと思いますが、数十万件、数百万件のオーダーともなってくればその差は歴然です。 ただどれくらいパフォーマンスに差が出るかなんてものはググれば死ぬほど記事が出てくるので書きません。

https://dev.mysql.com/doc/refman/8.0/ja/insert-optimization.html

なので元々実装されていた移行バッチも全てバルクインサートを使って新システムのDBへデータを永続化していました。

バルクインサートの限界

最強の永続化方法だと思われるバルクインサートですが、今回の移行データのようなデータボリュームの前ではさすがに限界がありました。

複数行をまとめてインサートできるバルクインサートといえど、一度にインサートできる行にも限界があるのである程度のキャップを設けながら永続化していく他ありません。

結果どうなっていたかと言うと、Auroraのリソース使用率は爆上がり、データの永続化処理の部分だけで数十分かかっていました。

前章までで移行バッチ全体の先頭〜中盤までをやっつけてきたわけですが、いよいよラスボスの登場といった具合です。

バルクインサート vs LOAD DATA

「こんな大量データ、どうやったらもっと永続化のパフォーマンス上げられるんだよぉ」と同僚に愚痴っていたところ、 「それ、LOAD DATAの方が良くないか?」 との一言。 そうやん、LOAD DATAって手があったやん、神かお前は。

同僚曰く、バルクインサートとLOAD DATAだとCPU効率が圧倒的に違うらしいです。 何がどう違ってLOAD DATAの方がいいかは調べてませんが、とにかくクソデカデータの場合はLOAD DATA一択とのこと。

LOAD DATAという武器を手に入れた私は勇んでラスボスを倒しにいきました。

AuroraでもLOAD DATAはできる

新たな武器を手に入れたものの、「あれ、どうやって使うんだっけ?」となった私はググるわけです。 でも調べて出てくるのはどれもMySQLに乗り込んだ後にLOAD DATAをしているケースだったり、同一サーバー上にあるMySQLにCLIベースでLOAD DATAをしているケースだったり、S3経由でAuroraにLOAD DATAしてるケースばかり・・・。

え、ワイ、JavaのプロセスからAuroraにLOAD DATAしたいんやけど

照英が泣きながら〇〇してる画像くださいばりに泣きながらググっていた私はとある記事を見つけます。 そこに書いてあったのはMySQLの接続文字列のオプションを指定するという方法。 あれ、これならJDBCの接続文字列(URL)で同じことすればいけんちゃうの?

application.yml

spring:
  datasource:
    url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?allowLoadLocalInfile=true

allowLoadLocalInfile=true のオプションを追加してあげるだけです。 これだけでOKです。号泣しました。

MyBatisでLOAD DATAをぶちかます

JavaのプロセスからLOAD DATAが可能になった今、もはや敵はいません。

変換処理でJavaのオブジェクトにしていた部分を適当なCSVファイルにするだけです。 OpenCSVのライブラリの依存関係は既に持っていたのであとはそれを使うだけ。

FetchAndDataConverter.java

@Component
@RequiredArgsConstructor
public class FetchAndDataConverter {

  private final SourceTableMapper sourceTableMapper;

  @Async("batchExecutor")
  public CompletableFuture<Path> execute(LocalDate targetDate) {
    var outputFile = Paths.get(System.getProperty("java.io.tmpdir"))
        .resolve("%s.csv".formatted(UUID.randomUUID().toString()));
    try (var cursor = sourceTableMapper.findByTargetDate(targetDate);
         var writer = new CSVWriterBuilder(Files.newBufferedWriter(outputFile, StandardCharsets.UTF_8, StandardOpenOption.CREATE))
        .withSeparator(',')
        .withLineEnd("\n")
        .build()
    ) {
      cursor.forEach(source -> {
    writer.writeNext(new String[] {
      // 変換処理
    }, true);
      });
      return CompletableFuture.completedFuture(outputFile);
    } catch (Exception e) {
      return CompletableFuture.failedFuture(e);
    }
  }
}

MapperでLOAD DATAをかます定義して、

TargetTableMapper.java

@Mapper
@Repository
public interface TargetTableMapper {

  Integer loadData(@Param("path") String path);
}

TargetTableMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="okuribito.domain.repository.TargetTableMapper">
  <insert
    id="loadData"
  >
    LOAD DATA LOCAL INFILE #{path}
    INTO TABLE target_table
    FIELDS
      TERMINATED BY ','
      ENCLOSED BY '"'
    LINES
      TERMINATED BY '\n'
  </insert>
</mapper>

Mapperを呼び出すだけです。

MigrateTask.java

@StepScope
@Component
@RequiredArgsConstructor
public class MigrateTask implements Tasklet {

  @Value("#{jobParameters['from']}")
  private LocalDate from;

  @Value("#{jobParameters['to']}")
  private LocalDate to;

  private final FetchAndDataConverter fetchAndDataConverter;

  private final TargetTableMapper targetTableMapper;

  @Override
  public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
    var fetchAndConvertTasks = from.datesUntil(to.plusDays(1))
        .toList()
    .stream()
    .map(fetchAndDataConverter::execute)
    .toArray(CompletableFuture[]::new);
    
    CompletableFuture.allOf(fetchAndConvertTasks).join();
    
    var loadedCount = Arrays.stream(fetchAndConvertTasks)
        .map(this::take)
    .map(Path::toString)
    .mapToInt(targetTableMapper::loadData)
    .sum();
  }
  
  private Path take(CompletableFuture<Path> future) {
    try {
      return future.get();
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }
}

注意点を挙げるとすれば、

  • NULLのデータの扱い
    • CSVファイル上、ブランクのものはNULLではなくブランクとして挿入しようとします
    • 明示的にNULLとしたい場合は、CSV上の文字列を "\\N" とするか、LOAD DATAのSET句で捻りましょう
  • BIT型のデータの扱い
    • こちらもCSV上、 "0""1" としてもBITには綺麗に変換してくれません
    • LOAD DATAのSET句でCASTして捻りましょう

ってくらいです。

いずれにせよ、LOAD DATAのSET句は割と何でも出来ます。

https://dev.mysql.com/doc/refman/8.0/ja/load-data.html

©︎ s-kugel All Rights Reserved.