大家好,又见面了,我是你们的朋友全栈君。
好几年没写技术博客了,今天写一个小的技术点给大家分享,关于MySQL JDBC StreamResult的原理分享,难度不大,就当程序员的闲聊。
如果使用MySQL JDBC读取过比较大的数据(例如超过1GB),应该清楚在读取的时候,很可能会Java堆内存溢出,我们的解决方案通常是使用useCursorFetch读取或Stream读取来处理。使用Stream读取的方式通常的操作方式是在执行SQL前,设置FetchSize:statement.setFetchSize(Integer.MIN_VALUE),同时确保游标是只读、向前滚动的(为游标的默认值),另一种做法是强制类型转换为com.mysql.jdbc.StatementImpl,然后调用MySQL JDBC的内部方法:enableStreamingResults(),这两者达到的效果是一致的,都是启动Stream流方式读取数据。也可以使用useCursorFetch方式,但是这种方式测试结果性能要比StreamResult慢很多,为什么?在本文会阐述其大致的原理。
我在前面的部分文章和书籍中都有介绍过其MySQL JDBC在这一块内部处理的代码,按照默认JDBC读取、useCursorFetch读取和Stream读取,其内部会分成3个不同的实现类来完成,不过我一直没有去深究过数据库和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,甚至于是SSH通信目前在Java中的实现做法也基本都是如此,也就是都是基于TCP通信的机制,它们的大致原理如下图所示:
【方式1:直接使用MySQL JDBC默认参数读取数据,为什么会挂?】
(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)关键是这个傻帽就想一次性地把传回来的数据读取完,根本不管家里放不放的下,整个表的内容读取到Java内存中,如果表很大,自然而然地先是FULL GC,接下来就是内存溢出。
【方式2:JDBC参数上设置useCursorFetch=true可以解决问题】
这个方案配合FetchSize设置,确实可以解决问题,这个方案其实就是告诉MySQL服务端我要多少数据,每次要多少数据,通信过程有点像这样:
这样做就像我们生活中的那样,我需要什么就去超市买什么,需要多少就去买多少。不过这种交互不像现在网购,坐在家里就可以把东西送到家里来,它一定要走路(网络链路),也就是需要网络的时间开销,假如数据有1亿数据,将FetchSize设置成1000的话,会进行10万次来回通信;如果在同一个物理机上不同虚拟机或不同进程0.02ms延迟,那么10万次通信会增加2秒的时间,不算大。那么如果是不同物理机有0.2~0.5ms延迟时间会达到20~50秒,同城跨机房2ms的延迟时间会多出来200秒(也就是3分20秒),如果国内跨城市10~40ms延迟,那么时间将会1000~4000秒,如果是跨国200~300ms呢?时间会多出十多个小时出来。
【PS:注意,这里计算的延迟增加,仅仅计算了每一次发送请求的RT,其实每一次发送TCP报文发送后,TCP是要求返回ACK报文,来确保对应数据传输的可靠性,也就是发送请求就会有一个来回通信,而不是等到数据返回的时候才有一次来回通信,真正数据返回的时候,还会有一次或多次来回通信】
在这里的计算中,还没有包含系统调用次数增加了很多,线程等待和唤醒的上下文次数变多,网络包重传的情况对整体性能的影响,因此这种方案看似合理,但是性能确不怎么样。
另外,由于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由“读写”转变为“读取”。
【userCursor原理说明】:
(1)在设置JDBC参数useCursorFetch=true后,通过Driver创建Connection的时候会自动将:detectServerPreparedStmts设置为true,这个对应JDBC参数是:useServerPrepStmts=true,也就是当设置useCursorFetch=true时useServerPrepStmt会被自动设置为true,源码片段(ConnectionPropertiesImpl类的postInitialization()中,也就是连接初始化的时候会用的):
内部多提供了另一个方法名,下面会提到:
(2)当执行SQL时,会调用到使用prepareStatment方法去执行(即使你自己用Statement内部也会转换成PrepareStatemet,因为它要用服务端预编译),代码如下:
跟下代码,这里的userServerFetch()就是useCursorFetch参数的判定以及游标类型和版本的判定,而游标类型判定的就是为默认值。
(3)步骤1已提到detectServerPreparedStmts被设置为true,在prepareStatement的时候会选择其ServerPreparedStatement作为实现类:具体请参考ConnectionImpl.prepareStatement(String , int , int)的代码,代码太长也不难,就不贴了。
(4)我要说的是ServerPreparedStatement在创建的时候,会在SQL发送前加一个指令在前面,让服务器端预编译,这个指令就是1个int值:22(MysqlDefs.COM_PREPARE),如下:
(5)这里仅仅是告知服务端预编译SQL,还没有指定游标也在服务器端,在真正发生execute、executeQuery,会调用到ServerPreparedStatement的serverExecute方法中。
(6)在步骤5描述的方法ServerPreparedStatement.serverExecute()方法中,会再一次判定useCursorFetch的判定,如果useCursorFetch成立,则在发送给服务端的package中,开启游标的指令:1(MysqlDefs.OPEN_CURSOR_FLAG),如下:
当开启游标的时候,服务端返回数据的时候,就会按照fetchSize的大小返回数据了,而客户端接收数据的时候每次都会把换缓冲区数据全部读取干净(可复用不开启游标方式的代码)。
PS:关于PreparedStatement在MySQL JDBC当中是有潜在问题的,无论是否开启服务端Prapare都有一些坑存在,这些我会在后续的一些文章当中逐步讲到。
【方式3:Stream读取数据】
方式1种默认参数读取数据库会导致Java挂掉,useCursorFetch通信效率较低,在数据库端前期准备数据的时候IOPS会非常高,,客户端响应也较慢,占用大量的磁盘空间,我们接下来再看看Stream读取方式。
前面提到当你使用statement.setFetchSize(Integer.MIN_VALUE)或com.mysql.jdbc.StatementImpl.enableStreamingResults()就可以开启Stream读取结果集的方式,在发起execute之前FetchSize不能再手工设置,且确保游标是FORWARD_ONLY的。
这种方式很神奇,似乎内存也不挂了,响应也变快了,对MySQL的影响也变小了,至少IOPS不会那么大了,磁盘占用也没有了。以前仅仅看到JDBC中走了单独的代码,认为这是MySQL和JDBC之间的另一种通信协议,殊不知,它竟然是“客户端行为”,没错,你没看错,它就是客户端行为。
它在发起enableStreamingResults()的时候,几乎不会做任何与服务端的交互工作,也就是服务端依然会按照方式1回传数据到JDBC的机器,那么服务端使劲向缓冲区怼数据,客户端是如何扛得住压力的呢?
服务端准备好从第一条数据开始返回时,向缓冲区怼入数据,这些数据通过TCP链路,怼入客户端机器的内核缓冲区,JDBC会的inputStream.read()方法会被唤醒去读取数据,唯一的区别是开启了stream读取的时候,它每次只是从内核中读取一些package大小的数据,更上层只是返回一行数据,如果1个package无法组装1行数据,会再读1个package。
对于业务程序来讲,当第一行数据组装好以后,程序就很快响应了,不过当应用程序在处理数据的过程中,消费速度一般来讲不会比数据传输速度更快,所以客户端机器的内核缓冲区就会被怼满(仅仅是这个Socket的缓冲区),当服务端、客户端两边的缓冲区都被怼满后,MySQL通过Socket继续write数据进去的时候,此时会被阻塞。这样就像水管一样,两边的蓄水池满了,水管里面的水也满了,进水口就进不了水了,消费水的一方消费一部分,就可以再进一些水,这就是所谓的stream模式,也就是双方会这样达到一个平衡。
对于JDBC客户端,数据获取的时候每次都在本地的内核缓冲区当中,就在小区的快递包裹箱拿回家一个距离,那么自然比起每次去大超市的时间要少得多。另外,这个过程的数据包裹是准备好的,所以没有I/O阻塞的过程(除非MySQL服务端传递的数据还不如消费端处理数据来得快,那一般也只有消费端不做任何业务逻辑,拿到数据直接放弃的测试代码,才会发生这样的事情,就像水厂的水在供应,每家每户都把所有水管打开,而且不用来做任何事情的可能性几乎为0),参考水管的道理,这个时候不论:跨机房、跨地区、跨国家,只要服务端开始响应第一条数据,就会源源不断地传递数据过来。
Stream读取方式是不是就没有问题了呢?肯定是有的,而且还不止一个两个坑,这篇文章我没法一一说清楚,也和每一个人所遇到的情况有所不同,也会遇到一些比较偏的问题和坑,在本文中主要针对对业务的影响程度来看:
【优缺点对比】:
编号 | 读取方式 | 优点 | 缺点 |
---|---|---|---|
1 | 默认参数读取 | 1、代码简单、JDBC逻辑简单 2、OLTP单行操作速度最佳 3、对MySQL的业务影响小 | 1、数据量大的时候内存会溢出 2、需要Java程序将所有的数据读取到JVM中才响应程序 3、一旦服务端开始返回数据(不是JDBC响应,是MySQL的服务端准备一条数据开始)无法cancel,且在数据准备好以前,cancel会被阻塞 |
2 | useCursorFetch | 1、相对方式1不会导致内存溢出 2、相对方式3对数据库影响时间更短 | 1、会占用数据库磁盘空间 2、占用更多的IOPS 3、需要MySQL Server将所有数据准备好,才会响应程序 4、网络RT会根据数据量产生数百倍乃至数千倍的放大。 5、数据准备阶段发起cancel操作会阻塞(可在MySQL服务端数据准备前cancel掉) 6、数据传输阶段发起cancel操作无效 |
3 | stream读取 | 1、相对方式1不会内存溢出 2、相对方式3整体速度更快 3、在几种方式中,读取大数据量,响应第一条数据的时间是最短的 4、跨地域传送大量数据,不会放大RT | 1、相对方式2,对数据库影响时间会更长一些 2、相对方式1,导致网络拥塞可能性较大 3、cancel、close、clearWarnnings等在读取数据过程中,操作无效(原因为第2点) 4、针对主键单行查询,相对方式1,RT至少翻倍,原因是其内部会隐藏执行SQL:set net_write_timeout=600,该问题可以通过建立连接时手工指定,将JDBC参数将netTimeoutForStreamingResults=0即可,此时主键查询的性能将会与方式1完全一致。 |
【对业务的影响对比】:在MySQL 5.7下分别测试MyISAM、InnoDB两种存储引擎:
MyISAM | InnoDB | |
---|---|---|
useCursor | 数据准备阶段: 单条操作:可读、可DDL、写操作阻塞 交叉操作:发起写操作阻塞,接着读操作会阻塞 交叉操作:DDL后,写操作阻塞,读操作不阻塞,但此时写操作阻塞阶段不同,不会阻塞读操作 PS:DDL需要等待数据准备阶段完成后才能执行下去,但在数据准备阶段DDL已在运行中。 读取数据过程中: 单条操作:可读、可做DDL、可写操作 交叉操作:写入后,可读、可DDL 交叉操作:DDL后,写操作阻塞,读操作不阻塞 | 数据准备阶段: 单条操作:可读、可写、可DDL 交叉操作:写操作,再读取和DDL不会阻塞 交叉操作:先DDL,读、写均会被阻塞 PS:DDL需要等待数据准备阶段完成后才能执行下去,但在数据准备阶段DDL已在运行中。 读取数据过程中: 单条操作:可读、可写、可DDL 交叉操作:写操作,再读取和DDL不会阻塞 交叉操作:先DDL,读、写均不会被阻塞 |
stream读取 | 整个Stream读取过程: 1、单条操作:可读、可做DDL、写操作阻塞 2、交叉操作:发起写操作阻塞,接着读操作会阻塞 3、交叉操作:先做DDL,读操作不阻塞,写操作阻塞,但此时写操作阻塞阶段不同,不会阻塞读操作 4、交叉操作:步骤3阻塞了写操作,此时将DDL Kill掉,写操作会进入步骤2的阻塞状态,阻塞掉所有的读取操作。 | 整个Stream读取过程: 单条操作:可读、可写、可DDL 交叉操作:写操作后,不阻塞读取和DDL 交叉操作:DDL后,读操作阻塞、写操作阻塞 PS:DDL本身可以在这个过程中运行但在Stream读取完成前它无法结束,要等待数据读取完成才结束(如果DDL本身比Stream要快),但DDL已到最后阶段,也就是说Stream读取的时候,DDL是在运行的,只是在最后阶段需获取meta锁时阻塞住了。 |
【理论上可以更进一步,只要你愿意】
理论上这种方式是比较好的了,但是就完美主义来讲,我们可以继续探讨一下,对于懒人来讲,我们连到小区楼下快递包裹箱去拿一下的动力也是没有的,我们心里想的就是要是谁给我拿到家里来送到我嘴巴里,连嘴巴都给我掰开多好。
在技术上理论上确实可以做到这样,因为JDBC从内核拷贝内存到Java当中是需要花时间的,要是有另一个人把这个事情做了,我在家里干别的事情的时候它就给我送到家里来了,我要用的时候就直接从家里来,这个时间岂不是省掉了。没错,对于你来讲确实省掉了,不过问题就是谁来送?
在程序中一定需要加一个线程来干这个事情,无论是应用线程还是内核线程,一定要有一个线程来做这个事情,来把内核的数据拷贝到应用内存,甚至于解析成JDBC的数据行,提供给应用程序直接使用,但这一定完美吗?其实这个中间不能忽略一个协调问题,例如家里要炒菜,缺一包调料,原本可以自己到楼下便利店去买,但是非要让别人送家里,这个时候送的速度不是取决于自己,而是送货人的安排,在家里其它的菜都下锅了,就剩一包调料没到位,那么你没别的办法,只能等这包调料送到家里来以后才能进行炒菜的下一道工序。所以,在理想情况下,它确实可以节约很多次内存拷贝时间,但是增加一些协调锁的开销。
【可不可以直接从内核缓冲区读取数据呢?】
理论上也是可以的,在解释这个问题之前,我们先了解下除了这一次内存拷贝还有那些:
JDBC按照二进制将内核缓冲区的数据读取后,也会进一步解析成具体的结构化数据,由于此时要给业务方返回ResultSet的具体行的结构化数据,也就是生成RowData的数据一定会有一次拷贝,而且JDBC返回某些对象类型数据的时候(例如byte []数组),由于JDBC不希望你通过结果集修改返回结果中的byte []的内容(例如:byte[1] = 0xFF)去修改ResultSet本身内容,在某些JDBC的实现中,可能还会再做1次内存拷贝,业务代码使用过程中还会存在拼字符串,网络输出等,又会存在大量的内存拷贝,这些在业务层面是无法避免的,相对来讲,内核到应用内存的这一点点拷贝,简直微不足道,所以基本也没去干这事情,而是把心思放在更重要的地方,除非程序瓶颈在这里,这种特殊的问题就需要特殊地探讨了。
另一个角度,从技术上来讲,虽然是可以做到直接从内核态直接读取数据的,但需要按照字节从Buffer将数据拿走,才能腾出空间让更多的数据进来,那么拿走的数据放哪里呢?如果说拿走的数据进去JVM,那么它本来是不是就是一次内存拷贝。
从服务端来讲,服务端倒是可以优化直接将数据通过直接IO的方式传递(不过这种方式数据的协议就和数据的存储格式一致了,显然只是理论上的), 要真正做到自定义的协议,又要通过内核态数据直接发送,需要通过修改OS级别的文件系统协议,来达到转换的目的,这又回到比较特殊的场景下才会需要,例如数据库的存储层和计算层如果需要分离,也就是以前计算和存储在一台机器上,直接通过文件协议访问数据,现在拆到不同的机器上,通过TCP去访问,要降低RT的手段之一就有这样的手段。
今天就说到这里,不知道各位对JDBC获取数据库数据是否有了一些新的认知?
发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/160513.html原文链接:https://javaforall.cn