TiDB 最佳實踐系列(五)Java 數據庫應用開發指南

做者:Su Li,Zhao Mingphp

Java 是當前很是流行的開發語言,不少 TiDB 用戶的業務層都是使用 Java 開發的,本文將從 Java 數據庫交互組件開發的角度出發,介紹各組件的推薦配置和推薦使用方式,但願能幫助 Java 開發者在使用 TiDB 時能更好的發揮數據庫性能。html

Java 應用中的數據庫相關組件

一般 Java 應用中和數據庫相關的經常使用組件有:java

  • 網絡協議:客戶端經過標準 MySQL 協議 和 TiDB 進行網絡交互。
  • JDBC API 及實現:Java 應用一般使用 JDBC (Java Database Connectivity) 來訪問數據庫。JDBC 定義了訪問數據庫 API,而 JDBC 實現完成標準 API 到 MySQL 協議的轉換,常見的 JDBC 實現是 MySQL Connector/J,此外有些用戶可能使用 MariaDB Connector/J
  • 數據庫鏈接池:爲了不每次建立鏈接,一般應用會選擇使用數據庫鏈接池來複用鏈接,JDBC DataSource 定義了鏈接池 API,開發者可根據實際需求選擇使用某種開源鏈接池實現。
  • 數據訪問框架:應用一般選擇經過數據訪問框架(MyBatisHibernate)的封裝來進一步簡化和管理數據庫訪問操做。
  • 業務實現:業務邏輯控制着什麼時候發送和發送什麼指令到數據庫,其中有些業務會使用 Spring Transaction 切面來控制管理事務的開始和提交邏輯。

如上圖所示,應用可能使用 Spring Transaction 來管理控制事務非手工啓停,經過相似 MyBatis 的數據訪問框架管理生成和執行 SQL,經過鏈接池獲取已池化的長鏈接,最後經過 JDBC 接口調用實現經過 MySQL 協議和 TiDB 完成交互。mysql

接下來將分別介紹使用各個組件時可能須要關注的問題。git

JDBC

Java 應用盡管能夠選擇在不一樣的框架中封裝,但在最底層通常會經過調用 JDBC 來與數據庫服務器進行交互。對於 JDBC,須要關注的主要有:API 的選擇和 API Implementer 的參數配置。github

1. JDBC API

對於基本的 JDBC API 使用能夠參考 JDBC 官方教程,本文主要強調幾個比較重要的 API 選擇。spring

1.1 使用 Prepare API

對於 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

1.2 使用 Batch 批量插入更新

對於批量插入更新,若是插入記錄較多,能夠選擇使用 addBatch/executeBatch API。經過 addBatch 的方式將多條 SQL 的插入更新記錄先緩存在客戶端,而後在 executeBatch 時一塊兒發送到數據庫服務器。

注意:

對於 MySQL Connector/J 實現,默認 Batch 只是將屢次 addBatch 的 SQL 發送時機延遲到調用 executeBatch 的時候,但實際網絡發送仍是會一條條的發送,一般不會下降與數據庫服務器的網絡交互次數。

若是但願 Batch 網絡發送批量插入,須要在 JDBC 鏈接參數中配置 rewriteBatchedStatements=true(下面參數配置章節有詳細介紹)。

1.3 使用 StreamingResult 流式獲取執行結果

通常狀況下,爲提高執行效率,JDBC 會默認提早獲取查詢結果並將其保存在客戶端內存中。但在查詢返回超大結果集的場景中,客戶端會但願數據庫服務器減小向客戶端一次返回的記錄數,等客戶端在有限內存處理完一部分後再去向服務器要下一批。

在 JDBC 中一般有如下兩種處理方式:

  • 設置 FetchSizeInteger.MIN_VALUE 讓客戶端不緩存,客戶端經過 StreamingResult 的方式從網絡鏈接上流式讀取執行結果。
  • 使用 Cursor Fetch 首先需 設置 FetchSize 爲正整數且在 JDBC URL 中配置 useCursorFetch=true

TiDB 中同時支持兩種方式,但更推薦使用第一種將 FetchSize 設置爲 Integer.MIN_VALUE 的方式,比第二種功能實現更簡單且執行效率更高。

2. MySQL JDBC 參數

JDBC 實現一般經過 JDBC URL 參數的形式來提供實現相關的配置。這裏以 MySQL 官方的 Connector/J 來介紹 參數配置(若是使用的是 MariaDB,能夠參考 MariaDB 的相似配置)。由於配置項較多,這裏主要關注幾個可能影響到性能的參數。

2.1 Prepare 相關參數

useServerPrepStmts

默認狀況下,useServerPrepStmts 爲 false,即儘管使用了 Prepare API,也只會在客戶端作 「prepare」。所以爲了不服務器重複解析的開銷,若是同一條 SQL 語句須要屢次使用 Prepare API,則建議設置該選項爲 true。

在 TiDB 監控中能夠經過 Query Summary > QPS By Instance 查看請求命令類型,若是請求中 COM_QUERYCOM_STMT_EXECUTECOM_STMT_PREPARE 代替即生效。

cachePrepStmts

雖然 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

prepStmtCacheSqlLimit

在配置 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

prepStmtCacheSize 控制緩存的 prepare 語句數目(默認爲 25),若是應用須要 prepare 的 SQL 種類不少且但願複用 prepare 語句,能夠調大該值。

和上一條相似,在監控中經過 Query Summary > QPS by Instance 查看請求中 COM_STMT_EXECUTE 數目是否遠遠多於 COM_STMT_PREPARE 來確認是否正常。

2.2 Batch 相關參數

在進行 batch 寫入處理時推薦配置 rewriteBatchedStatements=true,在已經使用 addBatchexecuteBatch 後默認 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

2.3 執行前檢查參數

經過監控可能會發現,雖然業務只向集羣進行 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 不會限定使用的鏈接池,應用能夠根據業務特色自行選擇鏈接池實現。

1. 鏈接數配置

比較常見的是應用須要根據自身狀況配置合適的鏈接池大小,以 HikariCP 爲例:

  • maximumPoolSize:鏈接池最大鏈接數,配置過大會致使 TiDB 消耗資源維護無用鏈接,配置太小則會致使應用獲取鏈接變慢,因此需根據應用自身特色配置合適的值,可參考 這篇文章
  • minimumIdle:鏈接池最大空閒鏈接數,主要用於在應用空閒時存留一些鏈接以應對突發請求,一樣是須要根據業務狀況進行配置。

應用在使用鏈接池時須要注意鏈接使用完成後歸還鏈接,推薦應用使用對應的鏈接池相關監控(如 metricRegistry),經過監控能及時定位鏈接池問題。

2. 探活配置

鏈接池維護到 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 中的 n0 或很小的值,則一般是執行的 SQL 致使 TiDB 異常退出引發的報錯,推薦查看 TiDB stderr 日誌;若是 n 是一個很是大的值(好比這裏的 3600000),極可能是由於這個鏈接空閒過久而後被中間 proxy 關閉了,一般解決方式除了調大 proxy 的 idle 配置,還可讓鏈接池:

  • 每次使用鏈接前檢查鏈接是否可用。
  • 使用單獨線程按期檢查鏈接是否可用。
  • 按期發送 test query 保活鏈接。

不一樣的鏈接池實現可能會支持其中一種或多種方式,能夠查看所使用的鏈接池文檔來尋找對應配置。

數據訪問框架

業務應用一般會使用某種數據訪問框架來簡化數據庫的訪問。

1. MyBatis

MyBatis 是目前比較流行的 Java 數據訪問框架,主要用於管理 SQL 並完成結果集和 Java 對象的來回映射工做。MyBatis 和 TiDB 兼容性很好,從歷史 issue 能夠看出 MyBatis 不多出現問題。這裏主要關注以下幾個配置。

1.1 Mapper 參數

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 有注入安全風險。

1.2 動態 SQL Batch

要支持將多條 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 語句的問題。

1.3 Streaming 結果

前面介紹了在 JDBC 中如何使用流式讀取結果,除了 JDBC 相應的配置外,在 MyBatis 中若是但願讀取超大結果集合也須要注意:

  • 能夠經過在 mapper 配置中對單獨一條 SQL 設置 fetchSize(見上一段代碼段),效果等同於調用 JDBC setFetchSize。
  • 可使用帶 ResultHandler 的查詢接口來避免一次獲取整個結果集。
  • 可使用 Cursor 類來進行流式讀取。

對於使用 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();

2. ExecutorType

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

在應用代碼中業務可能會經過使用 Spring Transaction 和 AOP 切面的方式來啓停事務。

經過在方法定義上添加 @Transactional 註解標記方法,AOP 將會在方法前開啓事務,方法返回結果前 commit 事務。若是遇到相似業務,能夠經過查找代碼 @Transactional 來肯定事務的開啓和關閉時機。須要特別注意有內嵌的狀況,若是發生內嵌,Spring 會根據 Propagation 配置使用不一樣的行爲,由於 TiDB 未支持 savepoint,因此不支持嵌套事務。

排查工具

在 Java 應用發生問題而且不知道業務邏輯狀況下,使用 JVM 強大的排查工具會比較有用。這裏簡單介紹幾個經常使用工具:

1. jstack

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 比較高,不知道它在執行什麼」的問題。

2. jmap & mat

和 Go 中的 pprof/heap 不一樣,jmap 會將整個進程的內存快照 dump 下來(go 是分配器的採樣),而後能夠經過另外一個工具 mat 作分析。

經過 mat 能夠看到進程中全部對象的關聯信息和屬性,還能夠觀察線程運行的狀態。好比:咱們能夠經過 mat 找到當前應用中有多少 MySQL 鏈接對象,每一個鏈接對象的地址和狀態信息是什麼。

須要注意 mat 默認只會處理 reachable objects,若是要排查 young gc 問題能夠在 mat 配置中設置查看 unreachable objects。另外對於調查 young gc 問題(或者大量生命週期較短的對象)的內存分配,用 Java Flight Recorder 比較方便。

3. trace

線上應用一般沒法修改代碼,又但願在 Java 中作動態插樁來定位問題,推薦使用 btrace 或 arthas trace。它們能夠在不重啓進程的狀況下動態插入 trace 代碼。

4. 火焰圖

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/

相關文章
相關標籤/搜索