做者:Su Li,Zhao Mingphp
Java 是当前很是流行的开发语言,不少 TiDB 用户的业务层都是使用 Java 开发的,本文将从 Java 数据库交互组件开发的角度出发,介绍各组件的推荐配置和推荐使用方式,但愿能帮助 Java 开发者在使用 TiDB 时能更好的发挥数据库性能。html
一般 Java 应用中和数据库相关的经常使用组件有:java
如上图所示,应用可能使用 Spring Transaction 来管理控制事务非手工启停,经过相似 MyBatis 的数据访问框架管理生成和执行 SQL,经过链接池获取已池化的长链接,最后经过 JDBC 接口调用实现经过 MySQL 协议和 TiDB 完成交互。mysql
接下来将分别介绍使用各个组件时可能须要关注的问题。git
Java 应用尽管能够选择在不一样的框架中封装,但在最底层通常会经过调用 JDBC 来与数据库服务器进行交互。对于 JDBC,须要关注的主要有:API 的选择和 API Implementer 的参数配置。github
对于基本的 JDBC API 使用能够参考 JDBC 官方教程,本文主要强调几个比较重要的 API 选择。spring
对于 OLTP 场景,程序发送给数据库的 SQL 语句在去除参数变化后都是可穷举的某几类,所以建议使用 预处理语句 (Prepared Statements) 代替普通的 文本执行,并复用 Prepared Statements 来直接执行,从而避免 TiDB 重复解析的开销。sql
目前多数上层框架都会调用 Prepare API 进行 SQL 执行,若是直接使用 JDBC API 进行开发,注意选择使用 Prepare API。数据库
另外须要注意 MySQL Connector/J 实现中默认只会作客户端的语句预处理,会将 ?
在客户端替换后以文本形式发送到客户端,因此除了要使用 Prepare API,还须要在 JDBC 链接参数中配置 useServerPrepStmts = true
,才能在 TiDB 服务器端进行语句预处理(下面参数配置章节有详细介绍)。apache
对于批量插入更新,若是插入记录较多,能够选择使用 addBatch/executeBatch API。经过 addBatch
的方式将多条 SQL 的插入更新记录先缓存在客户端,而后在 executeBatch
时一块儿发送到数据库服务器。
注意:对于 MySQL Connector/J 实现,默认 Batch 只是将屡次
addBatch
的 SQL 发送时机延迟到调用executeBatch
的时候,但实际网络发送仍是会一条条的发送,一般不会下降与数据库服务器的网络交互次数。若是但愿 Batch 网络发送批量插入,须要在 JDBC 链接参数中配置
rewriteBatchedStatements=true
(下面参数配置章节有详细介绍)。
通常状况下,为提高执行效率,JDBC 会默认提早获取查询结果并将其保存在客户端内存中。但在查询返回超大结果集的场景中,客户端会但愿数据库服务器减小向客户端一次返回的记录数,等客户端在有限内存处理完一部分后再去向服务器要下一批。
在 JDBC 中一般有如下两种处理方式:
FetchSize
为 Integer.MIN_VALUE
让客户端不缓存,客户端经过 StreamingResult
的方式从网络链接上流式读取执行结果。FetchSize
为正整数且在 JDBC URL 中配置 useCursorFetch=true
。TiDB 中同时支持两种方式,但更推荐使用第一种将 FetchSize
设置为 Integer.MIN_VALUE
的方式,比第二种功能实现更简单且执行效率更高。
JDBC 实现一般经过 JDBC URL 参数的形式来提供实现相关的配置。这里以 MySQL 官方的 Connector/J 来介绍 参数配置(若是使用的是 MariaDB,能够参考 MariaDB 的相似配置)。由于配置项较多,这里主要关注几个可能影响到性能的参数。
默认状况下,useServerPrepStmts
为 false,即尽管使用了 Prepare API,也只会在客户端作 “prepare”。所以为了不服务器重复解析的开销,若是同一条 SQL 语句须要屡次使用 Prepare API,则建议设置该选项为 true。
在 TiDB 监控中能够经过 Query Summary > QPS By Instance
查看请求命令类型,若是请求中 COM_QUERY
被 COM_STMT_EXECUTE
或 COM_STMT_PREPARE
代替即生效。
虽然 useServerPrepStmts=true
能让服务端执行 prepare 语句,但默认状况下客户端每次执行完后会 close prepared 的语句,并不会复用,这样 prepare 效率甚至不如文本执行。因此建议开启 useServerPrepStmts=true
后同时配置 cachePrepStmts=true
,这会让客户端缓存 prepare 语句。
在 TiDB 监控中能够经过 Query Summary > QPS By Instance
查看请求命令类型,若是相似下图,请求中 COM_STMT_EXECUTE
数目远远多于 COM_STMT_PREPARE
即生效。
另外,经过 useConfigs=maxPerformance
配置会同时配置多个参数,其中也包括 cachePrepStmts=true
。
在配置 cachePrepStmts
后还须要注意 prepStmtCacheSqlLimit
配置(默认为 256),该配置控制客户端缓存 prepare 语句的最大长度,超过该长度将不会被缓存。
在一些场景 SQL 的长度可能超过该配置,致使 prepared SQL 不能复用,建议根据应用 SQL 长度状况决定是否须要调大该值。
在 TiDB 监控中经过 Query Summary > QPS by Instance
查看请求命令类型,若是已经配置了 cachePrepStmts=true
,但 COM_STMT_PREPARE
仍是和 COM_STMT_EXECUTE
基本相等且有 COM_STMT_CLOSE
,须要检查这个配置项是否设置得过小。
prepStmtCacheSize
控制缓存的 prepare 语句数目(默认为 25),若是应用须要 prepare 的 SQL 种类不少且但愿复用 prepare 语句,能够调大该值。
和上一条相似,在监控中经过 Query Summary > QPS by Instance
查看请求中 COM_STMT_EXECUTE
数目是否远远多于 COM_STMT_PREPARE
来确认是否正常。
在进行 batch 写入处理时推荐配置 rewriteBatchedStatements=true
,在已经使用 addBatch
或 executeBatch
后默认 JDBC 仍是会一条条 SQL 发送,例如:
pstmt = prepare(“insert into t (a) values(?)”); pstmt.setInt(1, 10); pstmt.addBatch(); pstmt.setInt(1, 11); pstmt.addBatch(); pstmt.setInt(1, 12); pstmt.executeBatch();
虽然使用了 batch 但发送到 TiDB 语句仍是单独的多条 insert:
insert into t(a) values(10); insert into t(a) values(11); insert into t(a) values(12);
若是设置 rewriteBatchedStatements=true
,发送到 TiDB 的 SQL 将是:
insert into t(a) values(10),(11),(12);
须要注意的是,insert 语句的改写,只能将多个 values 后的值拼接成一整条 SQL,insert 语句若是有其余差别将没法被改写。 例如:
insert into t (a) values (10) on duplicate key update a = 10; insert into t (a) values (11) on duplicate key update a = 11; insert into t (a) values (12) on duplicate key update a = 12;
将没法被改写成一条语句。该例子中,若是将 SQL 改写成以下形式:
insert into t (a) values (10) on duplicate key update a = values(a); insert into t (a) values (11) on duplicate key update a = values(a); insert into t (a) values (12) on duplicate key update a = values(a);
便可知足改写条件,最终被改写成:
insert into t (a) values (10), (11), (12) on duplicate key update a = values(a);
批量更新时若是有 3 处或 3 处以上更新,则 SQL 语句会改写为 multiple-queries 的形式并发送,这样能够有效减小客户端到服务器的请求开销,但反作用是会产生较大的 SQL 语句,例如这样:
update t set a = 10 where id = 1; update t set a = 11 where id = 2; update t set a = 12 where id = 3;
另外由于一个 客户端 bug,不建议在批量 insert 之外的场景设置 rewriteBatchedStatements=true
。
经过监控可能会发现,虽然业务只向集群进行 insert 操做,却看到有不少多余的 select 语句。一般这是由于 JDBC 发送了一些查询设置类的 SQL 语句(例如 select @@session.transaction_read_only
)。这些 SQL 对 TiDB 无用,推荐配置 useConfigs=maxPerformance
来避免额外开销。
useConfigs=maxPerformance
会包含一组配置:
cacheServerConfiguration=true useLocalSessionState=true elideSetAutoCommits=true alwaysSendSetIsolation=false enableQueryTimeouts=false
配置后查看监控能够看到多余语句减小。
TiDB (MySQL) 链接创建是比较昂贵的操做(至少对于 OLTP),除了创建 TCP 链接外还须要进行链接鉴权操做,因此客户端一般会把 TiDB (MySQL) 链接保存到链接池中进行复用。
Java 的链接池实现不少(好比,HikariCP, tomcat-jdbc, durid, c3p0, dbcp),TiDB 不会限定使用的链接池,应用能够根据业务特色自行选择链接池实现。
比较常见的是应用须要根据自身状况配置合适的链接池大小,以 HikariCP 为例:
maximumPoolSize
:链接池最大链接数,配置过大会致使 TiDB 消耗资源维护无用链接,配置太小则会致使应用获取链接变慢,因此需根据应用自身特色配置合适的值,可参考 这篇文章。minimumIdle
:链接池最大空闲链接数,主要用于在应用空闲时存留一些链接以应对突发请求,一样是须要根据业务状况进行配置。应用在使用链接池时须要注意链接使用完成后归还链接,推荐应用使用对应的链接池相关监控(如 metricRegistry
),经过监控能及时定位链接池问题。
链接池维护到 TiDB 的长链接,TiDB 默认不会主动关闭客户端链接(除非报错),但通常客户端到 TiDB 之间还会有 LVS 或 HAProxy 之类的网络代理,它们一般会在链接空闲必定时间后主动清理链接。除了注意代理的 idle 配置外,链接池还须要进行保活或探测链接。
若是常在 Java 应用中看到如下错误:
The last packet sent successfully to the server was 3600000 milliseconds ago. The driver has not received any packets from the server. com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failure
若是 n milliseconds ago
中的 n
是 0
或很小的值,则一般是执行的 SQL 致使 TiDB 异常退出引发的报错,推荐查看 TiDB stderr 日志;若是 n
是一个很是大的值(好比这里的 3600000),极可能是由于这个链接空闲过久而后被中间 proxy 关闭了,一般解决方式除了调大 proxy 的 idle 配置,还可让链接池:
不一样的链接池实现可能会支持其中一种或多种方式,能够查看所使用的链接池文档来寻找对应配置。
业务应用一般会使用某种数据访问框架来简化数据库的访问。
MyBatis 是目前比较流行的 Java 数据访问框架,主要用于管理 SQL 并完成结果集和 Java 对象的来回映射工做。MyBatis 和 TiDB 兼容性很好,从历史 issue 能够看出 MyBatis 不多出现问题。这里主要关注以下几个配置。
MyBatis 的 Mapper 中支持两种参数:
select 1 from t where id = #{param1}
会做为 prepare 语句转换为 select 1 from t where id = ?
进行 prepare, 并使用实际参数来复用执行,经过配合前面的 Prepare 链接参数能得到最佳性能。select 1 from t where id = ${param2}
会作文本替换为 select 1 from t where id = 1
执行,若是这条语句被 prepare 成了不一样参数,可能会致使 TiDB 缓存大量的 prepare 语句,而且这种方式执行 SQL 有注入安全风险。要支持将多条 insert 语句自动重写为 insert ... values(...), (...), ...
的形式,除了前面所说的在 JDBC 配置 rewriteBatchedStatements=true
外,MyBatis 还可使用动态 SQL 的 foreach 语法 来半自动生成 batch insert。好比下面的 mapper:
<insert id="insertTestBatch" parameterType="java.util.List" fetchSize="1"> insert into test (id, v1, v2) values <foreach item="item" index="index" collection="list" separator=","> ( #{item.id}, #{item.v1}, #{item.v2} ) </foreach> on duplicate key update v2 = v1 + values(v1) </insert>
会生成一个 insert on duplicate key update
语句,values 后面的 (?, ?, ?)
数目是根据传入的 list 个数决定,最终效果和使用 rewriteBatchStatements=true
相似,能够有效减小客户端和 TiDB 的网络交互次数,一样须要注意 prepare 后超过 prepStmtCacheSqlLimit
限制致使不缓存 prepare 语句的问题。
前面介绍了在 JDBC 中如何使用流式读取结果,除了 JDBC 相应的配置外,在 MyBatis 中若是但愿读取超大结果集合也须要注意:
fetchSize
(见上一段代码段),效果等同于调用 JDBC setFetchSize。对于使用 xml 配置映射,能够经过在映射 <select>
部分配置 fetchSize="-2147483648"
(Integer.MIN_VALUE
) 来流式读取结果。
<select id="getAll" resultMap="postResultMap" fetchSize="-2147483648"> select * from post; </select>
而使用代码配置映射,则可使用 @Options(fetchSize = Integer.MIN_VALUE)
并返回 Cursor 从而让 SQL 结果能被流式读取。
@Select("select * from post") @Options(fetchSize = Integer.MIN_VALUE) Cursor<Post> queryAllPost();
在 openSession
的时候能够选择 ExecutorType
,MyBatis 支持三种 executor:
Simple
:每次执行都会向 JDBC 进行 prepare 语句的调用(若是 JDBC 配置有开启 cachePrepStmts
,重复的 prepare 语句会复用)。Reuse
:在 executor 中缓存 prepare 语句,这样不用 JDBC 的 cachePrepStmts
也能减小重复 prepare 语句的调用。Batch
:每次更新只有在 addBatch
到 query 或 commit 时才会调用 executeBatch
执行,若是 JDBC 层开启了 rewriteBatchStatements
,则会尝试改写,没有开启则会一条条发送。一般默认值是 Simple
,须要在调用 openSession
时改变 ExecutorType
。若是是 Batch 执行,会遇到事务中前面的 update 或 insert 都很是快,而在读数据或 commit 事务时比较慢的状况,这其实是正常的,在排查慢 SQL 时须要注意。
在应用代码中业务可能会经过使用 Spring Transaction 和 AOP 切面的方式来启停事务。
经过在方法定义上添加 @Transactional
注解标记方法,AOP 将会在方法前开启事务,方法返回结果前 commit 事务。若是遇到相似业务,能够经过查找代码 @Transactional
来肯定事务的开启和关闭时机。须要特别注意有内嵌的状况,若是发生内嵌,Spring 会根据 Propagation 配置使用不一样的行为,由于 TiDB 未支持 savepoint,因此不支持嵌套事务。
在 Java 应用发生问题而且不知道业务逻辑状况下,使用 JVM 强大的排查工具会比较有用。这里简单介绍几个经常使用工具:
jstack 对应于 Go 中的 pprof/goroutine
,能够比较方便地排查进程卡死的问题。
经过执行 jstack pid,便可输出目标进程中全部线程的线程 id 和堆栈信息。输出中默认只有 Java 堆栈,若是但愿同时输出 JVM 中的 C++ 堆栈,须要加 -m
选项。
经过屡次 jstack 能够方便地发现卡死问题(好比:都经过 Mybatis BatchExecutor flush
调用 update)或死锁问题(好比:测试程序都在抢占应用中某把锁致使没发送 SQL)
另外,top -p $PID -H
或者 Java swiss knife 都是经常使用的查看线程 ID 的方法。经过 printf "%x\n" pid
把线程 ID 转换成 16 进制,而后去 jstack 输出结果中找对应线程的栈信息,能够定位“某个线程占用 CPU 比较高,不知道它在执行什么”的问题。
和 Go 中的 pprof/heap
不一样,jmap 会将整个进程的内存快照 dump 下来(go 是分配器的采样),而后能够经过另外一个工具 mat 作分析。
经过 mat 能够看到进程中全部对象的关联信息和属性,还能够观察线程运行的状态。好比:咱们能够经过 mat 找到当前应用中有多少 MySQL 链接对象,每一个链接对象的地址和状态信息是什么。
须要注意 mat 默认只会处理 reachable objects,若是要排查 young gc 问题能够在 mat 配置中设置查看 unreachable objects。另外对于调查 young gc 问题(或者大量生命周期较短的对象)的内存分配,用 Java Flight Recorder 比较方便。
线上应用一般没法修改代码,又但愿在 Java 中作动态插桩来定位问题,推荐使用 btrace 或 arthas trace。它们能够在不重启进程的状况下动态插入 trace 代码。
Java 应用中获取火焰图较繁琐,可参阅 Java Flame Graphs Introduction: Fire For Everyone! 来手动获取。
本文从经常使用 Java 数据库交互组件的角度,阐述了开发 Java 应用程序使用 TiDB 的常见问题与解决办法。TiDB 是高度兼容 MySQL 协议的数据库,基于 MySQL 开发的 Java 应用的最佳实践也多适用于 TiDB。若是你们在使用上遇到了任何问题,能够在 asktug.com 提问,也欢迎更多小伙伴和咱们一块儿分享讨论 Java 应用使用 TiDB 的实践技巧。
原文阅读:https://pingcap.com/blog-cn/best-practice-java/