SpringBoot实现Mysql百万级数据量导出并避免OOM的解决⽅
案
前⾔
动态数据导出是⼀般项⽬都会涉及到的功能。它的基本实现逻辑就是从mysql查询数据,加载到内存,然后从内存创建excel或者csv,以流的形式响应给前端。
虽然这是个可⾏的⽅案,然⽽⼀旦mysql数据量太⼤,达到⼗万级,百万级,千万级,⼤规模数据加载到内存必然会引起OutofMemoryError。
要考虑如何避免OOM,⼀般有两个⽅⾯的思路。
⼀⽅⾯就是尽量不做呗,先怼产品下⾯⼏个问题啊:
1. 我们为什么要导出这么多数据呢?谁傻到去看这么⼤的数据啊,这个设计是不是合理的呢?
2. 怎么做好权限控制?百万级数据导出你确定不会泄露商业机密?
3. 如果要导出百万级数据,那为什么不直接⼤数据或者DBA来⼲呢?然后以邮件形式传递不⾏吗?
4. 为什么要通过后端的逻辑来实现,不考虑时间成本,流量成本吗?
5. 如果通过分页导出,每次点击按钮只导2万条,分批导出难道不能满⾜业务需求吗?
如果产品说 “甲⽅是爸爸,你去和甲⽅说啊”,“客户说这个做出来,才考虑付尾款!”,如果客户的确缺根筋要让你这样搞, 那就只能从技术上考虑如何实现了。
从技术上讲,为了避免OOM,我们⼀定要注意⼀个原则:
不能将全量数据⼀次性加载到内存之中。
全量加载不可⾏,那我们的⽬标就是如何实现数据的分批加载了。实事上,Mysql本⾝⽀持Stream查询,我们可以通过Stream流获取数据,然后将数据逐条刷⼊到⽂件中,每次刷⼊⽂件后再从内存中移除这条数据,从⽽避免OOM。
由于采⽤了数据逐条刷⼊⽂件,⽽且数据量达到百万级,所以⽂件格式就不要采⽤excel了,excel2007最⼤才⽀持104万⾏的数据。这⾥推荐:
以csv代替excel。
考虑到当前SpringBoot持久层框架通常为JPA和mybatis,我们可以分别从这两个框架实现百万级数据导出的⽅案。
JPA实现百万级数据导出
核⼼注解如下,需要加⼊到具体的Repository之上。⽅法的返回类型定义成Stream。Integer.MIN_VALUE告诉jdbc driver逐条返回数据。
@QueryHints(value = @QueryHint(name = HINT_FETCH_SIZE, value = "" + Integer.MIN_VALUE))
@Query(value = "select t from Todo t")
Stream<Todo> streamAll();
此外还需要在Stream处理数据的⽅法之上添加@Transactional(readOnly = true),保证事物是只读的。
同时需要注⼊javax.persistence.EntityManager,通过detach从内存中移除已经使⽤后的对象。
@RequestMapping(value = "/todos.csv", method = RequestMethod.GET)
@Transactional(readOnly = true)
public void exportTodosCSV(HttpServletResponse response) {
response.addHeader("Content-Type", "application/csv");
response.addHeader("Content-Disposition", "attachment; filename=todos.csv");
response.setCharacterEncoding("UTF-8");
try(Stream<Todo> todoStream = todoRepository.streamAll()) {
PrintWriter out = Writer();
todoStream.forEach(rethrowConsumer(todo -> {
String line = todoToCSV(todo);
out.write(line);
out.write("\n");
entityManager.detach(todo);
}));
out.flush();
} catch (IOException e) {
log.info("Exception occurred " + e.getMessage(), e);
throw new RuntimeException("Exception occurred while exporting results", e);
}
}
MyBatis实现百万级数据导出
MyBatis实现逐条获取数据,必须要⾃定义ResultHandler,然后在l⽂件中,对应的select语句中添加fetchSize="-2147483648"。
最后将⾃定义的ResultHandler传给SqlSession来执⾏查询,并将返回的结果进⾏处理。
MyBatis实现百万级数据导出的具体实例
以下是基于MyBatis Stream导出的完整的⼯程样例,我们将通过对⽐Stream⽂件导出和传统⽅式导出的内存占⽤率的差异,来验证Stream⽂件导出的有效性。
我们先定义⼀个⼯具类DownloadProcessor,它内部封装⼀个HttpServletResponse对象,⽤来将对象写⼊到csv。
public class DownloadProcessor {
private final HttpServletResponse response;
public DownloadProcessor(HttpServletResponse response) {
String fileName = System.currentTimeMillis() + ".csv";
}
public <E> void processData(E record) {
try {
}catch (IOException e){
e.printStackTrace();
}
}
}
然后通过实现org.apache.ibatis.session.ResultHandler,⾃定义我们的ResultHandler,它⽤于获取java对象,然后传递给上⾯的DownloadProcessor处理类进⾏写⽂件操作:
public class CustomResultHandler implements ResultHandler {
private final DownloadProcessor downloadProcessor;
public CustomResultHandler(
DownloadProcessor downloadProcessor) {
super();
this.downloadProcessor = downloadProcessor;
}
@Override
public void handleResult(ResultContext resultContext) {
Authors authors = (ResultObject();
downloadProcessor.processData(authors);
}
}
实体类:
mysql下载后如何使用public class Authors {
private Integer id;
private String firstName;
private String lastName;
private String email;
private Date birthdate;
private Date added;
public Integer getId() {
return id;
}
public void setId(Integer id) {
public void setId(Integer id) {
this.id = id;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName == null ? null : im();
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName == null ? null : im();
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
}
public Date getBirthdate() {
return birthdate;
}
public void setBirthdate(Date birthdate) {
this.birthdate = birthdate;
}
public Date getAdded() {
return added;
}
public void setAdded(Date added) {
this.added = added;
}
@Override
public String toString() {
return this.id + "," + this.firstName + "," + this.lastName + "," + ail + "," + this.birthdate + "," + this.added;
}
}
Mapper接⼝:
public interface AuthorsMapper {
List<Authors> selectByExample(AuthorsExample example);
List<Authors> streamByExample(AuthorsExample example); //以stream形式从mysql获取数据
}
Mapper xml⽂件核⼼⽚段,以下两条select的唯⼀差异就是在stream获取数据的⽅式中多了⼀条属性: fetchSize="-2147483648"
<select id="selectByExample" parameterType="sqlstreamingexport.domain.AuthorsExample" resultMap="BaseResultMap">
select
<if test="distinct">
distinct
</if>
'false' as QUERYID,
<include refid="Base_Column_List" />
from authors
<if test="_parameter != null">
<include refid="Example_Where_Clause" />
</if>
<if test="orderByClause != null">
order by ${orderByClause}
</if>
</select>
<select id="streamByExample" fetchSize="-2147483648" parameterType="sqlstreamingexport.domain.AuthorsExample" resultMap="BaseRes select
<if test="distinct">
distinct
</if>
'false' as QUERYID,
<include refid="Base_Column_List" />
from authors
<if test="_parameter != null">
<include refid="Example_Where_Clause" />
</if>
<if test="orderByClause != null">
order by ${orderByClause}
</if>
</select>
获取数据的核⼼service如下,由于只做个简单演⽰,就懒得写成接⼝了。其中 streamDownload ⽅法即为stream取数据写⽂件的实现,
它将以很低的内存占⽤从MySQL获取数据;此外还提供traditionDownload⽅法,它是⼀种传统的下载⽅式,批量获取全部数据,然后将
每个对象写⼊⽂件。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论