项目实战--批量插入数据接口优化过程详解

一、前言

最近在项目上遇到批量插入接口先响应缓慢然后抛出异常的问题,主要是将APP的业务数据和用户点击/操作的行为数据插入MySQL,且每次需要插入超过 10w+ 的数据量并且字段较多,导致使用循环插入的方式(即MyBatis的<foreach collection=“behaverList” item=“behaver” separator=“,”>…</foreach>)插入数据插入的效率不高甚至抛出异常。
项目是从 JDBC 升级到 MyBatis / MyBatis Plus ,那就依次记录实现及优化过程。
在这里插入图片描述

二、建表

数据库版本 mysql 5.7.19

DROP TABLE IF EXISTS `fee`;
CREATE TABLE `fee`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '编号',
  `owner` varchar(64) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL COMMENT '归属人',
  `fee1` decimal(30, 5) NULL DEFAULT NULL COMMENT '费用1',
  `fee2` decimal(30, 5) NULL DEFAULT NULL COMMENT '费用2',
  `fee3` decimal(30, 5) NULL DEFAULT NULL COMMENT '费用3',
  `fee4` decimal(30, 5) NULL DEFAULT NULL COMMENT '费用4',
  `fee5` decimal(30, 5) NULL DEFAULT NULL COMMENT '费用5',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_unicode_ci COMMENT = '费用表' ROW_FORMAT = Dynamic;

三、JDBC插入

3.1 jdbc普通循环插入

/**
 * JDBC - 普通插入(循环遍历一条一条插入)
 */
public class JDBCDemo {
    public static void main(String[] args) {
        String url = "jdbc:mysql://localhost:3306/test";
        String user = "root";
        String password = "123456";
        String driver = "com.mysql.jdbc.Driver";
        // sql语句
        String sql = "INSERT INTO fee(`owner`,`fee1`,`fee2`,`fee3`,`fee4`,`fee5`) VALUES (?,?,?,?,?,?);";
        Connection conn = null;
        PreparedStatement ps = null;
        // 开始时间
        long start = System.currentTimeMillis();
        try {
            Class.forName(driver);
            conn = DriverManager.getConnection(url, user, password);
            ps = conn.prepareStatement(sql);
            // 循环遍历插入数据
            for (int i = 1; i <= 100000; i++) {
                ps.setString(1, "o"+i);
                ps.setBigDecimal(2, new BigDecimal("11111.111"));
                ps.setBigDecimal(3, new BigDecimal("11111.111"));
                ps.setBigDecimal(4, new BigDecimal("11111.111"));
                ps.setBigDecimal(5, new BigDecimal("11111.111"));
                ps.setBigDecimal(6, new BigDecimal("11111.111"));
                ps.executeUpdate();
            }
        } catch (ClassNotFoundException | SQLException e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                try {
                    conn.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
            if (ps != null) {
                try {
                    ps.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }
        // 结束时间
        long end = System.currentTimeMillis();
        System.out.println("十万条数据插入时间(普通插入方式):" + (end - start) + " ms");
    }
}

执行结果:
在这里插入图片描述

3.2 jdbc批处理插入

批量插入+ 手动事务提交减少磁盘的写入次数可以提高插入速度,在配置 MySQL 的 url 时需要加上 rewriteBatchedStatements=true 开启允许重写批量提交。分片大小为 1000(参考 MP 框架的默认分片大小)避免一次性提交的数据量过大

/**
 * JDBC - 批处理插入
 */
public class JDBCPlusDemo {
    public static void main(String[] args) {
        // url 设置允许重写批量提交 rewriteBatchedStatements=true
        String url = "jdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true";
        String user = "root";
        String password = "123456";
        String driver = "com.mysql.jdbc.Driver";
        // sql语句(注意url设置为rewriteBatchedStatements=true时,不允许sql语句带有;号,否则会抛出BatchUpdateException异常)
        String sql = "INSERT INTO fee(`owner`,`fee1`,`fee2`,`fee3`,`fee4`,`fee5`) VALUES (?,?,?,?,?,?)";
        Connection conn = null;
        PreparedStatement ps = null;
        // 开始时间
        long start = System.currentTimeMillis();
        try {
            Class.forName(driver);
            conn = DriverManager.getConnection(url, user, password);
            ps = conn.prepareStatement(sql);
            // 关闭自动提交
            conn.setAutoCommit(false);
            for (int i = 1; i <= 100000; i++) {
                ps.setString(1, "o"+i);
                ps.setBigDecimal(2, new BigDecimal("11111.111"));
                ps.setBigDecimal(3, new BigDecimal("11111.111"));
                ps.setBigDecimal(4, new BigDecimal("11111.111"));
                ps.setBigDecimal(5, new BigDecimal("11111.111"));
                ps.setBigDecimal(6, new BigDecimal("11111.111"));
                // 加入批处理(将当前sql加入缓存)
                ps.addBatch();
                // 以 1000 条数据作为分片
                if (i % 1000 == 0) {
                    // 执行缓存中的sql语句
                    ps.executeBatch();
                    // 清空缓存
                    ps.clearBatch();
                }
            }
            ps.executeBatch();
            ps.clearBatch();
            // 事务提交(实际开发中需要判断有插入失败的需要在 finally 中做好事务回滚操作)
            conn.commit();
        } catch (ClassNotFoundException | SQLException e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                try {
                    conn.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
            if (ps != null) {
                try {
                    ps.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }
        // 结束时间
        long end = System.currentTimeMillis();
        System.out.println("十万条数据插入时间(批处理插入):" + (end - start) + " ms");
    }
}

执行结果:
在这里插入图片描述
可见使用批处理+手动提交的方式插入 10w 条数据的执行时间大概在 1s 左右,速度明显提高。

四、MyBatis批量插入

4.1 foreach动态拼接插入

使用<foreach>标签:此方式 MyBatis 和 MyBatis Plus 两个框架均可用
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="db.review.mapper.FeeMapper">
    <insert id="insertByForeach">
        INSERT INTO fee(`owner`,`fee1`,`fee2`,`fee3`,`fee4`,`fee5`)
        VALUES
        <foreach collection="feeList" item="fee" separator=",">
            (#{fee.owner}, #{fee.fee1}, #{fee.fee2}, #{fee.fee3}, #{fee.fee4}, #{fee.fee5})
        </foreach>
   </insert>
</mapper>

service层调用:

@Service
public class FeeServiceImpl extends ServiceImpl<FeeMapper, Fee> implements FeeService {

    @Resource
    private FeeMapper feeMapper;

	@Override
    public int saveByForeach(List<Fee> feeList) {
        // 通过mapper的foreach动态拼接sql插入
        return feeMapper.insertByForeach(feeList);
    }
}

测试代码:

@SpringBootTest
public class MPDemo {

    @Resource
    private FeeService feeService;

    @Test
    public void mpDemo2() {
        // 获取 10w 条测试数据
        List<Fee> feeList = getFeeList();
        // 开始时间
        long start = System.currentTimeMillis();
        // foreach动态拼接插入
        feeService.saveByForeach(feeList);
        // 结束时间
        long end = System.currentTimeMillis();
        System.out.println("十万条数据插入时间(foreach动态拼接插入方式):" + (end - start) + " ms");
    }

    private List<Fee> getFeeList() {
        List<Fee> list = new ArrayList<>();
        for (int i = 1; i <= 100000; i++) {
            list.add(new Fee(null, "o" + i,
                    new BigDecimal("11111.111"),
                    new BigDecimal("11111.111"),
                    new BigDecimal("11111.111"),
                    new BigDecimal("11111.111"),
                    new BigDecimal("11111.111")));
        }
        return list;
    }
}

ps:这里运行会报错,因为默认情况下 MySQL 可执行的最大 SQL 语句大小为 4194304 即 4MB,这里使用动态 SQL 拼接后的大小会远大于默认值,故报错。
在这里插入图片描述

解决方式,设置 MySQL 的默认 sql 大小为 10MB:

set global max_allowed_packet=10*1024*1024;

测试结果:
在这里插入图片描述
可见增大默认 SQL 大小后,插入的时间在 3s 左右,但这种方式的弊端明显,即无法确定 SQL 大小,也不能总更改默认的 SQL 大小,既不实用,也会引起项目异常。

4.2 批处理插入

在配置文件的数据库配置 url 中加上rewriteBatchedStatements=true

# 配置数据库
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true
spring.datasource.username=root
spring.datasource.password=123456

service层加入批处理代码:

@Transactional
@Override
public int saveByBatch(List<Fee> feeList) {
    // 记录结果(影响行数)
    int res = 0;
    // 开启批处理模式
    SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH);
    FeeMapper feeMapper = sqlSession.getMapper(FeeMapper.class);
    for (int i = 1; i <= feeList.size(); i++) {
        // 利用mapper的单条插入方法插入
        res += feeMapper.insertByOne(feeList.get(i-1));
        // 进行分片类似 JDBC 的批处理,分片量增加为100000
        if (i % 100000 == 0) {
            sqlSession.commit();
            sqlSession.clearCache();
        }
    }
    sqlSession.commit();
    sqlSession.clearCache();
    return res;
}

测试类:

@SpringBootTest
public class MPDemo {

    @Resource
    private FeeService feeService;

    @Test
    public void mpDemo3() {
        // 获取 10w 条测试数据
        List<Fee> feeList = getFeeList();
        // 开始时间
        long start = System.currentTimeMillis();
        // 批处理插入
        feeService.saveByBatch(feeList);
        // 结束时间
        long end = System.currentTimeMillis();
        System.out.println("十万条数据插入时间(批处理插入方式):" + (end - start) + " ms");
    }

    private List<Fee> getFeeList() {
        List<Fee> list = new ArrayList<>();
        for (int i = 0; i < 100000; i++) {
            list.add(new Fee(null, "o" + i,
                    new BigDecimal("11111.111"),
                    new BigDecimal("11111.111"),
                    new BigDecimal("11111.111"),
                    new BigDecimal("11111.111"),
                    new BigDecimal("11111.111")));
        }
        return list;
    }
}

测试结果:
在这里插入图片描述
可见插入效率在 1s 左右。

五、MyBatis Plus批处理插入

5.1 MyBatis Plus 自带批处理

使用MyBatis Plus 自带的saveBatch()批处理方法完成批量插入,配置文件同上,需要开启允许重写批量处理提交。

@SpringBootTest
public class MPDemo {

    @Resource
    private FeeService feeService;

    @Test
    public void mpDemo4() {
        // 获取 10w 条测试数据
        List<Fee> feeList = getFeeList();
        // 开始时间
        long start = System.currentTimeMillis();
        // MyBatis Plus 自带的批处理插入
        feeService.saveBatch(feeList);
        // 结束时间
        long end = System.currentTimeMillis();
        System.out.println("十万条数据插入时间(MP 自带的批处理插入方式):" + (end - start) + " ms");
    }

    private List<Fee> getFeeList() {
        List<Fee> list = new ArrayList<>();
        for (int i = 0; i < 100000; i++) {
            list.add(new Fee(null, "o" + i,
                    new BigDecimal("11111.111"),
                    new BigDecimal("11111.111"),
                    new BigDecimal("11111.111"),
                    new BigDecimal("11111.111"),
                    new BigDecimal("11111.111")));
        }
        return list;
    }
}

测试结果:
在这里插入图片描述
可见使用 MP 自带的批处理方法执行时间在 2s 左右,虽然比自定义实现的批处理方法差一点点,但它可开箱即用,所以这是一种最好的选择。

5.2 MP 自带的 saveBatch() 方法源码分析

第一层源码:
在这里插入图片描述
可见带上一个默认参数 batchSize = 1000,即分片大小 1000,也是上文jdbc借鉴的分片大小。接着进入 executeBatch() 方法:
在这里插入图片描述
可见 Lambda 表达式功能跟上面的实现批处理插入方式类似,先逐条插入数据,当达到分片大小后,提交并刷新,从而达到批处理的效果。再深入到下一个 executeBatch() 方法会看到底层使用的也是批处理模式。
在这里插入图片描述
因此 MP 自带的批处理方法和上文中实现的批处理方法类似但更易用。

六、MyBatis-Plus + ThreadPoolTaskExecutor

使用 Spring Boot + MyBatis-Plus + ThreadPoolTaskExecutor 构建一个快速、稳定且能够处理百万级数据批量插入的接口。

6.1 项目创建及依赖配置(pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.0.0</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.icoderoad</groupId>
    <artifactId>batch-insertion</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>Batch Insertion</name>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- Spring Boot Web 依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- MyBatis-Plus 依赖 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.3.1</version>
        </dependency>

        <!-- 数据库驱动 -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- 线程池依赖 -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-tx</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

6.2 配置文件(application.yml)

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/db_name?useUnicode=true&characterEncoding=UTF-8&useSSL=false
    username: username
    password: password
    driver-class-name: com.mysql.cj.jdbc.Driver

  task:
    executor:
      core-pool-size: 100
      max-pool-size: 300
      queue-capacity: 99999

6.3 实体类

import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("data_table")
public class DataEntity {
    private Long id;
    private String name;
    private String description;
}

6.4 Mapper 接口

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.icoderoad.entity.DataEntity;

public interface DataMapper extends BaseMapper<DataEntity> {
}

6.5 DataService 接口类

import java.util.List;
import com.icoderoad.entity.DataEntity;

public interface DataService {

    void batchInsertData(List<DataEntity> dataList);
}

6.6 服务类

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.icoderoad.entity.DataEntity;
import com.icoderoad.mapper.DataMapper;
import com.icoderoad.service.DataService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;

@Service
public class DataServiceImpl extends ServiceImpl<DataMapper, DataEntity> implements DataService {

    @Autowired
    private ThreadPoolTaskExecutor taskExecutor;

    @Transactional
    public void batchInsertData(List<DataEntity> dataList) {
        // 分批插入数据
        int batchSize = 1000;  // 每批插入的数量

        List<Future<?>> futures = new ArrayList<>();

        for (int i = 0; i < dataList.size(); i += batchSize) {
            List<DataEntity> subList = dataList.subList(i, Math.min(i + batchSize, dataList.size()));
            futures.add(taskExecutor.submit(new Callable<Void>() {
                @Override
                public Void call() throws Exception {
                    baseMapper.insertBatchSomeColumn(subList);
                    return null;
                }
            }));
        }

        for (Future<?> future : futures) {
            try {
                future.get();
            } catch (InterruptedException | ExecutionException e) {
                e.printStackTrace();
            }
        }
    }
}

6.7 Executor 配置类

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
public class ExecutorConfig {

    @Value("${spring.task.executor.core-pool-size}")
    private int corePoolSize;

    @Value("${spring.task.executor.max-pool-size}")
    private int maxPoolSize;

    @Value("${spring.task.executor.queue-capacity}")
    private int queueCapacity;

    @Bean
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(corePoolSize);
        executor.setMaxPoolSize(maxPoolSize);
        executor.setQueueCapacity(queueCapacity);
        return executor;
    }
}

6.8 控制器类

import com.icoderoad.entity.DataEntity;
import com.icoderoad.service.DataService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

@RestController
public class DataController {

    @Autowired
    private DataService dataService;

    @PostMapping("/batchInsert")
    public String batchInsert(@RequestBody List<DataEntity> dataEntities) {
        dataService.batchInsertData(dataEntities);
        return "Batch insertion successful";
    }

    public static void main(String[] args) {
        List<DataEntity> dataList = new ArrayList<>();
        Random random = new Random();
        for (int i = 0; i < 1000000; i++) {
            DataEntity dataEntity = new DataEntity();
            dataEntity.setName("Name " + i);
            dataEntity.setDescription("Description " + random.nextInt());
            dataList.add(dataEntity);
        }
    }
}

七、总结

通过以上的优化和完善步骤,充分利用Spring Boot的强大功能、MyBatis-Plus的便捷高效操作以及ThreadPoolTaskExecutor 的高效并发处理能力,经验证可实现百万级数据的批量插入,在实际应用中,还可根据具体的业务需求和性能要求,对代码进行进一步的优化和调整。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

容若只如初见

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值