乐趣区

关于java:数据库批量插入这么讲究的么

最近新的我的项目写了不少各种 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.aphysia.spingbootdemo.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.aphysia.springdemo.mapper")
public class SpringdemoApplication {public static void main(String[] args) {SpringApplication.run(SpringdemoApplication.class, args);
    }

}

Mapper文件一共筹备了几个办法,插入单个对象,删除所有对象,拼接插入多个对象:

import com.aphysia.springdemo.model.User;
import org.apache.ibatis.annotations.Param;

import java.util.List;

public interface UserMapper {int insertUser(User user);

    int deleteAllUsers();


    int insertBatch(@Param("users") List<User>users);
}

Mapper.xml文件如下:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.aphysia.springdemo.mapper.UserMapper">
    <insert id="insertUser" parameterType="com.aphysia.springdemo.model.User">
        insert  into user(id,age) values(#{id},#{age})
    </insert>

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

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

测试的时候,每次操作咱们都删除掉所有的数据,保障测试的主观,不受之前的数据影响。

不同的测试

1. foreach 插入

先获取列表,而后每一条数据都执行一次数据库操作,插入数据:

@SpringBootTest
@MapperScan("com.aphysia.springdemo.mapper")
class SpringdemoApplicationTests {

    @Autowired
    SqlSessionFactory sqlSessionFactory;

    @Resource
    UserMapper userMapper;

    static int num = 100000;

    static int id = 1;

    @Test
    void insertForEachTest() {List<User> users = getRandomUsers();
        long start = System.currentTimeMillis();
        for (int i = 0; i < users.size(); i++) {userMapper.insertUser(users.get(i));
        }
        long end = System.currentTimeMillis();
        System.out.println("time:" + (end - start));
    }
}

2. 拼接 sql 插入

其实就是用以下的形式插入数据:

INSERT INTO `user` (`id`, `age`) 
VALUES (1, 11),
(2, 12),
(3, 13),
(4, 14),
(5, 15);
    @Test
    void insertSplicingTest() {List<User> users = getRandomUsers();
        long start = System.currentTimeMillis();
        userMapper.insertBatch(users);
        long end = System.currentTimeMillis();
        System.out.println("time:" + (end - start));
    }

3. 应用 Batch 批量插入

MyBatis sessionexecutor type 设为 Batch ,应用sqlSessionFactory 将执行形式置为批量,主动提交置为false, 全副插入之后,再一次性提交:

    @Test
    public void insertBatch(){SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false);
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        List<User> users = getRandomUsers();
        long start = System.currentTimeMillis();
        for(int i=0;i<users.size();i++){mapper.insertUser(users.get(i));
        }
        sqlSession.commit();
        sqlSession.close();
        long end = System.currentTimeMillis();
        System.out.println("time:" + (end - start));
    }

4. 批量解决 + 分批提交

在批处理的根底上,每 1000 条数据,先提交一下,也就是分批提交。

    @Test
    public void insertBatchForEachTest(){SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false);
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        List<User> users = getRandomUsers();
        long start = System.currentTimeMillis();
        for(int i=0;i<users.size();i++){mapper.insertUser(users.get(i));
            if (i % 1000 == 0 || i == num - 1) {sqlSession.commit();
                sqlSession.clearCache();}
        }
        sqlSession.close();
        long end = System.currentTimeMillis();
        System.out.println("time:" + (end - start));
    }

首次后果,显著不对?

运行下面的代码,咱们能够失去上面的后果,for循环插入的效率的确很差,拼接的 sql 效率绝对高一点,看到有些材料说拼接 sql 可能会被 mysql 限度,然而我执行到 1000w 的时候,才看到堆内存溢出。

上面是不正确的后果!!!

插入方式 10 100 1000 1w 10w 100w 1000w
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 足足 600 多 M,太大了,怪不得不会报错,那咱们去改改一下它吧,改完从新测试:

  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
# Disabling symbolic-links is recommended to prevent assorted security risks
symbolic-links=0
 
# Custom config should go here
!includedir /etc/mysql/conf.d/
max_allowed_packet=2M
  1. 退出容器
# exit
  1. 查看 mysql 容器id
docker ps -a

  1. 重启mysql
docker restart c178e8998e68

重启胜利后查看最大的max_allowed_pactet,发现曾经批改胜利:

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,发现100w 的时候,sql就达到了 3.6M 左右,超过了咱们设置的2M,胜利的演示抛出了谬误:

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 语句拆散,然而执行的时候是一条一条地发给 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 1w 10w 100w 1000w
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条的时候就曾经须要 1s 了,不能抉择这种计划。

一开始发现批处理比较慢的时候,真的挺狐疑本人,前面发现是有一个参数,有一种拨开云雾的感觉,晓得得越多,不晓得的越多。

【作者简介】
秦怀,公众号【秦怀杂货店】作者,技术之路不在一时,山高水长,纵使迟缓,驰而不息。

剑指 Offer 全副题解 PDF

2020 年我写了什么?

开源编程笔记

退出移动版