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

作者:Su Li,Zhang Ming

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

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 完成交互。

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

JDBC

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

1. JDBC API

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

1.1 使用 Prepare API

對於 OLTP 場景,程序發送給數據庫的 SQL 語句在去除參數變化後都是可窮舉的某幾類,因此建議使用 預處理語句 (Prepared Statements) 代替普通的 文本執行,並複用 Prepared Statements 來直接執行,從而避免 TiDB 重複解析的開銷。

目前多數上層框架都會調用 Prepare API 進行 SQL 執行,如果直接使用 JDBC API 進行開發,注意選擇使用 Prepare API。

另外需要注意 MySQL Connector/J 實現中默認只會做客戶端的語句預處理,會將 ? 在客戶端替換後以文本形式發送到客戶端,所以除了要使用 Prepare API,還需要在 JDBC 連接參數中配置 useServerPrepStmts = true,才能在 TiDB 服務器端進行語句預處理(下面參數配置章節有詳細介紹)。

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/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章