问题场景:使用MySQL JDBC读取过比较大的数据(例如超过1GB),读取的时候很可能会出现Java堆内存溢出,对此我们的解决方案通常是使用useCursorFetch读取或Stream读取来处理。
Stream流方式处理:
这种操作方式通常是在执行SQL前进行的,通过设FetchSize,statement.setFetchSize(Integer.MIN_VALUE),Integer.MIN_VALUE的值为-2147483648 同时确保游标是只读、向前滚动的(为游标的默认值),即设置参数:ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY 。另一种做法是强制类型转换为com.mysql.jdbc.StatementImpl,然后调用MySQL JDBC的内部方法:enableStreamingResults(),这两者达到的效果是一致的,都是启动Stream流方式读取数据。
useCursorFetch方式处理:
在url连接参数中添加useCursorFetch=true,这种处理方式测试结果性能要比StreamResult慢很多。
上图是Mysql使用TCP通信机制原理图,JDBC与数据库之间的通信是通过Socket完成的,我们可以把数据库当成一个SocketServer的提供方,因此当SocketServer返回数据的时候(类似于SQL结果集的返回)其流程是:服务端程序数据(数据库) -> 内核Socket Buffer -> 网络 -> 客户端Socket Buffer -> 客户端程序(JDBC所在的JVM内存)。
1、问题探究:为什么直接使用Mysql JDBC默认参数读取大量(比如超过1G)数据时会挂掉?
(1)MySQL Server方在发起的SQL结果集会全部通过OutputStream向外输出数据,也就是向本地的Kennel对应的socket buffer中写入数据,这是一次内存拷贝。
(2)此时Kennel的Buffer有数据的时候就会把数据通过TCP链路(JDBC主动发起的Socket链路),回传数据,此时数据会回传到JDBC所在机器上,会先进入Kennel Buffer区(注意,sendBuffer和reveiveBuffer在内核区域是两个不同的Buffer,不同的socket相互不影响)。
(3)JDBC在发起SQL操作后,Java代码是在inputStream.read()操作上阻塞,当缓冲区有数据的时候,就会被唤醒,然后将缓冲区的数据读取到Java内存中,这是JDBC端的一次内存拷贝。
(4)接下来MySQL JDBC会不断读取缓冲区数据到Java内存中,MySQL Server会不断发送数据。注意在数据没有完全组装完之前,客户端发起的SQL操作不会响应,也就是给你的感觉MySQL服务端还没响应,其实数据已经到本地,JDBC还没对调用execute方法的地方返回结果集的第一条数据,而是不断从缓冲器读取数据。
(5)关键是JDBC想一次性地把传回来的数据读取完,根本不管自己的内存放不放的下,整个表的内容读取到Java内存中,如果表很大,自然而然地先是FULL GC,接下来就是内存溢出。
2、JDBC参数设置useCursorFetch=true为什么可以解决问题?
这个方案配合FetchSize设置,确实可以解决问题,这个方案其实就是告诉MySQL服务端我要多少数据,每次要多少数据,通信过程有点像这样:
这样做就像我们生活中的那样,我需要什么就去超市买什么,需要多少就去买多少。不过这种交互不像现在网购,坐在家里就可以把东西送到家里来,它一定要走路(网络链路),也就是需要网络的时间开销,假如数据有1亿数据,将FetchSize设置成1000的话,会进行10万次来回通信;如果在同一个物理机上不同虚拟机或不同进程0.02ms延迟,那么10万次通信会增加2秒的时间,不算大。那么如果是不同物理机有0.20.5ms延迟时间会达到2050秒,同城跨机房2ms的延迟时间会多出来200秒(也就是3分20秒),如果国内跨城市1040ms延迟,那么时间将会10004000秒,如果是跨国200~300ms呢?时间会多出十多个小时出来。
在这里的计算中,还没有包含系统调用次数增加了很多,线程等待和唤醒的上下文次数变多,网络包重传的情况对整体性能的影响,因此这种方案看似合理,但是性能确不怎么样。
另外,由于MySQL方不知道客户端什么时候将数据消费完,而自身的对应表可能会有DML写入操作,此时MySQL需要建立一个临时空间来存放需要拿走的数据。因此对于当你启用useCursorFetch读取大表的时候会看到MySQL上的几个现象:
(1)IOPS飙升,因为存在大量的IO读取和写入,这个动作是正在准备要返回的数据到临时空间中,此时监控MySQL的网络输出是没有变化的。由于IO写入很大,如果是普通硬盘,此时可能会引起业务写入的抖动。
(2)磁盘空间飙升,这块临时空间可能比原表更大,如果这个表在整个库内部占用相当大的比重有可能会导致数据库磁盘写满,空间会在结果集读取完成后或者客户端发起Result.close()时由MySQL去回收。
(3)CPU和内存会有一定比例的上升,根据CPU的能力决定。
(4)客户端JDBC发起SQL后,长时间等待SQL响应数据,这段时间就是服务端在准备数据,这个等待与原始的JDBC不设置任何参数的方式也表现出等待,在内部原理上是不一样的,前者是一直在读取网络缓冲区的数据,没有响应给业务,现在是MySQL数据库在准备临时数据空间,没有响应给JDBC。
(5)在数据准备完成后,开始传输数据的阶段,网络响应开始飙升,IOPS由“读写”转变为“读取”。
3、Stream读取数据
默认参数读取数据库会导致程序挂掉,useCursorFetch通信效率较低,在数据库端前期准备数据的时候IOPS会非常高,客户端响应也较慢,占用大量的磁盘空间,我们接下来再看看Stream读取方式。
当使用statement.setFetchSize(Integer.MIN_VALUE)或com.mysql.jdbc.StatementImpl.enableStreamingResults()就可以开启Stream读取结果集的方式,在发起execute之前FetchSize不能再手工设置,且确保游标是FORWARD_ONLY的。这种方式很神奇,似乎内存也不挂了,响应也变快了,对MySQL的影响也变小了,至少IOPS不会那么大了,磁盘占用也没有了。以前仅仅看到JDBC中走了单独的代码,认为这是MySQL和JDBC之间的另一种通信协议,仔细探究下,它竟然是“客户端行为”!
它在发起enableStreamingResults()的时候,几乎不会做任何与服务端的交互工作,也就是服务端依然会按照JDBC默认参数设置回传数据到JDBC的机器,那么服务端使劲向缓冲区怼数据,客户端是如何扛得住压力的呢?
其实JDBC本身并没有FetchSize方法, 它是通过使用CS阻塞方式的网络流控制实现服务端不会一下发送大量数据到客户端撑爆客户端内存。服务端准备好从第一条数据开始返回时,向缓冲区怼入数据,这些数据通过TCP链路,怼入客户端机器的内核缓冲区,JDBC会的inputStream.read()方法会被唤醒去读取数据,唯一的区别是开启了stream读取的时候,它每次只是从内核中读取一些package大小的数据,更上层只是返回一行数据,如果1个package无法组装1行数据,会再读1个package。
对于业务程序来讲,当第一行数据组装好以后,程序就很快响应了,不过当应用程序在处理数据的过程中,消费速度一般来讲不会比数据传输速度更快,所以客户端机器的内核缓冲区就会被怼满(仅仅是这个Socket的缓冲区),当服务端、客户端两边的缓冲区都被怼满后,MySQL通过Socket继续write数据进去的时候,此时会被阻塞。这样就像水管一样,两边的蓄水池满了,水管里面的水也满了,进水口就进不了水了,消费水的一方消费一部分,就可以再进一些水,这就是所谓的stream模式,也就是双方会这样达到一个平衡。
对于JDBC客户端,数据获取的时候每次都在本地的内核缓冲区当中,就像在小区的快递包裹箱拿回家一个距离,那么自然比起每次去大超市的时间要少得多。另外,这个过程的数据包裹是准备好的,所以没有I/O阻塞的过程(除非MySQL服务端传递的数据还不如消费端处理数据来得快,那一般也只有消费端不做任何业务逻辑,拿到数据直接放弃的测试代码,才会发生这样的事情,就像水厂的水在供应,每家每户都把所有水管打开,而且不用来做任何事情的可能性几乎为0),参考水管的道理,这个时候不论:跨机房、跨地区、跨国家,只要服务端开始响应第一条数据,就会源源不断地传递数据过来。
当然,流方式处理也是有很多问题的,而且还不少,很偏。