使用MySQL JDBC读取过较大数据量的人应该清楚(例如超过1GB),在读取的时候内存很可能会Java堆内存溢出,而我们的解决方案是statement.setFetchSize(Integer.MIN_VALUE)并确保游标是只读向前滚动的即可(为游标的默认值),也可以强制类型转换为com.mysql.jdbc.StatementImpl,然后调用其内部方法:enableStreamingResults()这样读取数据内存就不会挂掉了,这两者达到的效果是一致的。当然也可以使用useCursorFetch,但是这种方式测试结果性能要比StreamResult慢很多,为什么?在本文会阐述其大致的原理。
我在前面的部分文章和书籍中都有介绍过其MySQL JDBC在这一块内部处理的代码分成三个不同的类来完成的,不过我一直没有去深究过数据库和JDBC之间到底是如何通信的过程。有一段时间我一直认为这都属于服务端行为或者是客户端与服务端配合的行为,然后并不其然,今天我们来讲一下这个行为是怎么回事。
【先回顾一下简单的通信】:
JDBC与数据库之间的通信是通过Socket完成的,因此我们可以把数据库当成一个SocketServer的提供方,因此当SocketServer返回数据的时候(类似于SQL结果集的返回)其流程是:服务端程序数据(数据库) -> 内核Socket Buffer -> 网络 -> 客户端Socket Buffer -> 客户端程序(JDBC所在的JVM内存)
到目前为止,IT行业中大家所看到的JDBC无论是:MySQL JDBC、SQL Server JDBC、PG JDBC、Oracle JDBC。甚至于是NoSQL的Client:Redis Client、MongoDB Client、Memcached,数据的返回基本也是这样一个逻辑。
【使用MySQL JDBC默认直接读取数据为什么会挂?】
(1)MySQL Server方在发起的SQL结果集会全部通过OutputStream向外输出数据,也就是向本地的Kennel对应的socket buffer中写入数据,这是一次内存拷贝(内存拷贝这个不是本文的重点)。
(2)此时Kennel的Buffer有数据的时候就会把数据通过TCP链路(JDBC主动发起的Socket链路),回传数据,此时数据会回传到JDBC所在机器上,会先进入Kennel区域,同样进入到一个Buffer区。
(3)JDBC在发起SQL操作后,Java代码是在inputStream.read()操作上阻塞,当缓冲区有数据的时候,就会被唤醒,然后将缓冲区的数据读取到Java内存中,这是JDBC端的一次内存拷贝。
(4)接下来MySQL JDBC会不断读取缓冲区数据到Java内存中,MySQL Server会不断发送数据。注意在数据没有完全组装完之前,客户端发起的SQL操作不会响应,也就是给你的感觉MySQL服务端还没响应,其实数据已经到本地,JDBC还没对调用execute方法的地方返回结果集的第一条数据,而是不断从缓冲器读取数据。
(5)关键是这个傻帽就像一把这个数据读取完,根本不管家里放不放的下,就会将整个表的内容读取到Java内存中,先是FULL GC,接下来就是内存溢出。
【JDBC参数上设置useCursorFetch=true可以解决】
这个方案配合FetchSize设置,确实可以解决问题,这个方案其实就是告诉MySQL服务端我要多少数据,每次要多少数据,通信过程有点像这样:
这样做就像我们生活中的那样,我需要什么就去超市买什么,需要多少就去买多少。不过这种交互不像现在网购,坐在家里就可以把东西送到家里来,它一定要走路(网络链路),也就是需要网络的时间开销,假如数据有1亿数据,将FetchSize设置成1000的话,会进行10万次来回通信;如果网络延迟同机房0.02ms,那么10万次通信会增加2秒的时间,不算大。那么如果跨机房2ms的延迟时间会多出来200秒(也就是3分20秒),如果国内跨城市10~40ms延迟,那么时间将会1000~4000秒,如果是跨国200~300ms呢?时间会多出十多个小时出来。
在这里的计算中,我们还没有包含系统调用次数增加了很多,线程等待和唤醒的上下文次数变多,网络包重传的情况对整体性能的影响,因此这种方案看似合理,但是性能确不怎么样。
另外,由于MySQL方不知道客户端什么时候将数据消费完,而自身的对应表可能会有DML写入操作,此时MySQL需要建立一个临时表空间来存放需要拿走的数据。因此对于当你启用useCursorFetch读取大表的时候会看到MySQL上的几个现象:
(1)IOPS飙升,因为存在大量的IO读取,如果是普通硬盘,此时可能会引起业务写入的抖动
(2)磁盘空间飙升,这块临时空间可能比原表更大,如果这个表在整个库内部占用相当大的比重有可能会导致数据库磁盘写满,空间会在结果集读取完成后或者客户端发起Result.close()时由MySQL去回收。
(3)CPU和内存会有一定比例的上升,根据CPU的能力决定。
(4)客户端JDBC发起SQL后,长时间等待SQL响应数据,这段时间就是服务端在准备数据,这个等待与原始的JDBC不设置任何参数的方式也表现出等待,在内部原理上是不一样的,前者是一直在读取网络缓冲区的数据,没有响应给业务,现在是MySQL数据库在准备临时数据空间,没有响应给JDBC。
【Stream读取数据】
我们知道第1种方式会导致Java挂掉,第2种方式效率低而且对MySQL数据库的影响较大,客户端响应也较慢,仅仅能够解决问题而已,那么现在来看下Stream读取方式。
前面提到当你使用statement.setFetchSize(Integer.MIN_VALUE)或com.mysql.jdbc.StatementImpl.enableStreamingResults()就可以开启Stream读取结果集的方式,在发起execute之前FetchSize不能再手工设置,且确保游标是FORWARD_ONLY的。
这种方式很神奇,似乎内存也不挂了,响应也变快了,对MySQL的影响也变小了,至少IOPS不会那么大了,磁盘占用也没有了。以前仅仅看到JDBC中走了单独的代码,认为这是MySQL和JDBC之间的另一种通信协议,殊不知,它竟然是“客户端行为”,没错,你没看错,它就是客户端行为。
它在发起enableStreamingResults()的时候,几乎不会做任何与服务端的交互工作,也就是服务端会按照方式1回传数据,那么服务端使劲向缓冲区怼数据,客户端是如何扛得住压力的呢?
在JDBC当中,当你开启Stream结果集处理的时候,它并不是一把将所有数据读取到Java内存中的,也就是图1中并不是一次性将数据读取到Java缓冲区的,而是每次读取一个package(这个package可以理解成Java中的一个byte[]数组),一次最多读取这么多,然后会看是否继续向下读取保证数据的完整性。业务代码是按照字节解析成行也业务方使用的。
服务端刚开始使劲向缓冲区怼数据,这些数据也会怼满客户端的内核缓冲区,当两边的缓冲区都被怼满的时候,服务端的1个Buffer尝试通过TCP传递数据给接收方时,此时由于消费方的缓冲区也是满的,因此发送方的线程会阻塞住,等待对方消费,对方消费一部分,就可以推送一部分数据过去。连起来看就是JDBC的Stream数据未来得及消费之前,缓冲区数据如果是满的,那么MySQL发送数据的线程就阻塞住了,这样确保了一个平衡(关于这一点,大家可以使用Java的Socket来尝试下是否是这样的)。
对于JDBC客户端,数据获取的时候每次都在本地的内核缓冲区当中,就在小区的快递包裹箱拿回家一个距离,那么自然比起每次去超市的RT要小得多了,而且这个过程是准备好的数据,所以没有IO阻塞的过程(除非MySQL服务端传递的数据还不如消费端处理数据来得快,那一般也只有消费端不做任何业务,拿到数据直接放弃的测试代码,才会发生这样的事情),这个时候不论:跨机房、跨地区、跨国家,只要服务端开始响应就会源源不断地传递数据过来,而这个动作即使是第1种方式也是必然需要经历的过程。
相对于第1种方式,JDBC使用的时候会不导致内存溢出,即使读取大表不内存溢出也会很长时间才会响应;不过这种方式相对方式1来讲对数据库影响相对较大,在传递的数据的过程中,相应的数据行会被上锁(防止被修改),使用InnoDB会分段加锁处理,使用MyISAM会加全表锁,可能导致业务阻塞。
【理论上可以更进一步,只要你愿意】
理论上这种方式是比较好的了,但是就完美主义来讲,我们可以继续探讨一下,对于懒人来讲,我们连到小区楼下快递包裹箱去拿一下的动力也是没有的,我们心里想的就是要是谁给我拿到家里来送到我嘴巴里,连嘴巴都给我掰开多好。
在技术上理论上确实可以做到这样,因为JDBC从内核拷贝内存到Java当中是需要花时间的,要是有另一个人把这个事情做了,我在家里干别的事情的时候它就给我送到家里来了,我要用的时候就直接从家里来,这个时间岂不是省掉了。每错,对于你来讲确实省掉了,不过问题就是谁来送?
在程序中一定需要加一个线程来干这个事情,把内核的数据拷贝到应用内存,甚至于解析成行数据,应用程序直接使用,但这一定完美吗?其实这个中间就有个协调问题了,例如家里要炒菜,缺一包调料,原本可以自己到楼下买,但是非要让别人送家里,这个时候其它的菜都下锅了,就剩一包调料,那么你没别的办法,只能等这包调料送到家里来以后才能进行炒菜的下一道工序。所以,在理想情况下,它可以节约很多次内存拷贝时间,会增加一些协调锁的开销。
那么可以不可以直接从内核缓冲区读取数据呢?
理论上也是可以的,在解释这个问题之前,我们先了解下除了这一次内存拷贝还有那些:
JDBC按照二进制将内核缓冲区的数据读取后,也会进一步解析成具体的结构化数据,由于此时要给业务方返回ResultSet的具体行的结构化数据,也就是生成RowData的数据一定会有一次拷贝,而且JDBC返回某些对象类型数据的时候(例如byte []数组),在某些场景的实现,它不希望你通过结果集修改返回结果中的byte []的内容(byte[1] = 0xFF)去修改ResultSet本身内容,可能还会再做1次内存拷贝,业务代码使用过程中还会存在拼字符串,网络输出等,又是一堆的内存拷贝,这些在业务层面是无法避免的,相对这点点拷贝来讲,简直微不足道,所以我们也没去干这事情,以为从整体上看几乎微不足道,除非你的程序瓶颈在这里。
因此从整体上看内存拷贝是无法避免的,多的这一次无非是系统级的调用,开销会更大一点,从技术上来讲,我们是可以做到直接从内核态直接读取数据的;但这个时候就需要按照字节将Buffer从的数据拿走才能让远程更多的数据传递过来,没有第三个位置存放Buffer了,否则又回到了内核到应用的内存拷贝上来了。
相对来讲,服务端倒是可以优化直接将数据通过直接IO的方式传递(不过这种方式数据的协议就和数据的存储格式一致了,显然只是理论上的), 要真正做到自定义的协议,又要通过内核态数据直接发送,需要通过修改OS级别的文件系统协议,来达到转换的目的。