深入理解數據庫編程中的超時設置


數據庫是開發過程中最常用的組件,然而我們經常會遇到各種各樣的超時異常,如:

  • connect timeout:建立數據庫連接超時

  • socket timeout:socket讀取超時

  • statement timeout:單個sql執行超時

  • transaction timeout:事務執行超時,一個事務中可能包含多個sql

  • get connection timeout:從連接池中獲取鏈接超時

讀完此文,你將徹底掌握各種超時產生的根本原因,以及對應的解決方案。

1 connectTimeout與socketTimeout

connect timeout和socket timeout都屬於TCP層面的超時。

以mysql為例,我們可以在jdbc url中指定connectTimeout和socketTimeout。如:

jdbc:mysql://localhost:3306/db?connectTimeout=1000&socketTimeout=60000

     其中:

  • connectTimeout:表示的是數據庫驅動(mysql-connector-java)與mysql服務器建立TCP連接的超時時間。

  • socketTimeout:是通過TCP連接發送數據(在這里就是要執行的sql)后,等待響應的超時時間。

mysql驅動(mysql-connector-java)在與服務端建立Socket連接時,會將這兩個參數設置到socket對象上參見:

com.mysql.jdbc.MysqlIO類的構造方法:

提示:這里的mysqlConnection類型為java.net.Socket  

如果這兩個參數設置的不夠合理,都會導致mysql驅動拋出以下異常:

com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failure 

    相信大部分讀者對這個異常都不陌生。接下來筆者將分別演示這兩個異常是如何產生的,並提出對應的解決方案。

1.1 connectTimeout

下面首先通過一個案例演示如何模擬connectTimeout

@Testpublic void testConnectTimeout() throws SQLException { DruidDataSource dataSource = new DruidDataSource(); dataSource.setInitialSize(5); dataSource.setUrl("jdbc:mysql://127.0.0.1:3306/test?connectTimeout=5"); dataSource.setUsername("root"); dataSource.setPassword(“your password"); dataSource.setDriverClassName("com.mysql.jdbc.Driver”); dataSource.init();//初始化,底層通過mysql-connector-java建立數據庫連接}

筆者這里將connectTimeout設置為了5ms,表示mysql驅動與服務端建立一個連接最多不能超過5ms。由於這里是與本地(127.0.0.1)數據庫建立一個連接,5ms已經足夠。然而,如果你是與一個遠程數據庫建立連接,那么5ms可能無法完成建立一個連接,此時你極有可能會遇到類似以下異常:

com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failureThe last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.    at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)    at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)    ...Caused by: java.net.SocketTimeoutException: connect timed out    at java.net.PlainSocketImpl.socketConnect(Native Method)    at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:350) ...

到這里,我們看到了:

CommunicationsException異常,異常的Caused by部分是

java.net.SocketTimeoutException: connect timed out

        也就是說,建立底層socket 連接超時了。這通常意味着我們需要將connectTimeout值調大。

        這個問題並非無關緊要,特別是在公司有多個數據中心的情況下,尤其需要注意。筆者曾經遇到過有業務開發同學,應用部署在北京,數據庫集群在北京和上海都有部署,如下圖:

    

        上海和北京的一個RTT大概在20ms,而業務同學將connectTimeout設置為10ms。這就是導致,應用與北京的主庫建立連接可以成功,但是與上海的從庫建立連接總是經常失敗,顯然問題的解決方案,就是調大connectTimeout的值。需要注意的是,通常建議connectTimeout設置的值是需要大於RTT的,如果設置的剛剛好,很容易因為網絡擁堵或者抖動導致出現相同的異常。

        最后,connectTimeout的默認值為0,驅動層面不設置超時時間,但這並不意味着不會超時。此時將由操作系統來決定超時時間。一些內核參數,如net.ipv4.tcp_syn_retries可以影響connectTimeout,這里不做深入介紹。

1.2 socketTimeout

        socket timeout是我們實際開發中最容易遇到的另外一個導致CommunicationsException異常的原因,通常是在sql的執行時間超過了socket timeout設置的情況下出現。例如socket timeout設置的是3s,但是sql執行確需要5s,那么將會出現異常。

socket timeout異常演示:

@Test public void testSocketTimeout() throws SQLException { org.apache.tomcat.jdbc.pool.DataSource datasource = new org.apache.tomcat.jdbc.pool.DataSource();   //設置socketTimeout=3000,單位是ms datasource.setUrl("jdbc:mysql://localhost:3306/test?socketTimeout=3000"); datasource.setUsername("root"); datasource.setDriverClassName("com.mysql.jdbc.Driver"); datasource.setPassword(“your password"); Connection connection = datasource.getConnection(); PreparedStatement ps = connection.prepareStatement("select sleep(5)"); ps.executeQuery();}

在這個案例中,我們模擬了一個慢查詢,通過執行"select sleep(5)",sleep是mysql提供的函數,其接受一個休眠時間,單位是s,當我們把這個sql發送給mysql時,mysql服務端會休眠5秒后,再返回結果。

然而,由於我們在jdbc url中設置了socketTimeout=3000,意味着單條sql最大執行時間不能超過3s。因此運行以上案例,將會拋出類似以下異常:

com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failureThe last packet successfully received from the server was 3,080 milliseconds ago.  The last packet sent successfully to the server was 3,005 milliseconds ago.    at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)    at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)    at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)    at java.lang.reflect.Constructor.newInstance(Constructor.java:423) ...Caused by: java.net.SocketTimeoutException: Read timed out    at java.net.SocketInputStream.socketRead0(Native Method)    at java.net.SocketInputStream.socketRead(SocketInputStream.java:116) ...

        這個異常看起來與connectTimeout導致的異常很相似,但是實際卻有很大不同。這里我們是執行了一條sql,Caused By部分的異常提示為Read timed out,而之前是建立連接時拋出的異常,異常提示為connect timeout

在異常信息的開始部分,我們看到了詳細的錯誤提示信息:最后一次接收到服務端返回的報文是3080ms之前,最后一次發送報文給服務端是3005ms之前。

        細心的讀者已經發現,3005ms與我們設置的socketTimeout=3000如此接近,事實上,你可以認為多出的5ms是系統檢測到超過socketTimeout的耗時,之后拋出異常。當然,在實際開發中,系統檢測socket timeout的耗時並不是固定為5ms,每次檢測的耗時可能都不同,一般不過超過幾十毫秒。

另外,socketTimeout是配置在jdbc url上的,對於所有執行的sql都會有這個超時限制。因此在配置這個值的時候,應該比應用中耗時最長的sql還要稍大一點。

socketTimeout默認值也是0,也就是不超時。

2 statement timeout

socket timeout統一限制了所有SQL執行的最大耗時,有的時候,我們希望為不同的SQL指定不同的最大超時時間。這可以通過statement timeout來完成。

Statement對象提供了一個setQueryTimeout方法(其子類PreparedStatement繼承了這個方法),單位是秒,默認值為0,也就是 不超時。以下是一個設置statement timeout的案例:

Connection conn = datasource.getConnection();PreparedStatement ps = conn.prepareStatement("select sleep(5)");ps.setQueryTimeout(1);//設置statement timeoutps.executeQuery();

在這里:

  • 我們執行的sql是"select sleep(5)”,服務端需要休眠5s后才返回,

  • 另外,我們設置了sql查詢超時queryTimeout為1s

由於sql執行耗時超出了1s,因此,執行上述代碼片段將拋出類似以下異常:

com.mysql.jdbc.exceptions.MySQLTimeoutException: Statement cancelled due to timeout or client request at com.mysql.jdbc.PreparedStatement.executeInternal(PreparedStatement.java:1881) at com.mysql.jdbc.PreparedStatement.executeQuery(PreparedStatement.java:1962) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.apache.tomcat.jdbc.pool.StatementFacade$StatementProxy.invoke(StatementFacade.java:114) at com.sun.proxy.$Proxy6.executeQuery(Unknown Source) ...

可以看到,提示的異常信息為"Statement cancelled due to timeout or client request",表示sql由於執行超時而被取消了。

通過statement timeout,我們可以更加靈活的為不同的sql設置不同的超時時間。然而,在實際開發過程中,通常我們都是使用ORM框架,而不會直接使用原生的JDBC API,這意味着ORM要對此進行支持。

以mybatis為例,其提供了對statement timeout超時設置的支持。我們可以在<settings>元素中,為所有要執行的sql,設置一個默認的statement timeout。

如在mybatis-config.xml配置默認的statement timeout:

<settings>  <!--設置sql默認執行超時時間為25秒,如果為提供,則默認值為0,也就是沒有限制-->   <setting name="defaultStatementTimeout" value="25"/></settings>

或者在mapper映射文件中,指定單個sql的statement timeout,如

<!--設置sql超時時間為10秒--><select id="selectPerson" timeout="10" parameterType="int" resultType=“hashmap” >  SELECT * FROM PERSON WHERE ID = #{id}</select>

事實上,mybatis底層也是也只是我們我們配置的值,通過調用Statement.setQueryTimeout方法進行設置。

BaseStatementHandler#setStatementTimeout

需要注意的是,盡管statement timeout很靈活,但是在高並發的情況下,會創建大量的線程,一些場景下筆者並不建議使用原因在於,mysql-connector-java底層是通過定時器Timer來實現statement timeout的功能,也就是說,對於設置了statement timeout的sql,將會導致mysql創建定時Timer來執行sql,意味着高並發的情況下,mysql驅動可能會創建大量線程。

以下是筆者模擬設置statement timeout之后,通過jstack命令查看的結果。

可以看到這里包含了一個名為Mysql Statement Cancellation Timer的線程,這就是用於控制sql執行超時的定時器線程。在高並發的情況下,大量的sql同時執行,如果設置了statement timeout,就會出現需要這樣的線程。

在mysql-connector-java驅動的源碼中(這里使用的是5.1.39版本),體現了這個邏輯。在ConnectionImpl類中定義了一個超時Timer

com.mysql.jdbc.ConnectionImpl#getCancelTimer

這里我們看到ConnectionImpl內部,提供了一個名為MySQL Statement Cancellation Timer的定時器。

在sql執行時,如果設置了statement timeout,則將sql包裝成一個task,通過Timer進行執行:mysql 驅動源碼里有多處使用到了這個Timer,這里以StatementImpl的executeQuery方法為例進行講解,包含了以下代碼片段:

com.mysql.jdbc.StatementImpl#executeQuery

可以看到,在指定statement timeout的情況下,mysql內部會將sql執行操作包裝成一個CancelTask,然后通過定時器Timer來運行。Timer實際上是與ConnectionImpl綁定的,同一個ConnectionImpl執行的多個sql,會共用這個Timer。默認情況下,這個Timer是不會創建的,一旦某個ConnectionImpl上執行的一個sql,指定了statement timeout,此時這個Timer才創建,一直到這個ConnectionImpl被銷毀時,Timer才會取消。

在一些場景下,如分庫分表、讀寫分離,如果使用的數據庫中間件是基於smart-client方式實現的,會與很多庫建立連接,由於其底層最終也是通過mysql-connector-java創建連接,這種場景下,如果指定了statement timeout,那么應用中將會存在大量的Timer線程,在這種場景下,並不建議設置。

最后,需要提醒的是,socket timeout是TCP層面的超時,是操作系統層面進行的控制,statement timeout是驅動層面實現的超時,是應用層面進行的控制,如果同時設置了二者,那么 socket timeout必須比statement timeout大,否則statement timeout無法生效。

3 transaction timeout

前面提到的的socket timeout、statement timeout,都是限制單個sql的最大執行超時。在事務的情況下,可能需要執行多個sql,我們想針對整個事務設置一個最大的超時時間。

例如,我們在采用spring配置事務管理器的時候,可以指定一個defaultTimeout屬性,單位是秒,指定所有事務的默認超時時間。

    也可以在@Transactional注解上針對某個事務,指定超時時間,如:

@Transactional(timeout = 3)

如果同時配置了,@Transactional注解上的配置,將會覆蓋默認的配置。

transaction timeout的實現原理可以用以下流程進行描述,假設事務超時為5秒,需要執行3個sql:

 start transaction #事務超時為5秒 | \|/ sql1 #statement timeout設置為5秒 | | #執行耗時1s,那么整個事務超時還剩4秒  \|/ sql2 #設置statement timeout設置為4秒 | | #執行耗時2秒,整個事務超時還是2秒 \|/  sql3 #設置statement timeout設置為2秒 |  ---   #假設執行耗時超過2s,那么整個事務超時,拋出異常   

這里只是一個簡化的流程,但是可以幫助我們了解spring事務超時的原理。從這個流程中,我們可以看到,spring事務的超時機制,實際上是還是通過Statement.setQueryTimeout進行設置,每次都是把當前事務的剩余時間,設置到下一個要執行的sql中。

事實上,spring的事務超時機制,需要ORM框架進行支持,例如mybatis-spring提供了一個SpringManagedTransaction,里面有一個getTimeout方法,就是通過從spring中獲取事務的剩余時間。這里不在繼續進行源碼分析。

4 get connection timeout

check connection timeout或者get connection timeout,表示從數據庫連接池DataSource中獲取鏈接超時。通DataSource的實現有很多,如druid,c3p0、dbcp2、tomcat-jdbc、hicaricp等,不同的連接池,拋出的異常類型不同,但是從異常的名字中,都可以看出是獲取鏈接異常。連接池,底層也是通過mysql-connector-java創建連接,只不過在連接上做了一層代理,當關閉的時候,是返回連接池,而不是真正的關閉物理連接,從而達到連接復用。

 我們通常是需要首先獲取到一個連接Connection對象,然后才能創建事務,設置事務超時實現,在事務中執行sql,設置sql的超時時間。因此,要操作數據庫,Connection是基礎。從連接池中,獲取鏈接超時,是開發中,最常見的異常。

        通常是因為連接池大小設置的不合理。如何設置合理的線程池大小需要進行綜合考慮。

 這里以sql執行耗時、要支撐的qps為例:

        假設某個接口的sql執行耗時為5ms,要支撐的最大qps為1000。一個sql執行5ms,理想情況下,一個Connection一秒可以執行200個sql。又因為支持的qps為1000,那么理論上我們只需要5個連接即可。當然,實際情況遠遠比這復雜,例如,我們沒有考慮連接池內部的邏輯處理耗時,mysql負載較高執行sql變慢,應用發生了gc等,這些情況都會導致獲取連接時間變長。所以,我的建議是,比理論值,高3-5倍。

最后對以下兩種典型情況,進行說明:

1 應用啟動時,出現獲取連接超時異常

        可以通過調大initPoolSize。如果連接池有延遲初始化(lazy init)功能,也要設置為立即初始化,否則,只有第一次請求訪問數據庫時,才會初始化連接池。這個時候容易出現獲取鏈接超時。

2 業務高峰期,出現獲取連接超時異常

        如果是偶然出現,可以忽略。如果出現的較為頻繁,可以考慮調大maxPoolSize和minPoolSize。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM