Tomcat DBCP POC

## 本記事の目的
TomcatでDBCPを構成する手順とサンプルコード、DBCPパラメータに関するPOCについてまとめている。

DBCPについては公式ドキュメントを参照
[公式ドキュメント](https://tomcat.apache.org/tomcat-9.0-doc/jndi-datasource-examples-howto.html#Database_Connection_Pool_(DBCP_2)_Configurations
)

## 背景
とあるシステムでRDSインスタンス障害時にTomcatを再起動していた。
ミッションクリティカルなシステムではAP再起動はアプリ全体の停止につながるため、対応方法としてはありえない。影響範囲を極小化するためにせめてコネクションプールのリフレッシュだけにできないか？と思ったのがこの記事のモチベーション。

## 検証環境
- Tomcat V10.1.24
- OpenJDK 17.0.6
- Red Hat Enterprise Linux release 8.4 (Ootpa)：OracleDB
- Red Hat Enterprise Linux release 9.3 (Plow)：OracleDB以外
- Oracle 19c
- Aurora MySQL 8.0.mysql_aurora.3.05.2 db.t3.medium
- Apache JMeter 5.6.3
- JMXコマンドラインツール(https://qiita.com/uzresk/items/9142c24f218003a4b2a6)
- マシンスペック6core/8GB/3年前のDELLのノートPCレベル


## Tomcatの設定
他のAPサーバー製品と異なり、コネクションプールに関するメトリクスを取得する唯一の手段がMBeanとなる。リモートでMBeanアクセスするためにはcatalina.shに下記を追加
```
CATALINA_OPTS="$CATALINA_OPTS -Dcom.sun.management.jmxremote.port=9024 -Dcom.sun.management.jmxremote.rmi.port=9025 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false"
```

## アプリケーションの設定
アプリケーションのMETA-INF/context.xmlの設定内容
```
<Context>
    <WatchedResource>WEB-INF/web.xml</WatchedResource>
    <WatchedResource>WEB-INF/tomcat-web.xml</WatchedResource>
    <WatchedResource>${catalina.base}/conf/web.xml</WatchedResource>
    <Resource name="jdbc/TestDB" auth="Container" type="javax.sql.DataSource"
               username="test" password="test" driverClassName="oracle.jdbc.OracleDriver"
               url="jdbc:oracle:thin:@192.168.47.133:1521/orclpdb"
        initialSize="100"
        maxTotal="100"
        maxIdle="100"
        testOnBorrow="true"
        validationQuery="select 'dbcp2_validation' from dual"
        testWhileIdle="true"
        timeBetweenEvictionRunsMillis="1000"
        numTestsPerEvictionRun="100"
    />
</Context>
```


アプリケーションのWEB-INF/web.xmlの設定内容
```
<resource-ref>
      <description>DB Connection</description>
      <res-ref-name>jdbc/TestDB</res-ref-name>
      <res-type>javax.sql.DataSource</res-type>
      <res-auth>Container</res-auth>
</resource-ref>
```

アプリケーションのWEB-INF/libの下にojdbc.jar（OracleのJDBCドライバ）を配置

## アプリケーションのコード(DB参照用)
Oracleに単純なselect文を投げるだけのサーブレット
select col1 from testは1レコードのみを返すようにOracle側にデータ1件insertしているのみ。
```
package test;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.sql.DataSource;

/**
 * Servlet implementation class TestServlet
 */
public class TestServlet extends HttpServlet {
	private static final long serialVersionUID = 1L;
       
    /**
     * @see HttpServlet#HttpServlet()
     */
    public TestServlet() {
        super();
    }

	/**
	 * @see HttpServlet#doGet(HttpServletRequest request, HttpServletResponse response)
	 */
	protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		
		Context initContext;
		try {
			String sql = "select col1 from test";			
			initContext = new InitialContext();
			Context envContext  = (Context)initContext.lookup("java:/comp/env");
			DataSource ds = (DataSource)envContext.lookup("jdbc/TestDB");
			Connection conn = ds.getConnection();
            String result = executeQuery(conn, sql, 1);
            conn.close();
			PrintWriter out = response.getWriter();
			out.println(result);
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

	/**
	 * @see HttpServlet#doPost(HttpServletRequest request, HttpServletResponse response)
	 */
	protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		doGet(request, response);
	}
	
	private String executeQuery(Connection conn, String sql) {
		String result = "";
		Statement stmt;
		try {
			stmt = conn.createStatement();
			ResultSet rset = stmt.executeQuery(sql);
			while (rset.next()) {
				result = result + rset.getString(1);
			}
			rset.close();
	        stmt.close();
		} catch (SQLException e) {
			e.printStackTrace();
		}
		return result;
	}
	
	private String executeQuery(Connection conn, String sql, int count) {
		String result = "";
		for(int i = 0; i < count; i++) {
			result = result + executeQuery(conn, sql);
		}
		return result;
	}

}
```

## アプリケーションのコード(MBeanメトリクス参照)
TomcatのMBeanに接続してDBCP関連のメトリクスを参照するだけのサーブレット
```
package test;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

import javax.management.MBeanServerConnection;
import javax.management.MalformedObjectNameException;
import javax.management.ObjectInstance;
import javax.management.ObjectName;
import javax.management.remote.JMXConnector;
import javax.management.remote.JMXConnectorFactory;
import javax.management.remote.JMXServiceURL;

/**
 * Servlet implementation class MBeanServlet
 */
public class MBeanServlet extends HttpServlet {
	private static final long serialVersionUID = 1L;
       
    /**
     * @see HttpServlet#HttpServlet()
     */
    public MBeanServlet() {
        super();
    }

	/**
	 * @see HttpServlet#doGet(HttpServletRequest request, HttpServletResponse response)
	 */
	protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		
		PrintWriter out = response.getWriter();
		JMXConnector jmxConnector;
		MBeanServerConnection conn;
		try {
			String url = "service:jmx:rmi:///jndi/rmi://localhost:9024/jmxrmi";
			JMXServiceURL serviceUrl = new JMXServiceURL(url);
			jmxConnector = JMXConnectorFactory.connect(serviceUrl, null);
			conn = jmxConnector.getMBeanServerConnection();
			ObjectName objectName = new ObjectName("Catalina:class=javax.sql.DataSource,context=/DBCP,host=localhost,name=\"jdbc/TestDB\",type=DataSource");

			Integer activeConnections = (Integer)conn.getAttribute(objectName, "numIdle");
			out.println(activeConnections);
			jmxConnector.close();
			
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
		}
		
	}

	/**
	 * @see HttpServlet#doPost(HttpServletRequest request, HttpServletResponse response)
	 */
	protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		doGet(request, response);
	}
	

}

```

## アプリケーションのコード(MBean invoke)
TomcatのMBeanに接続してDBCP関連の操作を実行するだけのサーブレット。
下記の例はコネクションプール上の無効となったidle状態の接続オブジェクトをevictする目的で実装したサーブレット。DB接続障害トリガー以外のトリガーでevictさせる手段。（接続障害を検知するのに時間を要するユースケースに備える目的）
```
package test;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.management.ManagementFactory;

import javax.management.MBeanServer;
import javax.management.MBeanServerConnection;
import javax.management.ObjectName;
import javax.management.remote.JMXConnector;
import javax.management.remote.JMXConnectorFactory;
import javax.management.remote.JMXServiceURL;

/**
 * Servlet implementation class EvictServlet
 */
public class EvictServlet extends HttpServlet {
	private static final long serialVersionUID = 1L;
       
    /**
     * @see HttpServlet#HttpServlet()
     */
    public EvictServlet() {
        super();
    }

	/**
	 * @see HttpServlet#doGet(HttpServletRequest request, HttpServletResponse response)
	 */
	protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		PrintWriter out = response.getWriter();
		JMXConnector jmxConnector;
		MBeanServerConnection conn;
		try {
			MBeanServer mbeanServer = ManagementFactory.getPlatformMBeanServer();
			String url = "service:jmx:rmi:///jndi/rmi://localhost:9024/jmxrmi";
			JMXServiceURL serviceUrl = new JMXServiceURL(url);
			jmxConnector = JMXConnectorFactory.connect(serviceUrl, null);
			conn = jmxConnector.getMBeanServerConnection();
			ObjectName objectName = new ObjectName("Catalina:class=javax.sql.DataSource,context=/DBCP,host=localhost,name=\"jdbc/TestDB\",type=DataSource");

			mbeanServer.invoke(objectName, "evict", null, null);
			jmxConnector.close();
			out.print("evict success.");
		} catch (Exception e) {
			out.print("evict fail.");
			e.printStackTrace();
		} finally {
		}
	}

	/**
	 * @see HttpServlet#doPost(HttpServletRequest request, HttpServletResponse response)
	 */
	protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		doGet(request, response);
	}

}

```

## JMXコマンドラインツールの設定例

Tomcat_DBCP_metrics.txt
```
"Catalina:type=DataSource,context=/,host=localhost,class=javax.sql.DataSource,name="jdbc/TestDB"" "numActive"
"Catalina:type=DataSource,context=/,host=localhost,class=javax.sql.DataSource,name="jdbc/TestDB"" "numIdle"
```

- 実行方法
下記は1秒間隔でnumActiveとnumIdleを標準出力させる例
```
java -Dpath=./Tomcat_DBCP_metrics.txt -jar jmx-cmdclient-0.1.1.jar localhost:9024 1
```

## 検証：validationQueryの負荷
##### パラメータの説明：The SQL query that will be used to validate connections from this pool before returning them to the caller. If specified, this query MUST be an SQL SELECT statement that returns at least one row. If not specified, connections will be validation by calling the isValid() method.
　　
context.xmlでvalidationQuery="select 'dbcp2_validation' from dual"を指定する場合の負荷検証。処理は軽いのでスループットへの影響を計測した。
- シナリオ1
validationQueryありで1SQLをサーブレットで実行した場合、validationQueryなしで1SQLを実行した場合に比べてスループットが約52%(=48%減)になった。

- シナリオ2
validationQueryありで3SQLをサーブレットで実行した場合、validationQueryなしで3SQLをサーブレットで実行した場合に比べてスループットが約87%(=13%減)になった。

#### 結論＝負荷を気にせずvalidationQueryは使ってよい。
二つのシナリオからはアプリケーション本体のSQL実行負荷増によってvalidationQueryがあたえるスループットへの影響は相対的に小さくなる。（当たり前の話）
エンタープライズアプリケーションでは通常相応のデータ量のテーブルに対して結合等がはいった相応の複雑度のSQLを複数回実行する。Oracleに限らず、dual表のような機能は他社製品にもあるため、このような状況ではvalidationQuery(=dual表への極軽SQL利用)がシステムへ与えるスループット負荷は誤差レベルとみなしてよい。

## 検証：timeBetweenEvictionRunsMillisの設定値と負荷
##### パラメータの説明：The number of milliseconds to sleep between runs of the idle object evictor thread. When non-positive, no idle object evictor thread will be run.

検証目的はDB障害発生してDB復旧した場合を想定。このような場合にコネクションプール上の無効なidleObjectを短時間でevictさせたいが、evictスレッドの実行間隔をどこまで短くできるかをみること。
- 前提
Oracleはshutdown abortで強制的にインスタンス停止
- timeBetweenEvictionRunsMillis="1000"
複数回性能検証したスループット平均値：3643tps

- timeBetweenEvictionRunsMillis="100"
複数回性能検証したスループット平均値：3665tps

- timeBetweenEvictionRunsMillis="10"
複数回性能検証したスループット平均値：3696tps

- timeBetweenEvictionRunsMillis="1"
エラー発生

#### 結論
コネクションプールの数とTomcatに割り当てられるcore数によってチューニングは必要だが100ミリ秒は安全圏と思われる。
この検証では100個の無効オブジェクトを一度にevictする設定としている。（numTestsPerEvictionRunのデフォルト値が3になっているが100に変更）
この値はmaxIdleと同じ値でチューニングするのがよいと考えられる。
10ミリ秒と1000ミリ秒でスループットへの影響は変わらずの結果。DBダウンから復旧までの時間を鑑みて設定するのがよいが、DB停止時間は製品や障害のバリエーションにより異なるが、どのケースにおいても秒単位以上となると想定される。そのためevict間隔を頑張って2桁ミリ秒にする効果は大きくない。100ミリでスループット低下が無いなら100ミリにするのがよい。

## 検証：Aurora MySQL障害時の挙動
JDBCドライバはmysql-connector-j-8.4.0.jarを利用。ドライバの配置についてはOracleと同じ条件。JavaのソースコードはOracleの場合と同じで下記のようにcontext.xmlを変更する。
```
<Resource name="jdbc/TestDB" auth="Container" type="javax.sql.DataSource"
               username="XXXXXX" password="XXXXXX" driverClassName="com.mysql.cj.jdbc.Driver"
               url="jdbc:mysql://test.cluster-cofxoo0c8wlr.ap-northeast-1.rds.amazonaws.com:3306/test"
        initialSize="50"
        maxTotal="50"
        maxIdle="50"
        testOnBorrow="true"
        validationQuery="select 1"
        testWhileIdle="true"
        timeBetweenEvictionRunsMillis="1000"
        numTestsPerEvictionRun="50"
    />
```

Aurora MySQLの障害発生方法については下記を参考にした。
[障害挿入クエリを使用した Amazon Aurora MySQL のテスト](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/AuroraUserGuide/AuroraMySQL.Managing.FaultInjectionQueries.html
)

- 検証：alter system crash instanceによる強制停止
evictスレッドが機能し、即時で無効idleオブジェクトが破棄された。
MySQLは明示的にインスタンス起動オペレーションせずとも復旧した。再度SQL実行が成功するまでの時間は約7秒。(ほとんどトランザクションない状態)
「DB instance restarted」イベントの発生をマネコン上からみることができた。

```
mysql> alter system crash instance;
ERROR 2013 (HY000): Lost connection to MySQL server during query
No connection. Trying to reconnect...
Connection id:    1190
Current database: test

ERROR 2013 (HY000): Lost connection to MySQL server during query
No connection. Trying to reconnect...
ERROR 2003 (HY000): Can't connect to MySQL server on 'test.cluster-cofxoo0c8wlr.ap-northeast-1.rds.amazonaws.com:3306' (111)
ERROR:
Can't connect to the server
```
　　
- 検証：alter system crash dispatcherによる強制停止
crash instanceと同じ結果。（省略）

```
mysql> alter system crash dispatcher;
Query OK, 0 rows affected (0.03 sec)
```
　　
- 検証：alter system crash nodeによる強制停止
crash instanceと同じ結果。（省略）

```
mysql> alter system crash node;
No connection. Trying to reconnect...
Connection id:    66
Current database: test

ERROR 2013 (HY000): Lost connection to MySQL server during query
No connection. Trying to reconnect...
ERROR 2003 (HY000): Can't connect to MySQL server on 'test.cluster-cofxoo0c8wlr.ap-northeast-1.rds.amazonaws.com:3306' (111)
ERROR:
Can't connect to the server
```
　　
- 検証：alter systemによるディスク障害
evictスレッドは動かなかった。更新処理が待たされる結果となった。
```
mysql> ALTER SYSTEM SIMULATE 100 PERCENT DISK CONGESTION BETWEEN 3000 AND 5000 MILLISECONDS FOR INTERVAL 1 MINUTE;
Query OK, 0 rows affected (0.01 sec)
```
別窓の更新処理(通常一瞬で終わる処理が53秒)
```
mysql> insert into test values('hoge');
Query OK, 1 row affected (53.47 sec)
```
ALTER SYSTEM SIMULATE 100 PERCENT DISK FAILURE FOR INTERVAL 1 MINUTE;
についても上記と同様の結果。リクエストがブロックされるとドキュメント記載通りの挙動だった。

　　
- 検証：リードレプリカ障害(リードレプリカ無し)
```
mysql> ALTER SYSTEM SIMULATE 100 PERCENT READ REPLICA FAILURE TO ALL FOR INTERVAL 1 MINUTE;
Query OK, 0 rows affected (0.00 sec)
```
evictスレッドは動かず、DB参照処理、更新処理が待たされることなく正常終了。

　　
- 検証：AWSフォールトインジェクションサービス：aws:rds:failover-db-cluster
Aurora MySQLのインスタンス数1で実行したのでエラー発生。エラーメッセージの内容から複数インスタンスでないとFISが動かない仕様と判明（当たり前）。
※このケースはJBoss/Aurora PostgreSQLの複数インスタンス構成にて後日試す予定。
　　
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325048/7cbe597b-5382-2149-1ce9-94a8cb153727.png)

  　　
- 検証：AWSフォールトインジェクションサービス：aws:rds:reboot-db-instances
evictスレッドが機能し、即時で無効idleオブジェクトが破棄された。
MySQLは明示的にインスタンス起動オペレーションせずとも復旧した。再度SQL実行が成功するまでの時間は約3秒。(ほとんどトランザクションない状態)
FISの開始から実際にインスタンス再起動までの時間は約20秒。
「DB instance restarted」イベントと「DB instance restarted」の両方の発生をマネコン上からみることができた。alter system crashとの違いは「DB instance shutdown」イベントの有無。
もしかするとこのreboot-db-instancesは障害検証のエミュレーション目的で使うよりも、障害発生した時に、安全にrebootする目的で使えるかもしれない。
マニュアルで目に留まった機能は下記。
#### forceFailover - オプション。値が true の場合、インスタンスがマルチ AZ の場合は、1 つのアベイラビリティーゾーンから別のアベイラビリティーゾーンへのフェイルオーバーを強制的に実行できます。デフォルトは False です。

## TODO
本検証はAP：Tomcat、DB：Oracle(shutdown abort)とAurora MySQLシングルインスタンスのみで実施。
今後は強制停止のバリエーションによる挙動。AP製品やDB製品やDB構成(リードレプリカ有無)の違い（JBoss/WebLogic/WebSphere/Aurora PostgreSQL）を深堀していく。

- RDS利用時のDNSキャッシュの挙動に依存しない不要オブジェクトのevict設計
上記のEvictServletはそれを意図している。
- コンテナアプリにおけるevict設計
- Amazonのfault injectionの機能検証（マルチAZやマルチインスタンス）
[AWSフォールトインジェクションサービス](https://docs.aws.amazon.com/ja_jp/fis/latest/userguide/fis-actions-reference.html#failover-db-cluster)
- RDS Proxy機能検証とユースケースの見極め


## おまけ
DBがOracleでコネクションプールを利用しない場合のコネクションオブジェクト取得時間計測。
下記ソースコードのDriverManger.getConnection()の部分を計測した。
10回実行平均：47ミリ秒。（PostgreSQLの場合は23ミリ秒）
コネクションプール利用時(上記TestServletでConnectionを取得する部分)は0.9ミリ秒（JBossの場合は0.3ミリ秒）
レスポンスのオーバーヘッドが大きいが、リクエスト毎に新規コネクションを張るためそもそも設計としてはNG。実際DBCP構成の時と同じJMeterの負荷シナリオを実行したがエラー多発状態となった。
```
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		Connection conn = null;
		try {
			String sql = "select col1 from test";
			Class.forName("oracle.jdbc.driver.OracleDriver");
			long start = System.currentTimeMillis();
			conn=DriverManager.getConnection("jdbc:oracle:thin:@192.168.47.133:1521/orclpdb","test","test");
			long end = System.currentTimeMillis();
			System.out.println(end - start); // 10回実行平均：47msec。コネクションプール利用時は0.9msec
			String result = executeQuery(conn, sql);
            conn.close();
			PrintWriter out = response.getWriter();
			out.println(result);
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			try {
				conn.close();
			} catch (SQLException e) {
				e.printStackTrace();
			}
		}
	}
```

```
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		Connection conn = null;
		try {
			String sql = "select col1 from test";
			Class.forName("org.postgresql.Driver");
			
			long start = System.currentTimeMillis();
			conn=DriverManager.getConnection("jdbc:postgresql://192.168.47.135:5432/test","postgres","postgres");
			long end = System.currentTimeMillis();
			System.out.println(end - start); // 10回実行平均：23msec。コネクションプール利用時は0.3msec
			String result = executeQuery(conn, sql);
            conn.close();
			PrintWriter out = response.getWriter();
			out.println(result);
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			try {
				conn.close();
			} catch (SQLException e) {
				e.printStackTrace();
			}
		}
	}
```