关于 JDBC 驱动程序的性能。
当第一版 MariaDB Java Client 发布时,有人在评论中询问了该驱动程序与 ConnectorJ 相比的性能特性。我用手势回答说,没有人会做愚蠢的事情,驱动程序的性能应该大致相同,但我承诺总有一天会测量它并告诉全世界。现在,这一天到来了。在这一天,我们将对比三个 MySQL JDBC 驱动程序(ConnectorJ、MariaDB JDBC 和 Drizzle JDBC)的性能。与服务器一直受到基准测试的关注不同,连接器没有标准基准测试,所以我需要即兴发挥,同时尽量保持服务器的开销最小。所以我一开始做了一些非常原始的事情。我使用了我最喜欢的两个查询:
DO 1
— 这个不返回结果集,因此可以看作是一个小的“更新”。SELECT 1
— 最小的 SELECT 查询。
测试程序会运行查询 N 次,如果查询是 SELECT,它会使用 ResultSet.getObject(i)
检索结果集中的所有值,并计算每秒查询次数(QPS)。(最棒的是测试程序是单线程的,你多久能运行一次单线程测试呢?🙂 测试是在我自己的工作站上运行的,运行的是 Windows Server 2008 R2,我在 ConnectorJ 的 URL 中设置了 useConfigs=maxPerformance
。)
结果(每秒查询次数,未预处理)
ConnectorJ-5.1.24 | MariaDB-JDBC-1.1.2 | Drizzle-JDBC-1.3-SNAPSHOT | |
---|---|---|---|
DO 1 | 19543 | 22104 | 15288 |
SELECT 1 | 17004 | 19305 | 13410 |
MariaDB JDBC 看起来比 ConnectorJ 稍快(约 10%),比 Drizzle JDBC 快得多(约 30%)。
ConnectorJ 能做得更好吗?我敢肯定可以。通过分析器输出(NetBeans 中的 CPU 分析、插桩模式)查看循环执行“SELECT 1”的测试,显示 com.mysql.jdbc.StatementImpl.findStartOfStatement()
占用了 7.5% 的运行时。当然,插桩结果应该谨慎看待,但是使用字符串搜索的唯一原因是——如果在 ResultSet.executeQuery()
中执行更新 (DML) 语句,它会抛出异常而被拒绝。我相信这可以有不同的做法。如果绝对必要,可以延迟抛出异常,直到客户端发现服务器发送的是 OK 包而不是结果集。
更有趣的是 Drizzle JDBC 的情况。理论上,由于 MariaDB 驱动程序继承了 Drizzle JDBC 的血统,性能特性应该相似,但事实并非如此,因此某处肯定有 bug。它似乎很容易找到,根据分析器,50.2% 的 CPU 时间(这个数字要大打折扣地看待)花费在一个从字节缓冲区构造 hexdump 的函数中。查看源代码,我们发现下面这行代码是无条件执行的:
log.finest("Sending : " + MySQLProtocol.hexdump(byteHeader, 0));
虽然 hexdump 的结果从未使用过(除非日志级别是 FINEST),但仍会创建 dump 字符串,使用了相对昂贵的 Formatter
例程,并与字符串“Sending:”连接,然后被丢弃……替 Markus 辩护一下,hexdump()
不是他的错误,它是在 3 年前贡献的。但它在 3 年内一直未被发现。这个 bug 现已提交到 https://github.com/krummas/DrizzleJDBC/issues/21 [更新:这个 bug 在报告后的几小时内就解决了]
那么,让我们看看将这段有问题的代码放入 if (log.getLevel() == java.util.logging.Level.FINEST)
条件中能获得多少提升。
“DO 1”的 QPS 从 15288 提高到 19968 (30%),而“SELECT 1”从 13410 提高到可观的 16824 (25%)。对于一行代码的修复来说,这很不错了。
虽然这行代码使得 Drizzle JDBC 变得更快,甚至比 ConnectorJ 的数字稍好,但仍然不如 MariaDB 快。
在 MariaDB JDBC 连接器中,自从分叉以来进行了一些性能改进。早期的一项改进是避免在发送数据时进行不必要的复制,并减少字节缓冲区的数量。最近又有一项改进,是在分析后发现解析 Field 数据包的开销很大(主要是因为构造列名、别名等字符串......)。这项改进采用了延迟解析,推迟了字符串的构造,并在大多数情况下完全避免了它。例如,如果不使用列名,而是通过整数索引在 ResultSet.getXXX(int i)
中访问行,则不会完全解析元数据。此外,也许还有一些我记不清的其他修复。🙂
我们能否进一步提高 QPS?
我们可以试试。首先,语句可以进行预处理。MariaDB 和 Drizzle 目前只提供客户端预处理语句(ConnectorJ 可以同时进行客户端和服务器端预处理语句),但使用它们可以节省将查询转换为字节以及 JDBC 转义预处理的开销。从现在开始,我只测试被证明是最快的查询“DO 1”。在 MariaDB 驱动程序上测试显示 QPS 有一些微小的提升,从 22104(未预处理)到 22183(预处理),提升了 0.3%。在 ConnectorJ 上提升稍多(19543 vs 20096,或 2.9%)。到目前为止没有什么革命性的发现。
但是,我们还没有用尽所有选项来最大化“DO 1”的性能(虽然这有点傻)。回想一下,ConnectorJ 支持 Windows 上的命名管道,据说这比 TCP 连接快得多。使用命名管道重启服务器,将 JDBC URL 设置为“jdbc:mysql:///?socketFactory=com.mysql.jdbc.NamedPipeSocketFactory&namedPipePath=\\.\Pipe\MySQL&user=root&useConfigs=maxPerformance”,然后用 100 万次预处理查询重新运行测试。现在 QPS 增长到了 29542!这个数字很强劲,与迄今为止最好的结果相比提升了 33%。然而,不幸的是,仍然没有成功,因为当命名管道连接关闭时,JVM 会抛出堆栈跟踪。这是一个“不修复”的 MySQL bug Bug#62518(被归咎于 JVM 问题),这使得命名管道支持几乎无用——尽管在这种情况下可能有什么技巧可以阻止 JVM 抛出错误,但我并不知道这样的技巧。
C 客户端库相比之下有多快?
出于好奇,我也测试了原生客户端与 JDBC 的比较。使用 TCP 协议时,它比最快的 JDBC(MariaDB,预处理)稍好一些,但差距不大——24063 QPS vs 22183(相差 8.5%),我相信 Java 驱动程序还可以进一步改进。
使用命名管道时,QPS 为 33122,这比 ConnectorJ 在命名管道正常工作时能达到的性能好约 12%。
访问基准测试程序
我将基准测试程序连同驱动程序一起放在了 Launchpad 上。如果你使用 Windows,并且服务器运行在端口 3306,并且‘root’用户没有密码,你可以直接分支代码库并运行 bench_all.bat。对于使用其他操作系统的用户,我相信你们能够快速将批处理文件重写为 shell 脚本。
考虑到 Oracle 拥有 MySQL 和 Java,你觉得他们会优先修复命名管道的那个 bug 吗 🙂
另外,很高兴在 PLMCE 见到你。谢谢你让我向你展示 Shard-Query。
Justin,你肯定是在 PLMCE 遇到了其他人。我很久没参加会议了(上次是在 2011 年,还是 OReilly MySQL 会议 🙂 但无论如何还是谢谢!
思考之后,我相信 ConnectorJ 的 bug 可能无需联系 JVM 团队就能解决——既然 ConnectorJ 的 NamedPipeSocket 派生自 Socket,他们可以直接覆盖有问题的 Socket.shutdownInput() 方法,让它什么都不做。
哦,抱歉,我想我把你和另一个 Vlad 搞混了 🙂 哎呀。无论如何,关于覆盖方法的想法很棒。
谢谢分享!有几点看法:
“如果正在执行的 SQL 字符串不返回 ResultSet 对象,executeQuery 方法会抛出 SQLException”。
嗯,问题在于在这种情况下服务器已经执行了 UPDATE 语句,对吧?即使底层存储引擎允许回滚,你肯定也不想触及那个位置。或者可能是我遗漏了什么。
关于基准测试本身:我个人不确定 mariadb 驱动程序稍微好一些的性能在更现实的场景(即真实查询)中是否显著。我的假设是,在几乎所有情况下,数据库 IO,以及在处理结果集时,网络会比纯粹的 Java 性能更快成为限制因素。
然而,如果我的假设被证明是错误的,那会非常有趣。你做过这类测试吗?如果没有,那也没关系,也许你将来会找到时间做。如果你做过这类测试,那么如果能分享结果就更好了。如果结果证明没有太大区别,那么就不必发布详细数据了;我很乐意相信你的话。
尽管存在这些保留意见,但我认为这确实表明从质量角度来看,进行基准测试仍然有用,因为它帮助发现了并修复了 drizzle 的一个 bug。Drizzle 传统上避免发布基准测试,理由是它们不切实际,而且除非由独立方执行,否则也不值得信赖;这项工作表明,这样的基准测试仍然有价值,因为它们可以识别 bug 或优化不佳的代码。
另一个有趣的观察是将 C 客户端库与 JDBC 驱动程序进行比较。这再次证实了“Java 慢”的迷思应该被摒弃了。
所以,再次感谢!读起来很愉快。
关于 ConnectorJ 的 DML 检测——标准是这样说的:
“如果正在执行的 SQL 字符串不返回 ResultSet 对象,executeQuery 方法会抛出 SQLException”。它没有说——驱动程序应该尝试解析 SQL 查询以找出它是否可能是更新语句。
关于现实场景:我也不确定好的性能在现实场景中是否显著。我不得不即兴发挥,选择了那些查询——我本身也很好奇我是否能挖掘出一些有用的东西,或者学到一些新东西。我做到了,并分享了一些小点滴。
我仍然认为在客户端节省下的每一毫秒和微秒都是好的,用户不关心时间花在了哪里。
是的,我还用更大的结果集、更大的行以及连接/断开连接做了更多测试,但我没有时间详细理解结果,也不想在没有解释的情况下发布数字。我也认为我的“大结果集”测试不太好,因为它们是从 information schema 中检索数据,这有极大的开销。它们也是最不具决定性的。
然而,我注意到的一点是(已经修复的)Drizzle bug 是显而易见的,即使在慢得多的查询(约 60 QPS,甚至 20 QPS)上也是如此。如果我没记错,ConnectorJ 驱动程序在处理 blobs 时比 MariaDB 稍好,但在连接/断开连接方面表现不佳,即使使用了“useConfigs=maxPerformance”。Drizzle JDBC 在连接/断开连接方面表现出色(建立连接时什么都不做),MariaDB 启用了“fastConnect=true”后紧随其后。总有一天,当我分析了这些情况后,或许值得再次写一篇关于它的文章。
Vlad,谢谢提供额外信息。非常感谢。我同意所有性能提升都是好的,作为开发者,我当然会关心这些差异。
但我不同意用户不关心时间花在了哪里;我的意思是,如果他们遇到的性能问题主要是等待网络或数据库 IO 造成的,那么他们根本无法通过切换驱动程序来解决。如果他们不知道切换驱动程序并不能带来他们需要或期望的改变,他们就会因为投入时间和精力却没有解决问题而感到失望。
(当然,我并不是说用户不理解自己遇到的问题是驱动程序开发者的错 🙂
Roland,当我说“用户不关心时间花在了哪里”时,我指的是最终用户,他们等待网页加载,并且不知道背后有 Java 和数据库。当然,可以说我自己的用户(驱动程序的用户)是 DBA、程序员、顾问等,是那些使用 JDBC 编写 web 应用的人。是的,这些人希望能关心和衡量等等。
Vlad,干得好!🙂 看看在更真实的场景(我猜差异或多或少为零)和大结果集处理上的差异会很有趣,我过去在使用不同连接器时遇到过很多问题……另外,在你的测试中也能看到 CPU 使用率就更好了……
对真实场景没有头绪,希望有人比我更了解现实生活 :) 处理大结果集时,问题通常在于驱动程序会将整个巨大结果集读入内存,导致程序因 OOM 而崩溃。
将完整结果集读入内存——这也是 JDBC 连接器中的默认模式(Drizzle JDBC 不知道其他模式)
然而,ConnectorJ 和 MariaDB JDBC 有一个选项可以逐行读取大结果(“流式传输”)
通过以下方式激活:
stmt = conn.createStatement(java.sql.ResultSet.TYPE_FORWARD_ONLY,
java.sql.ResultSet.CONCUR_READ_ONLY);
stmt.setFetchSize(Integer.MIN_VALUE);
如果使用流式传输,在“现实生活中”经常出现的问题是客户端读取结果集的速度不够快,导致服务器在 socket write() 调用中阻塞,如果等待时间足够长(net_write_timeout),之后就会直接关闭客户端连接。ConnectorJ 在执行流式查询之前会增加 net_write_timeout。MariaDB JDBC 不会这样做,但当然人们可以自己设置更高的超时时间,例如在连接 URL 中设置“sessionVariables=net_write_timeout=1000”。
你在这个基准测试中为每个查询都新建了一个连接,对吗?如果使用连接池,不同驱动程序之间的性能差异会消失吗?
不是的,它是一次性连接,然后执行数千个完全相同的查询。完全没有测量连接的速度,只测量了查询的速度。连接/断开连接是另一回事。下次我可能会写一篇关于它的文章,这才是连接池发挥作用的地方。
使用 MySQL 的查询缓存时,差异会更明显吗?