在尼恩的(50+)读者社区中,经常遇到一个 非常、非常高频的一个面试题,但是很不好回答,类似如下:
- Java怎么实现几十万条数据插入?
- JDBC 添加几万条数据,要求保证效率,请写出代码?
最近,有个小伙伴网易一面,又遇到了这个问题。
咱们一直心心念念的 “Java怎么实现几十万条数据插入?” 的教科书式的答案,
接下来, 尼恩为大家梳理一个教科书式的答案,
超高并发写mysql,一定要用到 批处理方案 。
其实,很多小伙伴都用过,但是却会少说一些核心参数, 少说一些核心步骤,导致面试严重丢分。
这里,尼恩也一并把这个题目以及参考答案,收入咱们的 《尼恩Java面试宝典 PDF》V98版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请到公号【技术自由圈】取
当需要成批插入或者更新记录时,可以采用Java的批量更新机制,
这一机制允许多条语句一次性提交给数据库批量处理。通常情况下,批量处理 比单独提交处理更有效率
JDBC的批量处理语句包括下面三个方法:
通常我们会遇到两种批量执行SQL语句的情况:
没有对比,没有伤害
没有数据,不是高手
看看普通插入10000W条记录的 性能数据
/**
* 方式一
* 普通批量插入,直接将插入语句执行多次即可
*/
@Test
public void bulkSubmissionTest1() {
long start = System.currentTimeMillis();//开始计时【单位:毫秒】
Connection conn = jdbcUtils.getConnection();//获取数据库连接
String sql = "insert into a(id, name) VALUES (?,null)";
PreparedStatement ps = null;
try {
ps = conn.prepareStatement(sql);
for (int i = 1; i <= 1000000; i++) {
ps.setObject(1, i);//填充sql语句种得占位符
ps.execute();//执行sql语句
}
} catch (SQLException e) {
e.printStackTrace();
} finally {
jdbcUtils.close(conn, ps, null);
}
//打印耗时【单位:毫秒】
System.out.println("百万条数据插入用时:" + (System.currentTimeMillis() - start)+"【单位:毫秒】");
}
用时
折算:3736/60= 62分钟多
使用PreparedStatement
JDBC的批量处理语句包括下面三个方法:
注意 这三个方法实现sql语句打包,累计到一定数量一次提交
@Test
public void bulkSubmissionTest2() {
long start = System.currentTimeMillis();
Connection conn = jdbcUtils.getConnection();//获取数据库连接
String sql = "insert into a(id, name) VALUES (?,null)";
PreparedStatement ps = null;
try {
ps = conn.prepareStatement(sql);
for (int i = 1; i <= 1000000; i++) {
ps.setObject(1, i);
ps.addBatch();//将sql语句打包到一个容器中
if (i % 500 == 0) {
ps.executeBatch();//将容器中的sql语句提交
ps.clearBatch();//清空容器,为下一次打包做准备
}
}
//为防止有sql语句漏提交【如i结束时%500!=0的情况】,需再次提交sql语句
ps.executeBatch();//将容器中的sql语句提交
ps.clearBatch();//清空容器
} catch (SQLException e) {
e.printStackTrace();
} finally {
jdbcUtils.close(conn, ps, null);
}
System.out.println("百万条数据插入用时:" + (System.currentTimeMillis() - start)+"【单位:毫秒】");
}
用时
折算:3685/60= 61分钟多
方式一、二总结:到此可以看出其实批处理程序是没有起作用的
通过连接配置url设置&rewriteBatchedStatements=true,打开驱动的rewriteBatchedStatements 开关
在方式二的基础上, 允许jdbc驱动重写批量提交语句,在数据源的url需加上 &rewriteBatchedStatements=true ,表示(重写批处理语句=是)
驱动的url 设置参考如下:
spring.datasource.url = jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true&rewriteBatchedStatements=true
spring.datasource.username = root
spring.datasource.password = 123456
再执行 jdbc 的批处理
/**
* 方式三
*/
@Test
public void bulkSubmissionTest3() {
long start = System.currentTimeMillis();
Connection conn = jdbcUtils.getConnection();//获取数据库连接
String sql = "insert into a(id, name) VALUES (?,null)";
PreparedStatement ps = null;
try {
ps = conn.prepareStatement(sql);
for (int i = 1; i <= 1000000; i++) {
ps.setObject(1, i);
ps.addBatch();
if (i % 500 == 0) {
ps.executeBatch();
ps.clearBatch();
}
}
ps.executeBatch();
ps.clearBatch();
} catch (SQLException e) {
e.printStackTrace();
} finally {
jdbcUtils.close(conn, ps, null);
}
System.out.println("百万条数据插入用时:" + (System.currentTimeMillis() - start)+"【单位:毫秒】");
}
用时:
折算:10s
从60分钟到10s, 提升了多少倍?
360倍
到此批处理语句才正是生效
数据库连接的url设置了【&rewriteBatchedStatements=true】时,java代码种的sql语句不能有分号【;】号,
否则批处理语句打包就会出现错误,导致后面的sql语句提交出现【BatchUpdateException】异常
批量更新异常:BatchUpdateException
从 Java 1.2 开始,该Statement
接口一直提供addBatch
我们可以用来批处理多个语句的接口,以便在调用executeBatch
方法时通过单个请求发送它们,如下面的示例所示:
String INSERT = "insert into post (id, title) values (%1$d, 'Post no. %1$d')";
try(Statement statement = connection.createStatement()) {
for (long id = 1; id <= 10; id++) {
statement.addBatch(
String.format(INSERT, id)
);
}
statement.executeBatch();
}
通过分析源码,会发现以下代码块:
if (this.rewriteBatchedStatements.getValue() && nbrCommands > 4) {
return executeBatchUsingMultiQueries(
multiQueriesEnabled,
nbrCommands,
individualStatementTimeout
);
}
updateCounts = new long[nbrCommands];
for (int i = 0; i < nbrCommands; i++) {
updateCounts[i] = -3;
}
int commandIndex = 0;
for (commandIndex = 0; commandIndex < nbrCommands; commandIndex++) {
try {
String sql = (String) batchedArgs.get(commandIndex);
updateCounts[commandIndex] = executeUpdateInternal(sql, true, true);
...
} catch (SQLException ex) {
updateCounts[commandIndex] = EXECUTE_FAILED;
...
}
}
因为rewriteBatchedStatements
标志is flase ,每个 INSERT 语句还是单独执行 executeUpdateInternal, 并没有走 executeBatchUsingMultiQueries 批处理逻辑。
因此,即使我们使用addBatch ,默认情况下,MySQL 在使用普通 JDBC对象executeBatch
时仍会单独执行 INSERT 语句。
但是,如果我们启用rewriteBatchedStatements
JDBC 配置属性
方式一:在springboot应用中,调整dataSource的 url 参数
spring.datasource.url = jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true&rewriteBatchedStatements=true
方式二:通过dataSource的方法设置
MysqlDataSource dataSource = new MysqlDataSource();
String url = "jdbc:mysql://localhost/high_performance_java_persistence?useSSL=false";
dataSource.setURL(url);
dataSource.setUser(username());
dataSource.setPassword(password());
dataSource.setRewriteBatchedStatements(true);
设置完了之后,调试executeBatch
方法执行,你会看到:
这一次,executeBatchUsingMultiQueries
被调用了
if (this.rewriteBatchedStatements.getValue() && nbrCommands > 4) {
return executeBatchUsingMultiQueries(
multiQueriesEnabled,
nbrCommands,
individualStatementTimeout
);
}
并且该executeBatchUsingMultiQueries
方法会将各个 INSERT 语句拼接到 queryBuf (一个StringBuilder),
拼接后,运行单个execute
调用:
StringBuilder queryBuf = new StringBuilder();
batchStmt = locallyScopedConn.createStatement();
JdbcStatement jdbcBatchedStmt = (JdbcStatement) batchStmt;
...
int argumentSetsInBatchSoFar = 0;
for (commandIndex = 0; commandIndex < nbrCommands; commandIndex++) {
String nextQuery = (String) this.query.getBatchedArgs().get(commandIndex);
...
queryBuf.append(nextQuery);
queryBuf.append(";");
argumentSetsInBatchSoFar++;
}
if (queryBuf.length() > 0) {
try {
batchStmt.execute(queryBuf.toString(), java.sql.Statement.RETURN_GENERATED_KEYS);
} catch (SQLException ex) {
sqlEx = handleExceptionForBatch(
commandIndex - 1, argumentSetsInBatchSoFar, updateCounts, ex
);
}
...
}
因此,对于普通的 JDBCStatement
批处理,MySQLrewriteBatchedStatements
配置属性将附加当前批处理的语句并在单个数据库往返中执行它们。
使用 JPA 和 Hibernate 时, SQL 语句都将使用 JDBC 执行PreparedStatement
,而不是Statement,这是有充分理由的:
String.format
调用中那样注入它们。但是,由于 Hibernate 默认不启用 JDBC 批处理,我们需要提供以下配置属性来激活自动批处理机制:
spring.jpa.properties.hibernate.jdbc.batch_size=10
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
因此,当持久化 10 个Post
实体时:
for (long i = 1; i <= 10; i++) {
entityManager.persist(
new Post()
.setId(i)
.setTitle(String.format("Post no. %d", i))
);
}
Hibernate 将执行单个 JDBC INSERT,如datasource-proxy日志条目所示:
Type:Prepared, Batch:True, QuerySize:1, BatchSize:10,
Query:["
insert into post (title, id) values (?, ?)
"],
Params:[
(Post no. 1, 1), (Post no. 2, 2), (Post no. 3, 3),
(Post no. 4, 4), (Post no. 5, 5), (Post no. 6, 6),
(Post no. 7, 7), (Post no. 8, 8), (Post no. 9, 9),
(Post no. 10, 10)
]
注意:如果使用
IDENTITY
实体标识符策略,Hibernate 将无法自动批处理插入语句。看看这篇文章。
因此,使用默认的 MySQL JDBC 驱动程序设置,一条语句被发送到 MySQL 数据库服务器。
但是,如果你检查数据库服务器日志,我们可以看到语句到达后,MySQL 执行每个语句,就好像它们在 for 循环中运行一样:
Query insert into post (title, id) values ('Post no. 1', 1)
Query insert into post (title, id) values ('Post no. 2', 2)
Query insert into post (title, id) values ('Post no. 3', 3)
Query insert into post (title, id) values ('Post no. 4', 4)
Query insert into post (title, id) values ('Post no. 5', 5)
Query insert into post (title, id) values ('Post no. 6', 6)
Query insert into post (title, id) values ('Post no. 7', 7)
Query insert into post (title, id) values ('Post no. 8', 8)
Query insert into post (title, id) values ('Post no. 9', 9)
Query insert into post (title, id) values ('Post no. 10', 10)
Query commit
因此,启用rewriteBatchedStatements
MySQL JDBC Driver 设置后:
dataSource.setRewriteBatchedStatements(true);
当我们重新运行之前插入 10 个实体的测试用例时Post
,我们可以看到在数据库端执行了以下 INSERT 语句:
Query insert into post (title, id)
values ('Post no. 1', 1),('Post no. 2', 2),('Post no. 3', 3),
('Post no. 4', 4),('Post no. 5', 5),('Post no. 6', 6),
('Post no. 7', 7),('Post no. 8', 8),('Post no. 9', 9),
('Post no. 10', 10)
Query commit
语句更改的原因是 MySQL JDBC 驱动程序现在调用将executeBatchWithMultiValuesClause
批处理的 INSERT 语句重写为单个多值 INSERT 的方法。
if (!this.batchHasPlainStatements &&
this.rewriteBatchedStatements.getValue()) {
if (getQueryInfo().isRewritableWithMultiValuesClause()) {
return executeBatchWithMultiValuesClause(batchTimeout);
}
...
}
在方式三的基础上,取消自动提交sql语句,当sql语句都提交了才手动提交sql语句
需将Connection conn;连接的【conn.setAutoCommit(false)】(设置自动提交=否)
/**
* 方式四
*
*/
@Test
public void bulkSubmissionTest4() {
long start = System.currentTimeMillis();
Connection conn = jdbcUtils.getConnection();//获取数据库连接
String sql = "insert into a(id, name) VALUES (?,null)";
PreparedStatement ps = null;
try {
ps = conn.prepareStatement(sql);
conn.setAutoCommit(false);//取消自动提交
for (int i = 1; i <= 1000000; i++) {
ps.setObject(1, i);
ps.addBatch();
if (i % 500 == 0) {
ps.executeBatch();
ps.clearBatch();
}
}
ps.executeBatch();
ps.clearBatch();
conn.commit();//所有语句都执行完毕后才手动提交sql语句
} catch (SQLException e) {
e.printStackTrace();
} finally {
jdbcUtils.close(conn, ps, null);
}
System.out.println("百万条数据插入用时:" + (System.currentTimeMillis() - start)+"【单位:毫秒】");
}
用时:【4秒左右】
上述示例代码中,我们通过 JDBC 连接 MySQL 数据库,并执行批处理操作插入数据。
具体实现步骤如下:
使用setAutoCommit(false)
来禁止自动提交事务,然后在每次批量插入之后手动提交事务。
每次插入数据时都新建一个 PreparedStatement 对象以避免状态不一致问题。
在插入数据的循环中,每累计到一定量的数据 如 10000 条数据就执行一次 executeBatch()
插入数据。
索引
: 在大量数据插入前暂时去掉索引,最后再打上,这样可以大大减少写入时候的更新索引的时间。数据库连接池
:使用数据库连接池可以减少数据库连接建立和关闭的开销,提高性能。在没有使用数据库连接池的情况,记得在finally
中关闭相关连接。数据库参数调整
:增加MySQL数据库缓冲区大小、配置高性能的磁盘和I/O等。结合 B站的方案,大家回到前面的面试题:
以上的方案,才是完美的答案,才是“教科书式” 答案。 后续,尼恩会给大家结合行业案例,分析出更多,更加劲爆的答案。
当然,如果遇到这类问题,可以找尼恩求助。
《炸裂,靠“吹牛”过京东一面,月薪40K》
《太猛了,靠“吹牛”过顺丰一面,月薪30K》
《炸裂了…京东一面索命40问,过了就50W+》
《问麻了…阿里一面索命27问,过了就60W+》
《百度狂问3小时,大厂offer到手,小伙真狠!》
《饿了么太狠:面个高级Java,抖这多硬活、狠活》
《字节狂问一小时,小伙offer到手,太狠了!》
《收个滴滴Offer:从小伙三面经历,看看需要学点啥?》
《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》PDF,请到下面公号【技术自由圈】取↓↓↓