赞
踩
动态数据导出是一般项目都会涉及到的功能。它的基本实现逻辑就是从mysql
查询数据,加载到内存,然后从内存创建 excel
或者 csv
,以流的形式响应给前端。但是全量加载不可行,那我们的目标就是如何实现数据的分批加载了。实事上,Mysql
本身支持 Stream
查询,我们可以通过Stream
流获取数据,然后将数据逐条刷入到文件中,每次刷入文件后再从内存中移除这条数据,从而避免OOM
。
由于采用了数据逐条刷入文件,而且数据量达到百万级,所以文件格式就不要采用 excel
了,excel2007最大才支持104万行的数据。这里推荐,以csv
代替excel
流式查询
指的是查询成功后不是返回一个集合而是返回一个迭代器,应用每次从迭代器取一条查询结果。流式查询的好处是能够降低内存使用。
如果没有流式查询,我们想要从数据库取 1000 万条
记录而又没有足够的内存时,就不得不分页查询,而分页查询效率取决于表设计,如果设计的不好,就无法执行高效的分页查询。因此流式查询是一个数据库访问框架必须具备的功能。
MyBatis
中使用流式查询避免数据量过大导致 OOM
,但在流式查询的过程当中,数据库连接是保持打开状态的,因此要注意的是:
为什么要用流式查询?
如果有一个很大的查询结果需要遍历处理,又不想一次性将结果集装入客户端内存,就可以考虑使用流式查询;
分库分表场景下,单个表的查询结果集虽然不大,但如果某个查询跨了多个库多个表,又要做结果集的合并、排序等动作,依然有可能撑爆内存;详细研究了sharding-sphere的代码不难发现,除了group by与order by字段不一样之外,其他的场景都非常适合使用流式查询,可以最大限度的降低对客户端内存的消耗。
MyBatis
提供了一个叫 org.apache.ibatis.cursor.Cursor
的接口类用于流式查询,这个接口继承了 java.io.Closeable
和 java.lang.Iterable
接口,由此可知:
Cursor
是可关闭的;Cursor
是可遍历的。
除此之外,Cursor
还提供了三个方法:
isOpen()
: 用于在取数据之前判断 Cursor
对象是否是打开状态。只有当打开时 Cursor 才能取数据;isConsumed()
: 用于判断查询结果是否全部取完。getCurrentIndex()
: 返回已经获取了多少条数据使用流式查询,则要保持对产生结果集的语句所引用的表的并发访问,因为其查询会独占连接,所以必须尽快处理
我们举个实际例子。下面是一个 Mapper 类:
@Mapper
public interface FooMapper {
@Select("select * from foo limit #{limit}")
Cursor<Foo> scan(@Param("limit") int limit);
}
方法 scan()
是一个非常简单的查询。通过指定 Mapper
方法的返回值为 Cursor
类型,MyBatis
就知道这个查询方法一个流式查询。
然后我们再写一个 SpringMVC Controller
方法来调用 Mapper(无关的代码已经省略):
@GetMapping("foo/scan/0/{limit}")
public void scanFoo0(@PathVariable("limit") int limit) throws Exception {
try (Cursor<Foo> cursor = fooMapper.scan(limit)) { // 1
cursor.forEach(foo -> {}); // 2
}
}
上面的代码中,fooMapper
是 @Autowired
进来的。注释 1 处调用 scan 方法,得到 Cursor
对象并保证它能最后关闭;2 处则是从 cursor
中取数据。
上面的代码看上去没什么问题,但是执行 scanFoo0()
时会报错:
java.lang.IllegalStateException: A Cursor is already closed.
这是因为我们前面说了在取数据的过程中需要保持数据库连接,而 Mapper 方法通常在执行完后连接就关闭了,因此 Cusor 也一并关闭了。
我们可以用 SqlSessionFactory
来手工打开数据库连接,将 Controller 方法修改如下:
@Autowired
private SqlSessionFactory sqlSessionFactory;
@GetMapping("foo/scan/1/{limit}")
public void scanFoo1(@PathVariable("limit") int limit) throws Exception {
try (
SqlSession sqlSession = sqlSessionFactory.openSession(); // 1
Cursor<Foo> cursor =
sqlSession.getMapper(FooMapper.class).scan(limit) // 2
) {
cursor.forEach(foo -> { });
}
}
上面的代码中,1 处我们开启了一个 SqlSession
(实际上也代表了一个数据库连接),并保证它最后能关闭;2 处我们使用 SqlSession 来获得 Mapper 对象。这样才能保证得到的 Cursor 对象是打开状态的。
在 Spring
中,我们可以用 TransactionTemplate
来执行一个数据库事务,这个过程中数据库连接同样是打开的。代码如下:
@GetMapping("foo/scan/2/{limit}")
public void scanFoo2(@PathVariable("limit") int limit) throws Exception {
TransactionTemplate transactionTemplate =
new TransactionTemplate(transactionManager); // 1
transactionTemplate.execute(status -> { // 2
try (Cursor<Foo> cursor = fooMapper.scan(limit)) {
cursor.forEach(foo -> { });
} catch (IOException e) {
e.printStackTrace();
}
return null;
});
}
上面的代码中,1 处我们创建了一个 TransactionTemplate
对象,2 处执行数据库事务,而数据库事务的内容则是调用 Mapper 对象的流式查询。注意这里的 Mapper 对象无需通过 SqlSession 创建。
这个本质上和方案二一样,代码如下:
@GetMapping("foo/scan/3/{limit}")
@Transactional
public void scanFoo3(@PathVariable("limit") int limit) throws Exception {
try (Cursor<Foo> cursor = fooMapper.scan(limit)) {
cursor.forEach(foo -> { });
}
}
它仅仅是在原来方法上面加了个 @Transactional
注解。这个方案看上去最简洁,但请注意 Spring
框架当中注解使用的坑:只在外部调用时生效 。在当前类中调用这个方法,依旧会报错。
点击此处了解Spring事务
mybatis
的所谓流式查询,就是服务端程序查询数据的过程中,与远程数据库一直保持连接,不断的去数据库拉取数据,提交事务并关闭sqlsession
后,数据库连接断开,停止数据拉取,需要注意的是使用这种方式,需要自己手动维护sqlsession和事务的提交。
实现方式很简单,原来返回的类型是集合或对象,流式查询返回的的类型Curor
,泛型内表示实际的类型,其他没有变化;
@Mapper
public interface PersonDao {
Cursor<Person> selectByCursor();
Integer queryCount();
}
对应SQL文件
<select id="selectByCursor" resultMap="personMap">
select * from sys_person order by id desc
</select>
<select id="queryCount" resultType="java.lang.Integer">
select count(*) from sys_person
</select>
dao
层向service
层返回的是Cursor
类型对象,只要不提交关闭sqlsession
,服务端程序就可以一直从数据数据库读取数据,直到查询sql匹配到数据全部读取完;
示例里的主要业务逻辑是:从sys_person
表中读取所有的人员信息数据,然后按照每1000条数据为一组,读取到内存里进行处理,以此类推,直到查询sql匹配到数据全部处理完,再提交事务,关闭sqlSession;
@Service @Slf4j public class PersonServiceImpl implements IPersonService { @Autowired private SqlSessionFactory sqlSessionFactory; @Override public void getOneByAsync() throws InterruptedException { new Thread(new Runnable() { @SneakyThrows @Override public void run() { //使用sqlSessionFactory打开一个sqlSession,在没有读取完数据之前不要提交事务或关闭sqlSession log.info("----开启sqlSession"); SqlSession sqlSession = sqlSessionFactory.openSession(); try { //获取到指定mapper PersonDao mapper = sqlSession.getMapper(PersonDao.class); //调用指定mapper的方法,返回一个cursor Cursor<Person> cursor = mapper.selectByCursor(); //查询数据总量 Integer total = mapper.queryCount(); //定义一个list,用来从cursor中读取数据,每读取够1000条的时候,开始处理这批数据; //当前批数据处理完之后,清空list,准备接收下一批次数据;直到大量的数据全部处理完; List<Person> personList = new ArrayList<>(); int i = 0; if (cursor != null) { for (Person person : cursor) { if (personList.size() < 1000) { // log.info("----id:{},userName:{}", person.getId(), person.getUserName()); personList.add(person); } else if (personList.size() == 1000) { ++i; log.info("----{}、从cursor取数据达到1000条,开始处理数据", i); log.info("----处理数据中..."); Thread.sleep(1000);//休眠1s模拟处理数据需要消耗的时间; log.info("----{}、从cursor中取出的1000条数据已经处理完毕", i); personList.clear(); personList.add(person); } if (total == (cursor.getCurrentIndex() + 1)) { ++i; log.info("----{}、从cursor取数据达到1000条,开始处理数据", i); log.info("----处理数据中..."); Thread.sleep(1000);//休眠1s模拟处理数据需要消耗的时间; log.info("----{}、从cursor中取出的1000条数据已经处理完毕", i); personList.clear(); } } if (cursor.isConsumed()) { log.info("----查询sql匹配中的数据已经消费完毕!"); } } sqlSession.commit(); log.info("----提交事务"); }catch (Exception e){ e.printStackTrace(); sqlSession.rollback(); } finally { if (sqlSession != null) { //全部数据读取并且做好其他业务操作之后,提交事务并关闭连接; sqlSession.close(); log.info("----关闭sqlSession"); } } } }).start(); } }
对大量数据进行处理时,为防止内存泄漏情况发生,也可以采用游标方式进行数据查询处理。
当查询百万级的数据的时候,还可以使用游标方式进行数据查询处理,不仅可以节省内存的消耗,而且还不需要一次性取出所有数据,可以进行逐条处理或逐条取出部分批量处理。一次查询指定 fetchSize
的数据,直到把数据全部处理完。
Mybatis
的处理加了两个注解:@Options
和 @ResultType
@Mapper
public interface BigDataSearchMapper extends BaseMapper<BigDataSearchEntity> {
// 方式一 多次获取,一次多行
@Select("SELECT bds.* FROM big_data_search bds ${ew.customSqlSegment} ")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = 1000000)
Page<BigDataSearchEntity> pageList(@Param("page") Page<BigDataSearchEntity> page, @Param(Constants.WRAPPER) QueryWrapper<BigDataSearchEntity> queryWrapper);
// 方式二 一次获取,一次一行
@Select("SELECT bds.* FROM big_data_search bds ${ew.customSqlSegment} ")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = 100000)
@ResultType(BigDataSearchEntity.class)
void listData(@Param(Constants.WRAPPER) QueryWrapper<BigDataSearchEntity> queryWrapper, ResultHandler<BigDataSearchEntity> handler);
}
@Options
:ResultSet.FORWORD_ONLY
:结果集的游标只能向下滚动ResultSet.SCROLL_INSENSITIVE
:结果集的游标可以上下移动,当数据库变化时,当前结果集不变ResultSet.SCROLL_SENSITIVE
:返回可滚动的结果集,当数据库变化时,当前结果集同步改变fetchSize
:每次获取量@ResultType
:@ResultType(BigDataSearchEntity.class)
:转换成返回实体类型注意
:返回类型必须为 void
,因为查询的结果在 ResultHandler
里处理数据,所以这个 hander
也是必须的,可以使用 lambda
实现一个依次处理逻辑。
注意:虽然上面的代码中都有 @Options
但实际操作却有不同:
方式一是多次查询,一次返回多条;
方式二是一次查询,一次返回一条;
原因:
Oracle
是从服务器一次取出 fetch size
条记录放在客户端,客户端处理完成一个批次后再向服务器取下一个批次,直到所有数据处理完成。
MySQL
是在执行 ResultSet.next()
方法时,会通过数据库连接一条一条的返回。flush buffer
的过程是阻塞式的,如果网络中发生了拥塞,send buffer
被填满,会导致 buffer
一直 flush
不出去,那 MySQL
的处理线程会阻塞,从而避免数据把客户端内存撑爆。
MyBatis
实现逐条获取数据,必须要自定义ResultHandler
,然后在mapper.xml
文件中,对应的select语句中添加 fetchSize="-2147483648"
或者Integer.MIN_VALUE。最后将自定义的ResultHandler
传给SqlSession
来执行查询,并将返回的结果进行处理。
注意:
fetchSize设为-2147483648(Integer.MIN_VALUE) 一开始希望或许fetchSize
能够自己指定一次从服务器端获取的数据量;发现修改fetchSize
的值并没有差别;结果是MYSQL
并不支持自定义fetchSize
,由于其他大型数据库(oracl db2)是支持的;mysql
使用服务器端游标只能一条一条取数据。
如果接口方法参数没有声明回调函数 ResultHandler
,声明 fetchSize
也是没有任何作用的,依然会返回完整结果集
以下是基于MyBatis Stream
导出的完整的工程样例,我们将通过对比Stream文件导出和传统方式导出的内存占用率的差异,来验证Stream文件导出的有效性。
我们先定义一个工具类DownloadProcessor
,它内部封装一个HttpServletResponse
对象,用来将对象写入到csv。
public class DownloadProcessor { private final HttpServletResponse response; public DownloadProcessor(HttpServletResponse response) { this.response = response; String fileName = System.currentTimeMillis() + ".csv"; this.response.addHeader("Content-Type", "application/csv"); this.response.addHeader("Content-Disposition", "attachment; filename="+fileName); this.response.setCharacterEncoding("UTF-8"); } public <E> void processData(E record) { try { response.getWriter().write(record.toString()); //如果是要写入csv,需要重写toString,属性通过","分割 response.getWriter().write("\n"); }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 = (Authors)resultContext.getResultObject(); downloadProcessor.processData(authors); } } 实体类: @Data public class Authors { private Integer id; private String firstName; private String lastName; private String email; private Date birthdate; private Date 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="com.alphathur.mysqlstreamingexport.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="com.alphathur.mysqlstreamingexport.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>
获取数据的核心service
如下,由于只做个简单演示,就懒得写成接口了。其中 streamDownload
方法即为stream取数据写文件的实现,它将以很低的内存占用从MySQL获取数据;此外还提供traditionDownload方法,它是一种传统的下载方式,批量获取全部数据,然后将每个对象写入文件。
@Service public class AuthorsService { private final SqlSessionTemplate sqlSessionTemplate; private final AuthorsMapper authorsMapper; public AuthorsService(SqlSessionTemplate sqlSessionTemplate, AuthorsMapper authorsMapper) { this.sqlSessionTemplate = sqlSessionTemplate; this.authorsMapper = authorsMapper; } /** * stream读数据写文件方式 * @param httpServletResponse * @throws IOException */ public void streamDownload(HttpServletResponse httpServletResponse) throws IOException { AuthorsExample authorsExample = new AuthorsExample(); authorsExample.createCriteria(); HashMap<String, Object> param = new HashMap<>(); param.put("oredCriteria", authorsExample.getOredCriteria()); param.put("orderByClause", authorsExample.getOrderByClause()); CustomResultHandler customResultHandler = new CustomResultHandler(new DownloadProcessor (httpServletResponse)); sqlSessionTemplate.select( "com.alphathur.mysqlstreamingexport.mapper.AuthorsMapper.streamByExample", param, customResultHandler); httpServletResponse.getWriter().flush(); httpServletResponse.getWriter().close(); } /** * 传统下载方式 * @param httpServletResponse * @throws IOException */ public void traditionDownload(HttpServletResponse httpServletResponse) throws IOException { AuthorsExample authorsExample = new AuthorsExample(); authorsExample.createCriteria(); List<Authors> authors = authorsMapper.selectByExample (authorsExample); DownloadProcessor downloadProcessor = new DownloadProcessor (httpServletResponse); authors.forEach (downloadProcessor::processData); httpServletResponse.getWriter().flush(); httpServletResponse.getWriter().close(); } } 下载的入口controller: @RestController @RequestMapping("download") public class HelloController { private final AuthorsService authorsService; public HelloController(AuthorsService authorsService) { this.authorsService = authorsService; } @GetMapping("streamDownload") public void streamDownload(HttpServletResponse response) throws IOException { authorsService.streamDownload(response); } @GetMapping("traditionDownload") public void traditionDownload(HttpServletResponse response) throws IOException { authorsService.traditionDownload (response); } }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。