データベースのバッチ挿入は本当にこれだけの工夫が必要なのか?

最近のプロジェクトで様々なinsertBatchのコードを書いてきましたが、いつも「バッチ挿入はループ挿入よりずっと効率的だ」と言われます。そこで今回は、本当にそうなのかを検証してみましょう。

テスト環境:

  • SpringBoot 2.5
  • Mysql 8
  • JDK 8
  • Docker

まず、複数データの挿入には以下の選択肢があります:

  • foreachループによる挿入
  • SQLを結合して一度に実行
  • バッチ処理機能を使用した挿入

テスト環境の構築

SQLファイル:

drop database IF EXISTS test;
CREATE DATABASE test;
use test;
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
  `id` int(11) NOT NULL,
  `name` varchar(255) DEFAULT "",
  `age` int(11) DEFAULT 0,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

アプリケーションの設定ファイル:

server:
  port: 8081
spring:
  #データベース接続設定
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/test?characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true&&serverTimezone=UTC&setUnicode=true&characterEncoding=utf8&&nullCatalogMeansCurrent=true&&autoReconnect=true&&allowMultiQueries=true
    username: root
    password: 123456
#mybatisの関連設定
mybatis:
  #mapper設定ファイル
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.techdemo.model
  #キャメルケース名の有効化
  configuration:
    map-underscore-to-camel-case: true
logging:
  level:
    root: error

起動ファイル、Mapperファイルのスキャンパスを設定:

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.techdemo.mapper")
public class TechdemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(TechdemoApplication.class, args);
    }
}

Mapperファイルにはいくつかのメソッドを準備しました。単一オブジェクトの挿入、すべてのオブジェクトの削除、複数オブジェクトの結合挿入:

import com.techdemo.model.User;
import org.apache.ibatis.annotations.Param;

import java.util.List;

public interface UserMapper {

    int insertSingle(User user);

    int deleteAll();

    int batchInsert(@Param("items") List<User> items);
}

Mapper.xmlファイル:

<?xml version="1.0" encoding="UTF-8" ?>

<mapper namespace="com.techdemo.mapper.UserMapper">
    <insert id="insertSingle" parameterType="com.techdemo.model.User">
        insert into user(id,age) values(#{id},#{age})
    </insert>

    <delete id="deleteAll">
        delete from user where id>0;
    </delete>

    <insert id="batchInsert" parameterType="java.util.List">
        insert into user(id,age) VALUES
        <foreach collection="items" item="record" index="index" separator=",">
            (#{record.id}, #{record.age})
        </foreach>
    </insert>
</mapper>

テスト時は、毎回すべてのデータを削除して、前のデータの影響を受けないようにしてテストの客観性を確保します。

異なるテスト方法

1. foreachによる挿入

リストを取得し、各データごとにデータベース操作を実行してデータを挿入します:

@SpringBootTest
@MapperScan("com.techdemo.mapper")
class TechdemoApplicationTests {

    @Autowired
    SqlSessionFactory sqlSessionFactory;

    @Resource
    UserMapper userMapper;

    static int recordCount = 100000;

    static int startId = 1;

    @Test
    void testLoopInsertion() {
        List<User> records = generateRandomUsers();
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < records.size(); i++) {
            userMapper.insertSingle(records.get(i));
        }
        long endTime = System.currentTimeMillis();
        System.out.println("処理時間: " + (endTime - startTime));
    }
}

2. SQL結合による挿入

以下の方法でデータを挿入します:

INSERT INTO `user` (`id`, `age`) 
VALUES (1, 11),
(2, 12),
(3, 13),
(4, 14),
(5, 15);
    @Test
    void testSqlSplicing() {
        List<User> records = generateRandomUsers();
        long startTime = System.currentTimeMillis();
        userMapper.batchInsert(records);
        long endTime = System.currentTimeMillis();
        System.out.println("処理時間: " + (endTime - startTime));
    }

3. Batchによるバッチ挿入

MyBatisセッションのexecutor typeBatchに設定し、sqlSessionFactoryで実行方式をバッチに、自動コミットをfalseに設定し、すべて挿入後に一度にコミットします:

    @Test
    public void testBatchInsertion() {
        SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false);
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        List<User> records = generateRandomUsers();
        long startTime = System.currentTimeMillis();
        for(int i=0;i<records.size();i++){
            mapper.insertSingle(records.get(i));
        }
        sqlSession.commit();
        sqlSession.close();
        long endTime = System.currentTimeMillis();
        System.out.println("処理時間: " + (endTime - startTime));
    }

4. バッチ処理+分割コミット

バッチ処理の基礎上、1000件ごとに先にコミットし、分割コミットします:

    @Test
    public void testBatchWithChunkCommit() {
        SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false);
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        List<User> records = generateRandomUsers();
        long startTime = System.currentTimeMillis();
        for(int i=0;i<records.size();i++){
            mapper.insertSingle(records.get(i));
            if (i % 1000 == 0 || i == recordCount - 1) {
                sqlSession.commit();
                sqlSession.clearCache();
            }
        }
        sqlSession.close();
        long endTime = System.currentTimeMillis();
        System.out.println("処理時間: " + (endTime - startTime));
    }

初回の結果、明らかに間違っている?

上記のコードを実行すると、以下の結果が得られます。forループによる挿入の効率は確かに非常に悪く、SQL結合の効率は相対的に高いです。一部の資料ではSQL結合がMySQLによって制限される可能性があると述べていますが、1000万件まで実行したときにようやくヒープメモリオーバーフローが発生しました。

以下は間違った結果です!

挿入方法 10 100 1000 1万 10万 100万 1000万
forループ挿入 387 1150 7907 70026 635984 時間がかかりすぎ... 時間がかかりすぎ...
SQL結合挿入 308 320 392 838 3156 24948 OutOfMemoryError: ヒープメモリオーバーフロー
バッチ処理 392 917 5442 51647 470666 時間がかかりすぎ... 時間がかかりすぎ...
バッチ処理 + 分割コミット 359 893 5275 50270 472462 時間がかかりすぎ... 時間がかかりすぎ...

SQL結合はメモリを超えていない

MySQLの制限を見てみましょう:

mysql> show VARIABLES like '%max_allowed_packet%';
+---------------------------+------------+
| Variable_name             | Value      |
+---------------------------+------------+
| max_allowed_packet        | 67108864   |
| mysqlx_max_allowed_packet | 67108864   |
| slave_max_allowed_packet  | 1073741824 |
+---------------------------+------------+
3 rows in set (0.12 sec)

この67108864は600MB以上もあり、大きすぎます。そのためエラーにならないのは当然です。では、これを変更してみましょう:

  1. まずMySQLを起動した状態で、コンテナ内に入ります。Dockerデスクトップ版で直接Cliアイコンをクリックして入っても構いません:
docker exec -it mysql bash
  1. /etc/mysqlディレクトリに入り、my.cnfファイルを編集します:
cd /etc/mysql
  1. vimをインストールしないとファイルを編集できません:
apt-get update
apt-get install vim
  1. my.cnfを編集します:
vim my.cnf
  1. 最後の行にmax_allowed_packet=20Mを追加します(編集モードに入るにはiを押し、編集後はescを押して:wqで退出)
[mysqld]
pid-file        = /var/run/mysqld/mysqld.pid
socket          = /var/run/mysqld/mysqld.sock
datadir         = /var/lib/mysql
secure-file-priv= NULL
# シンボリックリンクを無効化することは、様々なセキュリティリスクを防ぐために推奨されます
symbolic-links=0
 
# カスタム設定はここに記述
!includedir /etc/mysql/conf.d/
max_allowed_packet=2M
  1. コンテナから退出します:
# exit
  1. MySQLコンテナのIDを確認します:
docker ps -a
  1. MySQLを再起動します:
docker restart c178e8998e68

再起動成功後、最大のmax_allowed_packetを確認すると、変更が成功したことがわかります:

mysql> show VARIABLES like '%max_allowed_packet%';
+---------------------------+------------+
| Variable_name             | Value      |
+---------------------------+------------+
| max_allowed_packet        | 2097152    |
| mysqlx_max_allowed_packet | 67108864   |
| slave_max_allowed_packet  | 1073741824 |
+---------------------------+------------+

再度SQL結合を実行すると、100万件の時点でSQLが約3.6MBに達し、設定した2MBを超えているため、エラーが発生することが確認できます:

org.springframework.dao.TransientDataAccessResourceException: 
### Cause: com.mysql.cj.jdbc.exceptions.PacketTooBigException: Packet for query is too large (36,788,583 > 2,097,152). You can change this value on the server by setting the 'max_allowed_packet' variable.
; Packet for query is too large (36,788,583 > 2,097,152). You can change this value on the server by setting the 'max_allowed_packet' variable.; nested exception is com.mysql.cj.jdbc.exceptions.PacketTooBigException: Packet for query is too large (36,788,583 > 2,097,152). You can change this value on the server by setting the 'max_allowed_packet' variable.

バッチ処理がなぜ遅いのか?

しかし、よく見ると、上記の方法ではバッチ処理時に優位性が示されておらず、forループと大差ないように見えます。これは正しいのでしょうか?

これは間違いです。公式ドキュメントによると、バッチ更新を行い、毎回プリペアドステートメントを作成しないため、理論上はより速いはずです。

その後、私が見つけた最も重要な問題:データベース接続URLにrewriteBatchedStatements=trueが欠けていました。

このパラメータを書かない場合、MySQL JDBCドライバはデフォルトでexecuteBatch()ステートメントを無視し、期待するバッチ実行のSQLステートメントを分割して、実際には1件ずつMySQLデータベースに送信し、実質的に単一挿入となり、性能が低くなります。なぜ性能がループ挿入と同じくらいになるのかがわかりました。

rewriteBatchedStatementsパラメータをtrueに設定することで、データベースドライバがバッチ実行SQLを助けてくれます。

正しいデータベース接続:

jdbc:mysql://127.0.0.1:3306/test?characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true&&serverTimezone=UTC&setUnicode=true&characterEncoding=utf8&&nullCatalogMeansCurrent=true&&autoReconnect=true&&allowMultiQueries=true&&&rewriteBatchedStatements=true

問題を見つけた後、再度バッチテストを実行し、最終的な結果は以下の通りです:

挿入方法 10 100 1000 1万 10万 100万 1000万
forループ挿入 387 1150 7907 70026 635984 時間がかかりすぎ... 時間がかかりすぎ...
SQL結合挿入 308 320 392 838 3156 24948(SQL長さ制限を超える可能性あり) OutOfMemoryError: ヒープメモリオーバーフロー
バッチ処理(重要) 333 323 362 636 1638 8978 OutOfMemoryError: ヒープメモリオーバーフロー
バッチ処理 + 分割コミット 359 313 394 630 2907 18631 OutOfMemoryError: ヒープメモリオーバーフロー

上記の結果から、バッチ処理は確かにずっと速いことがわかります。数量が大きすぎると、メモリオーバーフローを超える可能性があります。バッチ処理に分割コミットを加えても、バッチ処理とほぼ同じか、むしろ遅くなります。コミットが多すぎるためです。SQL結合の方案は数量が少ない場合、バッチ処理と大差ないですが、最悪の方案はforループによるデータ挿入で、100件の時点で既に1秒かかります。この方案は選択できません。

最初にバッチ処理が遅いと感じた時、本当に自分を疑いました。後でパラメータがあることに気づき、霧が晴れたような気持ちでした。知ること越多、知らないこと越多。

タグ: SpringBoot MyBatis MySQL バッチ処理 データベースパフォーマンス

7月7日 16:00 投稿